چگونه مدل‌سازی ساختارهای داده پیچیده در golang با استفاده از pointerها، referenceها و reverse indexها کار می‌کند؟

چگونه مدل‌سازی ساختارهای داده پیچیده در Golang با استفاده از Pointerها، Referenceها و Reverse Indexها کار می‌کند؟

Pointerها و Referenceها در Go یکی از ویژگی‌های بنیادین این زبان هستند. استفاده صحیح از آن‌ها می‌تواند به شما کمک کند سیستم‌های کارآمدی بسازید.
مدیریت صریح حافظه در Go از طریق Pointerها و Referenceها به توسعه‌دهندگان کنترل مستقیم روی مصرف حافظه می‌دهد و امکان دستیابی به عملکرد بهینه و قابل پیش‌بینی را در برنامه‌های حافظه‌محور فراهم می‌کند.
نوشتن ساختارهای داده پیچیده در Go می‌تواند به توسعه‌دهندگان کمک کند اصول Pointerها و Referenceها را بهتر درک کنند.
انتخاب زبان برنامه‌نویسی مناسب برای حل مسئله، گام مهمی در دستیابی به اهداف شماست.
گراف‌ها می‌توانند به شما در ساخت سیستم‌های مجوزدهی دقیق و جزئی کمک کنند.

درک Pointerها و Referenceها در Go برای بسیاری از توسعه‌دهندگان چالش‌برانگیز است، به‌ویژه برای کسانی که تازه وارد زبان‌های سطح پایین‌تر شده‌اند. من که از زبانی مانند Python آمده‌ام نیز، منحنی یادگیری آن را سخت یافتم. پروژه‌ای که اخیراً روی آن کار کردم و در آن مجبور بودم برای پیاده‌سازی محصولمان از Pointerها استفاده کنم، کمک کرد این مفاهیم را بهتر بفهمم.

در این مقاله، شما را با برنامه‌ای آشنا می‌کنم که از Pointerها و Referenceهای Go برای حل یک مسئله دنیای واقعی استفاده می‌کند. یادگیری از یک نمونه کاربردی واقعی همیشه برای من مؤثرتر از توضیحات انتزاعی بوده است و امیدوارم هنگام خواندن این مقاله شما هم همین تجربه را داشته باشید.

مقدمه‌ای بر Pointerها و Referenceها در Go

توسعه‌دهندگانی که از زبان‌هایی بدون Pointer و Reference صریح به Go مهاجرت می‌کنند، اغلب این مفهوم را چالش‌برانگیز می‌یابند. در واقع، زبان‌هایی مانند Python یا JavaScript مدیریت حافظه را به‌صورت خودکار انجام می‌دهند و این مفاهیم را پنهان می‌کنند.

پیش از آن‌که وارد بخش عملی این داستان شویم، بیایید نگاهی سریع به نحو Pointerها و Referenceها در Go بیندازیم.

Basic Syntax

در Go، Pointer نوعی داده است که یک آدرس حافظه را ذخیره می‌کند و امکان دسترسی و دستکاری مستقیم داده موجود در آن آدرس را فراهم می‌سازد. نحو اعلان Pointer از علامت * استفاده می‌کند.
برای مثال، var myPointer *int متغیری را اعلان می‌کند که نوع آن Pointer به یک عدد صحیح است.

گرفتن Reference به معنای خواندن آدرس حافظه یک متغیر است. خروجی این عمل یک Pointer خواهد بود.

گرفتن Reference با استفاده از عملگر & انجام می‌شود. برای مثال،
var myPointer *int = &myInt
متغیری را اعلان می‌کند که نوع آن Pointer به یک عدد صحیح است و آن را با Reference (آدرس حافظه) متغیر myInt مقداردهی اولیه می‌کند. در این مثال، تغییر مقدار myInt با myInt = 2 باعث تغییر مقدار زیرین myPointer نیز می‌شود. دلیل آن این است که myPointer مقدار واقعی را ذخیره نمی‌کند، بلکه Reference متغیر myInt را نگه می‌دارد. با توجه به مثال بالا، بررسی برابری myPointer و myInt نتیجه‌ای برابر با false خواهد داشت، زیرا myPointer عدد صحیح واقعی را نگه نمی‌دارد و فقط Reference حافظه را ذخیره کرده است.

