مالکیت چیست؟

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

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

وقتی مالکیت را درک کنید، پایه‌ای محکم برای درک ویژگی‌هایی که Rust را منحصر به فرد می‌کنند خواهید داشت. در این فصل، مالکیت را با کار بر روی چند مثال که بر یک ساختار داده بسیار رایج تمرکز دارند یاد خواهید گرفت: رشته‌ها.

پشته و حافظه

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

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

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

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

دسترسی به داده در حافظه کندتر از دسترسی به داده در پشته است زیرا باید یک اشاره‌گر (Pointer) را دنبال کنید تا به آن برسید. پردازنده‌های معاصر سریع‌تر هستند اگر در حافظه کمتر پرش کنند. ادامه دادن این تمثیل، در نظر بگیرید که یک پیشخدمت در رستوران سفارش‌های بسیاری از میزها را می‌گیرد. این کارآمدتر است که تمام سفارش‌های یک میز را بگیرد قبل از اینکه به میز بعدی برود. گرفتن سفارش از میز A، سپس از میز B، سپس دوباره یکی از A، و سپس یکی از B فرآیند بسیار کندتری خواهد بود. به همین ترتیب، یک پردازنده می‌تواند بهتر کار خود را انجام دهد اگر روی داده‌ای کار کند که به داده‌های دیگر نزدیک باشد (مانند آنچه در پشته است) تا داده‌ای که دورتر باشد (مانند آنچه ممکن است در حافظه باشد).

وقتی کد شما یک تابع را فراخوانی می‌کند، مقادیری که به تابع منتقل می‌شوند (از جمله، احتمالاً، اشاره‌گر (Pointer)هایی به داده در حافظه) و متغیرهای محلی تابع به پشته پوش می‌شوند. وقتی تابع تمام می‌شود، آن مقادیر از پشته پاپ می‌شوند.

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

قوانین مالکیت

ابتدا، بیایید نگاهی به قوانین مالکیت بیندازیم. این قوانین را در ذهن داشته باشید زیرا با مثال‌هایی که آن‌ها را نشان می‌دهند کار می‌کنیم:

  • هر مقدار در Rust یک مالک دارد.
  • در یک زمان فقط می‌تواند یک مالک وجود داشته باشد.
  • زمانی که مالک از دامنه خارج شود، مقدار حذف خواهد شد.

دامنه متغیر

