آموزش زبان برنامهنویسی 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 چیست. در حالت معمولی یک اشارهگر آدرس یک داده روی حافظه است. حالا یک اشارهگر چاق و چلّه علاوه بر آدرس داده، یکسری دادهی اضافی هم همراه خودش دارد که دلیل اصلی اضافهوزنش حساب میشود.
بیایید اوّل یک شمای کلّی از چیزی که در حافظه قرار میگیرد ببینیم. در این شکل فلشها نشاندهندهی اشارهگرها هستند:
بخش قرمزرنگ همان اشارهگری است که به دادهی اصلی اشاره میکند. بخش خاکستری هم همان دادههای اضافی است.
بخش خاکستری رنگ یک جدول مجازی (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
ها باقی مانده است که در جلسهی بعدی با هم آنها را هم یاد میگیریم.
The form you have selected does not exist.
دریافت کدهای این جلسه
شما میتوانید با کلیک روی این نوشته کدهای این جلسه را درون مخزن این مجموعه در گیتهاب ببینید. اوّلین بار است که این مجموعهی آموزشی را میبینید؟ با کلیک روی این نوشته به قسمت اوّل بروید و یادگیری زبان Rust را شروع کنید. در جلسهی قبلی فهمیدیم که Polymorphism (چندریختی) چیست و کار با trait ها را شروع کردیم. اگر آن جلسه را ازدستدادهاید با کلیک روی این نوشته خیلی سریع آن را بخوانید.یا حرفهای شو یا برنامهنویسی را رها کن.
چطور میشود در بازار کار خالی از نیروی سنیور ایران، بدون کمک دیگران و وقت تلفکردن خودت را تبدیل به یک نیروی باتجربه بکنی؟
پاسخ ساده است. باید حرفهای بشوی. چیزی که من در این کتاب به تو یاد خواهم داد.
آقا دمت گرم
خسته نباشی
سلام خسته نباشی من این بخش رو درست متوجه نشدم
یعنی یه جوری مثل دکوراتور abstractmethod توی پایتون هست ؟ که مطمئن بشیم اون ابجکت همچین متدی رو حتما داشته باشه ؟
میشود اینگونه هم به آن نگاه کرد. در حقیقت هردو از منظر تعیین رابط مشابه هم هستند.