اگر درک این سطح از پیچیدگی برایتان سخت است، اوضاع می‌تواند بدتر هم بشود. بیایید مثالی را در نظر بگیریم که در بسیاری از وب‌سایت‌های آموزش Go دیده می‌شود: چگونه مقدار واقعی یک Pointer را به‌دست آوریم. برای این کار، می‌توانیم از عملگر * استفاده کنیم.
برای مثال،
*myPointer == myInt
مقدار واقعی‌ای را که myPointer به آن اشاره می‌کند بازیابی کرده و آن را با متغیر myInt مقایسه می‌کند. این عمل همچنین می‌تواند مقدار ذخیره‌شده در آن آدرس حافظه را تغییر دهد؛ برای مثال،
*myPointer = 2
مقدار واقعی را به ۲ تغییر می‌دهد. این تغییر روی متغیر myInt نیز تأثیر خواهد گذاشت.

درک تفاوت میان استفاده از عملگر * در اعلان متغیر، مانند var myPointer *int، و استفاده از آن برای گرفتن مقدار واقعی، مانند var concrete int = *myPointer، ضروری است. زمانی که از آن در اعلان متغیر یا به‌عنوان نوع بازگشتی یک تابع استفاده می‌کنید، عملگر * به نوع Pointer (Reference) اشاره دارد که برای بررسی نوع و کامپایل استفاده می‌شود. اما زمانی که از آن برای گرفتن مقدار واقعی متغیر استفاده می‌کنید، به مقدار Referenced متغیری اشاره دارید که برنامه در حال حاضر در حافظه نگه داشته است.

نمونه کدها

راه دیگری برای کنار آمدن با پیچیدگی درک Pointerها و Referenceها، نوشتن کدی است که رفتار آن‌ها را نشان دهد. برای نمایش این موضوع، بیایید برنامه‌ای ساده بر اساس مثال‌های قبلی ایجاد کنیم:

چگونه مدل‌سازی ساختارهای داده پیچیده در golang با استفاده از pointerها، referenceها و reverse indexها کار می‌کند؟

کد بالا خروجی زیر را در کنسول چاپ می‌کند:

چگونه مدل‌سازی ساختارهای داده پیچیده در golang با استفاده از pointerها، referenceها و reverse indexها کار می‌کند؟

مورد استفاده: مجوزدهی مبتنی بر گراف

چیزی که بیش از همه به من در یادگیری مفاهیم جدید کمک کرد، پیاده‌سازی قابلیت‌هایی بود که به‌طور گسترده از آن‌ها استفاده می‌کردند. در مورد فعلی ما، در شرکتی که با آن کار می‌کردم، نیازی به یک سیستم مجوزدهی وجود داشت که به کاربران اجازه دهد بر اساس روابط میان منابع در یک سیستم شخص ثالث، کوئری اجرا کنند. این موضوع مرا به پیاده‌سازی یک سیستم مجوزدهی مبتنی بر گراف با استفاده گسترده از Pointerها رساند.

مجوزدهی مبتنی بر گراف چیست؟

ساده‌ترین راه برای توضیح مجوزدهی مبتنی بر گراف، استفاده از Google Drive است که سیستم مجوزدهی آن در اینجا مستند شده است. مدلی که Google Drive برای ذخیره، بازیابی و ویرایش اسناد استفاده می‌کند، چندین نیاز ویژه در زمینه مجوزها دارد، از جمله:

  • Object Hierarchy: یک حساب Google Drive می‌تواند شامل پوشه‌ها و فایل‌های زیادی باشد. پوشه‌ها نیز می‌توانند شامل فایل‌ها و پوشه‌های دیگر باشند.

  • Direct File access: یک کاربر می‌تواند مستقیماً روی یک فایل نقش دریافت کند و این نقش مجموعه محدودی از مجوزها را برای همان فایل خاص به او می‌دهد. برای مثال، کاربری که به‌عنوان viewer به یک فایل تخصیص داده شده، می‌تواند آن را مشاهده کند، در حالی که کاربر editor قادر به مشاهده و ویرایش آن خواهد بود.

  • Folder-level access: یک کاربر می‌تواند به یک پوشه دسترسی داشته باشد؛ این دسترسی به‌صورت بازگشتی به تمام فایل‌ها و پوشه‌های درون آن منتقل می‌شود.

  • Cross-application access: اگر تاکنون سعی کرده باشید یک سند Drive را در Gmail به اشتراک بگذارید، احتمالاً با پنجره‌ای مواجه شده‌اید که از شما می‌خواهد به چند گیرنده ایمیل مجوز بدهید. این قابلیت نیازمند درک سطوح مجوز در برنامه‌های مختلف است؛ برای مثال، Gmail از سیستم مجوزدهی Google Drive آگاه است و همین موضوع امکان نمایش این پنجره و ارائه تجربه کاربری بهتر را فراهم می‌کند.

