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

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

آموزش زبان برنامه‌نویسی Rust - قسمت ۱۶: نگاهی دقیق‌تر به ویژگی ها

آموزش زبان برنامه‌نویسی Rust - قسمت ۱۶: نگاهی دقیق‌تر به ویژگی‌ها

در جلسه‌ی پیش فهمیدیم که چندریختی (Polymorphism) چیست و چطوری می‌توان در زبان Rust یک ویژگی (trait) تعریف کرد.

ما جلسه‌ی قبل را با یک سؤال خیلی بزرگ تمام کردیم. در Rust چطوری می‌توان به جای پیاده‌سازی، برای رابط کد بنویسیم (Coding to Interface)؟

در این جلسه می‌خواهیم با هم ببینیم که چطوری می‌توانیم از این قابلیّت که چندریختی برایمان فراهم می‌کند استفاده کنیم.

Trait Object

بیایید اوّل کدی که جلسه‌ی پیش با آن کار را تمام کردیم یک بار دیگر ببینیم:

struct Kaftar ();
struct AirPlane();

impl Fly for Kaftar {
    fn fly(&self) {
        println!("Kafter The Kakol Be Sar is flying");
    }

    fn land(&self) {
        println!("Kafter The Kakol Be Sar is landing");
    }

}

impl Fly for AirPlane {
    fn fly(&self) {
        println!("Airplane is flying.");
    }

    fn land(&self) {
        println!("Airplane is landing.");
    }
}

fn fly_bird(flyable: &Fly) {
        flyable.fly();
}

fn main() {
    let airplane = AirPlane{};
    let kakol_be_sar = Kaftar{};
    fly_bird(&airplane);
    fly_bird(&kakol_be_sar);
}

ما در تابع fly_bird یک رفرنس به ویژگی Fly را به عنوان ورودی گرفته‌ایم و یکی از متدهای این ویژگی را فراخوانی کرده‌ایم.

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

fn fly_bird(flyable: Fly) {
        flyable.fly();
}

fn main() {
    let airplane = AirPlane{};
    let kakol_be_sar = Kaftar{};
    fly_bird(&airplane);
    fly_bird(&kakol_be_sar);
}

حالا این برنامه را مثل برنامه‌ی قبلی اجرا می‌کنیم:

error[E0277]: the size for values of type `(dyn Fly + 'static)` cannot be known at compilation time
  --> src/main.rs:30:13
   |
