مالکیت و قرض‌دادن (Ownership و Borrowing)

شاید کامل‌ترین و عمیق‌ترین توضیح از Ownership و Borrowing در تمام اینترنت! این است جمله‌ای که برای نتیجۀ ساعت‌ها تحقیق و بررسی که انجام دادم به کار می‌برم. با تقریباً تمام جوانب مالکیت و قرض‌دادن در Rust آشنا شوید.

Ownership و Borrowing در Rust
user_avatar
علی برزگر
1404/02/28

مسئلۀ مدیریت حافظه

مدیریت حافظه یکی از نقاط ثقل اصلی زبان‌های برنامه‌نویسی است و سیاستی که زبان‌ها در این موضوع اتخاذ می‌کنند، یکی از پارامترها و ملاک‌های اصلی دسته‌بندی زبان‌ها است.

مدیریت حافظه به چالش‌های مختلفی اشاره دارد. از اختصاص بخشی از حافظه، تا دسترسی به آن، نوشتن در آن و سپس آزادسازی آن. همچنین انواع مختلف حافظه شامل Stack و Heap. گونه‌های مختلفی از راهکارها برای مدیریت حافظه توسط زبان‌های مختلف برنامه‌نویسی عرضه شده است:

  1. مدیریت دستی حافظه، نظیر آنچه در C و C++ می‌بینیم. اگرچه C++ راهکارهایی جهت تسهیل در این امر ارائه کرده، اما کافی نیست و همچنان بخش بزرگی از خطاهای نرم‌افزاری به سبب همین نوع از مدیریت حافظه است.
  2. روش جمع‌آوری زباله (Garbage Collection) که در زبان‌های سطح بالایی همچون جاوا می‌بینیم؛ روشی پرهزینه که هم حجم فایل نهایی نرم‌افزار را افزایش می‌دهد و هم در زمان اجرا، اثر منفی بر روی کارکرد برنامه دارد. اگرچه کار را برای توسعه‌دهنده راحت می‌کند.
  3. اعمال محدودیت در زمان کامپایل، و ایجاد تغییر در پارادایم برنامه‌نویسی؛ همان کاری که Rust توسط قوانین «مالکیت و قرض‌دادن» یا همان Ownership & Borrowing انجام می‌دهد.

Ownership & Borrowing

آنچه که توسعه‌دهندگان C++ مجبور بودند بادقت فراوانی مراقبش باشند تا برنامه در زمان اجرا دچار خطا نشود، Rust در زمان کامپایل به ما گوشزد می‌کند.

بسیاری از کدها از منظر دستور زبان C++ معتبر هستند، اما در زمان اجرا مشکلات عدیده‌ای را پدید می‌آورند؛ از جمله:

  • استفاده بعد از آزادسازی (Use After Free): این مشکل زمانی پیش می‌آید که هنگام استفاده از اشاره‌گر (Pointer) دادۀ اصلی وجود نداشته باشد.
  • آزادسازی دوباره (Double Free): یک‌بار حافظه را آزاد می‌کنیم و سپس بدون توجه به اینکه احتمالاً داده‌ای دیگر در آن ذخیره شده، مجدداً آن را آزاد کنیم.
  • متغیرهای بدون مقدار اولیه (Uninitialized Variables): استفاده از چنین متغیری موجب عملکردی غیرقابل‌پیش‌بینی یا خطا می‌شود؛ خصوصاً وقتی ارجاعی به آن داده شود، یا سعی کنیم که حافظۀ آن را (که چنین حافظه‌ای وجود ندارد) آزاد کنیم.

البته موارد متعدد دیگری نیز وجود دارند، اما عمدۀ معضلاتی که راست سعی دارد تا با قوانین مالکیت و قرض‌دادن آن را پوشش دهد، همین موارد هستند.

پیش‌نیاز ورود فهم مبحث

پیش از ادامۀ خواندن این مقاله، مطمئن باشید که مقاله‌های حافظۀ Stack و Heap، متغیرها در Rust، و ارجاعات در Rust را به‌خوبی درک کرده باشید. مسئلۀ مالکیت و قرض‌دادن بسیار به مباحث پیش‌گفته گره‌خورده است.

