وبلاگ شخصی
محمّدرضا
علی حسینی

جایی که تجربیات, علایق
و چیزهایی که یادگرفته‌ام را
با هم مرور می‌کنیم.

آموزش زبان برنامه‌نویسی Rust - قسمت۷: مالکیت

آموزش زبان برنامه‌نویسی Rust – قسمت۷: مالکیت

حالا که با تمامی موارد پایه‌ای زبان Rust آشنا شدیم، زمان آن است که به مهم‌ترین و خاص‌ترین ویژگی این زبان بپردازیم. ویژگی‌ای که باعث می‌شود Rust از بسیاری از زبان‌های برنامه‌نویسی دنیا سریع‌تر باشد و با گارانتی کردن memory safety، آن را تبدیل به یکی از ایمن‌ترین زبان‌ها برای برنامه‌نویسی می‌کند.

مالکیت یا Ownership به زبان خیلی ساده

حافظه‌ای که می‌تواند در اختیار یک برنامه قراربگیرد محدود است. به علاوه مدیریت و دسترسی به حافظه می‌تواند هزینه‌بر باشد یا به خاطر استفاده‌ی ناایمن از آن کارکرد برنامه‌ها اشتباه شود.
تا قبل از Rust زبان‌های مختلف ۲ راه حل مختلف را برای مدیریت حافظه به کار می‌بردند:

۱-استفاده از garbage collector

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

۲-مدیریت دستی حافظه

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

مالکیّت چه مزیّت‌هایی دارد که اینقدر مهم است؟

همانطوری که خواهیم دید، بحث مالکیّت (ownership) برای ما memory safety را تضمین می‌کند. یعنی مطمئن خواهیم بود که اشاره‌گری نخواهیم داشت که قبلاً آزاد (free) شده باشد. به علاوه به خاطر ویژگی‌هایی که در این قسمت و قسمت آینده خواهیم دید، بسیاری از مشکلات مثل: استفاده از اشاره‌گری که دیگر معتبر نیست، استفاده از فایل/سوکت بسته شده یا فراموش‌کردن بستن آن‌ها و… هم دیگری رخ نخواهند داد. مشکلاتی که هرروز میلیون‌ها برنامه‌ی بزرگ و کوچک را با مشکل روبه‌رو می‌کنند.
همچنین به خاطر شیوه‌ی کاری آن، هیچ‌گونه سرباری ایجاد نمی‌شود و برنامه‌ی ما به سرعت برنامه‌های نوشته‌شده با زبان‌هایی که مدیریت حافظه‌ی دستی دارند اجرا خواهد شد.

قبل از اینکه به خود مالکیّت بپردازیم، اوّل از همه باید دو مفهوم بنیادی در کامپیوتر را با هم مرور کنیم.

Stack و Heap

احتمالاً شما هم مانند بقیه‌ی برنامه‌نویس‌ها میلیون‌ها بار در زندگیتان این دو اسم‌را شنیده‌اید. اسامی‌ای که تکرار زیادشان گاهی اوقات باعث می‌شود که از معانی واقعی آن‌ها غافل بشویم.
در این بخش عمداً کلمات Stack و Heap را ترجمه نمی‌کنم، چون به نظر این کار تنها فهم نوشته‌را سخت‌تر می‌کند.
اگر روزانه با زبان‌هایی مثل: پایتون، جاوااسکریپت و بقیه‌ی زبان‌های سطح بالا سر و کار دارید، احتمالاً اهمّیّتی برای این دو مفهموم قائل نیستید. در برنامه‌ای که به زبان پایتون نوشته شده، مهم نیست که متغیّر یا Object در کجا ذخیره شده اند، به هر حال interpreter یکجوری همه‌چیز را مدیریت می‌کند.
امّا وقتی داریم با یک زبان برنامه‌نویسی سیستم مثل c یا Rust کار می‌کنیم قضیه فرق می‌کند. در اینجا اینکه متغیّر در Stack ذخیره شده است یا Heap می‌تواند نحوه‌ی رفتار زبان، شیوه‌ی تفکّر برنامه‌نویس و کارایی برنامه‌را تعیین کند.
بخش‌هایی از مفهوم مالکیّت هم مستقیماً به مفاهیم Stack و Heap مربوط می‌شود. پس بهتر است با هم این دو مفهوم را مرور کنیم.
Stack و Heap هر دو بخش‌هایی از حافظه هستند که هنگام اجرا در اختیار برنامه قرار می‌گیرند. امّا شیوه‌ی کاری آن‌ها متفاوت است.
داده‌ها در Stack به صورت first in, last out ذخیره می‌شوند. یعنی آخرین داده‌ای که در Stack قرار می‌گیرد، اوّلین داده‌ای است که از آن خارج می‌شود.
اگر هنوز دچار آلزایمر نشده‌اید باید استوانه‌هایی‌را که در‌ آن‌ها CDهارا نگه می‌داشتیم به خاطر بیاورید (چون افرادی که CD را به خاطر نمی‌آورند بعید است که الان در سن یادگیری زبان Rust باشند). چیزی شبیه شکل زیر:

