استفاده از اشیاء صفت برای مقادیر با انواع مختلف

در فصل 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 تعریف کرد:

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}
Listing 18-3: Definition of the Draw trait

این نحو باید از بحث‌های ما در فصل 10 در مورد نحوه تعریف صفات آشنا باشد. حالا به نحو جدیدی می‌رسیم: لیستینگ 18-4 یک ساختار به نام Screen را تعریف می‌کند که یک بردار به نام components دارد. این بردار از نوع Box<dyn Draw> است، که یک شیء صفت است؛ این به‌عنوان جایگزینی برای هر نوع داخل یک Box که صفت Draw را پیاده‌سازی کرده عمل می‌کند.

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}
Listing 18-4: تعریف ساختار Screen با یک فیلد components که یک بردار از اشیاء صفت را نگه می‌دارد که صفت Draw را پیاده‌سازی کرده‌اند

روی ساختار Screen، متدی به نام run تعریف می‌کنیم که متد draw را روی هر یک از components خود فراخوانی می‌کند، همان‌طور که در لیستینگ 18-5 نشان داده شده است:

Filename: src/lib.rs
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();
        }
    }
}
Listing 18-5: متد run روی Screen که متد draw را روی هر کامپوننت فراخوانی می‌کند

این روش متفاوت از تعریف ساختاری است که از یک پارامتر نوع جنریک با محدودیت‌های صفت استفاده می‌کند. یک پارامتر نوع جنریک فقط می‌تواند یک نوع مشخص را در هر زمان جایگزین کند، در حالی که اشیاء صفت به ما اجازه می‌دهند چندین نوع مشخص را در زمان اجرا به جای اشیاء صفت قرار دهیم. برای مثال، می‌توانستیم ساختار Screen را با استفاده از یک نوع جنریک و یک محدودیت صفت به صورت لیستینگ 18-6 تعریف کنیم:

Filename: src/lib.rs
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();
        }
    }
}
Listing 18-6: یک پیاده‌سازی جایگزین برای ساختار Screen و متد run آن با استفاده از جنریک‌ها و محدودیت‌های صفت

این روش ما را محدود به یک نمونه Screen می‌کند که لیستی از کامپوننت‌ها همه از نوع Button یا همه از نوع TextField داشته باشد. اگر فقط مجموعه‌های همگن داشته باشید، استفاده از جنریک‌ها و محدودیت‌های صفت ترجیح داده می‌شود زیرا این تعاریف در زمان کامپایل با استفاده از انواع مشخص مونومورفیزه می‌شوند.

از طرف دیگر، با استفاده از روش مبتنی بر اشیاء صفت، یک نمونه Screen می‌تواند یک Vec<T> داشته باشد که شامل یک Box<Button> و همچنین یک Box<TextField> باشد. بیایید ببینیم که چگونه این کار می‌کند، سپس درباره پیامدهای عملکرد در زمان اجرا صحبت کنیم.

پیاده‌سازی صفت

حالا برخی از انواعی که صفت Draw را پیاده‌سازی می‌کنند اضافه می‌کنیم. نوع Button را ارائه می‌دهیم. دوباره، پیاده‌سازی یک کتابخانه GUI کامل فراتر از محدوده این کتاب است، بنابراین متد draw هیچ پیاده‌سازی مفیدی در بدنه خود نخواهد داشت. برای تصور اینکه پیاده‌سازی ممکن است چگونه باشد، یک ساختار Button ممکن است فیلدهایی برای width، height و label داشته باشد، همان‌طور که در لیستینگ 18-7 نشان داده شده است:

Filename: src/lib.rs
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
    }
}
Listing 18-7: یک ساختار Button که صفت Draw را پیاده‌سازی می‌کند

فیلدهای width، height و label در Button با فیلدهای کامپوننت‌های دیگر متفاوت خواهند بود. برای مثال، یک نوع TextField ممکن است همان فیلدها به‌علاوه یک فیلد placeholder داشته باشد. هر یک از انواعی که می‌خواهیم روی صفحه رسم شوند، صفت Draw را پیاده‌سازی می‌کنند اما از کد متفاوتی در متد draw برای تعریف نحوه رسم آن نوع خاص استفاده می‌کنند، همان‌طور که در اینجا برای Button آمده است (بدون کد GUI واقعی، همان‌طور که ذکر شد). نوع Button، برای مثال، ممکن است یک بلوک impl اضافی شامل متدهایی مرتبط با آنچه هنگام کلیک کاربر روی دکمه اتفاق می‌افتد داشته باشد. این نوع متدها برای انواعی مانند TextField اعمال نمی‌شوند.

اگر کسی که از کتابخانه ما استفاده می‌کند تصمیم بگیرد یک ساختار SelectBox با فیلدهای width، height و options پیاده‌سازی کند، می‌تواند صفت Draw را روی نوع SelectBox نیز پیاده‌سازی کند، همان‌طور که در لیستینگ 18-8 نشان داده شده است:

Filename: src/main.rs
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() {}
Listing 18-8: یک crate دیگر که از gui استفاده می‌کند و صفت Draw را روی یک ساختار SelectBox پیاده‌سازی می‌کند

اکنون کاربر کتابخانه ما می‌تواند تابع main خود را بنویسد تا یک نمونه Screen ایجاد کند. به نمونه Screen، آن‌ها می‌توانند یک SelectBox و یک Button اضافه کنند، با قرار دادن هر یک در یک Box<T> تا به یک شیء صفت تبدیل شوند. سپس می‌توانند متد run را روی نمونه Screen فراخوانی کنند، که متد draw را روی هر یک از کامپوننت‌ها فراخوانی می‌کند. لیستینگ 18-9 این پیاده‌سازی را نشان می‌دهد:

Filename: src/main.rs
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();
}
Listing 18-9: استفاده از اشیاء صفت برای ذخیره مقادیری با انواع مختلف که یک صفت یکسان را پیاده‌سازی می‌کنند

وقتی کتابخانه را نوشتیم، نمی‌دانستیم که کسی ممکن است نوع 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 به‌عنوان یک کامپوننت ایجاد کنیم:

Filename: src/main.rs
use gui::Screen;

fn main() {
    let screen = Screen {
        components: vec![Box::new(String::from("Hi"))],
    };

    screen.run();
}
Listing 18-10: تلاش برای استفاده از نوعی که صفت شیء صفت را پیاده‌سازی نکرده است

ما این خطا را دریافت خواهیم کرد زیرا 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 پشتیبانی کنیم، انعطاف‌پذیری بیشتری به دست آوردیم، بنابراین این موضوع یک موازنه است که باید مورد توجه قرار گیرد.