حال که از سینتکس پایه Rust گذشته‌ایم، در مثال‌ها کد کامل fn main() { را نخواهیم آورد. بنابراین، اگر دنبال می‌کنید، مطمئن شوید که مثال‌های زیر را به صورت دستی داخل یک تابع main قرار دهید. در نتیجه، مثال‌های ما کمی مختصرتر خواهند بود و می‌توانیم بر روی جزئیات واقعی به جای کد ابتدایی تمرکز کنیم.

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

#![allow(unused)]
fn main() {
let s = "hello";
}

متغیر s به یک رشته‌ی ثابت اشاره دارد، جایی که مقدار رشته به صورت ثابت در متن برنامه ما کدنویسی شده است. این متغیر از نقطه‌ای که اعلام شده معتبر است تا انتهای دامنه جاری. لیست 4-1 برنامه‌ای را با توضیحاتی که نشان می‌دهند متغیر s در کجا معتبر است، نمایش می‌دهد.

fn main() {
    {                      // s is not valid here, it’s not yet declared
        let s = "hello";   // s is valid from this point forward

        // do stuff with s
    }                      // this scope is now over, and s is no longer valid
}
Listing 4-1: یک متغیر و دامنه‌ای که در آن معتبر است

به عبارت دیگر، در اینجا دو نقطه‌ی مهم زمانی وجود دارد:

  • وقتی s وارد دامنه می‌شود، معتبر است.
  • تا زمانی که از دامنه خارج شود معتبر باقی می‌ماند.

در این نقطه، رابطه بین دامنه‌ها و زمان‌هایی که متغیرها معتبر هستند مشابه با زبان‌های برنامه‌نویسی دیگر است. حالا بر اساس این درک، نوع String را معرفی می‌کنیم.

نوع String

برای نشان دادن قوانین مالکیت، به نوع داده‌ای نیاز داریم که پیچیده‌تر از آن‌هایی باشد که در بخش “انواع داده” فصل ۳ بررسی کردیم. انواعی که قبلاً پوشش داده شد، اندازه‌ی مشخصی دارند، می‌توانند در استک ذخیره شوند و وقتی دامنه‌شان تمام شد از استک برداشته شوند و می‌توانند به سرعت و به سادگی برای ساختن یک نمونه‌ی جدید و مستقل کپی شوند اگر قسمت دیگری از کد بخواهد همان مقدار را در دامنه‌ی دیگری استفاده کند. اما ما می‌خواهیم به داده‌هایی نگاه کنیم که در هیپ ذخیره شده‌اند و بررسی کنیم چگونه Rust می‌داند چه زمانی باید این داده‌ها را پاکسازی کند، و نوع String یک مثال عالی است.

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

قبلاً رشته‌های ثابت را دیده‌ایم، جایی که مقدار رشته در کد ما به صورت ثابت قرار گرفته است. رشته‌های ثابت راحت هستند، اما برای هر موقعیتی که ممکن است بخواهیم از متن استفاده کنیم مناسب نیستند. یکی از دلایل این است که آن‌ها تغییرناپذیر هستند. دلیل دیگر این است که نمی‌توان هر مقدار رشته را هنگام نوشتن کد خود دانست: به عنوان مثال، اگر بخواهیم ورودی کاربر را بگیریم و ذخیره کنیم چه؟ برای این شرایط، Rust یک نوع رشته‌ی دیگر به نام String دارد. این نوع داده‌های تخصیص‌یافته در هیپ را مدیریت می‌کند و به همین دلیل می‌تواند مقدار متنی را ذخیره کند که اندازه‌ی آن در زمان کامپایل برای ما ناشناخته است. شما می‌توانید یک String را از یک رشته‌ی ثابت با استفاده از تابع from ایجاد کنید، به این صورت:

#![allow(unused)]
fn main() {
let s = String::from("hello");
}

عملگر :: به ما اجازه می‌دهد این تابع from خاص را تحت نوع String نام‌گذاری کنیم به جای استفاده از نوعی نام مانند string_from. این سینتکس را بیشتر در بخش “سینتکس متد” فصل ۵ و هنگامی که در مورد نام‌گذاری با ماژول‌ها صحبت می‌کنیم در بخش “مسیرها برای ارجاع به یک آیتم در درخت ماژول” فصل ۷ بررسی خواهیم کرد.

این نوع رشته می‌تواند تغییر کند:

fn main() {
    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str() appends a literal to a String

    println!("{s}"); // This will print `hello, world!`
}

پس، تفاوت اینجا چیست؟ چرا String می‌تواند تغییر کند اما رشته‌های ثابت نمی‌توانند؟ تفاوت در نحوه‌ی مدیریت حافظه توسط این دو نوع است.

حافظه و تخصیص

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

با نوع String، برای پشتیبانی از یک متن قابل تغییر و قابل رشد، ما نیاز داریم مقداری حافظه را در هیپ تخصیص دهیم که در زمان کامپایل ناشناخته است تا محتوا را نگه داریم. این به این معناست که:

  • حافظه باید در زمان اجرا از تخصیص‌دهنده حافظه درخواست شود.
  • ما نیاز داریم راهی برای بازگرداندن این حافظه به تخصیص‌دهنده زمانی که کارمان با String تمام شد، داشته باشیم.

قسمت اول توسط ما انجام می‌شود: وقتی که String::from را فراخوانی می‌کنیم، پیاده‌سازی آن حافظه‌ای را که نیاز دارد درخواست می‌کند. این تقریباً در تمام زبان‌های برنامه‌نویسی رایج است.

اما قسمت دوم متفاوت است. در زبان‌هایی که دارای جمع‌کننده زباله (GC) هستند، GC حافظه‌ای را که دیگر استفاده نمی‌شود پیگیری و پاک‌سازی می‌کند و ما نیازی به فکر کردن در مورد آن نداریم. در بیشتر زبان‌هایی که GC ندارند، این مسئولیت بر عهده ماست که مشخص کنیم چه زمانی حافظه دیگر استفاده نمی‌شود و کدی را برای آزادسازی صریح آن فراخوانی کنیم، دقیقاً همان‌طور که آن را درخواست کرده‌ایم. انجام درست این کار در تاریخ برنامه‌نویسی یک مشکل دشوار بوده است. اگر فراموش کنیم، حافظه هدر می‌رود. اگر خیلی زود این کار را انجام دهیم، یک متغیر نامعتبر خواهیم داشت. اگر دو بار این کار را انجام دهیم، این هم یک باگ است. ما نیاز داریم دقیقاً یک allocate را با دقیقاً یک free جفت کنیم.

Rust مسیر متفاوتی را طی می‌کند: حافظه به طور خودکار وقتی که متغیری که مالک آن است از دامنه خارج می‌شود بازگردانده می‌شود. در اینجا نسخه‌ای از مثال دامنه ما از فهرست ۴-۱ وجود دارد که از یک String به جای یک رشته‌ی ثابت استفاده می‌کند:

fn main() {
    {
        let s = String::from("hello"); // s is valid from this point forward

        // do stuff with s
    }                                  // this scope is now over, and s is no
                                       // longer valid
}

یک نقطه طبیعی وجود دارد که می‌توانیم حافظه‌ای را که String ما نیاز دارد به تخصیص‌دهنده بازگردانیم: وقتی s از دامنه خارج می‌شود. وقتی یک متغیر از دامنه خارج می‌شود، Rust یک تابع خاص را برای ما فراخوانی می‌کند. این تابع drop نامیده می‌شود، و اینجا جایی است که نویسنده String می‌تواند کدی برای بازگرداندن حافظه قرار دهد. Rust به طور خودکار drop را در زمان بستن آکولاد فراخوانی می‌کند.

نکته: در C++، این الگو که منابع در انتهای دوره عمر یک آیتم آزاد می‌شوند گاهی اوقات Resource Acquisition Is Initialization (RAII) نامیده می‌شود. تابع drop در Rust برای کسانی که از الگوهای RAII استفاده کرده‌اند آشنا خواهد بود.

این الگو تأثیر عمیقی بر نحوه نوشتن کد در Rust دارد. ممکن است اکنون ساده به نظر برسد، اما رفتار کد می‌تواند در موقعیت‌های پیچیده‌تر که می‌خواهیم متغیرهای متعددی از داده‌هایی که در هیپ تخصیص داده‌ایم استفاده کنند، غیرمنتظره باشد. اکنون به بررسی برخی از این موقعیت‌ها می‌پردازیم.

تعامل متغیرها و داده‌ها با انتقال (Move)

متغیرهای مختلف می‌توانند در Rust به روش‌های مختلفی با داده‌ها تعامل داشته باشند. بیایید به مثالی با استفاده از یک عدد صحیح در فهرست ۴-۲ نگاه کنیم.

fn main() {
    let x = 5;
    let y = x;
}
Listing 4-2: اختصاص مقدار عدد صحیح متغیر x به y

ما احتمالاً می‌توانیم حدس بزنیم این کد چه می‌کند: “مقدار 5 را به x اختصاص بده؛ سپس یک کپی از مقدار x بگیر و آن را به y اختصاص بده.” اکنون دو متغیر داریم، x و y، و هر دو برابر 5 هستند. این دقیقاً همان چیزی است که اتفاق می‌افتد، زیرا اعداد صحیح مقادیر ساده‌ای با اندازه‌ی مشخص هستند، و این دو مقدار 5 به استک اضافه می‌شوند.

اکنون بیایید به نسخه String نگاه کنیم:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
}

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