جا cd عمودی که مثل stack عمل می‌کند

اگر ما هر CD را یک شی درنظر بگیریم که باید در حافظه ذخیره شود، این نگهدارنده دقیقاً مانند یک Stack کار می‌کند.

وقتی که می‌خواهیم یک CD را به مجموعه اضافه کنیم آن‌را روی همه قرار می‌دهیم، امّا وقتی که می‌خواهیم یک CD را از مجموعه برداریم رویی‌ترین CD را برمی‌داریم. یعنی اولین CDای که به این مجموعه اضافه شده است آن پایین پایین قرار دارد و آخرین CDای است که از نگهدارنده خارج می‌شود.
استفاده از Stack سریع است چون برای اضافه کردن یا برداشتن از آن لازم نیست جست‌و‌جو بکنیم. به‌علاوه اندازه‌ی Stack همیشه مشخص است.
این از Stack، حالا ببینیم Heap این وسط چه کار می‌کند. نحوه‌ی استفاده از Heap متفاوت است و مثل Stack ساخت‌یافته نیست. وقتی که می‌خواهیم داده‌ای را به Heap اضافه کنیم، ابتدا از سیستم عامل درخواست می‌کنیم تا فضایی‌را در اختیارمان قرار بدهد. سیستم عامل هم در جایی از حافظه یک بخش به اندازه‌ی کافی بزرگ‌را جدا می‌کند و یک اشاره‌گر (pointer) به آن فضا را بر می‌گرداند.
این فرآیند زمان‌بر و مشکل‌زا است. برای اینکه یک فضا به ما اختصاص داده شود ابتدا باید یک system call زد. یعنی اجرای برنامه متوقف شود، cpu در اختیار سیستم عامل قرار بگیرد تا فضای مورد نیاز را در حافظه پیدا کند و به برنامه اختصاص دهد، سپس دوباره cpu در اختیار برنامه قرار بگیرد.
به علاوه چون برخلاف Stack مکان حافظه مشخص نیست و کنار دیگر داده‌ها قرار ندارد، دسترسی به آن هم زمان بیشتری می‌گیرد.
همچنین مشکلات زیادی ممکن است پیش بیاید: یک برنامه ممکن است حافظه‌هایی که به آن اختصاص داده شده‌اند را آزاد نکند و باعث شود دیگر حافظه‌ای برای دیگر برنامه‌ها باقی نماند. ممکن است در یک برنامه حافظه‌ی اختصاص داده شده آزاد شود، امّا بعد از مدّتی به علّت اشتباه برنامه‌نویس دوباره به آنجا مراجعه شود یا ممکن است صدها مشکل دیگر به وجود بیاید.
ما زمانی از Heap استفاده می‌کنیم که اندازه‌ی داده‌ها مشخص نیست یا اندازه‌ی آن به مرور زمان تغییر می‌کند. در باقی موارد، مثل زمانی که یک تابع فراخوانی می‌شود، از Stack استفاده می‌کنیم.

خب حالا که دوباره یادمان آمد که Heap و Stack با هم چه فرقی می‌کنند، برویم سر اصل مطلب. مالکیّت (Ownership) در Rust چطوری به داد ما می‌رسد؟

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