مدل فوق همچنین تضمین می‌کند که مدل مجوزدهی هم مقیاس‌پذیر و هم انعطاف‌پذیر باشد و همان‌طور که بعداً خواهیم دید، این نیازها کاملاً با یک گراف سازگار هستند.

راه‌حل‌های آماده (Out-of-the-box)

ابزارها و پایگاه‌های داده متعددی برای استفاده از گراف‌ها در سیستم‌های مجوزدهی وجود دارند، مانند Graph DB، پیاده‌سازی‌های Google Zanzibar مبتنی بر Graph DB یا SpiceDB. در مورد ما، این راه‌حل‌ها مناسب نبودند، زیرا نیاز سخت‌گیرانه‌ای برای ترکیب قوانین شرطی با مدل مجوزدهی مبتنی بر گراف داشتیم. ما مجبور شدیم قابلیت‌های Open Policy Agent (OPA) را گسترش دهیم تا چنین مدلی را پیاده‌سازی کنیم. با استفاده از یک موتور سیاست‌گذاری مانند OPA، توانستیم مدل مجوزدهی مبتنی بر قوانین را با مدل مجوزدهی مبتنی بر گراف ترکیب کنیم.

گراف‌ها در Go

هنگام جست‌وجوی راهی برای پیاده‌سازی مدل مجوزدهی خود با استفاده از یک موتور سیاست‌گذاری، متوجه شدیم OPA از پلاگین‌های Go پشتیبانی می‌کند و این امکان را به ما می‌دهد که یک پلاگین Go بنویسیم تا عملیات مبتنی بر گراف را روی داده‌های مجوز ذخیره‌شده در OPA انجام دهد.

هنگام طراحی راه‌حل و تصمیم‌گیری درباره نحوه ساخت پلاگین Go، متوجه شدیم Pointerها و Referenceهای Go می‌توانند به ما کمک کنند گرافی کارآمد بسازیم، تعداد عملیات موردنیاز برای ساخت آن را کاهش دهیم و مصرف حافظه کلی پلاگین را کم کنیم.

گراف‌ها، Pointerها و Referenceها

نوشتن ساختارهای داده پیچیده و سفارشی اغلب مستلزم درک عمیق مسئله و قابلیت‌های زبان است. بخش بعدی شما را با تجربه ما در ساخت چنین ساختار داده‌ای با استفاده از Go آشنا می‌کند.

بلوک‌های سازنده گراف

هر زبان برنامه‌نویسی مجموعه‌ای از ساختارهای داده پایه مانند آرایه‌ها، لیست‌ها و Mapها را ارائه می‌دهد که به‌عنوان بلوک‌های سازنده برای مدیریت و دستکاری داده‌ها عمل می‌کنند. با این حال، هنگام مواجهه با سناریوهای پیچیده‌تر، این ساختارهای پایه همیشه کافی نیستند. در چنین مواردی، توسعه‌دهندگان باید از ساختارهای داده متناسب با نیازهای خاص خود استفاده کنند، مانند گراف‌ها، درخت‌ها، پشته‌ها و غیره.

انواع مختلفی از گراف‌ها وجود دارد که همگی از دو عنصر اصلی تشکیل شده‌اند:

  • Node: یک نقطه یا رأس که می‌تواند داده ذخیره کند و از طریق یال‌ها به گره‌های دیگر متصل شود. ما این را با استفاده از یک struct پیاده‌سازی کردیم که فیلدی از نوع map برای Reference دادن به یال‌های متصل به گره‌های دیگر داشت.

  • Edge: یک اتصال یا لینک بین دو گره که یک رابطه یا مسیر را نشان می‌دهد. در برخی گراف‌ها، یال نام نیز دارد. ما این را به‌صورت یک map پیاده‌سازی کردیم که کلید آن نام یال و مقدار آن گره Referenced شده است.

