اشارهگرها در Rust
اکثر زبانهای برنامهنویسی امروزی دو روش برای دسترسی به دادههای موجود در حافظه ارائه میدهند؛ Stack و Heap. هر متغیری در Stack ذخیره میشود و مقداری که درون Stack برای آن وجود دارد، یا مقدار واقعی است، یا آدرس مقداری دیگر در درون حافظه.
در بخش Stack و Heap گفتیم که متغیری که دادهای که در Heap حضور دارد، همیشه آدرس آن در درون متغیرِ موجود در Stack قرار میگیرد و ما از آن طریق میتوانیم به آن دسترسی داشته باشیم.
حال میخواهیم اتفاق دیگری که میتواند رخ بدهد را بررسی کنیم؛ آدرس متغیر موجود در Stack!
آری؛ درست شنیدید! فرض کنید در Stack متغیری دارید که مقدار 5 را دارد:
let x = 5;
حال اگر بگویید:
let y = x;
در واقع مقدار x در y کپی شده است. در نتیجه اگر مقدار x را تغییر دهید، مقدار y همان 5 باقی میماند:
x = 10;
println!("y is {y}"); //y is 5
اما میتوان کاری کرد که y بهجای اینکه مقدار x را در خود کپی کند، حاوی آدرس x باشد تا هرگاه که مقدار x تغییر کند، y نیز به همان مقدار اشاره کند. دقت کنید در اینجا آدرس بخشی از حافظۀ Heap را به y نمیدهیم، بلکه آدرس یک متغیر موجود در Stack را به y دادهایم. نتیجه چنین میشود:
let x = 5;
let y = &x;
x = 10;
println!("y is {y}"); //y is 10
در این صورت متغیر y حاوی «آدرس» x است و در واقع اشارهگر (Pointer) به x خواهد بود.
حتی میتوان آدرس y را نیز به متغیری دیگر داد تا اشارهگر چندگانه تولید شود:
let z = &y;
در این صورت z آدرس y را دارد، y آدرس x را دارد و x مقدار نهایی را در خود دارد.
حتی میتوان آدرسِ آدرس داشت:
let a = &&x;
در این صورت آدرس x درون یک نقطه از حافظه ذخیره میشود (که برای آن نقطه، نام نداریم تا در قالب یک متغیر بهصورت مستقیم از آن استفاده کنیم) و سپس آدرس آن وارد a میشود. میتوان این سلسله را همینطور ادامه داد. اشارهگرهای چندگانه موارد مصرف خاص خود در مهندسی نرمافزار را دارند.
اشارهگرها میتوانند حتی به متغیری اشاره کنند که آدرس دادهای در Heap را در خود دارد. برای مثال:
let x = Box::new(5);
let y = &x;
در کد بالا آدرس دادۀ 5 در Heap ایجاد میشود و آدرس آن در x ذخیره میشود. آنگاه آدرس x در y ذخیره میشود. البته در مثال ما به y اشارهگر چندگانه نمیگویند (یکی به x و یکی به Heap) چراکه متغیری که مستقیما آدرس دادۀ موجود در Heap را دارد، عموما «اشارهگر» نمیخوانند؛ اشارهگر چیزی است که با & شروع شود.
در نتیجه چند نوع ذخیرهسازی داده وجود دارد:
- ذخیرهسازی مستقیم داده در Stack
- ذخیرهسازی داده در Heap و ذخیرۀ آدرس آن در Stack
- ذخیرهسازی آدرس دادهای که درون Stack است، در متغیری دیگر در Stack
- ذخیرهسازی آدرس متغیری که به دادهای درون Heap اشاره دارد و خود آن متغیر درون Stack است، در متغیر دیگری در Stack.
تفاوت اشارهگرها (Pointers) با ارجاعات (References) در Rust
از لحاظ منطقی هر متغیری که آدرس دادهای را در خود نگهداری کند، یک اشارهگر است؛ اما Rust از آنجا که به نحوۀ مدیریت حافظه بسیار اهمیت میدهد، تفاوتی بین این دو مفهوم ایجاد کرده است.
- Referenceها همان Pointerها هستند، منتهی در حالت Safe.
- Pointerها از طرف دیگر منحصر در فضای unsafe هستند.
آنچه ما در نوشتۀ جاری بدان میپردازیم، تماما در مورد Referenceها و ارجاعات است نه دربارۀ Pointerها یا اشارهگرها. اشارهگرها در فضای unsafe مطرح میشوند که موضوع نوشتار جاری نیست.
چگونه مقدار ارجاعات در Rust را بخوانیم؟
هنگام خواندن مقدار ارجاعات، این آدرس آنها نیست که خوانده میشود، بلکه مقدار نهایی داده است که خوانده میشود. این مسئله حتی در ارجاعات چندگانه نیز صادق است. کد زیر این مسئله را نشان میدهد:
let x = 5;
let y = &x;
let z = &y;
println!("{y}"); // 5
println!("{z}") ; // 5
کد بالا نشان میدهد که هنگام خواندن مقدار متغیری که آدرس دادهای دیگر در آن هست، مقدار دادۀ اصلی است که خوانده میشود نه آدرس آن. بهعبارتدیگر، هر اشارهگر در حالت طبیعی خود، یک «ارجاع» یا «Reference» است و عملیات «مراجعه» یا «Dereference» را خود Rust بهصورت خودکار انجام میدهد.
نوشتن در ارجاعات در Rust
ارجاعات نیز نوعی از متغیرها هستند و همچون همۀ متغیرها، تغییرپذیری و تغییرناپذیری دارند. اما باید توجه داشت که تغییرپذیری ارجاعات دارای دو جنبه است؛ یکی تغییر مقدار نهایی، و یکی تغییر آدرس متغیر. بهعبارتدیگر متغیری که آدرس نقطهای دیگر از Stack را در خود دارد، باری میتوان آدرس موجود در آن را تغییر داد تا به نقطهای دیگر ارجاع داشته باشد، و باری میتوان مقدار اصلی را تغییر داد. به این مثال توجه کنید:
let x = 5;
let y = &x;
در این مثال y حاوی آدرس x است. اما x از طریق y قابلتغییر نیست. برای مثال نمیتوانیم بگوییم:
y = 10;
و سپس توقع داشته باشیم که مقدار x نیز برابر با 10 شود.
برای اینکه یک دسترسی قابلتغییر به x پیدا کنیم بهطوریکه بشود از طریق y، مقدار x را تغییر داد، باید هم x بهصورت mutable باشد و هم ارجاعی که به x میدهیم mutable باشد. ارجاع متغیر توسط کلمۀ &mut ایجاد میشود.
let mut x = 5;
let y = &mut x;
در مثال بالا یک متغیر قابلتغییر داریم، و یک متغیر که حاوی آدرس x است منتهی با دسترسی تغییرپذیری. توجه کنید که y حاوی آدرس x است و این آدرسِ درون y هرگز قابلتغییر نیست. اما مقدار x را میتوان از طریق y تغییر داد. کافی است از عملگر * استفاده کنیم:
*y = 10;
در این هنگام مقدار درون x به 10 تغییر میکند. اگر علامت * را نمیآوردیم، کامپایلر گمان میکرد که میخواهیم آدرس موجود در درون y را تغییر دهیم و از آنجا که عدد 10 بیانگر آدرس نیست بلکه صرفا بیانگر یک عدد سادۀ 32 بیتی است، نمیتواند وارد y شود و در نتیجه به خطای کامپایلر میخوردیم. چرا؟ چون نوع عدد 10 با نوع ارجاع به عدد 10 متفاوت است؛ یکی i32 است و دیگری &i32. علامت * میگوید من با خود y کاری ندارم، بلکه مقدار داخلی آن را میخواهم. به این عملگر، dereferencer میگویند.
توجه داشته باشید که هنگام خواندن متغیر اشارهگر نیازی به * نیست؛ اما هنگام نوشتن نیازمند به آن هستیم.
حال دو حالت دیگر را نیز بیان میکنیم. این حالتها عبارتاند از:
let mut y = &x;
let mut y = &mut x;
خط اول اجازه میدهد تا آدرس موجود در y را با آدرسی دیگر جایگزین کنیم. مثلاً در دنباله بگوییم:
let z = 4;
y = &z;
اما اجازه نمیدهد که مقدار x را تغییر دهیم، حتی اگر x در حالت mutable باشد.
خط دوم اجازه میدهد که هم آدرس موجود در y را تغییر دهیم، و هم مقدار x را از طریق y.
در نتیجه ۴ حالت داریم:
let y = &x;
let y = &mut x;
let mut y = &x;
let mut y = &mut x;
- اولی دسترسی تغییرناپذیر به متغیر x را فراهم میآورد (چه x تغییرپذیر باشد یا نباشد)، و آدرس موجود در y نیز قابلتغییر نیست.
- دومی دسترسی تغییرپذیر به متغیر x را فراهم میآورد تنها درصورتیکه x هم تغییرپذیر باشد، ولی آدرس موجود در y قابلتغییر نیست.
- سومی دسترسی تغییرناپذیر به متغیر x را فراهم میآورد (چه خود x تغییرپذیر باشد چه نباشد)، ولی آدرس موجود در y قابلتغییر است.
- چهارمی دسترسی تغییرپذیر به متغیر x را فراهم میآورد تنها درصورتیکه خود x هم تغییرپذیر باشد، و آدرس موجود در y نیز قابلتغییر است.
* البته در مواردی خاص نیازی به mut بودن دادۀ اصلی نیست. این مسئله را در ذیل بحث Smart Pointers و ساختار RefCell پیگیری خواهیم کرد انشاءالله.
نوشتن از طریق ارجاعات زنجیرهای
حالت پنجمی نیز در اینجا وجود دارد. در نظر بگیرید:
let mut x = 5;
let y = &mut x:
let z = &y;
z = 10;
در اینجا دومرتبه پشتسرهم عمل dereference را انجام دادهایم. اولین دفعه به y رسیدیم و دومین دفعه به x. اما چون y در حالت mutable نیست، تغییر x از طریق آن ممکن نیست و لذا کد بالا خطا میدهد. بهعبارتدیگر اگرچه mutable بودن y صرفاً بر روی «آدرس» y اثرگذار است، اما y اگر mutable نباشد امکان تغییر «مقدار نهایی» از طریق آن وجود ندارد. این بدین خاطر است که mutable بودن همۀ متغیرهای مسیر دسترسی به دادۀ نهایی برای Rust مهم است و اگر اینگونه نباشد، امکان دسترسی چند متغیر immutable به یک متغیر mutable وجود خواهد داشت و این موجب نقض قوانین Borrowing میشود. لذا اگر کد بالا را اینگونه تصحیح کنیم مشکل حل خواهد شد:
let mut x = 5;
let mut y = &mut x:
let z = &mut y;
z = 10;
در کد بالا هم y را mutable کردیم و هم z را برابر با ارجاع mutable قرار دادیم. اکنون همۀ مسیر دسترسی به x باز است.
نوع متغیرهای ارجاعی
میدانید که ساختار اصلی تعریف متغیرها در Rust بدین شکل است:
let var: TYPE = value;
اما نوع دادهای یک متغیر که از نوع ارجاع باشد چیست؟ آری؛ &TYPE برای متغیرهایی که به دادهای immutable اشاره دارند، و &mut TYPE برای متغیرهایی که به دادههای mutable اشاره دارند.
let y: &i32 = &x;
let y: &mut i32 = &mut x;
در مثال بالا خود y چه mut باشد چه نباشد، تفاوتی به حال نوع آن ندارد. بهعبارتدیگر نوع متغیر ارجاعی، همواره برابر است با آدرسی به نوعی خاص.
همین مسئله را در اشارهگرهای چندگانه داریم که چون ترکیبات آن ممکن است بسیار پیچیده شود، تمرین آن را به خودتان واگذار میکنیم.
نتیجه
در این مقاله با ارجاعات و نحوۀ عملکرد آنها آشنا شدیم و دیدیم که چگونه میتوان متغیری از نوع ارجاع ساخت، مقدار آن را خواند و در آن نوشت. همچنین با ارتباط میان این نوع متغیرها با Stack و Heap مأنوس شدیم. اشارهای نیز به اشارهگرهای هوشمند (Smart Pointers) داشتهایم که موضوع مبحث مفصل دیگری است.