ویژگی‌های زبان‌های شی‌گرا

در جامعه برنامه‌نویسی هیچ توافقی درباره اینکه یک زبان باید چه ویژگی‌هایی داشته باشد تا به‌عنوان شی‌گرا در نظر گرفته شود، وجود ندارد. Rust تحت تأثیر بسیاری از پارادایم‌های برنامه‌نویسی قرار گرفته است، از جمله OOP؛ برای مثال، ما ویژگی‌هایی که از برنامه‌نویسی تابعی آمده بودند را در فصل 13 بررسی کردیم. می‌توان گفت که زبان‌های شی‌گرا برخی ویژگی‌های مشترک دارند، یعنی اشیاء، کپسوله‌سازی (encapsulation) و وراثت (inheritance). بیایید بررسی کنیم که هر یک از این ویژگی‌ها چه معنایی دارند و آیا Rust از آن‌ها پشتیبانی می‌کند یا خیر.

اشیاء شامل داده‌ها و رفتار هستند

کتاب Design Patterns: Elements of Reusable Object-Oriented Software نوشته Erich Gamma، Richard Helm، Ralph Johnson و John Vlissides (انتشارات Addison-Wesley Professional، 1994)، که به طور غیررسمی به عنوان کتاب Gang of Four شناخته می‌شود، یک فهرست از الگوهای طراحی شی‌گرا است. این کتاب OOP را به این صورت تعریف می‌کند:

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

با استفاده از این تعریف، Rust یک زبان شی‌گرا است: structها و enumها داده دارند، و بلوک‌های impl متدهایی را برای structها و enumها ارائه می‌دهند. حتی اگر structها و enumها با متدهایی که دارند اشیاء نامیده نشوند، بر اساس تعریف Gang of Four، آن‌ها همان عملکرد را ارائه می‌دهند.

کپسوله‌سازی برای مخفی کردن جزئیات پیاده‌سازی

یکی دیگر از جنبه‌هایی که معمولاً با OOP مرتبط است، مفهوم کپسوله‌سازی است، که به این معناست که جزئیات پیاده‌سازی یک شیء برای کدی که از آن شیء استفاده می‌کند قابل دسترسی نیست. بنابراین تنها راه تعامل با یک شیء از طریق API عمومی آن است؛ کدی که از شیء استفاده می‌کند نباید بتواند به جزئیات داخلی شیء دسترسی پیدا کند و داده‌ها یا رفتار را به صورت مستقیم تغییر دهد. این امکان را به برنامه‌نویس می‌دهد که جزئیات داخلی شیء را تغییر داده و بازسازی کند بدون اینکه نیازی به تغییر کدی که از آن شیء استفاده می‌کند، داشته باشد.

ما در فصل 7 بحث کردیم که چگونه می‌توان کپسوله‌سازی را کنترل کرد: می‌توانیم از کلمه کلیدی pub استفاده کنیم تا تصمیم بگیریم کدام ماژول‌ها، انواع، توابع و متدها در کد ما عمومی باشند، و به‌طور پیش‌فرض همه چیز دیگر خصوصی است. برای مثال، می‌توانیم یک struct به نام AveragedCollection تعریف کنیم که یک فیلد شامل یک بردار از مقادیر i32 دارد. این struct همچنین می‌تواند یک فیلد داشته باشد که میانگین مقادیر موجود در بردار را نگه می‌دارد، به این معنا که نیازی به محاسبه میانگین به صورت لحظه‌ای نیست هر زمان که کسی به آن نیاز داشت. به عبارت دیگر، AveragedCollection میانگین محاسبه‌شده را برای ما کش می‌کند. لیستینگ 18-1 تعریف struct AveragedCollection را نشان می‌دهد:

Filename: src/lib.rs
pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}
Listing 18-1: An AveragedCollection struct that maintains a list of integers and the average of the items in the collection