به شکل ۴-۱ نگاه کنید تا ببینید که در پشت صحنه با String چه اتفاقی می‌افتد. یک String از سه بخش تشکیل شده است که در سمت چپ نشان داده شده‌اند: یک اشاره‌گر (Pointer) به حافظه‌ای که محتوای رشته را نگه می‌دارد، یک طول، و یک ظرفیت. این گروه داده‌ها روی استک ذخیره می‌شوند. در سمت راست، حافظه روی هیپ قرار دارد که محتوای رشته را نگه می‌دارد.

دو جدول: جدول اول نمایش s1 روی استک را نشان می‌دهد که شامل طول (۵)، ظرفیت (۵)، و اشاره‌گر (Pointer)ی به اولین مقدار در جدول دوم است. جدول دوم نمایش داده‌های رشته روی هیپ را بایت به بایت نشان می‌دهد.

شکل ۴-۱: نمایش در حافظه یک String که مقدار "hello" به s1 متصل است

طول مشخص می‌کند که محتوای String در حال حاضر چقدر حافظه به بایت استفاده می‌کند. ظرفیت مقدار کل حافظه‌ای است که String از تخصیص‌دهنده دریافت کرده است. تفاوت بین طول و ظرفیت اهمیت دارد، اما نه در این زمینه، بنابراین در حال حاضر می‌توان ظرفیت را نادیده گرفت.