مالکیت یا Ownership و مزایای آن

سه قانون برای مالکیت در Rust وجود دارد:

  1. هر مقداری حتما دارای مالکی است و هیچ مقداری بدون مالک نمی‌ماند.
  2. فقط یک مالک می‌تواند در هر زمان برای یک مقدار وجود داشته باشد.
  3. وقتی این مالک یگانه، از محدوده (Scope) خارج شود (حتی قبل از خروج از محدوده و صرفا با مقداردهی مجدد متغیری که مالک آن داده بوده)، آن مقدار نیز از بین می‌رود (drop می‌شود). پیاده‌سازی این قانون در C++ با نام RAII شناخته می‌شود، اما Rust این قانون را به صورت الزامی و در مرحلۀ کامپایل اعمال می‌کند.

قوانین مالکیت دقیقاً همان چیزی هستند که زبان Rust را از زبان‌های Garbage Collected جدا می‌کنند. کار Garbage Collector این است که حافظه را پایش کند و داده‌هایی که هیچ ارجاعی به آن‌ها وجود ندارد و بدون مالک هستند را بیابد. حال در نظر بگیرید که هیچ دادۀ بدون مالکی وجود نداشته باشد؛ خب دیگر نیازی به Garbage Collector نیست! همچنین بدین خاطر که فقط یک مالک وجود دارد، به‌سادگی می‌توان با پایان Scope، آن داده را نابود کرد؛ اصلاً داده‌ها برای خارج از Scope باقی نمی‌مانند تا Garbage Collector بخواهد وارد عمل شود، یا اینکه ندانسته یک داده را دو بار پاک کنیم.

قوانین مالکیت یک برگ برندۀ بزرگ دیگر نیز درست می‌کنند؛ ازآنجاکه فقط یک مالک می‌تواند وجود داشته باشد، هرگز دو رشتۀ پردازشی مختلف نمی‌توانند مالک یک دادۀ واحد باشند تا دسترسی مشترک آن‌ها، شرایط رقابتی (Race Condition) یا به عبارت بهتر، رقابت داده‌ای (Data Race) ایجاد کند.

در مورد قوانین مالکیت باید توجه داشت که فرقی نمی‌کند که دادۀ اصلی در Heap ذخیره شده باشد یا در Stack؛ در همه حال این قوانین جاری هستند.

قوانین مالکیت چگونه مشکلات حافظه را رفع می‌کنند؟

  • با نابودی خودکار داده پس از خروج از Scope، دچار نشتی حافظه (Memory Leak) نمی‌شویم.
  • اینکه فقط یک مالک وجود دارد، امکان Use After Free و Double Free را از ما می‌گیرد، زیرا مالک دیگری نیست تا دسترسی دیگری به آن داشته باشد و با نابودی داده از طریق مالک اول، مالک دوم بتواند به داده دسترسی یابد یا مجدداً آن را نابود کند.
  • ازآنجاکه هر متغیری نیز لزوماً باید مقداری داشته باشد، مشکل Uninitialized Variable را نخواهیم داشت.

محدودیت‌های قوانین مالکیت

البته قوانین مالکیت موجب محدودیت‌هایی هم می‌شوند. شما نمی‌توانید به همان سادگی که در جاوا یا C++ مقادیر متغیرها را جابه‌جا می‌کردید عمل کنید. در نتیجه الگوی برنامه‌نویسی شما اندکی متفاوت خواهد شد تا بتوانید مفاهیمی را که سابقاً در سایر زبان‌ها پیاده‌سازی می‌کردید را این بار با روشی اندکی متفاوت پیاده‌سازی نمایید.

به‌عنوان‌مثال کد زیر را که مربوط به زبان جاوا است را در نظر بگیرید:

CuatomClass cc = new CuatomClass();
my_func(cc);
my_func(cc);
my_func(cc);