در ابتدای بخش قبلی گفتیم که مالکیّت در Rust برپایه‌ی چند قانون ساده است. این ۳ قانون خیلی ساده باعث به وجود آمدن این‌همه ویژگی فوق‌العاده در Rust شده اند:
۱. برای هر مقدار، یک متغیّر وجود دارد که مالک (Owner) آن مقدار نامیده می‌شود.
۲. هر مقدار در یک لحظه تنها می‌تواند یک مالک داشته باشد.
۳. وقتی که مالک یک مقدار از scope خارج می‌شود، آن مقدار هم از بین می‌رود.
دیدید چقدر ساده بود؟ حالا ببینیم که در عمل هرکدام از این قوانین چه معنایی می‌دهند. بیاید اوّل از قانون آخر شروع کنیم.
ولی قبل از آن باید ببینیم که دقیقاً منظور از Scope یک مقدار چیست:

قلمرو یک مقدار(Value Scope)

(از اینجا به بعد از همان کلمه‌ی Scope به جای «قلمرو» استفاده می‌کنم، چون به نظرم استفاده از معادل فارسی آن باعث سردرگمی می‌شود.)
Scope یک مقدار، ناحیه‌ای از کد است که آن مقدار در آن ناحیه معتبر (valid) است. یعنی وقتی از آن بخش از کد خارج می‌شویم دیگر آن مقدار وجود ندارد.
بدنه‌ی تابع یا بخش‌هایی از کد که با { و } از باقی بخش‌ها جداشد‌ه‌اند، هرکدام یک Scope جدا محسوب می‌شوند.
بیایید نگاهی به یک مثال بیندازیم. برنامه‌ی زیر را درنظر بگیرید:

 

fn main() {
    let local_variable = "این یک متغیر محلی است و خارج از تابع main قابل دسترس نیست.";
    println!("{}", local_variable);
    another_function();
}
fn another_function() {
    println!("{}", local_variable);   // Error!
}

اگر این برنامه‌را کامپایل کنیم، با پیام ارور زیر مواجه می‌شویم:

error[E0425]: cannot find value `local_variable` in this scope
 --> src/main.rs:8:20
  |
8 |     println!("{}", local_variable);   // Error!
  |                    ^^^^^^^^^^^^^^ not found in this scope

امیدوارم به اندازه‌ی من از دیدن این پیام خطای دقیق هیجان‌زده شده باشید. همانطوری که به زبان انگلیسی سلیس در پیام خطا نوشته شده است، متغیّر local_variable اصلاً درون Scope تابع another_function قرار ندارد.
این متغیّر درون تابع main تعریف شده است و شما نمی‌توانید درون توابع دیگر به آن دسترسی داشته باشید.
اگر قسمت آموزش مربوط به متغیّرها و ثوابت را به خاطر دارید (اگر هم ندارید، با کلیک روی این نوشته خیلی سریع همه‌ی مطالب آن قسمت را مرور کنید) ، حتماً یادتان هست که گفتیم می‌توان ثوابت را درون Scope جهانی (Global) تعریف کرد.
حالا بیایید یک نگاهی به این حالت هم بیندازیم:

const GLOBAL_CONSTANT: &str = "این یک مقدار Global است و همه‌جا می‌توان به آن دسترسی داشت";
fn main() {
    println!("{}", GLOBAL_CONSTANT);
    another_function();
}
fn another_function() {
    println!("Now in the another_function: {}", GLOBAL_CONSTANT);
}

حالا اگر این برنامه‌را اجرا کنیم همه‌چیز به‌خوبی پیش می‌رود:

این یک مقدار Global است و همه‌جا می‌توان به آن دسترسی داشت
Now in the another_function: این یک مقدار Global است و همه‌جا می‌توان به آن دسترسی داشت

همانطوری که دیدیم هر مقداری یک Scope دارد که تنها در آن Scope تعریف‌شده است و می‌توان از آن استفاده کرد. با خارج شدن از Scope، متغیّر دیگر در دسترس نیست.
خب حالا که با مفهوم Scope در Rust آشنا شدیم، که البته با تعریف Scope در اکثر زبان‌های دیگر یکی است، زمان آن است که بیشتر وارد جزئیات مالکیت (Ownership) بشویم.
مثل بخش‌های قبلی دوباره باید یک مقدّمه‌ی کوچک‌را ببینیم و یک آشنایی خیلی کوچک با نوع داده‌ی String پیدا کنیم. نگران کلّی بودن این آشنایی نباشید، در قسمت‌های بعدی هر بخش‌را با جزئیات کامل بررسی می‌کنیم.