ساختار struct با کلمه کلیدی pub علامت‌گذاری شده است تا کدهای دیگر بتوانند از آن استفاده کنند، اما فیلدهای داخل struct همچنان خصوصی باقی می‌مانند. این نکته در این مثال مهم است، زیرا می‌خواهیم اطمینان حاصل کنیم که هر زمان مقداری به لیست اضافه یا از آن حذف می‌شود، میانگین نیز به‌روزرسانی می‌شود. این کار را با پیاده‌سازی متدهای add، remove و average روی struct انجام می‌دهیم، همان‌طور که در لیستینگ 18-2 نشان داده شده است:

Filename: src/lib.rs
pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

impl AveragedCollection {
    pub fn add(&mut self, value: i32) {
        self.list.push(value);
        self.update_average();
    }

    pub fn remove(&mut self) -> Option<i32> {
        let result = self.list.pop();
        match result {
            Some(value) => {
                self.update_average();
                Some(value)
            }
            None => None,
        }
    }

    pub fn average(&self) -> f64 {
        self.average
    }

    fn update_average(&mut self) {
        let total: i32 = self.list.iter().sum();
        self.average = total as f64 / self.list.len() as f64;
    }
}
Listing 18-2: پیاده‌سازی متدهای عمومی add، remove و average در AveragedCollection

متدهای عمومی add، remove و average تنها راه‌های دسترسی یا تغییر داده‌ها در یک نمونه از AveragedCollection هستند. زمانی که یک آیتم با استفاده از متد add به list اضافه می‌شود یا با استفاده از متد remove از آن حذف می‌شود، پیاده‌سازی هر یک از آن‌ها متد خصوصی update_average را فراخوانی می‌کند که به‌روزرسانی فیلد average را مدیریت می‌کند.

ما فیلدهای list و average را خصوصی نگه می‌داریم تا هیچ راهی برای کد خارجی وجود نداشته باشد که مستقیماً آیتم‌ها را به list اضافه یا از آن حذف کند. در غیر این صورت، فیلد average ممکن است با تغییرات list هماهنگ نباشد. متد average مقدار موجود در فیلد average را بازمی‌گرداند و به کد خارجی اجازه می‌دهد تا مقدار میانگین را بخواند اما آن را تغییر ندهد.

از آنجایی که جزئیات پیاده‌سازی ساختار AveragedCollection را کپسوله کرده‌ایم، می‌توانیم به راحتی جنبه‌هایی از آن را در آینده تغییر دهیم. برای مثال، می‌توانیم به جای استفاده از Vec<i32> برای فیلد list، از یک HashSet<i32> استفاده کنیم. تا زمانی که امضای متدهای عمومی add، remove و average یکسان باقی بماند، کدی که از AveragedCollection استفاده می‌کند نیازی به تغییر برای کامپایل شدن نخواهد داشت. اگر list عمومی بود، این موضوع لزوماً صادق نبود: HashSet<i32> و Vec<i32> متدهای متفاوتی برای اضافه کردن و حذف آیتم‌ها دارند، بنابراین کد خارجی احتمالاً باید تغییر کند اگر مستقیماً list را تغییر می‌داد.

اگر کپسوله‌سازی یکی از جنبه‌های ضروری برای در نظر گرفتن یک زبان به عنوان شی‌گرا باشد، Rust این نیاز را برآورده می‌کند. امکان استفاده یا عدم استفاده از pub برای بخش‌های مختلف کد، کپسوله‌سازی جزئیات پیاده‌سازی را ممکن می‌سازد.

وراثت به‌عنوان سیستم نوع و به‌عنوان اشتراک‌گذاری کد

وراثت مکانیزمی است که به یک شیء اجازه می‌دهد عناصر را از تعریف یک شیء دیگر به ارث ببرد و در نتیجه داده‌ها و رفتار شیء والد را بدون نیاز به تعریف مجدد آن‌ها به دست آورد.