در کد بالا شیء cc را به 3 تابع فرستادیم و مادامی که هنوز با شیء cc کار داریم، Garbage Collector آن را آزاد نمی‌کند.

حال کد زیر را در زبان Rust در نظر بگیرید:

let cc = CustomStruct {};
my_func(cc);
my_func(cc);
my_func(cc);

در کد بالا هنگام فراخوانی تابع دوم به خطا برمی‌خوریم، چراکه پارامتر تابع اول، خودش یک متغیر است و مالکیت دادۀ موجود در cc را گرفته است. در نتیجه اکنون شیء cc اشاره به هیچ داده‌ای ندارد. لذا اعطای آن به تابع دوم به معنای اعطای متغیری خالی است و این از نظر راست یک خطای زمان کامپایل محسوب می‌شود.

برای حل این مسئله راهکارهای متعددی وجود دارد که در ادامه به آن خواهیم پرداخت، اما به‌هرحال با ذهنیت یک متخصص جاوا تفاوت دارد.

محدودیت دیگر روش مالکیت این است امکان دسترسی مشترک دو Thread به دادۀ واحد را از بین می‌برد. اما این نیز توسط سایر ابزارهای Rust قابل‌حل است؛ ابزارهایی همچون Arc و Mutex که اولی نوعی از Garbage Collection داخلی را فراهم می‌کند، و دومی مکانیزمی جهت انجام قفل (Lock) بر روی دادۀ مشترک ارائه می‌دهد. اما به من اعتماد کنید؛ همواره نوشتن برنامه‌ای که شرایط رقابتی در آن پیش نیاید بسیار بهتر است!

الگوی متفاوتی از کدنویسی که Rust به کد شما تحمیل می‌کند ممکن است در ابتدای کار برای کسانی که از زبان‌های دیگر آمده‌اند اندکی سخت جلوه کند، اما به‌محض اینکه آن را آموختید برایتان عادی می‌شود.

ارتباط قوانین مالیکت با Heap و Stack

دربارۀ این مسئله در مقالۀ متغیرها در Rust مفصلا توضیح داده‌ایم. می‌توانید به آنجا رجوع نمایید.

قرض‌دادن (Borrowing)

مسئلۀ قرض‌دادن همیشه با Pointer (اشاره‌گر) و Reference (ارجاع) گره‌خورده است. در واقع Referenceها همان Pointerهای Safe هستند (در مقابل Pointerهای Unsafe). ارجاعات، اشاره‌گرهایی هستند که قوانین Borrowing یا قرض‌دادن دربارۀ آن‌ها إعمال می‌شود.

قوانین قرض‌دادن

قوانین مالکیت می‌تواند بسیاری از مشکلات را حل کند، اما ممکن است محدودیت‌های بی‌موردی را نیز ایجاد کند؛ ما لزوماً نمی‌خواهیم از یک متغیر فقط درون یک تابع، آن هم به‌صورت یکسان استفاده کنیم، بلکه بسیار پیش می‌آید که از دادۀ درون یک متغیر، در متغیرهای دیگر نیز استفاده کنیم.

یک مثال برای این مسئله، ارسال متغیر به توابع است که در بخش مالکیت آن را بیان کردیم. اما مثال دیگر آن می‌تواند ذخیره‌سازی مقدار آن متغیر درون فیلدهای یک Struct باشد (Struct مفهومی مشابه – و نه عیناً – Class در سایر زبان‌ها است). قوانین مالکیت این اجازه را نمی‌دهد که هم به داده از درون تابع مادر دسترسی داشته باشیم، و هم از توابع دیگر یا Struct دیگر.

ولی بگذارید از نگاهی دیگر به مسئله نگاه کنیم. قوانین مالکیت برای حل دو مشکل ایجاد شدند؛ یکی داده‌هایی که هیچ ارجاعی به آن‌ها وجود نداشته باشد، و یکی دسترسی دو رشتۀ پردازشی به دادۀ واحد. حال چه می‌شود اگر بتوانیم در برخی مواقع، به کامپایلر راست تضمین بدهیم که حتی اگر از چند جا به داده دسترسی داشته باشیم، مشکلی از این بابت پیش نخواهد آمد؟ یعنی نه دادۀ بدون ارجاع خواهیم داشت، و نه مشکل دسترسی دو رشتۀ پردازشی؟