آشنایی با String

ما تا اینجای کار با نوع str کار می‌کردیم. اندازه‌ی string های ما از قبل مشخص بودند و مقادیرشان در برنامه Hard code شده بودند. این نوع زیاد کاربردی نیست، چون اکثر مواقع string نامشخص است و از طرف کاربر گرفته می‌شود.
برای کار با رشته‌ (string) هایی که اندازه و مقدارشان مشخص نیست باید از نوع String استفاده کرد. داده‌هایی که از نوع String هستند مستقیماً در Heap ذخیره می‌شوند و مقدار و اندازه‌شان می‌تواند در طول اجرای برنامه تغییر کند.
برای ساخت یک متن از نوع String به شکل زیر عمل می‌کنیم:

let my_string = String::from("یک رشته جدید.");

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

fn main() {
    let mut my_string = String::from("یک رشته جدید");
    my_string.push_str(" که این متن به انتهایش اضافه شده است.");
    println!("{}", my_string);
}

اجرای این برنامه خروجی زیر را تولید می‌کند:

یک رشته جدید که این متن به انتهایش اضافه شده است.

در این حالت به اندازه‌ی داده‌ی جدید، فضای جدید به این مقدار اختصاص‌داده می‌شود و رشته‌ی جدید به رشته‌ی قبلی اضافه می‌گردد.
فقط حواستان باشد که زمانی که می‌خواهیم مقدار رشته‌ی اوّلیّه را تغییر بدهیم، باید آن را به صورت mutable تعریف کرده باشیم.
خب الان به اندازه‌ی کافی درمورد نوع String می‌دانیم. حالا دوباره برویم سراغ همان مبحث مالکیّت.

مالکیّت چطوری حافظه‌را مدیریت می‌کند؟

برای اینکه بتوانیم یک string قابل گسترش داشته باشیم، باید آن را درون heap ذخیره کنیم. امّا همانطوری که اوایل همین نوشته دیدیم، هربار که حافظه‌ای در heap به برنامه اختصاص داده می‌شود، باید پس از اتمام کار با آن شی حافظه‌را آزاد کرد.
باز هم همانطوری که دیدیم Rust نه از garbage collector استفاده می‌کند و نه در آن لازم است تا برنامه‌نویس به صورت دستی حافظه‌را مدیریت کند.
در Rust هنگامی که متغیّری که مالک آن بخش حافظه حساب می‌شود از scope خارج می‌شود، به صورت خودکار یک تابع مخصوص فراخوانی می‌شود و آن بخش حافظه‌را آزاد می‌کند.
بگذارید با کد جلو برویم. برنامه‌ی زیر را ببینید و کامنت‌های آن را با دقت بخوانید:

fn main() {
    {
            let my_variable = String::from("این متن درون heap ذخیره می‌شود");
    // End of 'my_variable' scope. compiler adds drop() function here automatically in compile time.
    }
    // Here 'my_variable' is unknown. If you try to use it, an error will raise in compile time.
}