چالش‌ها هنگام انجام جست‌وجو در گراف

یکی از چالش‌های اصلی گراف‌ها، پیاده‌سازی «کوئری‌های گراف» است، به‌ویژه زمانی که بخواهید مسیر مجوزهایی را که به یک کاربر داده شده ردیابی کنید. از بسیاری جهات، گراف شبیه یک درخت است، اما تفاوت اصلی این است که گراف مانند درخت ریشه ندارد و ممکن است شامل حلقه باشد. این موضوع کوئری‌ها را بسیار پیچیده‌تر می‌کند، به‌ویژه از نظر جست‌وجوی کارآمد گراف و جلوگیری از بازگشت بازگشتی بی‌نهایت در صورت وجود حلقه.

جست‌وجوی گراف یا Reverse Indexها چیستند؟

یکی از پرسش‌های رایج در گراف این است که کدام گره‌ها به یک گره مشخص x متصل هستند. پاسخ به این سؤال مستلزم آن است که Reverse Indexهایی از گره x به تمام گره‌هایی که به آن متصل هستند داشته باشیم.
برای مثال، فرض کنید گره Folder:rnd از طریق رابطه‌ای به نام parent به گره File:architecture.pdf متصل است. ذخیره فرزندان در Folder:rnd به‌تنهایی کافی نیست، زیرا اگر بپرسیم «والد File:architecture.pdf چیست؟» مجبور خواهیم بود تمام گره‌های گراف را پیمایش کنیم. یکی از راه‌های حل این مشکل استفاده از Reverse Linkهاست که به آن‌ها «Reverse Indices» نیز گفته می‌شود و این یک تکنیک رایج در پایگاه‌های داده گراف و سیستم‌های مبتنی بر گراف است.

نیازهای خاص گراف

ساختار داده گراف ما نیازهای متعددی داشت:

  • Reverse indices – انجام کوئری‌های پیچیده گراف

  • Minimal Memory Footprint – اجتناب از مصرف بالای حافظه برای کاهش مصرف منابع

  • Performance Aware – ایجاد کوئری‌های کارآمد روی گراف برای دستیابی به یک سیستم مجوزدهی با کارایی بالا

استفاده از Pointerها و Referenceها برای پاسخ به نیازها