پاسخ در ارجاعات نهفته است. ارجاعات دو ویژگی برای ما ایجاد می‌کنند که همان دو قانون قرض‌دادن هستند:

  1. ما می‌توانیم به جای انتقال مالکیت داده از متغیری به متغیر دیگر، صرفا ارجاعی از متغیر اول را به متغیر دوم بدهیم تا او با این ارجاع کند نه با دادۀ اصلی. در این صورت آن ارجاع نیز مادامی که متغیر اصلی موجود باشد معتبر خواهد بود و اگر آن متغیر از بین برود و drop شود، خود کامپایلر اجازۀ استفاده از ارجاع را به ما نمی‌دهد.
  2. نمی‌توان به صورت هم‌زمان، دو دسترسی به دادۀ اصلی داشت که یکی از آن‌ها mutable باشد؛ چه از طریق متغیر اصلی، و چه از طریق متغیر ارجاعی. به عبارت دیگر اگر مالک یا یکی از ارجاعات، mutable باشند، هیچ ارجاع دیگری نمی‌تواند به داده وجود داشته باشد؛ چه ارجاع mutable و چه ارجاع immutable. این روش موجب حل مشکل دسترسی چند رشتۀ پردازشی به داده می‌شود.

نگاهی عمیق‌تر به قوانین Borrowing

قانون اول

فلسفۀ قوانین مالکیت را یادآوری کنید؛ قرار بود برای اینکه دچار داده‌هایی در حافظه که هیچ متغیری به آن‌ها اشاره ندارد، چاره‌ای بیندیشیم. بالاخره وقتی داده‌ای در حافظه باشد که هیچ متغیری برای آن وجود نداشته باشد، چنین چیزی موجب اشغال بی‌خودی حافظه می‌شود که از آن به‌عنوان نشتی حافظه (Memory Leak) یاد می‌کنند. راهکاری که قوانین مالکیت می‌دادند این بود که از اساس اجازه ندهیم که دو متغیر بتوانند مالک یک داده باشند؛ این‌گونه می‌توانستیم به‌محض خروج متغیر مالک از Scope، آن بخش از حافظه را آزاد کنیم؛ چراکه مطمئن بودیم هیچ متغیر دیگری نیست که بخواهد با آن داده تعامل کند.

اما دیدیم که این روش موجب محدودیت‌هایی می‌شد. اگر واقعاً هر داده‌ای صرفاً بتواند با یک متغیر پیوند بخورد، برنامه‌نویسی تبدیل به یک کابوس خواهد شد!

برای رفع این محدودیت، ارجاعات به میان آمدند تا به‌جای اینکه مالکیت داده را از متغیری به متغیر دیگر منتقل کنیم، بتوانیم از چند نقطه به دادۀ اصلی دسترسی داشته باشیم. اما این دسترسی طبعاً دسترسی مالکانه و بدون واسطه نیست، بلکه همواره فقط یک متغیر وجود دارد که مالک اصلی است، و ارجاعات در واقع ارجاع به آن متغیر هستند نه ارجاع به دادۀ اصلی؛ لذا دسترسی ارجاعات به دادۀ اصلی، همواره از طریق متغیر مالک است و به عبارتی دسترسی غیرمستقیم به دادۀ اصلی دارند.

حال در اینجا سؤال بسیار مهمی پیش می‌آید؛ چه می‌شود اگر متغیر مالک نابود شود، اما ارجاع آن همچنان موجود باشد؟ برای مثال کد زیر را در نظر بگیرید:

let y;
{
    let x = 5;
    y = &x;
}
println!("{y}");

