استفاده از اشیاء صفت برای مقادیر با انواع مختلف
در فصل 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
پیادهسازی کند، میتواند صفت 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
ارسال میکنیم که قصد نداشتیم ارسال کنیم و بنابراین باید نوع دیگری را ارسال کنیم یا باید Draw
را روی String
پیادهسازی کنیم تا Screen
بتواند متد draw
را روی آن فراخوانی کند.
اشیاء صفت اجرای Dispatch پویا را انجام میدهند
به یاد بیاورید که در بخش “عملکرد کد با استفاده از جنریکها” در فصل 10 بحث کردیم که کامپایلر فرایند مونومورفیزه کردن را روی جنریکها انجام میدهد: کامپایلر پیادهسازیهای غیربنریک از توابع و متدها را برای هر نوع مشخصی که به جای یک پارامتر نوع جنریک استفاده میکنیم، تولید میکند. کدی که از مونومورفیزه کردن به دست میآید، dispatch استاتیک انجام میدهد، به این معنا که کامپایلر در زمان کامپایل میداند کدام متد را فراخوانی میکنید. این برخلاف dispatch پویا است، که در آن کامپایلر نمیتواند در زمان کامپایل تشخیص دهد کدام متد را فراخوانی میکنید. در موارد dispatch پویا، کامپایلر کدی تولید میکند که در زمان اجرا تشخیص میدهد کدام متد باید فراخوانی شود.
وقتی از اشیاء صفت استفاده میکنیم، Rust مجبور است از dispatch پویا استفاده کند. کامپایلر نمیداند که چه نوعهایی ممکن است با کدی که از اشیاء صفت استفاده میکند، استفاده شوند، بنابراین نمیداند کدام متد پیادهسازیشده روی کدام نوع را باید فراخوانی کند. در عوض، در زمان اجرا، Rust از اشارهگر (Pointer)های داخل شیء صفت استفاده میکند تا بداند کدام متد را باید فراخوانی کند. این جستجو هزینه زمان اجرایی به همراه دارد که با dispatch استاتیک اتفاق نمیافتد. dispatch پویا همچنین از این جلوگیری میکند که کامپایلر کد یک متد را inline کند، که به نوبه خود از برخی بهینهسازیها جلوگیری میکند. Rust همچنین قوانینی دارد که مشخص میکنند کجا میتوانید و کجا نمیتوانید از dispatch پویا استفاده کنید، که به سازگاری dyn معروف است. با این حال، ما در کدی که در لیستینگ 18-5 نوشتیم و توانستیم در لیستینگ 18-9 پشتیبانی کنیم، انعطافپذیری بیشتری به دست آوردیم، بنابراین این موضوع یک موازنه است که باید مورد توجه قرار گیرد.