ما درون تابع main با گذاشتن {} یک scope جدید تعریف کرده‌ایم. درون این scope یک String را به متغیّر my_variable ارجاع داده‌ایم. از این لحظه به بعد متغیّر my_variable مالک (owner) آن بخشی از حافظه که String ما را نگهداری می‌کند حساب می‌شود.
وقتی که به { می‌رسیم، تابع drop برای این متغیّر فراخوانی می‌شود و حافظه‌ای که به آن ارجاع‌داده‌شده بود را آزاد می‌کند.
این‌طوری نه دیگر لازم است که ما نگران مدیریت حافظه باشیم، و نه سربارهای ناشی از garbage collector را داریم. چون خود کامپایلر کدی‌را که مستقیماً حافظه‌را آزاد می‌کند فراخوانی می‌کند و دیگر لازم نیست تا وضعیت متغیّرهای برنامه‌را در ساختمان‌داده‌های مختلف نگهداری کنیم.
به علاوه دیگر نیازی به اجرای برنامه‌های اضافی برای پیداکردن حافظه‌هایی که دیگر از آن‌ها استفاده نمی‌شود نداریم.
اینطوری هم وظایف برنامه‌نویس کاهش می‌یابد و احتمال خطای انسانی کم می‌شود، هم بدون ایجاد سربار برنامه با سرعت زیاد اجرا می‌شود و ایمنی حافظه تضمین می‌شود.
کار با حافظه در heap مشکلات زیادی را در زبان‌های مثل c ایجاد می‌کرد. حالا می‌خواهیم ببینم که Rust هرکدام از آن مشکلات‌را چطوری حل کرده است و مفاهیم خیلی مهمی را که درمورد شیوه‌ی عملکرد مالکیّت (Ownership) وجود دارد را یاد بگیریم.
پس بیایید اوّل ببینیم که اشاره‌گر (pointer) ها در Rust چه شکلی هستند.

اشاره‌گرها در Rust

کد قبلی را نگاه کنید. در آن کد my_variable در حقیقت دارد به جایی که String در heap ذخیره‌شده است اشاره می‌کند. چیزی که به عنوان my_variable در stack ذخیره می‌شود این شکلی است:

 پوینتر در زبان Rust

Ptr آدرس ابتدای داده‌ی اصلی‌را در heap نشان می‌دهد. Len طول فعلی این string است و نشان می‌دهد چند byte داده برای نگهداری این string استفاده شده است (حروف فارسی در فرمت utf-8 نگهداری می‌شوند و بیشتر از ۱ بایت فضا برای نگهداری آن‌ها نیاز است. اینجا برای سادگی هر حرف فارسی و انگلیسی را ۱ بایت درنظر گرفتم).
مقدار capacity نشان‌دهنده‌ی میزان فضایی است که سیستم‌عامل به ما برای نگهداری این مقدار اختصاص داده است. این مقدار ممکن است با len متفاوت باشد. فعلاً از این مقدار می‌گذریم تا سر فرصت درموردش صحبت کنیم. مقدار capacity هم مثل len با byte اندازه‌گیری می‌شود.
فعلاً دانستن همین‌قدر درمورد اشاره‌گرها کافی است.

Move

اوّل از همه کد زیر را ببینید:

fn main() {
    let a = 10;
    let b = a;
    println!("a: {}", a);
    println!("b: {}", b);
}

انتظار داریم که این برنامه بدون مشکل اجرا شود و خروجی زیر را بدهد:

a: 10
b: 10

خب این اتفاق واقعاً می‌افتد. نوع داده‌ i32 ساده است و درون stack ذخیره می‌شود. یعنی پس از اجرای این برنامه یک بار مقدار ۱۰ برای متغیّر a و یک بار هم برای متغیّر b درون stack قرار می‌گیرد.
حالا به کد زیر با دقّت نگاه کنید. انتظار دارید چه خروجی‌ای بدهد؟

fn main() {
    let a = String::from("hello");
    let b = a;
    println!("a: {}", a);
    println!("b: {}", b);
}

انتظار دارید که این برنامه هم بدون مشکل اجرا شود؟ متأسفم. 🙂 وقتی این برنامه‌را کامپایل کنید با خطای زیر روبه‌رو می‌شوید:

error[E0382]: use of moved value: `a`
 --> src/main.rs:4:23
  |
3 |     let b = a;
  |         - value moved here
4 |     println!("a: {}", a);
  |                       ^ value used here after move
  |
  = note: move occurs because `a` has type `std::string::String`, which does not implement the `Copy` trait

امّا چرا اینطوری شد؟ وقتی که b = a را اجرا می‌کنیم، اتّفاقی که می‌افتد این شکلی است (این تصویر را از اینجا برداشته‌ام):

کپی شدن پوینتر در Rust

 

برخلاف مثال قبلی که عدد ۱۰ واقعاً کپی می‌شود و ما درون stack دوتا عدد ۱۰ داریم، اینجا فقط اشاره‌گر (pointer) کپی می‌شود. یعنی داده‌ای که درون heap قرار دارد کپی نمی‌شود. به این اتّفاق در Rust عمل move می‌گویند.
دلیل این اتّفاق این است که کپی کردن داده‌ی درون heap زمان‌بر است. باید دوباره به سیستم‌عامل درخواست اختصاص‌دادن فضا داده شود، بعد که فضا اختصاص داده شد، داده‌ی اکثراً حجیم قبلی از درون heap خوانده شود و دوباره روی مکان جدید که باز هم درون heap قرار دارد نوشته شود.
حالا شاید از خودتان بپرسید که کپی نشدن داده چه مشکلی ایجاد می‌کند که کامپایلر جلوی کامپایل شدن برنامه را می‌گیرد؟
دلیلش واضح است، امّا نمی‌توان در نگاه اوّل آن را فهمید.
همانطوری که دیدیم کامپایلر برای آزاد کردن حافظه آخر scope تابع drop را فراخوانی می‌کند. پس اینجا یکبار تابع drop برای آزاد کردن فضای اختصاص داده شده به متغیّر a فراخوانی می‌شود و یک بار هم برای آزادسازی فضای اختصاص‌داده شده به متغیّر b. ولی وقتی می‌خواهد فضای اختصاص‌داده‌شده به متغیّر b را آزاد کند، از آنجایی که آن فضا یک بار قبل از این آزاد شده است، اتفاقات پیش‌بینی نشده‌ای می‌افتد.
ولی این تنها مشکلی نیست که می‌تواند رخ بدهد. برنامه‌ی ساده‌ی زیر را به زبان c درنظر بگیرید:

#include <stdio.h>

void writer1(FILE* filePointer) {
    fprintf(filePointer, "Some text.\n");
    fclose(filePointer);
}

void writer2(FILE* filePointer) {
    fprintf(filePointer, "Some other text.");
}

int main() {
    FILE * file = fopen("example.txt", "a");
    writer1(file);
    writer2(file);
    fclose(file);
}

این برنامه چه کار می‌کند؟ ما دو تا تابع داریم. یکی writer1 و یکی هم writer2. هرکدام از این دو تابع یک پوینتر به یک فایل بازشده را می‌گیرند و متن خودشان را درون آن می‌نویسند.
حالا درون تابع main، جایی که اجرای برنامه از آن‌جا شروع می‌شود، ما فایل‌را در مود append باز می‌کنیم (در این حالت اگر فایل وجود نداشته باشد ساخته می‌شود و اگر بخواهیم درون آن بنویسیم محتوای قبلی پاک نمی‌شود، بلکه داده‌ی جدید به انتهای آن افزوده می‌شود) و دو تابع‌را فراخوانی می‌کنیم. آخر سر هم فایل‌را می‌بندیم.
اگر این برنامه‌را کامپایل و اجرا کنیم، می‌بینیم که در محل اجرا یک فایل به نام example.txt اضافه شده است. حالا داخل این فایل چی قرارگرفته است؟

Some text.

همانطوری که می‌بینید تابع write2 متن خودش‌را داخل فایل ننوشته است، چون به اشتباه، برنامه‌نویس فایل‌را درون تابع write1 بسته است.
حالا دوباره برنامه‌ای که به زبان Rust نوشته بودیم را ببینید:

fn main() {
    let a = String::from("hello");
    let b = a;
    println!("a: {}", a);
    println!("b: {}", b);
}

در اینجا هم ممکن است مشکلی مشابه پیش بیاید. به همین دلیل کامپایلر، وقتی که یک مقدار را move می‌کنیم و به یک متغیّر جدید نسبت می‌دهیم، دیگر اجازه‌ی استفاده از متغیّر اوّل را نمی‌دهد (اگر چند خط صبرکنید می‌بینید که پیاده‌سازی تابعی مشابه کد cای که دیدیم چطوری در Rust مدیریت می‌شود).
در حقیقت وقتی که ما move انجام می‌دهیم، دیگر متغیّر اول که مالکیّت داده‌اش را انتقال‌داده است (move کرده) معتبر نیست و حق نداریم از آن استفاده کنیم. مثل اینکه شما ماشینتان‌را بفروشید و بعد از آن دوباره بخواهید سوار همان ماشین شوید. مطمئناً اجازه‌ی این کار را پیدا نمی‌کنید.
تنها حالت ممکن این است که متغیّر اوّلی که طی عمل move مقدارش منتقل می‌شود از نوع mutable باشد و بعد از آن یک مقدار جدید بگیرد. یعنی وقتی ماشینتان‌را فروختید، یکی دیگر می‌خرید و سوار آن می‌شوید (اگر مباحث مربوط به mutable و immutable بودن را به خاطر ندارید با کلیک روی این نوشته به آموزش مربوط به آن بروید و خیلی سریع این مبحث کلیدی را یادبگیرید).

چه مقادیری کپی می‌شوند و چه مقادیری move ؟

حالا قبل از اینکه به کاربرد مالکیّت در توابع بپردازیم، بیایید یک مرور خیلی سریع بکنیم که چه مقادیری واقعاً کپی می‌شوند و چه مقادیری، وقتی به یک متغیّر جدید assign می‌شوند، move می‌شوند؟
تمامی مقادیر عددی صحیح و اعشاری، مقادیر boolean و کاراکترها کپی می‌شوند (برای آشنایی با مقادیر عددی روی این نوشته و برای آشنایی با آرایه‌ها، تاپل‌ها و مقادیر boolean روی این نوشته کلیک کنید). به‌علاوه تمامی tuppleهایی که شامل typeهای بالا می‌شوند هم کپی می‌شوند.
تمام چیزهایی که باقی‌مانده‌اند move خواهند شد. مگر اینکه به شیوه‌ای که بعداً با هم می‌بینیم به عنوان نوع داده‌ای که قابل کپی شدن است معرفی شوند.
خب برویم تا بخش آخر این قسمت‌را ببینیم:

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

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

fn main() {
    let a = String::from("hello");
    i_am_owner(a);
    println!("a in main function: {}", a);
}
fn i_am_owner(input: String) {
    println!("The input value is: {}", input);
}

اگر بخواهیم این کدرا کامپایل کنیم، مثل کد قبلی با ارور زیر روبه‌رو می‌شویم:

error[E0382]: use of moved value: `a`
 --> src/main.rs:4:40
  |
3 |     i_am_owner(a);
  |                - value moved here
4 |     println!("a in main function: {}", a);
  |                                        ^ value used here after move
  |
  = note: move occurs because `a` has type `std::string::String`, which does not implement the `Copy` trait

دیدید؟ درست مثل بخش قبلی اینجا هم مالکیّت به آرگومان ورودی تابع منتقل می‌شود و دیگر متغیّر اوّلی مالک آن String محسوب نمی‌شود.
خب حالا شاید بگویید من به String خودم بعد از اجرای تابع احتیاج دارم. اینطوری که دیگر نمی‌توانم از آن استفاده کنم. برای این هم راه حلی هست.
همانطوری که فراخوانی یک تابع مالکیّت ورودی‌ها را به آن می‌سپارد، return کردن از آن هم مالکیّت داده را به کسی‌ (متغیّری) که آن مقدار را می‌گیرد منتقل می‌شود.
همان برنامه‌ی قبلی‌را با همین تغییری که گفتم ببینید:

fn main() {
    let mut a = String::from("hello");
    a = i_am_owner(a);
    println!("a in main function: {}", a);
}
fn i_am_owner(input: String) -> String {
    println!("The input value is: {}", input);
    return input;
}

اینجا چندتا تغییر داده‌ایم. اوّل از همه متغیّر a را mutable کردیم تا بتوانیم دوباره به آن مقدار بدهیم.
بعد مقدار متغیّر a را برابر با خروجی تابع i_am_owner گذاشتیم.
حالا داخل همین تابع در پایان کار مقدار input را خروجی داده‌ایم. یعنی همان مقداری که از a گرفتیم را برگرداندیم تا دوباره داخل خودش ذخیره کنیم (همانطوری که بالاتر دیدیم در حقیقت خود داده جابه‌جا نمی‌شود و صرفاً pointer این وسط تغییر می‌کند، پس سربار خیلی کمی خواهیم داشت).
حالا اگر بخواهیم داخل scope متغیّر a همچنان از آن استفاده کنیم دیگر مشکلی نخواهیم داشت. پس برنامه به خوبی اجرا می‌شود و خروجی زیر را به ما می‌دهد:

The input value is: hello
a in main function: hello

انتقال مقدار بدون انتقال مالکیّت

راه حل مواجهه با انتقال مالکیّت (Ownership) هنگام فراخوانی تابع‌را دیدم. ولی آیا امکان دارد که بدون اینکه مالکیّت مقدارمان‌را منتقل کنیم، از آن داخل یک تابع استفاده کنیم؟
بله. می‌توانیم با مفاهیم borrowing و referencing این کارها و کارهای خیلی مهم‌تری را انجام بدهیم. امّا دیگر در این نوشته از آن‌ها صحبت نمی‌کنم تا بیش از این طولانی نشود.
اینجا با پایه‌ای‌ترین امکاناتی که مالکیّت یا همان Ownership برایمان فراهم می‌کنم آشنا شدیم و دیدیم که آن چطوری کار می‌کند. در بخش بعدی می‌بینیم که چطوری می‌توانیم با استفاده از borrowing و referencing کدمان‌را ایمن‌تر بکنیم. هدف این است که با هم ذهنیّت پشت این مفاهیم را یاد بگیریم. در این صورت، اگر شما با همین ذهنیّت به زبان‌های دیگر هم برنامه بنویسید، احتمالاً نسبت به دیگر برنامه‌نویسان آن زبان کد ایمن‌تر و اصولی‌تری خواهید زد.

آخرش مالکیّت چرا اینقدر مهم است؟ هنوز قانع نشده‌ام

مالکیّت خیلی مهم است. چرا؟ در همه‌ی زبان‌ها شما «نباید» از پوینتر طوری استفاده کنید که داده‌ی آن برای دیگر بخش‌ها اشتباه یا غیرقابل استفاده باشد. «نباید» یک پوینتررا دوبار آزاد کنید و صدها نباید دیگر. امّا درون زبان هیچ سازوکاری وجود ندارد که تضمین کند این «نباید»ها اجرا نمی‌شوند.

حالا Rust تمامی این «نباید»هارا درون زبان پیاده‌کرده است تا شما مطمئن شوید که وقتی که برنامه بدون مشکل کامپایل شد، مشکلی هم از این جنبه‌ها ندارد.

 

 

خواندن قسمت اوّل

دیدن قسمت قبلی

«نوشته‌های مرتبط»

4 پاسخ به “آموزش زبان برنامه‌نویسی Rust – قسمت۷: مالکیت”

  1. مهدی به کار گفت:

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

    • محمّدرضا علی حسینی گفت:

      سلام. واقعاً از دیدن پیامتان خوشحال شدم. تمام تلاش من این است که آموزش کاملی برای این زبان درست کنم که به‌درد همه بخورد.
      امیدوارم که با حمایت دوستانی مثل شما بتوانم این مجموعه را به‌خوبی ادامه بدهم.

  2. میلاد گفت:

    سلام
    خیلی ممنون که ادامه دادید واقعا خوش حال شدم
    و سایتتون بوک مارک شده در مرورگرم
    لطفا با قدرت ادامه بدید آموزش های زبان راست رو که خیلی نیازه و آموزش فارسی هم موجود نیست
    اگر ممکنه اموزش های ویدئویی قرار بدید تا بهتر درک کنیم
    ممنون

    • محمّدرضا علی حسینی گفت:

      سلام میلاد عزیز.
      واقعاً از دیدن پیامت خوشحال شدم.
      اگر مشغله‌ها بگذارند قصددارم این دوره‌را ادامه بدهم و جنبه‌های مختلف زبان Rust و ویژگی‌های آن‌را تا حد امکان پوشش بدهم.
      درمورد آموزش ویدیویی هم ممنون از پیشنهادت. خودم هم زمان زیادی است که به فکرش هستم. اگر کمی بیشتر وقت خالی پیداکنم حتماً شروع خواهم کرد.
      با تشکّر از پیامت.

پاسخی بگذارید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

«نوشته‌های ویژه»

«نوشته‌های محبوب»

«دیدگاه کاربران»