در کد بالا متغیر y را تعریف کردیم. سپس یک Scope جدید ایجاد کردیم و متغیر x را با مقدار 5 در آن مقداردهی نمودیم. سپس y را به عنوان ارجاعی به x معرفی کردیم. تا اینجا هیج مشکلی وجود ندارد، اما وقتی می‌خواهیم مقدار y را چاپ کنیم، به خطا برمی‌خوریم. خطا می‌گوید متغیر g به اندازۀ کافی زنده نمی‌ماند تا بخواهید ارجاعی به آن را به کار بگیرید (مثلا چاپ کنید)، چراکه با پایان Scope عملا x نیز از بین رفته است. به y در زمانی که متغیر مرجع آن (x) نابود شده، ارجاع آویزان یا Dangling Reference می‌گویند.

کامپایلر Rust به‌صورت خودکار، چنین مواردی را در کد پیدا می‌کند و جلوی آن را می‌گیرد.

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

fn main() {
    let x = Box::new(5);
    my_func(&x);
    my_func(&x);
    my_func(&x);
}

fn my_func(param: &Box<i32>) {

}

تابع my_func در کد بالا به‌جای دریافت مالکیت یک متغیر از نوع Box، ارجاعی به آن را می‌گیرد. در این صورت به هر تعدادی که این تابع را فراخوانی کنیم مشکلی پیش نمی‌آید. اگر به‌جای param: &Bkx می‌گفتیم param: Box، مالکیت را دریافت می‌کردیم نه ارجاع را و این موجب می‌شد برای بار دوم نشود x را به my_func ارسال کرد. همچنین توجه کنید از Box استفاده کردیم نه از خود i32، زیرا i32 مستقیماً کپی می‌شود نه اینکه مالکیتش منتقل شود. در واقع دلیل استفاده از Box این نبود که Box درون Heap جای می‌گیرد، بلکه این بوده که مقدار Box کپی نمی‌شود و مالکیتش انتقال می‌یابد. در نتیجه اگر از چیزی استفاده کنیم که در Stack جا می‌گیرد اما مشابه i32 کپی می‌شود، باز هم دچار خطا می‌شدیم.

مثال بالا برای توابع بود، اما می‌شود برای فرستادن مقدار به فیلدهای Struct نیز مثال زد. منتهی ارجاعات در Structها عمیقاً با مقولۀ Lifetime پیوند خورده است و ان‌شاءالله آن را در مبحث مرتبط با Lifetime مطرح خواهیم کرد.

تأثیر قانون اول در برنامه‌نویسی چندرشته‌ای

آنچه تا کنون از قانون اول گفتیم مربوط به رشتۀ پردازشی (Thread) واحد بود. اکنون می‌گوییم که تمام آنچه گفته‌ایم، در هنگامی که دو Thread بخواهند به دادۀ واحد دسترسی بیابند نیز جاری است. هر Thread برای خودش Scope اختصاصی دارد و در نتیجه نمی‌توان ارجاعی از آن را به رشتۀ دیگر انتقال داد. این موجب می‌شود هم مالکیت و هم ارجاع به دادۀ واحد، صرفاً از طریق رشتۀ پردازشی واحد صورت بگیرد و هیچ رشته‌ای به‌صورت طبیعی نتواند داده‌ای را با رشتۀ دیگر به اشتراک بگذارد؛ مگر آنکه از مکانیزم‌های اختصاصی چندرشته‌ای استفاده کنیم که به حول و قوۀ الهی در بخش مربوطه به آن خواهیم پرداخت.

قانون دوم

برخی گمان می‌کنند در برنامه‌نویسی چندرشته‌ای، چالش اصلی مربوط است به دسترسی هم‌زمان دو رشته به دادۀ مشترک. اما این گمانی اشتباه است، چراکه صرف دسترسی هم‌زمان موجب اشکال نمی‌شود، بلکه صرفاً در جایی مشکل‌ساز می‌شود که دست‌کم یکی از دو رشته، قصد «تغییر» داده را داشته باشد و الا اگر هزاران رشته صرفاً بخواهند دادۀ مشترک را بخوانند، هیچ مشکلی ایجاد نمی‌شود.