در یک پیاده‌سازی ساده‌لوحانه از چنین ساختار گراف سفارشی، من چندین تابع کمکی برای گراف می‌نوشتم تا نیازهای ذکرشده را برآورده کنم. در کدهای بعدی، نشان می‌دهم چگونه با استفاده از Pointerها و Referenceها، ساختار داده گراف را به‌صورت کارآمد پیاده‌سازی کردم، جست‌وجوهای سریع انجام دادم و کمترین مصرف حافظه را داشتم.

  1. بیایید با پیاده‌سازی دو ساختار داده بنیادی Node و Edge شروع کنیم:

    چگونه مدل‌سازی ساختارهای داده پیچیده در golang با استفاده از pointerها، referenceها و reverse indexها کار می‌کند؟

  2. در ادامه‌ی پیاده‌سازی structها، بیایید سازنده‌ها (constructors) و توابع کمکی‌ای ایجاد کنیم که در ادامه مسیر به ما کمک خواهند کرد:

    چگونه مدل‌سازی ساختارهای داده پیچیده در golang با استفاده از pointerها، referenceها و reverse indexها کار می‌کند؟چگونه مدل‌سازی ساختارهای داده پیچیده در golang با استفاده از pointerها، referenceها و reverse indexها کار می‌کند؟چگونه مدل‌سازی ساختارهای داده پیچیده در golang با استفاده از pointerها، referenceها و reverse indexها کار می‌کند؟

  3. پس از آن‌که عناصر بنیادی گراف خود را تعریف کردیم، بیایید ساختار داده‌ی اصلی Graph را پیاده‌سازی کنیم.

    چگونه مدل‌سازی ساختارهای داده پیچیده در golang با استفاده از pointerها، referenceها و reverse indexها کار می‌کند؟

  4. وقتی تعریف structها و سازنده‌ها را به پایان رساندیم، آخرین گام نوشتن توابعی برای کوئری گرفتن از گراف بود. در مثال ما، دو مورد استفاده‌ی اصلی وجود دارد:

    • کدام گره به این گره متصل است (به‌عنوان object

    • کدام گره از این گره به‌عنوان مبدأ اتصال دارد (به‌عنوان subject

    به لطف نحوه‌ای که داده‌های خود را ساختاربندی کرده‌ایم، می‌توانیم هر دوی این موارد را به‌راحتی پیاده‌سازی کنیم:

    چگونه مدل‌سازی ساختارهای داده پیچیده در golang با استفاده از pointerها، referenceها و reverse indexها کار می‌کند؟ چگونه مدل‌سازی ساختارهای داده پیچیده در golang با استفاده از pointerها، referenceها و reverse indexها کار می‌کند؟چگونه مدل‌سازی ساختارهای داده پیچیده در golang با استفاده از pointerها، referenceها و reverse indexها کار می‌کند؟

همان‌طور که در این مثال‌های کد می‌بینید، استفاده از Pointerها و Referenceها نه‌تنها به من کمک کرد کدی تمیزتر و کم‌مصرف‌تر از نظر حافظه بنویسم، بلکه راهی قابل‌اعتمادتر برای اطمینان از عملکرد گراف نیز فراهم کرد. حالا بیایید به مزایایی که این رویکرد برای سیستم مجوزدهی مبتنی بر گراف ما به همراه دارد نگاه کنیم.

پیاده‌سازی گراف با Pointer و بدون Pointer

همان‌طور که می‌بینید، در پیاده‌سازی ما، از Pointerها و Referenceها به‌طور گسترده استفاده شده است؛ این کار به ما کمک کرد خطوط کد کمتری بنویسیم و مهم‌تر از آن، عملکرد و مصرف حافظه را بهینه کنیم.

دو نمونه سریع از بهینه‌سازی‌هایی که این رویکرد برای ما فراهم کرد عبارت‌اند از:

  • Memory Optimization – به ما کمک کرد فقط Referenceها را هنگام ذخیره Indexهای دوطرفه نگه داریم. بدون دسترسی به Pointerهای حافظه، این کار منجر به مصرف بیش‌ازحد حافظه می‌شد.

  • Cascading Effect – هنگام حذف یک گره، باید تمام Referenceهای مربوط به آن را حذف می‌کردیم؛ با ترکیب Reverse Indexهای ذخیره‌شده و Pointerهای حافظه، به‌راحتی اطمینان حاصل کردیم که هیچ Referenceای به شیء حذف‌شده باقی نمانده است.

علاوه بر این موارد و بسیاری مشکلات دیگر که بدون دسترسی مستقیم به حافظه ممکن بود پیش بیاید، کد انجام چنین کاری بدون استفاده از Pointerها هرگز به این تمیزی نبود.

جمع‌بندی

هنگام مواجهه با یک مسئله یا وظیفه پیچیده، ارزیابی نیازهای پروژه و در نظر گرفتن زبانی که بهترین تناسب را با آن نیازها دارد، ضروری است. بسیاری از توسعه‌دهندگان امروزی رویکردی مستقل از زبان اتخاذ می‌کنند و توانایی انتخاب زبان مناسب برای هر مسئله را یک مهارت کلیدی می‌دانند.

این مقاله استفاده عملی از Pointerها و Referenceهای Go را در توسعه یک سیستم مجوزدهی مبتنی بر گراف کارآمد و مقیاس‌پذیر برجسته کرد. استفاده از این سازه‌های سطح پایین باعث ساده‌سازی کد و افزایش عملکرد سیستم شد. این مثال نشان می‌دهد درک عمیق ابزارهای برنامه‌نویسی و انتخاب آگاهانه زبان تا چه اندازه اهمیت دارد.

مدیریت کارآمد منابع با مدل‌های زبانی کوچک (SLMs) در رایانش لبه‌ای چگونه اجرایی می‌شود؟
چرا اندازه باینری (Binary Size) از اهمیت بالایی برخوردار است؟

دیدگاهتان را بنویسید

سبد خرید
علاقه‌مندی‌ها
مشاهدات اخیر
دسته بندی ها