آموزش زبان برنامهنویسی Rust - قسمت ۱۵: چندریختی
شما از یک زبان سطح پایین چه انتظاری دارید؟ هنوز فکر میکنید که نوشتن برنامه به یک زبان سطح پایین یعنی خطوط زیاد و غیرقابل فهم کد که آدم برای اضافهکردن هر ویژگی کوچک به آن باید ده سال از عمرش را تلف کند؟
اگر تا اینجای این آموزشها قانعنشدهاید که این تصوّر اشتباه است حالا وقتش است. وقت آنکه با دو ویژگی بینهایت پرکاربرد زبان Rust آشنا بشویم و ببینیم که چطوری Rust با این دو ویژگی، نوشتن کد سطح پایین را به حد زبانهای سطح بالاتر آسان کرده است.بیایید در چند قسمت همهچیز را درمورد Generic
و trait
یاد بگیریم تا زندگی خودمان و دیگران را زیبا کنیم.
فهرست مطالب
چندریختی بودن
قبل از اینکه به سراغ زبان Rust برویم، بیایید با هم یک مفهوم اساسی را مرورکنیم. مفهومی که بیشتر در برنامههای شئگرا به کار میرود.
حالا ربط مفهومی که بیشتر برای زبانهای شئگرا مطرح است به Rust که اصلاً شئگرا نیست چیست را بعداً میبینیم.
چندریختی بودن (polymorphism) یعنی چه؟
اگر بخواهیم خیلی خلاصه بگوییم که چندریختی یا polymorphic بودن یعنی چه، میتوانیم به این تعریف بسنده کنیم: «چندریختی یا polymorphism یعنی فراهم کردن یک رابط (interface) برای نوعدادههای مختلف. به صورتی که همه با یک API به یک ویژگی دسترسی داشته باشند».
واضح بود؟ اگر نه هم مهم نیست. چون جلوتر با مثال میفهمیم که این تعریف دقیقاً چه میگوید.
رابط (Interface) چیست؟
کلمهی رابط یا interface در دنیای کامپیوتر خیلی زیاد استفاده میشود. اکثراً هم این استفادهها دارند درمورد چیزهای مختلفی صحبت میکنند. امّا اگر بخواهیم مفهوم مشترک بین تمام اینها را به عنوان تعریف رابط در نظر بگیریم، به چنین تعریفی میرسیم:
رابط یا Interface یک قرارداد انتزاعی (abstract) بین دو بخش سیستم است که به صورت مشخّص و دقیق رفتار و ویژگی بخشی از سیستم را تعریف میکند و امکان استفاده از آن را برای ما فراهم میکند. بدون اینکه به پیادهسازی وابستگی داشته باشد.
ولی چیزی که ما اینجا از آن به عنوان رابط نام میبریم را میشود خیلی سادهتر هم بیان کرد. «رابط یا interface یک رفتار خاص را توصیف میکند. به علاوه مشخّص میکند که برای اجرای آن ویژگی یا رفتار باید از چه API هایی (در اینجا همان متدها و توابع) استفاده کرد».
خب حالا احتمالاً با این تعریف معنی تعریف قبلی برای مفهوم چندریختی را بهتر درک میکنید.
پیادهسازی چندریختی بودن در زبانهای شئگرا
قبل از اینکه بیشتر درمورد چندریختی بودن صحبت کنیم، بیایید یک مثال از کد چندریختی در یک زبان شئگرا ببینیم. من در این مثالها از cpp11 استفاده کردهام، ولی اگر سیپلاسپلاس بلد نیستید نگران نباشید. بعد از دیدن کد خط به خطش را با هم بررسی میکنیم.
برنامهی ما خیلی ساده است. دو تا کلاس داریم که قرار است نمایندهی موجودیّت (entity) هایی باشند که میتوانند پرواز کنند. هر موجودیّت میتواند پرواز کند و (طبیعتاً) فرود بیاید.
نوشتن رابط
اوّل از همه میخواهیم Interface را بنویسیم. چرا اصلاً به نوشتن یک رابط نیاز داریم؟ چون قرار است هر موجودیّت پرنده، بتواند پرواز کند و فرود بیاید. یعنی تمام پرندهها یک رفتار مشخّص دارند که ما میتوانیم از آن تحت یک رابط یکسان استفاده کنیم.
در زبان cpp به صورت مستقیم چیزی به نام Interface نداریم. به همین خاطر برای نوشتن یک رابط باید از یک کلاس معمولی استفاده کنیم. رفتارها را هم به شکل متدهای virtual
مینویسیم.
class CouldFlyInterface { public: virtual void fly() = 0; virtual void land() = 0; };
کدمان خیلی ساده است. اوّل یک کلاس با نام CouldFlyInterface
تعریف کردهایم. با نوشتن کلمهی کلیدی public
به کامپایلر میفهمانیم که چیزهایی که از این به بعد مینویسیم میتوانند از خارج از کلاس هم فراخوانی بشوند.
بعد دو رفتاری که از یک پرنده انتظار داریم را مشخّص میکنیم. پرنده قرار بود پرواز کند و فرود بیاید. پس دو تا متد برای این دو رفتار نوشتهایم. قبل از اسم و نوع خروجی هر متد، کلمهی کلیدی virtual
را نوشتهایم و پس از تعریف تابع آن را مساوی با صفر قرار داده ایم. اینطوری هر کلاسی که از این کلاس ارثبری کند، مجبور است که این متدها را پیادهسازی کند. در غیر این صورت موقع کامپایل به ارور خواهد خورد.
امّا چرا برای این کلاس constructor نگذاشتیم؟ چون قرار نیست که از این کلاس نمونهای بسازیم. فراموش نکنید که این کلاس در حقیقت یک Interface است و ساخت نمونه از Interface بیمعنی است. چون رابط(interface) قرار است رفتار یک نوعداده (Data Type) را توصیف کند.
هر چند به خاطر وجود abstract method (همان متدهای virtual که اصطلاحاً به آنها pure virtual میگویند) اصلاً نمیتوان از این کلاس نمونهای ساخت. حتّی اگر constructor داشته باشد.
ساخت پرندهها
حالا که رابط ویژگی پرواز کردن را تعریف کردیم، زمان آن است که کلاسهایی که قرار است پرندههایمان را بسازند بنویسیم.
اوّلین چیزی که میخواهیم بسازیم یک هواپیما است. پس یک کلاس برای آن میسازیم و از رابطی که ساختهایم ارثبری میکنیم.
class Airplane: public CouldFlyInterface { public: Airplane() = default; void fly() { std::cout << "Airplane is flying" << std::endl; } void land() { std::cout << "Airplane is landing" << std::endl; } };
اگر با syntax ساخت کلاس در ++c آشنا نیست مهم نیست. ما در خط اوّل از CouldFlyInterface
ارثبری کردهایم.
اگر متدهایی که در آن رابط (Interface) به عنوان Abstract method تعریف شده اند را در این کلاس پیادهسازی نکنیم، موقع کامپایل کد به خطا بر میخوریم.
به همین خاطر ما متدهای fly
و land
را پیادهسازی کردهایم. در این مثال ساده، این متدها صرفاً یک پیام را چاپ میکنند.
حالا برویم سراغ پرندهی بعدی: کفتر کاکل به سر.
class Kaftar: public CouldFlyInterface { public: Kaftar() = default; void fly() { std::cout << "Kaftar the Kakol Be Sar is flying" << std::endl; } void land() { std::cout << "Kaftar the Kakol Be Sar is landing"; } };
این کلاس هم دقیقاً مثل قبلی است. با این تفاوت که پیادهسازی آن دو متد متفاوت است و حالا آنها پیام متفاوتی را چاپ میکنند.
این چندریختی بودن کجا به درد میخورد؟
خب حالا زمان آن است که بفهمیم این چندریختی بودن کجا به کارمان میآید. از آنجایی که مثالمان خیلی ساده است، برای این بخش هم یک چیز خیلی ساده را در نظر میگیریم.
فرض کنید که ما به یک تابع نیاز داریم که آرایهای از پرندهها را بگیرد و آنها را به پرواز در بیاورد. اگر نخواهیم از رابطی (Interface) که ساختیم استفاده کنیم، باید برای هر نوع پرندهای که در کدمان داریم یک بار این تابع را بنویسیم:
void flyTheAirplanes(Airplane *airplaneArray[], int numOfAirplanes) { for (int i = 0; i < numOfAirplanes; i++) { airplaneArray[i]->fly(); } }
این تابع یک آرایه از دادههایی که نوعشان Airplane
است را به عنوان پارامتر ورودی میگیرد. به علاوه طول آرایه را هم دریافت میکند تا بدانیم باید چند بار حلقه را تکرار کنیم.
داخل حلقه هم ما صرفاً متد fly
را برای هر هواپیما فراخوانی میکنیم. به همین سادگی.
حالا میتوانیم یک آرایه که اعضایش اشارهگر (pointer) هایی به اشیائی از نوع Airplane
هستند را به این تابع بدهیم تا آنها را به پرواز در بیاورد.
int main() { Airplane *Airplane1 = new Airplane(); Airplane *Airplane2 = new Airplane(); Airplane *Airplane3 = new Airplane(); Airplane *airplanes[3] = {Airplane1, Airplane2, Airplane3}; flyTheAirplanes(airplanes, 3); return 0; }
اگر این برنامه را اجرا کنیم خروجی زیر را میبینیم:
Airplane is flying Airplane is flying Airplane is flying
خب حالا اگر بخواهیم کفترهایمان هم پرواز کنند، باید عیناً همین کد را، ولی این بار برای نوع دادهی Kaftar
تکرار کنیم:
void flyTheKaftars(Kaftar *kaftarsArray[], int numOfKaftars) { for (int i = 0; i < numOfKaftars; i++) { kaftarsArray[i]->fly(); } } int main() { Kaftar *kakolBeSar1 = new Kaftar(); Kaftar *kakolBeSar2 = new Kaftar(); Kaftar *kakolBeSar3 = new Kaftar(); Kaftar * kaftars[3] = {kakolBeSar1, kakolBeSar2, kakolBeSar3}; flyTheKaftars(kaftars, 3); return 0; }
خروجی این برنامه هم، همانطوری که انتظارش را داریم، اینطوری خواهد بود:
Kaftar the Kakol Be Sar is flying Kaftar the Kakol Be Sar is flying Kaftar the Kakol Be Sar is flying
این که برای هر پرندهای بخواهیم یک بار تمام این تابع را از نو بنویسیم و فقط نوع ورودی را عوض کنیم کار خستهکنندهای است. به علاوه، هر تغییری در این تابع باید برای تمامی پرندهها تکرار شود. یعنی اگر ۱۵ تا پرنده در برنامه داشته باشیم، هر تغییر برای به پرواز در آوردن پرندهها باید ۱۵ جای مختلف تکرار شود.
حالا میتوانیم اوّلین مزیّت استفاده از چندریختی را ببینیم.
برای رابطها برنامهنویس، نه برای پیادهسازیها
وقتی حرف از چندریختی (Polymorphism) میشود، اصطلاحی که زیاد به کار میرود همین است. Programming to the interface ، یا برنامهنویسی برای رابطها مسئلهی خیلی مهمی است. مشکل مثال ما هم به خاطر رعایت نکردن همین اصل است.
ما باید برای رفتارهایی که قرار است بخشهای مختلف از خودشان نشان بدهند برنامهبنویسیم. اگر کدی که مینویسیم به پیادهسازی وابستگی کامل داشته باشد، به احتمال خیلی خیلی زیاد برنامهی بدی نوشتهایم.
بیایید کدی که زده بودیم را اصلاح کنیم. این بار میخواهیم تابع را برای رفتار پروازکردن بنویسیم، نه پرندهها.
void flyBird(CouldFlyInterface * flyableArray[], int numOfFlyables) { for (int i = 0; i < numOfFlyables; i++) { flyableArray[i]->fly(); } }
این بار هم تابعی که نوشتهایم دقیقاً مثل دو تابع قبلی است. با این تفاوت که نوع آرایهی ورودی تغییر کرده است. این بار به جای اینکه یکی از پیادهسازیهای رابط (Interface) را به عنوان ورودی تعیین کنیم، خود رابط را به عنوان ورودی در نظر گرفتهایم.
حالا میتوانیم کد اجرای برنامه را مثل زیر تغییر بدهیم:
int main() { Airplane *Airplane1 = new Airplane(); Airplane *Airplane2 = new Airplane(); Airplane *Airplane3 = new Airplane(); Kaftar *kakolBeSar1 = new Kaftar(); Kaftar *kakolBeSar2 = new Kaftar(); Kaftar *kakolBeSar3 = new Kaftar(); CouldFlyInterface * flyablesArray[] = {Airplane1, Airplane2, Airplane3, kakolBeSar1, kakolBeSar2,kakolBeSar3}; flyBird(flyablesArray, 6); return 0; }
نتیجهی کد دقیقاً چیزی است که ما میخواهیم:
Airplane is flying Airplane is flying Airplane is flying Kaftar the Kakol Be Sar is flying Kaftar the Kakol Be Sar is flying Kaftar the Kakol Be Sar is flying
چطوری اینطوری شد؟ هر کلاسی که رابط CouldFlyInterface
را پیادهسازی میکند، حتماً متد fly
را هم پیادهسازی کرده است. پس حالا کامپایلر صرفاً لازم است این موضوع را بررسی کند که آیا شئ ورودی این رابط (Interface) را پیادهسازی کرده یا نه؟ دیگر اصلاً اهمّیّتی ندارد که کلاس آن شئ چه چیزی است.
اینطوری با یک کد میتوانیم تمامی پرندهها را به پرواز در بیاوریم.
با استفاده از رابط کدت را راحتتر گسترش بده
استفاده از رابطها باعث میشود که گسترش کد هم راحتتر بشود. فرضکنید ما در برنامهمان علاوه بر موجودیّتهایی که میتوانند پرواز کنند، موجودیّتهایی داریم که میتوانند غذا بخورند.
خب حالا چه کار کنیم که مثل قبل بتوانیم برای رابط (Interface) کد بنویسیم؟ خیلی راحت یک رابط دیگر هم تعریف میکنیم.
class CouldEatInterface { public: virtual void eat() = 0; };
حالا کافی است در کلاس Kaftar
علاوه بر رابط قبلی، از رابط CouldEatInterface
هم ارثبری کنیم:
class Kaftar: public CouldFlyInterface, CouldEatInterface { public: Kaftar() = default; void eat() { std::cout << "Kaftar the Kakol Be Sar is eating" << std::etl; } void fly() { std::cout << "Kaftar the Kakol Be Sar is flying" << std::endl; } void land() { std::cout << "Kaftar the Kakol Be Sar is landing"; } };
حالا دقیقاً مثل تابع پرواز، اینجا هم میتوانیم برای تمام چیزهایی که قابلیّت غذا خوردن دارند، از code to interface استفاده کنیم. کد نویسی برای رابط باعث میشود که با نوشتن توابع کلّی، خیلی سریع منطق برنامه را برای اشیائی که رفتارهای مشابه دارند پیادهسازی کنیم. اشیائی از نوعدادههایی که شاید هنوز وجود ندارند، امّا هرگاه به کد اضافه شوند منطقشان آماده است.
خب فهمیدیم که رابط (Interface) چیست و به چه دردی میخورد. حالا ببینیم که این حرفها به Rust چه ربطی دارد.
ویژگیها را مشخّص کن
Rust برای پیادهسازی چندریختی دو امکان را در اختیار ما گذاشته است. Trait ها و Generic ها. ما در این قسمت کار با ویژگیها (trait) را شروع میکنیم.
نحوهی تعریف یک ویژگی (trait) جدید در زبان Rust
ویژگیها، همانطوری که از نامشان پیدا است، مشخّص میکنند که یک نوع داده (Data Type) چه رفتارهایی دارد. یعنی دقیقاً همان چیزی که ما از یک رابط (Interface) انتظار داریم.
شکل کلّی تعریف یک ویژگی (trait) در Rust اینطوری است (بله. بله. اصلاً هم قابل فهم نیست. جلوتر با مثال شیوهی کارش را میفهمیم):
trait TraitName { fn method_name(params); ... }
تعریف یک ویژگی (trait) با کلمهی کلیدی trait
آغاز میشود. بعد از این کلمهی کلیدی، نام ویژگی را باید بنویسیم. باز هم حواستان باشد که مثل struct و enum، اینجا هم اسم را باید به صورت PascalCase بنویسیم.
داخل بدنهی ویژگی هم متدها و توابع وابسته قرار میگیرند.
خب بیایید همان رابط (Interface) ویژگی پروازکردن، یعنی CouldFlyInterface، را در اینجا پیادهسازی کنیم.
trait Fly { fn fly(&self); fn land(&self); }
همانطوری که میبینید نوشتن رابط در Rust خیلی سرراستتر است. اوّل اسم رابط (Interface) را مشخّص کردهایم. چون trait
ها در زبان Rust به صورت مجزّا مشخّص شده اند، دیگر لازم نیست برای ایجاد تمایز با ساختارهای دیگر آخر اسمش کلمهی Interface را اضافه کنیم.
ویژگیها همان متدهایی هستند که برای struct ها هم از آنها استفاده میکردیم. با این تفاوت که دیگر آنها را پیادهسازی نکردهایم. یعنی فقط مشخّص کردهایم
که قرار است متدهایی با نام fly
و land
داشته باشیم. امّا پیادهسازی آن متدها را به ساختارهایی که قرار است این ویژگی (trait) را پیادهسازی کنند واگذار کردهایم.
پیادهسازی یک ویژگی برای یک ساختار
حالا چطوری یک ویژگی (trait) را برای یک ساختار (struct) پیادهسازی کنیم؟ جواب خیلی شبیه به همان روشی است که با آن متدها را برای ساختارها تعریف میکردیم.
اوّل بیایید دو تا ساختار Airplane
و Kaftar
را بنویسیم:
struct Kaftar (); struct AirPlane();
چون میخواهیم مثل مثالی که به زبان ++c نوشتیم عمل کنیم، اینجا از tuple like struct استفاده میکنیم. چون ساختارهای ما قرار نیست هیچ مقداری را نگهداری کنند.
حالا ویژگی Fly
را برای 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"); } }
همانطوری که میبینید، برای پیادهسازی یک ویژگی (trait)، ابتدا کلمهی کلیدی imp
را مینویسم. بعد از آن اسم ویژگیای را میاوریم که میخواهیم آن را
پیادهسازی کنیم. حالا نوبت کلمهی کلیدی بعدی است. بعد از اسم ویژگی، کلمهی for
را مینویسیم و بعد از آن هم اسم ساختار را میآوریم.
درون بدنهی این پیادهسازی، باید تمام متدها و توابع مرتبط را پیادهسازی کنیم (بعداً میبینم که در صورت وجود مقادیر پیشفرض میتوان برخی چیزها را پیادهسازی نکرد).
به علاوه حواستان باشد که اگر چیزی در بدنهی پیادهسازی قرار بگیرد که درون ویژگی (trait) تعریف نشده باشد، هنگام کامپایل برنامه با ارور مواجه میشوید.
حالا بیایید همین کار را برای ساختار Airplane
انجام بدهیم:
impl Fly for AirPlane { fn fly(&self) { println!("Airplane is flying."); } fn land(&self) { println!("Airplane is landing."); } }
نوشتن کد برای رابط
حالا میخواهیم اصل نوشتن کد برای رابط (Interface) را ببینیم. دقیقاً مثل مثال قبلی، یک تابع میخواهیم که هر چیزی که میتواند پرواز کند را بگیرد و آن را به پرواز در بیاورد.
fn fly_bird(flyable: &Fly) { flyable.fly(); }
این کد به عنوان ورودی یک رفرنس از هر چیزی که ویژگی Fly
را پیادهسازی کرده باشد میگیرد. سپس متد fly
آن را فراخوانی میکند.
حالا برای اجرای این برنامه، کافی است کد زیر را بنویسیم:
fn main() { let airplane = AirPlane{}; let kakol_be_sar = Kaftar{}; fly_bird(&airplane); fly_bird(&kakol_be_sar); }
حالا اگر برنامه را اجرا کنیم، خروجی زیر را دریافت میکنیم:
Airplane is flying. Kafter The Kakol Be Sar is flying
شاید برایتان سؤال شده باشد که چرا مثل مثال اصلی از یک آرایه از این ساختارها استفاده نکردیم؟ برای فهمیدن پاسخش باید چیزهای خیلی بیشتری را بدانیم. پس باید تا جلسهی بعدی صبرکنید. 🙂
این جلسه همین جا تمام میشود. در جلسهی بعدی با هم چیزهای خیلی بیشتری را درمورد ویژگی (trait) ها یاد میگیریم و میتوانیم با هم چیزهای هیجانانگیزی را با آن بنویسیم.
دریافت کدهای این قسمت
یا حرفهای شو یا برنامهنویسی را رها کن.
چطور میشود در بازار کار خالی از نیروی سنیور ایران، بدون کمک دیگران و وقت تلفکردن خودت را تبدیل به یک نیروی باتجربه بکنی؟
پاسخ ساده است. باید حرفهای بشوی. چیزی که من در این کتاب به تو یاد خواهم داد.