فهم این مسئله نیز بسیار ساده است. اگر یک رشته در صدد تغییر داده باشد، رشتۀ دیگر نمی‌داند که اکنون با دادۀ قبلی مواجه است یا با دادۀ جدید. اما اگر همه فقط خواننده باشند، همه با داده‌ای معتبر مواجه خواهند بود.

راهکار زبان Rust برای حل این مسئله بسیار ساده است. در هر زمان فقط یک نویسنده می‌تواند وجود داشته باشد؛ چه این نویسنده همان متغیر مالک باشد، یا متغیری که ارجاعی mutable به متغیر مالک دارد. در این صورت فقط یک نویسنده می‌تواند باشد، اما خواننده‌ها می‌توانند بسیار باشند.

البته زبان Rust این مسئله را صرفاً در چندرشته‌ای پیاده نمی‌کند، بلکه حتی در رشتۀ پردازشی واحد نیز صرفاً یک نویسنده می‌تواند موجود باشد. اگرچه استدلالی که در بالا توضیح دادیم چنین القا می‌کند که قانون دوم صرفاً در برنامه‌نویسی چندرشته‌ای جاری است، اما این‌طور نیست؛ حتی در رشتۀ واحد نیز نمی‌توان چند نویسنده داشت. برداشت نگارنده از چرایی این مسئله این است که هیچ مکانیزمی مرتبط با خود زبان راست وجود ندارد که کامپایلر از طریق آن بتواند بفهمد که ما داریم با چند رشتۀ پردازشی کار می‌کنیم. بله، کتابخانۀ استاندارد هم هست، ولی این کتابخانه که ویژگی استاندارد خود زبان نیست؛ ممکن است کسی از کتابخانه‌ای دیگر برای چند رشته‌ای استفاده کند؛ حتی ساختار async/await نیز تنها راه چندرشته‌ای نیست تا کامپایلر روی آن حساب باز کند. لذا از ابتدا کامپایلر جلوی چندنویسندگی را می‌گیرد.

کد زیر را در نظر بگیرید:

let mut x = 5;
let y = &mut x;
x = 10;

کد بالا طبق قانون Borrowing باید خطا بدهد (گرچه کامپایلر در اینجا ارفاقی دارد که در آینده به آن خواهیم پرداخت و اینجا خطا نمی‌دهد. ولی فعلاً از آن ارفاق صرف‌نظر می‌کنیم) و می‌گوید شما قبلاً متغیر x را به‌صورت mutable به جایی دیگر قرض داده‌اید (borrow کرده‌اید) و لذا قابلیت نوشتن در داده را به y واگذار کرده‌اید؛ در نتیجه نمی‌توانید مقدار را از طریق x تغییر دهید، بلکه صرفاً از طریق y است که می‌توانید مقدار را تغییر دهید. در نتیجه به‌جای x = 10 باید بگوییم:

*y = 10;

در اینجا البته کامپایلر Rust بسیار هوشمندانه عمل می‌کند و ارفاقی به ما می‌دهد؛ کامپایلر به این نگاه نمی‌کند که شما متغیر را چگونه تعریف کرده‌اید، بلکه می‌بیند شما چگونه از متغیر «استفاده» کرده‌اید. اگر در کد اول پس از x = 10 سعی کنیم مقدار x را چاپ کنیم، یعنی بگوییم:

let mut x = 5;
let y = &mut x;
x = 10;
println!("{x}");

خطایی نمی‌دهد، اما اگر سعی کنیم y را چاپ کنیم دچار خطا می‌شویم. چرا؟ چون کامپایلر تا آخرین خط کد را می‌خواند و آخرین استفادۀ ما را پیدا می‌کند! در مثال بالا کار ما با y، درست با مقداردهی آن، تمام شده است؛ لذا بعد از y می‌توانیم از x استفاده کنیم. ولی اگر به جای x متغیر y را چاپ می‌کردیم دچار خطا می‌شدیم زیرا وقتی هنوز کارمان با y تمام نشده، سعی در استفاده از x داشته‌ایم.

این بسیار جالب است که زبان راست بر اساس کد آینده، در مورد کد گذشته خطا می‌دهد!

