استفاده از اشیاء صفت برای مقادیر با انواع مختلف
در فصل 8، اشاره کردیم که یکی از محدودیتهای وکتورها این است که میتوانند فقط عناصر یک نوع را ذخیره کنند. در لیستینگ 8-9، راهحلی ایجاد کردیم که در آن یک enum به نام SpreadsheetCell تعریف کردیم که انواع مختلفی مانند اعداد صحیح، اعداد اعشاری و متن را در خود جای میداد. این به ما اجازه میداد دادههای مختلفی را در هر سلول ذخیره کنیم و همچنان یک وکتور داشته باشیم که نمایانگر یک ردیف از سلولها باشد. این راهحل زمانی مناسب است که آیتمهای قابل تعویض ما مجموعهای ثابت از انواع باشد که هنگام کامپایل کد میدانیم.
با این حال، گاهی اوقات میخواهیم کاربران کتابخانه ما بتوانند مجموعهای از انواع معتبر در یک وضعیت خاص را گسترش دهند. برای نشان دادن نحوه انجام این کار، یک ابزار رابط کاربری گرافیکی (GUI) نمونه ایجاد میکنیم که از طریق یک لیست از آیتمها تکرار میکند و متدی به نام draw را برای هر آیتم فراخوانی میکند تا آن را روی صفحه رسم کند—یک تکنیک رایج برای ابزارهای GUI. یک crate کتابخانهای به نام gui ایجاد میکنیم که ساختار یک کتابخانه GUI را شامل میشود. این crate ممکن است شامل برخی انواع باشد که افراد از آنها استفاده کنند، مانند Button یا TextField. علاوه بر این، کاربران gui میخواهند انواع خود را که میتوانند رسم شوند ایجاد کنند: برای مثال، یک برنامهنویس ممکن است یک Image اضافه کند و دیگری ممکن است یک SelectBox اضافه کند.
ما برای این مثال یک کتابخانه GUI کامل پیادهسازی نخواهیم کرد، اما نشان خواهیم داد که قطعات چگونه به هم متصل میشوند. هنگام نوشتن این کتابخانه، نمیتوانیم تمام انواعی که برنامهنویسان دیگر ممکن است بخواهند ایجاد کنند را بدانیم و تعریف کنیم. اما میدانیم که gui باید مقادیر زیادی از انواع مختلف را پیگیری کند و باید متدی به نام draw را برای هر یک از این مقادیر با نوع متفاوت فراخوانی کند. نیازی به دانستن دقیق آنچه هنگام فراخوانی متد draw اتفاق میافتد نداریم، فقط اینکه مقدار باید این متد را داشته باشد.
برای انجام این کار در یک زبان با وراثت، ممکن است یک کلاس به نام Component تعریف کنیم که یک متد به نام draw داشته باشد. سایر کلاسها، مانند Button، Image و SelectBox، از Component ارث میبرند و به این ترتیب متد draw را به ارث میبرند. آنها میتوانند متد draw را بازنویسی کنند تا رفتار سفارشی خود را تعریف کنند، اما فریمورک میتواند تمام این انواع را به گونهای مدیریت کند که گویی نمونههایی از Component هستند و متد draw را روی آنها فراخوانی کند. اما چون Rust وراثت ندارد، باید راه دیگری برای ساختاردهی کتابخانه gui پیدا کنیم تا به کاربران اجازه دهد آن را با انواع جدید گسترش دهند.
تعریف یک صفت برای رفتار مشترک
برای پیادهسازی رفتاری که میخواهیم gui داشته باشد، یک صفت به نام Draw تعریف میکنیم که یک متد به نام draw خواهد داشت. سپس میتوانیم یک وکتور تعریف کنیم که یک شیء صفت را بگیرد. یک شیء صفت به یک نمونه از یک نوع که صفت مشخصی را پیادهسازی کرده اشاره میکند و همچنین یک جدول برای جستجوی متدهای صفت روی آن نوع در زمان اجرا را شامل میشود. برای ایجاد یک شیء صفت، باید نوع اشارهگر (Pointer) (مانند یک ارجاع & یا یک اشارهگر (Pointer) هوشمند Box<T>)، کلمه کلیدی dyn و سپس صفت مربوطه را مشخص کنیم. (در فصل 20، بخش “انواع با اندازه پویا و صفت Sized” دلیل اینکه اشیاء صفت باید از یک اشارهگر (Pointer) استفاده کنند را توضیح خواهیم داد.) میتوانیم از اشیاء صفت به جای یک نوع جنریک یا نوع مشخص استفاده کنیم. هر جا که از یک شیء صفت استفاده کنیم، سیستم نوع Rust در زمان کامپایل تضمین میکند که هر مقداری که در آن زمینه استفاده شود، صفت شیء صفت را پیادهسازی میکند. بنابراین نیازی به دانستن تمام انواع ممکن در زمان کامپایل نداریم.
اشاره کردیم که در Rust از استفاده از اصطلاح “اشیاء” برای structها و enumها اجتناب میکنیم تا آنها را از اشیاء سایر زبانها متمایز کنیم. در یک struct یا enum، دادهها در فیلدهای struct و رفتار در بلوکهای impl جدا شدهاند، در حالی که در سایر زبانها دادهها و رفتار معمولاً در یک مفهوم واحد به نام شیء ترکیب میشوند. اما اشیاء صفت در Rust بیشتر شبیه اشیاء در سایر زبانها هستند، زیرا دادهها و رفتار را ترکیب میکنند. با این حال، اشیاء صفت از اشیاء سنتی متفاوت هستند زیرا نمیتوان دادهای به یک شیء صفت اضافه کرد. اشیاء صفت به اندازه اشیاء در سایر زبانها عمومی نیستند: هدف خاص آنها فراهم کردن انتزاع در رفتار مشترک است.
لیستینگ 18-3 نشان میدهد چگونه میتوان یک صفت به نام Draw با یک متد به نام draw تعریف کرد:
pub trait Draw {
fn draw(&self);
}
Draw traitاین نحو باید از بحثهای ما در فصل 10 در مورد نحوه تعریف صفات آشنا باشد. حالا به نحو جدیدی میرسیم: لیستینگ 18-4 یک ساختار به نام Screen را تعریف میکند که یک بردار به نام components دارد. این بردار از نوع Box<dyn Draw> است، که یک شیء صفت است؛ این بهعنوان جایگزینی برای هر نوع داخل یک Box که صفت Draw را پیادهسازی کرده عمل میکند.
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
Screen با یک فیلد components که یک بردار از اشیاء صفت را نگه میدارد که صفت Draw را پیادهسازی کردهاندروی ساختار Screen، متدی به نام run تعریف میکنیم که متد draw را روی هر یک از components خود فراخوانی میکند، همانطور که در لیستینگ 18-5 نشان داده شده است:
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
run روی Screen که متد draw را روی هر کامپوننت فراخوانی میکنداین روش متفاوت از تعریف ساختاری است که از یک پارامتر نوع جنریک با محدودیتهای صفت استفاده میکند. یک پارامتر نوع جنریک فقط میتواند یک نوع مشخص را در هر زمان جایگزین کند، در حالی که اشیاء صفت به ما اجازه میدهند چندین نوع مشخص را در زمان اجرا به جای اشیاء صفت قرار دهیم. برای مثال، میتوانستیم ساختار Screen را با استفاده از یک نوع جنریک و یک محدودیت صفت به صورت لیستینگ 18-6 تعریف کنیم:
pub trait Draw {
fn draw(&self);
}
pub struct Screen<T: Draw> {
pub components: Vec<T>,
}
impl<T> Screen<T>
where
T: Draw,
{
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
Screen و متد run آن با استفاده از جنریکها و محدودیتهای صفتاین روش ما را محدود به یک نمونه Screen میکند که لیستی از کامپوننتها همه از نوع Button یا همه از نوع TextField داشته باشد. اگر فقط مجموعههای همگن داشته باشید، استفاده از جنریکها و محدودیتهای صفت ترجیح داده میشود زیرا این تعاریف در زمان کامپایل با استفاده از انواع مشخص مونومورفیزه میشوند.
از طرف دیگر، با استفاده از روش مبتنی بر اشیاء صفت، یک نمونه Screen میتواند یک Vec<T> داشته باشد که شامل یک Box<Button> و همچنین یک Box<TextField> باشد. بیایید ببینیم که چگونه این کار میکند، سپس درباره پیامدهای عملکرد در زمان اجرا صحبت کنیم.
پیادهسازی صفت
حالا برخی از انواعی که صفت Draw را پیادهسازی میکنند اضافه میکنیم. نوع Button را ارائه میدهیم. دوباره، پیادهسازی یک کتابخانه GUI کامل فراتر از محدوده این کتاب است، بنابراین متد draw هیچ پیادهسازی مفیدی در بدنه خود نخواهد داشت. برای تصور اینکه پیادهسازی ممکن است چگونه باشد، یک ساختار Button ممکن است فیلدهایی برای width، height و label داشته باشد، همانطور که در لیستینگ 18-7 نشان داده شده است:
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}
impl Draw for Button {
fn draw(&self) {
// code to actually draw a button
}
}
Button که صفت Draw را پیادهسازی میکندفیلدهای width، height و label در Button با فیلدهای کامپوننتهای دیگر متفاوت خواهند بود. برای مثال، یک نوع TextField ممکن است همان فیلدها بهعلاوه یک فیلد placeholder داشته باشد. هر یک از انواعی که میخواهیم روی صفحه رسم شوند، صفت Draw را پیادهسازی میکنند اما از کد متفاوتی در متد draw برای تعریف نحوه رسم آن نوع خاص استفاده میکنند، همانطور که در اینجا برای Button آمده است (بدون کد GUI واقعی، همانطور که ذکر شد). نوع Button، برای مثال، ممکن است یک بلوک impl اضافی شامل متدهایی مرتبط با آنچه هنگام کلیک کاربر روی دکمه اتفاق میافتد داشته باشد. این نوع متدها برای انواعی مانند TextField اعمال نمیشوند.
اگر کسی که از کتابخانهی ما استفاده میکند بخواهد یک ساختار SelectBox تعریف کند که شامل فیلدهای width، height و options باشد، او همچنین باید trait Draw را برای نوع SelectBox پیادهسازی کند، همانطور که در لیستینگ 18-8 نشان داده شده است.
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// code to actually draw a select box
}
}
fn main() {}
gui استفاده میکند و صفت Draw را روی یک ساختار SelectBox پیادهسازی میکنداکنون کاربر کتابخانه ما میتواند تابع main خود را بنویسد تا یک نمونه Screen ایجاد کند. به نمونه Screen، آنها میتوانند یک SelectBox و یک Button اضافه کنند، با قرار دادن هر یک در یک Box<T> تا به یک شیء صفت تبدیل شوند. سپس میتوانند متد run را روی نمونه Screen فراخوانی کنند، که متد draw را روی هر یک از کامپوننتها فراخوانی میکند. لیستینگ 18-9 این پیادهسازی را نشان میدهد:
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// code to actually draw a select box
}
}
use gui::{Button, Screen};
fn main() {
let screen = Screen {
components: vec![
Box::new(SelectBox {
width: 75,
height: 10,
options: vec![
String::from("Yes"),
String::from("Maybe"),
String::from("No"),
],
}),
Box::new(Button {
width: 50,
height: 10,
label: String::from("OK"),
}),
],
};
screen.run();
}
وقتی کتابخانه را نوشتیم، نمیدانستیم که کسی ممکن است نوع SelectBox را اضافه کند، اما پیادهسازی Screen ما توانست روی نوع جدید عمل کند و آن را رسم کند زیرا SelectBox صفت Draw را پیادهسازی کرده است، که به این معناست که متد draw را پیادهسازی کرده است.
این مفهوم—فقط به پیامهایی که یک مقدار به آنها پاسخ میدهد اهمیت داده میشود، نه نوع دقیق مقدار—مشابه مفهوم duck typing در زبانهای با نوعدهی پویا است: اگر مانند اردک حرکت میکند و مانند اردک صدا میکند، پس حتماً یک اردک است! در پیادهسازی متد run روی Screen در لیستینگ 18-5، run نیازی ندارد بداند نوع دقیق هر کامپوننت چیست. نیازی ندارد بررسی کند که آیا یک کامپوننت نمونهای از Button یا SelectBox است؛ فقط متد draw را روی کامپوننت فراخوانی میکند. با مشخص کردن Box<dyn Draw> بهعنوان نوع مقادیر در بردار components، ما تعریف کردهایم که Screen به مقادیری نیاز دارد که بتوانیم متد draw را روی آنها فراخوانی کنیم.
مزیت استفاده از اشیاء صفت و سیستم نوع Rust برای نوشتن کدی مشابه با duck typing این است که هرگز نیازی به بررسی نداریم که آیا یک مقدار متدی خاص را در زمان اجرا پیادهسازی کرده است یا خیر، یا نگران خطاهایی باشیم اگر یک مقدار متدی را پیادهسازی نکرده اما ما آن را فراخوانی کنیم. Rust کد ما را کامپایل نمیکند اگر مقادیر صفاتی را که اشیاء صفت نیاز دارند پیادهسازی نکنند.
برای مثال، لیستینگ 18-10 نشان میدهد چه اتفاقی میافتد اگر بخواهیم یک Screen با یک String بهعنوان یک کامپوننت ایجاد کنیم:
use gui::Screen;
fn main() {
let screen = Screen {
components: vec![Box::new(String::from("Hi"))],
};
screen.run();
}
ما این خطا را دریافت خواهیم کرد زیرا String صفت Draw را پیادهسازی نکرده است:
$ cargo run
Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
--> src/main.rs:5:26
|
5 | components: vec![Box::new(String::from("Hi"))],
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
|
= help: the trait `Draw` is implemented for `Button`
= note: required for the cast from `Box<String>` to `Box<dyn Draw>`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` (bin "gui") due to 1 previous error
این خطا به ما اطلاع میدهد که یا چیزی را به Screen پاس دادهایم که منظورمان نبوده و باید نوع متفاوتی پاس دهیم، یا باید trait Draw را برای نوع String پیادهسازی کنیم تا Screen بتواند متد draw را روی آن فراخوانی کند.
اشیاء صفت اجرای Dispatch پویا را انجام میدهند
در بخش «کارایی کدی که از Genericها استفاده میکند» در فصل ۱۰، دربارهی فرآیند مونومورفیزاسیون (monomorphization) که توسط کامپایلر روی genericها انجام میشود صحبت کردیم: کامپایلر پیادهسازیهای غیر generic از توابع و متدها را برای هر نوع مشخصی که بهجای پارامتر generic استفاده میکنیم تولید میکند. کدی که در نتیجهی مونومورفیزاسیون بهدست میآید از ارسال ایستا (static dispatch) استفاده میکند، به این معنا که کامپایلر در زمان کامپایل میداند کدام متد را فراخوانی میکنید. این در مقابل ارسال پویا (dynamic dispatch) است، که در آن کامپایلر نمیتواند در زمان کامپایل تشخیص دهد کدام متد فراخوانی خواهد شد. در حالت ارسال پویا، کامپایلر کدی تولید میکند که در زمان اجرا تشخیص میدهد کدام متد را باید فراخوانی کند.
زمانی که از trait objectها استفاده میکنیم، Rust مجبور است از ارسال پویا استفاده کند. کامپایلر نمیداند همهی نوعهایی که ممکن است با کدی که از trait object استفاده میکند به کار روند، کدامند؛ بنابراین نمیتواند مشخص کند کدام متد روی کدام نوع باید فراخوانی شود. در عوض، Rust در زمان اجرا از اشارهگرهای درون trait object استفاده میکند تا بداند کدام متد را باید فراخوانی کند. این جستجو هزینهای در زمان اجرا دارد که در ارسال ایستا رخ نمیدهد. همچنین ارسال پویا مانع از این میشود که کامپایلر کد متد را inline کند که این موضوع باعث جلوگیری از برخی بهینهسازیها میشود. Rust قوانینی در مورد محلهایی که میتوان و نمیتوان از ارسال پویا استفاده کرد دارد که به آنها هماهنگی dyn (dyn compatibility) گفته میشود. این قوانین فراتر از محدودهی این بحث هستند، اما میتوانید دربارهی آنها بیشتر در مستندات مرجع بخوانید.
با این حال، کدی که در لیستینگ 18-5 نوشتیم و در لیستینگ 18-9 پشتیبانی کردیم، انعطافپذیری بیشتری داشت؛ بنابراین این یک معاملهی قابل توجه است که باید در نظر گرفته شود.