وقتی s1 را به s2 اختصاص می‌دهیم، داده‌های String کپی می‌شوند، به این معنی که اشاره‌گر (Pointer)، طول، و ظرفیت موجود روی استک را کپی می‌کنیم. ما داده‌های روی هیپ را که اشاره‌گر (Pointer) به آن اشاره می‌کند، کپی نمی‌کنیم. به عبارت دیگر، نمایش داده‌ها در حافظه به شکل ۴-۲ به نظر می‌رسد.

سه جدول: جدول‌های s1 و s2 به ترتیب نمایش‌دهنده رشته‌ها روی استک هستند و هر دو به داده‌های رشته یکسان روی هیپ اشاره می‌کنند.

شکل ۴-۲: نمایش در حافظه متغیر s2 که یک کپی از اشاره‌گر (Pointer)، طول، و ظرفیت s1 دارد

نمایش داده‌ها به این شکل نیست که در شکل ۴-۳ آمده است، که نشان می‌دهد حافظه به گونه‌ای باشد که Rust همچنین داده‌های هیپ را کپی کند. اگر Rust این کار را انجام می‌داد، عملیات s2 = s1 می‌توانست از نظر عملکرد زمان اجرا بسیار گران باشد اگر داده‌های روی هیپ بزرگ بودند.

چهار جدول: دو جدول نمایانگر داده‌های استک برای s1 و s2 هستند و هر کدام به نسخه خود از داده‌های رشته روی هیپ اشاره می‌کنند.

شکل ۴-۳: یک امکان دیگر برای آنچه که s2 = s1 ممکن است انجام دهد اگر Rust داده‌های هیپ را نیز کپی کند

قبلاً گفتیم که وقتی یک متغیر از دامنه خارج می‌شود، Rust به طور خودکار تابع drop را فراخوانی می‌کند و حافظه هیپ را برای آن متغیر پاک‌سازی می‌کند. اما شکل ۴-۲ نشان می‌دهد که هر دو اشاره‌گر (Pointer) داده‌ها به یک مکان اشاره می‌کنند. این یک مشکل است: وقتی s2 و s1 از دامنه خارج می‌شوند، هر دو سعی می‌کنند همان حافظه را آزاد کنند. این به عنوان یک خطای آزادسازی دوباره شناخته می‌شود و یکی از مشکلات ایمنی حافظه است که قبلاً ذکر کردیم. آزادسازی حافظه دو بار می‌تواند منجر به خراب شدن حافظه شود، که به طور بالقوه می‌تواند منجر به آسیب‌پذیری‌های امنیتی شود.

