مالکیت چیست؟
مالکیت مجموعهای از قوانین است که نحوه مدیریت حافظه را در برنامههای 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 }
به عبارت دیگر، در اینجا دو نقطهی مهم زمانی وجود دارد:
- وقتی
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; }
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) به حافظهای که محتوای رشته را نگه میدارد، یک طول، و یک ظرفیت. این گروه دادهها روی استک ذخیره میشوند. در سمت راست، حافظه روی هیپ قرار دارد که محتوای رشته را نگه میدارد.
شکل ۴-۱: نمایش در حافظه یک String
که مقدار "hello"
به s1
متصل است
طول مشخص میکند که محتوای String
در حال حاضر چقدر حافظه به بایت استفاده میکند. ظرفیت مقدار کل حافظهای است که String
از تخصیصدهنده دریافت کرده است. تفاوت بین طول و ظرفیت اهمیت دارد، اما نه در این زمینه، بنابراین در حال حاضر میتوان ظرفیت را نادیده گرفت.
وقتی s1
را به s2
اختصاص میدهیم، دادههای String
کپی میشوند، به این معنی که اشارهگر (Pointer)، طول، و ظرفیت موجود روی استک را کپی میکنیم. ما دادههای روی هیپ را که اشارهگر (Pointer) به آن اشاره میکند، کپی نمیکنیم. به عبارت دیگر، نمایش دادهها در حافظه به شکل ۴-۲ به نظر میرسد.
شکل ۴-۲: نمایش در حافظه متغیر s2
که یک کپی از اشارهگر (Pointer)، طول، و ظرفیت s1
دارد
نمایش دادهها به این شکل نیست که در شکل ۴-۳ آمده است، که نشان میدهد حافظه به گونهای باشد که Rust همچنین دادههای هیپ را کپی کند. اگر Rust این کار را انجام میداد، عملیات s2 = s1
میتوانست از نظر عملکرد زمان اجرا بسیار گران باشد اگر دادههای روی هیپ بزرگ بودند.
شکل ۴-۳: یک امکان دیگر برای آنچه که 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
که معتبر است، وقتی از دامنه خارج میشود، تنها آن حافظه را آزاد خواهد کرد و کار ما تمام است.
علاوه بر این، یک انتخاب طراحی وجود دارد که از این نتیجهگیری میشود: 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
اختصاص میدهیم. در این نقطه، هیچ چیزی به مقدار اصلی روی هیپ اشاره نمیکند.
شکل ۴-۵: نمایش در حافظه پس از اینکه مقدار اولیه به طور کامل جایگزین شده است.
رشته اصلی بلافاصله از دامنه خارج میشود. 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)
این کار را نمیکند.
مالکیت و توابع
مکانیزمهای انتقال یک مقدار به یک تابع مشابه زمانی است که مقداری را به یک متغیر اختصاص میدهیم. انتقال یک متغیر به یک تابع به همان صورت که تخصیص انجام میشود، جابهجا یا کپی میشود. لیستینگ ۴-۳ مثالی با برخی حاشیهنویسیها دارد که نشان میدهد متغیرها کجا وارد و از دامنه خارج میشوند.
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.
اگر بخواهیم از s
پس از فراخوانی به takes_ownership
استفاده کنیم، Rust یک خطای زمان کامپایل صادر میکند. این بررسیهای استاتیک ما را از اشتباهات محافظت میکنند. سعی کنید کدی به main
اضافه کنید که از s
و x
استفاده کند تا ببینید کجا میتوانید از آنها استفاده کنید و کجا قوانین مالکیت مانع شما میشوند.
مقادیر بازگشتی و دامنه
بازگرداندن مقادیر نیز میتواند مالکیت را منتقل کند. لیستینگ ۴-۴ مثالی از یک تابع که مقداری را بازمیگرداند نشان میدهد، با حاشیهنویسیهایی مشابه آنچه در لیستینگ ۴-۳ وجود داشت.
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 }
مالکیت یک متغیر همیشه از یک الگوی یکسان پیروی میکند: تخصیص یک مقدار به متغیر دیگر آن را جابهجا میکند. زمانی که یک متغیر شامل دادههایی در هیپ از دامنه خارج میشود، مقدار با استفاده از drop
پاکسازی میشود مگر اینکه مالکیت دادهها به متغیر دیگری منتقل شده باشد.
در حالی که این روش کار میکند، گرفتن مالکیت و سپس بازگرداندن آن با هر تابع کمی خستهکننده است. اگر بخواهیم اجازه دهیم یک تابع از یک مقدار استفاده کند اما مالکیت آن را نگیرد، چه میشود؟ این که هر چیزی که به تابع ارسال میکنیم باید بازگردانده شود تا بتوانیم دوباره از آن استفاده کنیم، علاوه بر هر دادهای که از بدنه تابع ممکن است بخواهیم بازگردانیم، کمی آزاردهنده است.
Rust به ما اجازه میدهد مقادیر متعددی را با استفاده از یک tuple بازگردانیم، همانطور که در لیستینگ ۴-۵ نشان داده شده است.
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) }
اما این کار بسیار رسمی و زمانبر است برای مفهومی که باید رایج باشد. خوشبختانه، Rust ویژگیای برای استفاده از یک مقدار بدون انتقال مالکیت دارد که ارجاعات نامیده میشود.