البته قانون دوم به توضیح بیشتری لازم دارد. در این قانون گفتیم که صرفاً یک دسترسی mutable می‌تواند به داده وجود داشته باشد. اما این محدودیت تا پایان کد برقرار نیست، بلکه تا زمان آخرین استفاده از قرض برقرار است. به‌عبارت‌دیگر:

  1. قرض mutable تا زمانی که کارمان با قرض تمام نشد، تمام دسترسی به مالک را می‌بندد؛ اعم از خواندن، نوشتن و استقراض mutable و immutable.
  2. اما قرض immutable تا زمانی که کارمان با قرض تمام نشد، فقط اجازۀ نوشتن در مالک، و انجام قرض mutable را سلب می‌کند. ولی خواندن و قرض‌دادن immutable مجاز است.
  3. قرض mutable تا زمانی که کارمان با قرض تمام نشد، تمام دسترسی به سایر قرض‌ها را نیز می‌بندد.
  4. قرض immutable تا زمانی که کارمان با قرض تمام نشد، صرفا دسترسی به قرض‌های mutable را می‌بندد، اما انجام قرض‌های immutable دیگر کاملا مجاز است.

نتیجۀ این 4 مورد این است که در زمان واحد، فقط یک دسترسی mutable می‌توانیم داشته باشیم؛ اگر یک قرض mutable وجود داشته باشد، فقط و فقط همان قرض است که می‌تواند مورد استفاده قرار گیرد؛ در این زمان نه خود متغیر اصلی قابل استفاده است، و نه هیچ قرض mutable یا immutable دیگری. اگر قرض immutable داشته باشیم، هم خود متغیر اصلی قابل تغییر نیست، و هم هیچ قرض mutableی قابل ایجاد نیست؛ تا وقتی که کارمان با قرض immutable به اتمام برسد.

به عبارتی ساده‌تر، یک دسترسی نوشتن، کاملاً انحصارطلب است. او می‌گوید تا وقتی من هستم اجازۀ دخالت هیچ‌کس دیگر را نمی‌دهم. اگر هم کس دیگری در کار باشد، من وارد نمی‌شوم. حال اگر خود مالک باشد که می‌خواهد بنویسد، هیچ قرض mutable و هیچ قرض immutable دیگری نباید در کار باشد؛ اگر یک قرض mutable باشد، هیچ‌کس دیگری اعم از مالک یا سایر قرض‌های mutable و immutable حق دخالت ندارند.

می‌توانید چنین در نظر بگیرید که هر ارجاع mutable برای خودش محدوده‌ای ایجاد می‌کند؛ محدوده‌ای که از تعریف متغیر ارجاعی شروع می‌شود و تا آخرین استفاده از آن به اتمام می‌رسد؛ در درون این محدوده فقط و فقط حق داریم از همان متغیر ارجاعی استفاده کنیم نه از مالک اصلی یا سایر ارجاعات immutable. همچنین متغیرهای immutable نیز برای خودشان محدوده‌ای ایجاد می‌کنند که از تعریف اولین متغیر immutable آغاز می‌شود و تا استفاده از آخرین آن‌ها به اتمام می‌رسد؛ در این محدوده نه می‌شود مقدار متغیر اصلی را تغییر داد، و نه هیچ ارجاع mutable از آن ساخت. لازم به یادآوری است که ارجاعات immutable می‌توانند چند تا باشند.

حالات مختلف زیر را بنگرید تا با نحوۀ عملکرد این قانون بهتر آشنا شوید. همگی حالات زیر، به‌عنوان حالاتی بعد از کد اول در نظر گرفته شوند.

let mut x = 5;
let y = &mut x;

//حالت اول
x = 10;
println!("{x}");

//حالت دوم
x = 10;
println!("{y}");

//حالت سوم
*y = 10;
println!("{x}");