برای اطمینان از ایمنی حافظه، پس از خط let s2 = s1;، Rust متغیر s1 را دیگر معتبر نمی‌داند. بنابراین، Rust نیازی به آزادسازی هیچ چیزی ندارد وقتی s1 از دامنه خارج می‌شود. بررسی کنید که وقتی سعی می‌کنید s1 را پس از ایجاد s2 استفاده کنید، چه اتفاقی می‌افتد؛ این کار جواب نمی‌دهد:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{s1}, world!");
}

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

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:15
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |
5 |     println!("{s1}, world!");
  |               ^^^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let s2 = s1.clone();
  |                ++++++++

For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

اگر اصطلاحات کپی سطحی و کپی عمیق را هنگام کار با زبان‌های دیگر شنیده‌اید، مفهوم کپی کردن اشاره‌گر (Pointer)، طول، و ظرفیت بدون کپی کردن داده احتمالاً شبیه به انجام یک کپی سطحی است. اما به دلیل اینکه Rust همچنین متغیر اول را نامعتبر می‌کند، به جای اینکه آن را کپی سطحی بنامند، به عنوان یک انتقال شناخته می‌شود. در این مثال، می‌توانیم بگوییم که s1 به s2 منتقل شده است. بنابراین، آنچه در واقع اتفاق می‌افتد در شکل ۴-۴ نشان داده شده است.

سه جدول: جدول‌های s1 و s2 که به ترتیب نمایش‌دهنده رشته‌ها روی استک هستند و هر دو به داده‌های رشته یکسان روی هیپ اشاره می‌کنند. جدول s1 خاکستری شده زیرا s1 دیگر معتبر نیست؛ تنها s2 می‌تواند برای دسترسی به داده‌های هیپ استفاده شود.

شکل ۴-۴: نمایش در حافظه پس از اینکه s1 نامعتبر شده است

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

علاوه بر این، یک انتخاب طراحی وجود دارد که از این نتیجه‌گیری می‌شود: Rust هرگز به طور خودکار “کپی عمیق” داده‌های شما را ایجاد نمی‌کند. بنابراین، هر گونه کپی خودکار می‌تواند به‌عنوان عملی ارزان از نظر عملکرد زمان اجرا در نظر گرفته شود.

دامنه و انتساب

عکس این رابطه بین دامنه‌بندی، مالکیت، و آزادسازی حافظه از طریق تابع drop نیز صحیح است. وقتی یک مقدار کاملاً جدید به یک متغیر موجود اختصاص می‌دهید، Rust تابع drop را فراخوانی می‌کند و حافظه مقدار اصلی را بلافاصله آزاد می‌کند. به این کد توجه کنید:

fn main() {
    let mut s = String::from("hello");
    s = String::from("ahoy");

    println!("{s}, world!");
}

ابتدا یک متغیر s را اعلان می‌کنیم و آن را به یک String با مقدار "hello" اختصاص می‌دهیم. سپس بلافاصله یک String جدید با مقدار "ahoy" ایجاد می‌کنیم و آن را به s اختصاص می‌دهیم. در این نقطه، هیچ چیزی به مقدار اصلی روی هیپ اشاره نمی‌کند.

یک جدول s که نمایانگر مقدار رشته روی استک است و به بخش دوم داده‌های رشته (ahoy) روی هیپ اشاره می‌کند، با داده‌های رشته اصلی (hello) که خاکستری شده زیرا دیگر نمی‌توان به آن دسترسی داشت.

شکل ۴-۵: نمایش در حافظه پس از اینکه مقدار اولیه به طور کامل جایگزین شده است.

