مکانیزم عملکرد متغیر
پردازنده بر روی مجموعهای از دادهها عملیات پردازشی انجام میدهد. این دادهها باید در جایی باشند که پردازنده بتواند به آن دسترسی داشته باشد؛ این مکان همان حافظۀ RAM در رایانه است؛ هرچه RAM ظرفیت بیشتری داشته باشد، تعداد دادههایی که پردازنده به آن دسترسی دارد بیشتر میشود.
هر برنامهای بخشی از حافظۀ RAM را اشغال میکند و در درون آن، Stack و Heap خود را میسازد. حال شما بهعنوان کدنویس چگونه میتوانید به بخش مورد نظر از حافظه دسترسی پیدا کنید؟ بله؛ درست حدس زدید! آدرس!
هر دادهای در بخشی از حافظه ذخیره میشود و آدرس مختص به خود را هم دارد. برای مثال اگر اولین بیت از RAM را بیت 0 در نظر بگیریم و دادۀ مور نظر ما درون بیت 10000 باشد یا از بیت 10000 شروع شود، آدرس دادۀ ما برابر با عدد 10000 خواهد بود و هرگاه به پردازنده بگوییم که دادۀ نقطۀ 10000 را بخوان و روی آن عملیاتی انجام بده، او دقیقا میداند که به کدام داده اشاره میکنید.
اما حفظکردن آدرس هر داده کاری سخت است. بهعلاوه، اصلاً نمیدانیم هر داده در کجای حافظه ذخیره شده است! ممکن است باری که برنامه اجرا میشود داده در آدرس 10000 قرا بگیرد و ممکن است در آدرس 125699856 قرار بگیرد!
پس ما نیاز به مکانیزمی داریم که این دو مشکل را برای ما حل کند؛ هم بتوانیم فارغ از اینکه دادۀ ما در کجای حافظه قرار دارد به آن دسترسی پیدا کنیم، و هم بتوانیم آدرس فعلی آن داده را در جایی نگهداری کنیم. این مکانیزم، همان «متغیر» در زبانهای برنامهنویسی است. متغیرها مکانیزمی هستند که کامپایلرها ارائه میدهند و خودشان آدرس مدنظر را بهجای نام متغیر جایگزین میکنند. سپس با سازوکار Virtual Memory آن آدرس را با نقطۀ واقعی حافظه پیوند میدهند. توضیح بیشتر این مسئله در حوصلۀ نوشتار جاری نیست، اما اجمالاً همینقدر را مدنظر داشته باشید که ما در واقع با آدرسها کار میکنیم نه با x و y و نام متغیرها؛ هرجایی که نام متغیر را دیدید، آن را آدرس دادۀ موجود در حافظه در نظر بگیرید.
ارتباط متغیرها با حافظۀ Stack و Heap
فرض کنید کامپایلر به این دستور در Rust میرسد:
let x = 3;
برنامه با رسیدن به این دستور، کد جدیدی تولید میکند که به اندازۀ 32 بیت از حافظه را سوا میکند و مقدار آن را برابر با 3 قرار میدهد (اعداد در Rust در حالت عادی از نوع i32 هستند). اما کدام حافظه؟ حافظۀ Stack. همانطور که در بخش Heap و Stack توضیح دادهایم، حافظۀ اصلی که در جریان دستورات برنامه مورد استفاده قرار میگیرد، حافظۀ Stack است و هر مقداری که در جریان دستورات مورد استفاده است، باید در Stack ذخیره شود.
کامپایلر Rust میداند که انواع عددی باید همگی درون Stack ذخیره شوند، اما برخی انواع هستند که در Heap ذخیره میشوند نه Stack. برای مثال اگر بهجای:
let x = 3;
بگوییم:
let x = Box::new(3);
مقدار 3 درون Heap ذخیره میشود نه Stack، و آنگاه آدرس این مقدار در Heap، وارد متغیر x میشود نه خود 3؛ خود متغیر x نیز در Stack قرار دارد.
بهعبارتدیگر این کد موجب میشود که در جایی در Heap مقدار 3 نوشته شود، و سپس درون Stack متغیری ایجاد شود با نام x، و آنگاه «آدرس» آنچه درون Heap است (نه خود مقدار درون Heap)، در x (که در Stack حضور دارد) نوشته شود.
حال وقتی در جریان دستورات برنامه بخواهیم از x استفاده کنیم، برنامه از Stack، آدرس موجود درون x را میخواند و سپس به آن نقطه از Heap میرود و مقدار 3 را ملاحظه میکند.
* البته Box چیزی فراتر از این است که در بحث اشارهگرهای هوشمند (Smart Pointers) باید بدان پرداخت.
تغییرناپذیری (immutability) و تغییرپذیری (mutability) متغیرها
متغیرها در Rust بر خلاف بسیاری از زبانهای دیگر، بهصورت پیشفرض immutable و تغییرناپذیر هستند. این یکی از ویژگیهای امنیتی مهم زبان راست است. اما چرا بهصورت پیشفرض چنین است؟
زبان Rust بسیار بر روی تصحیح خطاهای ناخواستۀ برنامهنویس و باگهای احتمالی تأکید دارد و توسعهدهندگان این زبان، تمرکز بسیار زیادی بر روی کامپایلر این زبان گذاشتهاند تا از همان ابتدا جلوی ایجاد خطا را بگیرند، بهجای اینکه در زمان اجرای برنامه بخشی از کارایی برنامه را فدای بررسی خطا بودن و خطا بودن دستورات کنند.
برای مثال در بحث جاری ما فرض کنید بخشی از کد ما برایناساس ساخته شده که مقدار یک متغیر هرگز تغییر نمیکند؛ حال اگر بعداً فراموش کرده باشیم و آن متغیر را تغییر دهیم، برنامه دچار باگ میشود. یافتن چنین تغییر ناخواستهای میتواند بهتناسب پیچیدگی کد، بسیار دشوار باشد. راست از همان ابتدا جلوی اینگونه خطاها را میگیرد.
اگر بخواهیم متغیری قابلتغییر داشته باشیم، از کلمۀ mut استفاده میکنیم.
let mut x = 3;
let mut y = Box::new(3);
یک گمان اشتباه دربارۀ متغیرها
برخی گمان میکنند علت اینکه متغیرها بهصورت پیشفرض بهصورت immutable هستند، مسئلۀ همزمانی است؛ یعنی چند رشتۀ پردازشی ممکن است به دادۀ مشترک واحد دسترسی بیابند و اگر آن داده mutable باشد، ناهماهنگی ایجاد میشود.
ولی این دو مسئله هیچ ربطی به هم ندارند!
اولاً دادههای مشترک تنها و تنها در Heap قرار میگیرند نه در Stack؛ حالآنکه مسئلۀ تغییرناپذیری متغیرها منحصر در Heap نیست و متغیرهای موجود در Stack نیز بهصورت پیشفرض، immutable هستند.
ثانیاً اساساً دادههای موجود در Heap اصلاً مسئلۀ تغییرپذیری و تغییرناپذیری در موردشان مطرح نیست تا بخواهیم از دسترسی چند Thread به آن سخن بگوییم! مهم این است که متغیری که در Stack ساخته میشود و آدرس بخشی از Heap را در خود دارد، mutable یا immutable باشد. شما «متغیر» را بهصورت immutable یا mutable تعریف میکنید نه «داده» را. داده ممکن است در Stack یا Heap باشد، اما متغیر همیشه در Stack است؛ چه این متغیر مقدار اصلی را در خود داشته باشد، چه آدرس به مقدار موجود در Heap را.
ثالثاً متغیر چه mutable باشد و چه immutable، همیشه مالکیت آن به رشتۀ پردازشی دوم منتقل میشود و وقتی رشتۀ دوم متغیر را به دست میآورد، رشتۀ اول دیگر دسترسی به مقدار آن متغیر نخواهد داشت. در نتیجه مسئلۀ تغییرپذیری هیچ ارتباطی با چندنخی ندارد.
تفاوت متغیرهای immutable و ثوابت (Consts)
خصوصاً اگر از سایر زبانها آمده باشید، احتمالاً برایتان سؤال شده که متغیرهای immutable چه تفاوتی با ثوابت در سایر زبانهای برنامهنویسی دارند؟ در پاسخ باید گفت که اتفاقاً ثوابت در Rust نیز حضور دارند و بااینحال مفهومی غیر متغیرهای immutable هستند.
- متغیرها همیشه در Stack ذخیره میشوند، اما ثوابت نه در Stack ذخیره میشوند و نه در Heap، بلکه در فضایی ذخیره میشوند با عنوان read-only data یا rodata. در نتیجه در تمام طول استفاده از برنامه در دسترس هستند.
- در ثوابت نیز یک نام داریم و یک مقدار. اما در هر دفعه که از آن نام استفاده میکنیم، اگرچه همان مقدار را به دست میآوریم، لیکن آدرس آن مقدار ممکن است در هر دفعه متفاوت باشد. مثلاً اگر متغیری را با ثابت مقداردهی کنیم، مقدار ثابت درون Heap یا Stack مربوط به آن متغیر کپی میشود.
- متغیرها توسط کلمۀ let تعریف میشوند؛ اما ثوابت با const.
- در ثوابت همواره الزامی است که نوع داده را مشخص کنیم، اما در متغیرها خود کامپایلر در بسیاری از جاها میتواند نوع را حدس بزند.
- ثوابت را میتوان در Global Scope (بیرون از هر تابعی) و در هر جایی تعریف کرد و محدود به همان محدوده از آن استفاده کرد، اما متغیرها را صرفاً در برخی محدودهها میتوان بهکار برد.
- «طول عمر (Lifetime)» ثوابت، ضوابط متفاوتی با متغیرها دارد. طول عمر، مفهومی خاص در زبان راست است.
- متغیرها را میتوان با متغیری دیگر مقداردهی کرد، اما ثوابت حتماً باید بهصورت مقداریِ محض مقداردهی شوند.
- ثوابت اساساً امکان تغییرپذیری ندارند، اما متغیرها میتوانند توسط کلمۀ mut تغییرپذیر شوند. این مسئله بهویژه هنگامی اثر خود را نشان میدهد که در نقطهای دیگر از برنامه، اشتباها نمیتوانیم ثابت را دستکاری کنیم، اما متغیر ممکن است اشتباها mutable تعریف شده باشد.
- برای جلوگیری از اشتباهات، استانداردی وجود دارد که همواره ثوابت را با حروف بزرگ نامگذاری میکنیم و متغیرها را با حروف کوچک.
- کاربرد const برای مقداردهیهای اولیه در زمان کامپایل است، اما مقدار متغیرها حتماً در زمان اجرا است که تعیین میشود.
- ثوابت از نظر معنایی همیشه ثابت هستند. برای مثال تعداد روزهای سال را میتوان در یک ثابت که در کل طول برنامه معتبر میماند ذخیره کرد، بهجای اینکه هر بار درون یک متغیر آن را ذخیره کنیم.
ممکن است چنین بیندیشید که ثوابت نمیتوانند نابود (Destruct) شوند، اما همانگونه که گفتیم مقدار ثوابت کپی میشود و آن مقدار کپیشده، مستعد نابودی است.
همچنین قابل ذکر است که مسئلۀ دیگری نیز وجود دارد با عنوان static که مشابه یا ثوابت عمل میکند، اما تفاوتهایی با آن دارد که باید در نوشتهای مقتضی به آن پرداخت.
تعریف متغیر
متغیرها با دستور let تعریف میشوند. شِمای کلی تعریف متغیر بدین نحو است:
let [mut] [_]var_name[: TYPE] [= value];
تعریف سادۀ یک متغیر:
let var;
تعریف با مشخصکردن نوع:
let var:i32;
let var2:Box<i32>;
تعریف با مشخصکردن نوع و مقدار:
let var:i32 = 5;
let var2:Box = Box::new(5);
راست در برخی انواع (نه همه) میتواند نوع را حدس بزند و در نتیجه نیازی به ذکر صریح نوع نیست:
let var = 5;
let var2 = Box::new(5);
در تمام موارد میتوان برای تعریف متغیر بهصورت mutable (قابلتغییر) از کلمۀ mut استفاده کرد. برای مثال:
let mut var = 5;
let mut var2 = Box::new(5);
امنیت و کارایی برای راست بسیار مهم است. در نتیجه اگر متغیری تعریف شود ولی هرگز از آن استفاده نشود، دچار اخطار (نه خطا) از سوی کامپایلر میشویم. برای رفع آن میتوان نام متغیر را با _ شروع کرد:
let _var = 5;
مقدار متغیر میتواند مقدار برگشتی یک بلوک باشد (دربارۀ مقدار برگشتی بلوک در مقالهای دیگر صحبت خواهیم کرد انشاءالله):
let var = {
5
} // var is 5 now
اگر دو بار یک متغیر را تعریف کنیم، اولی از بین میرود:
let var = 5;
let var = 10;
//اکنون فقط یک متغیر var وجود دارد با مقدار 10
حتی نوع متغیر دوم میتواند متفاوت باشد:
let var = 5;
let var = "string";
هنگام استفاده از متغیرها این نکات را مدنظر داشته باشید:
- نوع متغیر هرگز قابلتغییر نیست و نمیتوان پس از تعریف متغیر، مقداری از نوع دیگر به آن داد. به تعبیر دیگر Rust یک زبان Type Safe است.
- راست هرگز یک نوع را تبدیل به نوع دیگر نمیکند. به تعبیر دیگر، راست یک زبان Strongly Typed و Statically Typed است. برای مثال کد زیر کار نمیکند:
let x = 5 + "5";
let mut y = 5;
y = "5";
- مقداردهی پیاپی متغیرها مجاز نیست:
x = y = z; //خطا
- برخی از محدودیتهای زبان Rust توسط روش Unsafe قابلتغییر است؛ لذا آنچه در نوشتۀ جاری بدان اشاره شد، مربوط به وضعیت Safe است نه Unsafe.
- استاندارد نامگذاری متغیرها در Rust از استاندارد ماری (snake-case) تبعیت میکند. یعنی حروف کوچک، به همراه _ برای جداسازی کلمات. البته تخطی از این استاندارد ممکن است، اما دچار اخطارهای کامپایلر میشویم. راست علاقۀ وافری به این دارد که همۀ کدهایی که با آن نوشته میشوند، از نگارش مشابهی استفاده کنند تا خواندن کدها برای همه راحتتر باشد.
- متغیرها معمولاً تحتتأثیر قوانین مالکیت و قرضدادن (Ownership و Borrowing) هستند.
انواع متغیرها
بحث از انواع متغیرها در Rust، نوشتار مختص به خود را میطلبد. اما در اینجا صرفا به مقادیر Scalar در Rust اشاره میکنیم.
اعطای مقدار یک متغیر به متغیری دیگر
در زبانهای برنامهنویسی انواع مختلفی از متغیر وجود دارد که دادۀ هرکدام به شکل خاصی قابلیت انتقال به متغیری دیگر را دارند. این مسئله در زبان Rust نیز مورد استثنا نیست.
در نظر بگیرید:
let x = 5;
let y = x;
در اینجا عین مقدار x درون y کپی میشود.
حال در نظر بگیرید:
let x = Box::new(5);
let y = x;
حتی اگر x در درون Heap قرار بگیرد، باز هم مقدار x درون y کپی میشود؛ منتهی در اینجا باید توجه داشت که مقدار x در حقیقت آدرس Heap است نه خود دادۀ موجود در Heap. چرا؟ این بدین خاطر نیست که مقدار Box همواره در Heap قرار میگیرد، بلکه چون Box همانند مقادیر عددی محض، قابلیت کپیشدن را ندارد و نمیتواند مقدار درون خود را به متغیری دیگر بدهد. در نتیجه حتی اگر مقدار سمت راست در Stack هم ذخیره شود، فقط آدرس آن است که کپی میشود؛ لذا فرقی ندارد که همچون مثال بالا داده را در Heap نگهداری کنیم، یا همچون مثال پایین، داده را در Stack:
struct MyStruct {a: i32}
let x = MyStruct {a: 123};
let y = x;
در مثال بالا بهجای Box از struct استفاده کردیم؛ structها به طور طبیعی در Stack ذخیره میشوند، اما مقادیرشان قابل کپیشدن نیست، بلکه فقط آدرسشان است که کپی میشود. البته میتوان Struct را طوری نوشت که قابل کپیشدن باشد، اما این مسئله خارج از حوصلۀ نوشتار حاضر است و انشاءالله در بخش مناسب خود در ذیل traitهای Copy و Clone به آن خواهیم پرداخت.
در جاهایی که امکان کپیشدن مقدار وجود ندارد، قوانین مالکیت به میان میآیند؛ طبق قوانین مالکیت، یک مقدار نمیتواند دو مالک داشته باشد. در نتیجه یا آن مقدار چنین است که همیشه در متغیرهای مختلف کپی میشود، یا اینکه اگر آدرسش کپی میشود، هرگز دو متغیر نمیتوانند آدرس دادۀ واحد را داشته باشند.
البته مقادیر موجود در Heap چون همیشه با آدرس کار میکنند، همیشه قوانین مالکیت در موردشان جاری است، اما مقادیر موجود در Stack دودسته هستند؛ آنهایی که قابلیت کپی دارند، کپی میشوند و بهصورت خودبخودی هر مالک، یک کپی از آن را دارد. آنهایی که قابلیت کپی ندارند، همانند دادههای موجود در Heap تحت قوانین مالکیت قرار میگیرند.
خلاصه
در این مقاله با چگونگی عملکرد متغیرها در زبان Rust آشنا شدیم و دریافتیم که ارتباط متغیرها با Heap و Stack چیست. همچنین آموختیم که متغیرهای mutable و immutable چه هستند، و تفاوت میان متغیرهای immutable با ثوابت (Consts) را بیان نمودیم. همچنین دیدیم که چگونه میتوان متغیرها را تعریف و مقداردهی نمود و روشهای مختلف این کار کدام است.