//حالت چهارم
*y = 10;
println!("{y}");
  1. حالت اول کاملا معتبر است. چراکه عملا بعد از تغییر مقدار از طریق x، از y هیچ استفاده‌ای نشده و عملا کار ما با y تا قبل از تغییر مقدار از طریق x به اتمام رسیده است. اما اگر بعد از چاپ x سعی می‌کردیم مقدار را از طریق y بخوانیم یا تغییر دهیم، دچار خطا می‌شدیم، چراکه تا قبل از اتمام کارمان با y داریم از x استفاده می‌کنیم.
  2. حالت دوم نامعتبر است. خطای کامپایلر بر روی مقداردهی x خود را نشان می‌دهد، چراکه وقتی هنوز کار ما با y تمام نشده، نمی‌توانیم مقدار را از طریق x  تغییر دهیم. توجه شود که حتی اگر مقدار y را برابر با &x قرار می‌دادیم، باز هم این کد نامعتبر می‌بود. چاپ x بعد از چاپ y نیز دردی را دوا نمی‌کند.
  3. حالت سوم صحیح است. چراکه کار ما با y با تغییر مقدار به اتمام رسیده و اکنون x آزاد است. اما اگر بعد از چاپ x سعی می‌کردیم مقدار را از طریق y بخوانیم یا تغییر دهیم، دچار خطا می‌شدیم، چراکه تا قبل از اتمام کارمان با y داریم از x استفاده می‌کنیم.
  4. حالت چهارم همانند حالت دوم، و صحیح است. چاپ x بعد از چاپ y نیز همان مشکل مذکور را دارد.
  • ارجاعات با پایان Scope نابود می‌شوند، اما با نابودی ارجاعات، دادۀ اصلی از بین نمی‌رود. صرفاً درصورتی‌که متغیر مالک از Scope خارج شود، دادۀ اصلی نیز نابود خواهد شد.

موارد اجرای قوانین Ownership و Borrowing

این قوانین صرفاً در راست Safe اعمال می‌شوند نه در راست Unsafe. زبان Rust این اجازه را می‌دهد که این قوانین را دور بزنیم و کدهایی همانند C++ بنویسیم بدون تضمین امنیت توسط کامپایلر. اما این حالت عادی نیست و برای نوشتن کد Unsafe نیازمند به ملاحظاتی هستیم که در حوصلۀ نوشتار جاری نیست. ان‌شاءالله در بخش مستقلی در آینده به آن خواهیم پرداخت.

همچنین لازم به ذکر است که قوانین قرض‌دادن نیز همانند قوانین مالکیت، هم برای داده‌های Stack صادق هستند و هم داده‌های Heap.

قوانین مالکیت و قرض‌دادن در پارامتر توابع

پارامترهای توابع نیز نوعی متغیر هستند. در نتیجه ایجاد یک متغیر مالک و اعطای آن به تابع، مالکیت داده را از متغیر اول سلب می‌کند و به پارامتر تابع می‌دهد. پس از پایان تابع نیز آن متغیر و مقدارش به‌طورکلی نابود می‌شوند و دیگر آن داده وجود ندارد. تنها راهش این است که همان پارامتر را مجدداً برگردانیم تا عملاً مالکیت آن مقدار مجدداً به تابع مادر برگردد. حال تابع مادر می‌تواند آن مقدار را درون متغیری ذخیره کند:

fn main() {
    let x = Box::new(5);
    take_ownership(x);
    println!("{}", x); //Errors

    let x = Box::new(5);
    let returned_ownership = take_ownership_and_return_back(x);
    println!("{}", returned_ownership); //Works!! 
}

fn take_ownership(param: Box<i32>){
    
}

fn take_ownership_and_return_back(param: Box<i32>) {
    param
}

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

fn main() {
    let x = 5;
    borrower_func(&x);
    println!("{}", x); //Works
}
fn borrower_func(&i32) //می‌شد از Box نیز استفاده کرد. فرقی ندارد
{
    
}

نتیجه

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

تگ‌ها:
سوالی دارید؟ در بخش پایین از ما بپرسید.
همچنین می‌توانید جهت شرکت در دورۀ آموزشی زبان Rust از
اینجا
اقدام نمایید.

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

فهرست مطلب