رشته اصلی بلافاصله از دامنه خارج می‌شود. Rust تابع drop را روی آن اجرا می‌کند و حافظه آن بلافاصله آزاد می‌شود. وقتی مقدار را در انتها چاپ می‌کنیم، مقدار "ahoy, world!" خواهد بود.

تعامل متغیرها و داده‌ها با Clone

اگر بخواهیم داده‌های هیپ String را عمیقاً کپی کنیم، نه فقط داده‌های استک، می‌توانیم از یک متد معمول به نام clone استفاده کنیم. ما نحو متدها را در فصل ۵ بررسی خواهیم کرد، اما از آنجا که متدها یک ویژگی رایج در بسیاری از زبان‌های برنامه‌نویسی هستند، احتمالاً قبلاً آنها را دیده‌اید.

در اینجا یک مثال از روش clone در عمل آورده شده است:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();

    println!("s1 = {s1}, s2 = {s2}");
}

این کد به خوبی کار می‌کند و به وضوح رفتار نشان داده شده در شکل ۴-۳ را تولید می‌کند، جایی که داده‌های هیپ کپی می‌شوند.

وقتی یک فراخوانی به clone می‌بینید، می‌دانید که کدی دلخواه اجرا می‌شود و ممکن است این کد هزینه‌بر باشد. این یک شاخص بصری است که نشان می‌دهد چیزی متفاوت در حال رخ دادن است.

داده‌های فقط استک: Copy

یک نکته دیگر وجود دارد که هنوز درباره آن صحبت نکرده‌ایم. این کد که از اعداد صحیح استفاده می‌کند - بخشی از آن در لیستینگ ۴-۲ نشان داده شده است - کار می‌کند و معتبر است:

fn main() {
    let x = 5;
    let y = x;

    println!("x = {x}, y = {y}");
}

اما این کد به نظر می‌رسد با آنچه که به تازگی یاد گرفتیم تناقض دارد: ما یک فراخوانی به clone نداریم، اما x همچنان معتبر است و به y منتقل نشده است.

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

Rust دارای یک نشانه‌گذاری ویژه به نام ویژگی Copy است که می‌توانیم روی انواعی که روی استک ذخیره می‌شوند (مانند اعداد صحیح) اعمال کنیم (ما در فصل ۱۰ بیشتر درباره ویژگی‌ها صحبت خواهیم کرد). اگر یک نوع ویژگی Copy را پیاده‌سازی کند، متغیرهایی که از آن استفاده می‌کنند جابه‌جا نمی‌شوند، بلکه به سادگی کپی می‌شوند و پس از اختصاص به متغیر دیگری همچنان معتبر باقی می‌مانند.

Rust به ما اجازه نمی‌دهد یک نوع را با Copy نشانه‌گذاری کنیم اگر نوع یا هر یک از اجزای آن ویژگی Drop را پیاده‌سازی کرده باشند. اگر نوع به چیزی خاص نیاز داشته باشد تا زمانی که مقدار از دامنه خارج شود و ما ویژگی Copy را به آن نوع اضافه کنیم، یک خطای زمان کامپایل دریافت خواهیم کرد. برای یادگیری نحوه افزودن ویژگی Copy به نوع خود برای پیاده‌سازی این ویژگی، به “ویژگی‌های قابل اشتقاق” در ضمیمه ج مراجعه کنید.