30 | fn fly_bird(flyable: Fly) {
   |             ^^^^^^^ doesn't have a size known at compile-time
   |
   = help: the trait `std::marker::Sized` is not implemented for `(dyn Fly + 'static)`
   = note: to learn more, visit <https://doc.rust-lang.org/book/ch19-04-advanced-types.html#dynamically-sized-types-and-the-sized-trait>
   = note: all local variables must have a statically known size
   = help: unsized locals are gated as an unstable feature

خطایی که کامپایلر به ما نشان می‌دهد می‌گوید که اندازه‌ی پارامتر ورودی مشخّص نیست.

چرا کامپایلر باید اندازه‌ی ورودی‌ها را موقع کامپایل بداند؟

اوّلین سؤالی که برای آدم پیش‌می‌آید این است که اصلاً چرا کامپایلر به دانستن اندازه‌ی پارامترهای ورودی احتیاج دارد؟

وقتی که تابعی می‌نویسیم، کامپایلر برای آن تابع یک تکّه کد اسمبلی (Assembly) تولید می‌کند. این کد اسمبلی مشخّص می‌کند که وقتی این تابع صدا زده می‌شود باید چه اتّفاقاتی بیفتد.

صورت خلاصه و ساده‌شده‌ی نحوه‌ی فراخوانی یک تابع اینطوری است: قبل از اینکه تابعی را صدا بزنیم ابتدا باید آدرس بازگشت را درون پشته (stack) بریزیم. اگر این کار را نکنیم تابع نمی‌داند وقتی که تمام شد باید جریان اجرای برنامه را به کجا برگرداند. پس برای اینکه پس از پایان اجرای تابع برنامه از جایی که تابع صدا زده شده بود ادامه پیدا کند باید این کار را انجام بدهیم.

پس از آن باید پارامترهای ورودی تابع را درون پشته (stack) بریزیم تا تابع بتواند به آن‌ها دسترسی داشته باشد. بعد از آن هم اجرای تابع شروع می‌شود: متغیّرهای محلی (Local Variables) درون پشته قرار می‌گیرند و … .

برنامه‌ی خیلی خیلی ساده‌ی زیر را به زبان C درنظر بگیرید:

void c(int param1, int param2) {
}

int main() {
    int a = 10;
    c(a, 20);
    return 0;
}

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

c(int, int):
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        mov     DWORD PTR [rbp-8], esi
        nop
        pop     rbp
        ret
main:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     DWORD PTR [rbp-4], 10
        mov     eax, DWORD PTR [rbp-4]
        mov     esi, 20
        mov     edi, eax
        call    c(int, int)
        mov     eax, 0
        leave
        ret

از آنجایی که قرار نیست اینجا وارد جزئیّات فراخوانی تابع بشویم از اکثر بخش‌های این کد می‌گذریم.

چیزی که برای ما مهم است خط ۱۵ و ۱۶ است. در خط ۱۶ عدد ۲۰ را درون ثبّات esi می‌ریزیم و مقدار متغیّر a را، که درون ثبّات eax ریخته‌شده است، درون ثبّات edi قرار می‌دهیم.

حالا در خط ۴، درون بخش مربوط به تابع c، مقدار پارامتر اوّل از ثبّات edi خوانده می‌شود و درون پشته (stack) ریخته می‌شود. در خط بعدی هم این کار برای ثبّات esi و پارامتر بعدی می‌افتد.

حالا بیایید یک دقیق‌تر به خط چهار نگاه کنیم:

        mov     DWORD PTR [rbp-20], edi

کلمه‌ی mov در اوّل این خط نشان می‌دهد که ما می‌خواهیم مقدار موجود در ثبّات edi را در ورودی اوّل این دستور کپی کنیم (ورودی اوّل مقصد عمل کپی و ورودی دوم این دستور، جایی است که داده‌ی اصلی قرار دارد).

گفتیم پس از کپی کردن آدرس بازگشت باید پارامترهای ورودی را درون استک بریزیم. ما در تابع main ورودی‌های تابع c را درون متغیّرهای esi و edi ریختیم. پس اینجا باید مقدار این ثبّات‌ها را درون پشته ذخیره کنیم. الان می‌خواهیم در این خط همین کار را بکنیم.

همانطوری که می‌بینید پارامتر اوّل دستور move یک ثبّات نیست. بلکه یک آدرس در حافظه است. حالا کجای حافظه؟ rbp آدرس نقطه‌ی شروع پشته (stack) را در خودش نگهداری می‌کند. عموماً نحوه‌ی آدرس دهی پشته بر خلاف انتظار ما است. یعنی پشته از آدرسی که مقدار بزرگتری دارد شروع می‌شود و به آدرسی که مقدارش کمتر است ختم می‌شود. مثلاً نقطه‌ی شروع پشته آدرس ۱۰۰ است و نقطه‌ی پایانش ۵۰.

ما در این دستور می‌گوییم که مقدار ثبّات edi را (که دومین پارامتر ورودی تابع را درون خودش نگه‌می‌دارد) به صورت یک double word درون آدرسی که در براکت مشخص شده است بریز.

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

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

شکل ساده‌سازی شده‌ی پشته (stack) بعد از فراخوانی تابع c در تابع main شبیه به شکل زیر خواهد بود (پشته از بالا شروع می‌شود و به سمت پایین زیاد می‌شود):

تصویر ساده‌ی پشته پس از فراخوانی تابع

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

چرا کامپایلر جلوی استفاده از ویژگی‌ها را به عنوان پارامتر ورودی می‌گیرد؟

حالا بیایید فرض کنیم که اندازه‌ی پارامتر param1 برای تابع c زمان کامپایل مشخّص نیست. در کد اسمبلی‌ای که کامپایلر تولید می‌کند چطوری می‌توان آدرس شروع و اندازه‌ی ورودی را نوشت؟

همانطوری که در قسمت قبلی دیدیم شما می‌توانید یک ویژگی (trait) را برای انواع داده‌ها پیاده‌سازی کنید. تازه در پایان همین قسمت می‌بینیم که می‌توان یک ویژگی را به نوع‌داده‌ای (Data Type) که قبلاً وجود داشته هم اضافه کرد.

این یعنی وقتی می‌گوییم که ورودی ما ویژگی Fly را پیاده‌سازی کرده است، معلوم نیست که این ورودی یک struct با هزار بخش مختلف است یا اینکه یک tuple like struct مثل چیزی که ما در کدمان داریم.

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

Trait Object

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

fn fly_bird(flyable: &Fly) {
        flyable.fly();
}

ما در اینجا نوع داده‌ی ورودی را یک رفرنس به ویژگی Fly در نظر گرفته‌ایم. Trait Object هم همین است. یک رفرنس به یک ویژگی (trait). 🙂

حالا فرق Trait Object با رفرنس‌های معمولی چیست؟

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

پیاده‌سازی Trait Object در سطح زبان

حالا چی کار کنیم که هم سریع باشد و هم مقدار کمی از حافظه را مصرف کند؟ راهی که Rust انتخاب کرده است استفاده از یک Fat Pointer است.

اشاره‌گر خیکی

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

بیایید اوّل یک شمای کلّی از چیزی که در حافظه قرار می‌گیرد ببینیم. در این شکل فلش‌ها نشان‌دهنده‌ی اشاره‌گرها هستند:

پیاده‌سازی trait object در زبان Rust

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

بخش خاکستری رنگ یک جدول مجازی (Virtual Table) است که اطّلاعاتی که برای فهمیدن نوع داده در زمان اجرا لازم است را نگهداری می‌کند.

ما ویژگی Fly را یکبار برای ساختار AirPlane پیاده‌سازی کردیم:

impl Fly for AirPlane {
    fn fly(&self) {
        println!("Airplane is flying.");
    }

    fn land(&self) {
        println!("Airplane is landing.");
    }
}

یکبار هم برای ساختار Kaftar:

impl Fly for Kaftar {
    fn fly(&self) {
        println!("Kafter The Kakol Be Sar is flying");
    }

    fn land(&self) {
        println!("Kafter The Kakol Be Sar is landing");
    }

}

متد fly برای کفتر یک تابع مجزا با کدهای متفاوت نسبت به همان متد برای هواپیما است. در حقیقت این دوتا دو تابع کاملاً متفاوت هستند که ما صرفاً موقع نوشتن برنامه به هر دو یک نام داده‌ایم.

به همین خاطر در virtual table مربوط به هر نوع باید یک رفرنس به کدهای تابع مربوطه وجود داشته باشد تا در زمان اجرا معلوم شود که وقتی متد fly را فراخوانی می‌کنیم جریان اجرای برنامه باید به کجا منتقل شود.

برای هر نوع (Data Type) ما فقط به یک virtual table احتیاج داریم. چون متدها، مکان ذخیره‌سازی آن‌ها، اندازه و… برای تمامی موجودیّت‌هایی که از یک نوع خاص ساخته شده اند یکسان است. به همین دلیل استفاده از virtual table از نظر حافظه کار به‌صرفه‌ای است.

کامپایلر Rust به صورت خودکار می‌فهمد که چه جاهایی باید از Trait Object استفاده کند و چه جاهایی از رفرنس عادی. به همین خاطر لازم نیست موقع استفاده از Trait Object ها خودمان کار خاصی بکنیم. کدمان در نهایت مثل زمانی است که داریم از رفرنس‌های معمولی استفاده می‌کنیم.

بعد از مبحث Generic دوباره به همین Trait Object ها بر می‌گردیم و این بار به صورت کامل همه‌چیز را درموردشان یاد می‌گیریم. برای الان همین معرّفی کوتاه و یادگرفتن نحوه‌ی استفاده از آن‌ها کافی است.

تعریف پیاده‌سازی پیش‌فرض

ما می‌توانیم هنگام تعریف یک ویژگی (trait) برای هرکدام از متدها که خواستیم یک پیاده‌سازی پیش‌فرض تعریف کنیم. اینطوری اگر هنگام پیاده‌سازی آن ویژگی برای یک نوع داده‌ی خاص آن متد را پیاده‌سازی نکنیم، همان پیاده‌سازی پیش‌فرض برایش در نظر گرفته می‌شود.

مثلاً بیایید ویژگی Fly را که قبلاً نوشته بودیم کمی تغییر بدهیم:

trait Fly {
    fn fly(&self);
    fn land(&self) {
        println!("Flyable Object now landing.");
    }
}

حالا یک ساختار دیگه به نام UnknownFlayble تعریف می‌کنیم و ویژگی Fly را برایش پیاده‌سازی می‌کنیم:

struct UnknownFlyable();

impl Fly for UnknownFlyable {
    fn fly(&self) {
        println!("Unknown flyable is flying.");
    }
}

حالا از هر کدام از ساختارهای AirPlane، Kaftar و UnknownFlyable یک نمونه می‌سازیم و متد land آن‌ها را فراخوانی می‌کنیم:

fn main() {
    let airplane = AirPlane{};
    let kakol_be_sar = Kaftar{};
    let unknown = UnknownFlyable{};

    airplane.land();
    kakol_be_sar.land();
    unknown.land();

}

اگر این کد را اجرا کنیم خروجی این شکلی می‌شود:

Airplane is landing.
Kafter The Kakol Be Sar is landing
Flyable Object now landing.

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

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

اضافه‌کردن ویژگی به Data Type های دیگران

یک کار خیلی هیجان‌انگیزی که می‌توان با ویژگی (trait) ها انجام داد این است که می‌توان آن‌ها را برای نوع‌داده (Data type) هایی که دیگران نوشته‌اند یا حتّی به صورت پیش‌فرض در زبان وجود دارند هم پیاده‌سازی کرد.

مثلاً ما می‌توانیم ویژگی Fly را برای String تعریف کنیم:

impl Fly for String {
    fn fly(&self) {
        println!("Oh my گاج. It's a flying string!");
    }
}

حالا می‌توانیم در کدمان از String پرنده استفاده کنیم:

fn main() {
    let flying_string = String::from("بغبغو");
    flying_string.fly();
}

حالا اگر برنامه را اجرا کنیم این خروجی را می‌گیریم:

Oh my گاج. It's a flying string!

اضافه‌کردن ویژگی‌هایی که دیگران نوشته‌اند به کد

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

مثلاً در کتابخانه‌ی استاندارد Rust یک ویژگی به نام Clone وجود دارد. برای اینکه بتوانیم این ویژگی را برای یک داده در کدمان پیاده‌سازی کنیم، ابتدا باید این ویژگی را وارد Scope فعلی کنیم:

use std::clone::Clone;

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

البته Clone به صورت پیش‌فرض در scope لود می‌شود. امّا از آنجایی که اکثر tarit ها اینطوری نیستند و باید به صورت دقیق آن‌ها را به scope وارد کرد، بهتر است که برای یکدست ماندن کد همیشه از use استفاده کنیم.

حالا یک struct جدید تعریف می‌کنیم و clone را برایش پیاده‌سازی می‌کنیم:

#[derive(Debug)]
struct YouCanCloneMe {
    name: String,
    age: u8
}

impl Clone for YouCanCloneMe {
    fn clone(&self) -> Self {
        return YouCanCloneMe{name: self.name.clone(), age: self.age};
    }
}

همانطوری که می‌بینید ورودی متد clone یک رفرنس به self، یعنی یک رفرنس به همان نمونه‌ای از ساختار YouCanCloneMe است که این متد روی آن فراخوانی شده است. امّا خروجی از نوع Self خالی است.

این Self یعنی اینکه خروجی این متد یک struct، و نه یک رفرنس به struct، آن هم از همان نوع YouCanCloneMe است.

خوبی استفاده از Self به عنوان خروجی این است که مقدارش با توجّه به typeی که این ویژگی برایش تعریف شده تغییر می‌کند. یعنی خروجی متد clone ویژگی Clone همیشه از جنس داده‌ای است که این ویژگی برایش پیاده‌سازی شده است.

کاری هم که در این متد می‌کنیم خیلی ساده است. یک ساختار جدید از نوع YouCanCloneMe می‌سازیم که مقدار age آن همان age ورودی است. مقدار name هم یک String است که چون از نوع داده های قابل کپی‌کردن نیست، با فراخوانی متد clone روی آن، مقدارش را برای ساختار جدید کپی می‌کنیم. این یعنی ویژگی Clone قبل از این برای String پیاده‌سازی شده است.

اگر یادتان باشد قبلاً‌ دیدیم که گذاشتن خط #[derive(Debug)] قبل از تعریف ساختار (struct) خودش ویژگی Debug را برای این ساختار پیاده‌سازی می‌کند.

خب حالا برویم و کدمان را امتحان کنیم:

fn main() {
    let yaroo = YouCanCloneMe {
        name: String::from("Name"),
        age: 22
    };

    println!("cloned yaroo: {:#?}", yaroo.clone());
}

اگر کد را اجرا کنیم خروجی‌ای را که انتظارش را داشتیم دریافت می‌کنیم:

cloned yaroo: YouCanCloneMe {
    name: "Name",
    age: 22,
}

ارث‌بری ویژگی‌ها

ما چیزی شبیه به مفهوم ارث‌بری در شئ‌گرایی را بین ویژگی (trait) ها داریم. یعنی شما می‌توانید مشخّص کنید که یک ویژگی، subtrait یک ویژگی دیگر باشد. بنابراین هروقت این ویژگی برای نوع‌داده‌ای پیاده‌سازی شد، ویژگی پدر (که این ویژگی از آن ارث‌بری کرده است) هم باید پیاده‌سازی بشود.

بیایید این بار یکم مثالمان را تغییر بدهیم:

trait Animal {
    fn eat(&self);
}

trait Fly: Animal {
    fn fly(&self);
    fn land(&self) {
        println!("Flyable Object now landing.");
    }
}

ما اوّل یک ویژگی به نام Animal تعریف کرده‌ایم. این ویژگی یک متد به نام eat دارد.

حالا هنگام تعریف ویژگی Fly، پس از تعیین نام ویژگی یک علامت : گذاشته‌ایم و مقابل آن نام ویژگی Animal را نوشته‌ایم. این یعنی اینکه Fly یک subtrait برای Animal محسوب می‌شود و یکجورهایی دارد از آن ارث‌بری می‌کند.

حالا بیایید مثل دفعه‌ی پیش Fly را برای ساختار Kaftar پیاده‌سازی کنیم:

struct Kaftar ();

impl Fly for Kaftar {
    fn fly(&self) {
        println!("Kafter The Kakol Be Sar is flying");
    }
}

ما خیلی ساده گفتیم که می‌خواهیم ویژگی Fly را برای ساختار Kaftar پیاده‌سازی کنیم. حالا بیاید یک نمونه از ساختار Kaftar بسازیم و آن را پر بدهیم:

fn main() {
    let kakol_be_sar = Kaftar{};
    kakol_be_sar.fly();
}

۱، ۲، ۳ امتحان می‌کنیم:

error[E0277]: the trait bound `Kaftar: Animal` is not satisfied
  --> src/main.rs:17:6
   |
17 | impl Fly for Kaftar {
   |      ^^^ the trait `Animal` is not implemented for `Kaftar`

error: aborting due to previous error

برنامه اجرا نشد. کامپایلر می‌گوید که ویژگی Animal برای ساختار Kaftar پیاده‌سازی نشده است.

حالا برای اینکه این مشکل حل بشود چه کار کنیم؟ باید Animal را هم برای Kaftar پیاده‌سازی کنیم:

impl Animal for Kaftar {
    fn eat(&self) {
        println!("I'm busy now. Let me eat my Arzans.");
    }
}

حالا کدمان را اجرا می‌کنیم:

fn main() {
    let kakol_be_sar = Kaftar{};
    kakol_be_sar.fly();
    kakol_be_sar.eat();
}

نتیجه همان چیزی است که از اوّل انتظارش را داشتیم:

Kafter The Kakol Be Sar is flying
I'm busy now. Let me eat my Arzans.

این قابلیّت به چه دردی می‌خورد؟ وقتی که چند ویژگی «منطقاً» متفاوت داریم که بعضی از آن‌ها به وجود بقیه نیاز دارند می‌توانیم از subtrait استفاده کنیم. مثلاً شما برای اینکه غذا بخورید اوّل باید آدم باشید و برای آدم بودن باید جانور باشید.

این کار توسعه و نگهداری نرم‌افزار را خیلی راحت‌تر می‌کند و انعطاف ما را برای نوشتن کد برای رابط‌ها (Coding to Interface) بیشتر می‌کند.

خب دیگر برای این جلسه کافی است. هنوز یکم از مباحث مربوط به trait ها باقی مانده است که در جلسه‌ی بعدی با هم آن‌ها را هم یاد می‌گیریم.

دریافت کدهای این جلسه

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

اوّلین بار است که این مجموعه‌ی آموزشی را می‌بینید؟ با کلیک روی این نوشته به قسمت اوّل بروید و یادگیری زبان Rust را شروع کنید.

در جلسه‌ی قبلی فهمیدیم که Polymorphism (چندریختی) چیست و کار با trait ها را شروع کردیم. اگر آن جلسه را ازدست‌داده‌اید با کلیک روی این نوشته خیلی سریع آن را بخوانید.

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

2 پاسخ به “آموزش زبان برنامه‌نویسی Rust – قسمت ۱۶: نگاهی دقیق‌تر به ویژگی ها”

  1. یارو گفت:

    آقا دمت گرم
    خسته نباشی

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

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

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

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

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