اگر وراثت باید برای یک زبان وجود داشته باشد تا شی‌گرا در نظر گرفته شود، Rust یک زبان شی‌گرا نیست. در Rust، نمی‌توانید یک struct تعریف کنید که فیلدها و پیاده‌سازی متدهای struct والد را بدون استفاده از یک ماکرو به ارث ببرد.

با این حال، اگر به استفاده از وراثت در ابزارهای برنامه‌نویسی خود عادت کرده‌اید، می‌توانید بسته به دلیل خود برای استفاده از وراثت، از راه‌حل‌های دیگری در Rust استفاده کنید.

دو دلیل اصلی برای انتخاب وراثت وجود دارد. یکی برای استفاده مجدد از کد: می‌توانید یک رفتار خاص را برای یک نوع پیاده‌سازی کنید و وراثت این امکان را فراهم می‌کند که از آن پیاده‌سازی برای یک نوع دیگر استفاده مجدد کنید. در Rust، این کار را به صورت محدود با استفاده از پیاده‌سازی‌های پیش‌فرض متدهای صفت (trait) انجام دهید، همان‌طور که در لیستینگ 10-14 دیدیم که یک پیاده‌سازی پیش‌فرض برای متد summarize در صفت Summary اضافه کردیم. هر نوعی که صفت Summary را پیاده‌سازی کند، متد summarize را بدون نیاز به کد اضافی خواهد داشت. این شبیه به این است که یک کلاس والد یک پیاده‌سازی از یک متد داشته باشد و یک کلاس فرزند ارث‌برده نیز آن پیاده‌سازی متد را داشته باشد. همچنین می‌توانیم پیاده‌سازی پیش‌فرض متد summarize را زمانی که صفت Summary را پیاده‌سازی می‌کنیم، بازنویسی کنیم که شبیه به بازنویسی پیاده‌سازی یک متد ارث‌برده شده در کلاس فرزند است.

دلیل دیگر استفاده از وراثت مربوط به سیستم نوع است: برای این که یک نوع فرزند بتواند در همان مکان‌هایی که نوع والد استفاده می‌شود، مورد استفاده قرار گیرد. این مفهوم چندریختی (polymorphism) نیز نامیده می‌شود، که به این معناست که می‌توانید چندین شیء را در زمان اجرا جایگزین یکدیگر کنید اگر آن‌ها ویژگی‌های خاصی را به اشتراک بگذارند.

چندریختی (Polymorphism)

برای بسیاری از افراد، چندریختی مترادف با وراثت است. اما در واقع یک مفهوم عمومی‌تر است که به کدی اشاره دارد که می‌تواند با داده‌هایی از انواع مختلف کار کند. در مورد وراثت، این انواع معمولاً زیرکلاس‌ها هستند.

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

وراثت اخیراً به‌عنوان یک راه‌حل طراحی برنامه‌نویسی در بسیاری از زبان‌ها محبوبیت خود را از دست داده است زیرا اغلب خطر اشتراک‌گذاری بیش از حد کد را به همراه دارد. زیرکلاس‌ها نباید همیشه تمام ویژگی‌های کلاس والد خود را به اشتراک بگذارند، اما با وراثت این اتفاق می‌افتد. این می‌تواند طراحی برنامه را کمتر انعطاف‌پذیر کند. همچنین امکان فراخوانی متدهایی روی زیرکلاس‌ها را فراهم می‌کند که معنا ندارند یا باعث خطا می‌شوند زیرا متدها برای زیرکلاس اعمال نمی‌شوند. علاوه بر این، برخی زبان‌ها فقط اجازه وراثت تک (single inheritance) را می‌دهند (یعنی یک زیرکلاس فقط می‌تواند از یک کلاس ارث ببرد)، که انعطاف‌پذیری طراحی برنامه را بیشتر محدود می‌کند.

به این دلایل، Rust رویکرد متفاوتی را با استفاده از اشیاء صفت (trait objects) به جای وراثت اتخاذ می‌کند. بیایید ببینیم که چگونه اشیاء صفت در Rust چندریختی را ممکن می‌سازند.