پس، چه نوع‌هایی ویژگی Copy را پیاده‌سازی می‌کنند؟ می‌توانید برای اطمینان، مستندات نوع داده شده را بررسی کنید، اما به عنوان یک قانون کلی، هر گروه از مقادیر ساده و اسکالر می‌توانند ویژگی Copy را پیاده‌سازی کنند و هیچ چیزی که نیاز به تخصیص یا نوعی منبع داشته باشد نمی‌تواند ویژگی Copy را پیاده‌سازی کند. در اینجا تعدادی از انواعی که ویژگی Copy را پیاده‌سازی می‌کنند آورده شده است:

  • تمام انواع اعداد صحیح، مانند u32.
  • نوع بولی، bool، با مقادیر true و false.
  • تمام انواع اعشاری، مانند f64.
  • نوع کاراکتر، char.
  • تاپل‌ها، اگر تنها شامل انواعی باشند که ویژگی Copy را نیز پیاده‌سازی می‌کنند. برای مثال، (i32, i32) ویژگی Copy را پیاده‌سازی می‌کند، اما (i32, String) این کار را نمی‌کند.

مالکیت و توابع

مکانیزم‌های انتقال یک مقدار به یک تابع مشابه زمانی است که مقداری را به یک متغیر اختصاص می‌دهیم. انتقال یک متغیر به یک تابع به همان صورت که تخصیص انجام می‌شود، جابه‌جا یا کپی می‌شود. لیستینگ ۴-۳ مثالی با برخی حاشیه‌نویسی‌ها دارد که نشان می‌دهد متغیرها کجا وارد و از دامنه خارج می‌شوند.

Filename: src/main.rs
fn main() {
    let s = String::from("hello");  // s comes into scope

    takes_ownership(s);             // s's value moves into the function...
                                    // ... and so is no longer valid here

    let x = 5;                      // x comes into scope

    makes_copy(x);                  // because i32 implements the Copy trait,
                                    // x does NOT move into the function,
    println!("{}", x);              // so it's okay to use x afterward

} // Here, x goes out of scope, then s. But because s's value was moved, nothing
  // special happens.

fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{some_string}");
} // Here, some_string goes out of scope and `drop` is called. The backing
  // memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{some_integer}");
} // Here, some_integer goes out of scope. Nothing special happens.
Listing 4-3: توابع با مالکیت و دامنه حاشیه‌نویسی شده

اگر بخواهیم از s پس از فراخوانی به takes_ownership استفاده کنیم، Rust یک خطای زمان کامپایل صادر می‌کند. این بررسی‌های استاتیک ما را از اشتباهات محافظت می‌کنند. سعی کنید کدی به main اضافه کنید که از s و x استفاده کند تا ببینید کجا می‌توانید از آن‌ها استفاده کنید و کجا قوانین مالکیت مانع شما می‌شوند.

مقادیر بازگشتی و دامنه

بازگرداندن مقادیر نیز می‌تواند مالکیت را منتقل کند. لیستینگ ۴-۴ مثالی از یک تابع که مقداری را بازمی‌گرداند نشان می‌دهد، با حاشیه‌نویسی‌هایی مشابه آنچه در لیستینگ ۴-۳ وجود داشت.

Filename: src/main.rs
fn main() {
    let s1 = gives_ownership();         // gives_ownership moves its return
                                        // value into s1

    let s2 = String::from("hello");     // s2 comes into scope

    let s3 = takes_and_gives_back(s2);  // s2 is moved into
                                        // takes_and_gives_back, which also
                                        // moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
  // happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String {             // gives_ownership will move its
                                             // return value into the function
                                             // that calls it

    let some_string = String::from("yours"); // some_string comes into scope

    some_string                              // some_string is returned and
                                             // moves out to the calling
                                             // function
}

// This function takes a String and returns one
fn takes_and_gives_back(a_string: String) -> String { // a_string comes into
                                                      // scope

    a_string  // a_string is returned and moves out to the calling function
}
Listing 4-4: انتقال مالکیت مقادیر بازگشتی

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

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

Rust به ما اجازه می‌دهد مقادیر متعددی را با استفاده از یک tuple بازگردانیم، همانطور که در لیستینگ ۴-۵ نشان داده شده است.

Filename: src/main.rs
fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{s2}' is {len}.");
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String

    (s, length)
}
Listing 4-5: بازگرداندن مالکیت پارامترها

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