وبلاگ شخصی
محمّدرضا
علی حسینی

جایی که تجربیات, علایق
و چیزهایی که یادگرفته‌ام را
با هم مرور می‌کنیم.

آموزش زبان برنامه‌نویسی Rust - قسمت ۱۹: نگاهی دقیق‌تر به Generic ها

آموزش زبان برنامه‌نویسی Rust - قسمت ۱۹: نگاهی دقیق‌تر به Generic ها

در قسمت قبلی با اینکه Generic ها چه هستند و چطوری می‌توان از آن‌ها استفاده کرد صحبت کردیم. حالا در این قسمت می‌خواهیم با هم بقیه‌ی کاربردهای Generic را ببینیم، شیوه‌ی Operator Overloading در Rust را یادبگیریم و درمورد کارآیی و شرایط استفاده از آن صحبت کنیم.

محدودکردن پارامتر تعیین نوع

گفتیم که ما می‌توانیم هر چیزی را به جای پارامتر تعیین نوع (Type Parameter) قرار بدهیم. این کار مشکلات زیادی را ایجاد می‌کند.

بیایید اوّل یک تابع Generic خیلی ساده بنویسم. تابع یک مقدار را دریافت می‌کند و آن را همراه با یک پیام چاپ می‌کند:

fn print_anything<T>(value: T) {
    println!("مقداری که برای چاپ ارسال کرده‌اید: {}", value);
}

حالا می‌خواهیم دو تا متغیّر را با استفاده از این تابع چاپ کنیم. متغیّر اوّل یک i32 است و متغیّر دوم یک f64.

fn main() {
    let a = 10;
    let b = 0.0;
    print_anything(a);
    print_anything(b);
}

اگر برنامه را اجرا کنیم چه اتّفاقی می‌افتد؟

error[E0277]: `T` doesn't implement `std::fmt::Display`
 --> src/main.rs:3:55
  |
3 |     println!("مقداری که برای چاپ ارسال کرده‌اید: {}", value);
  |                                                      ^^^^^ `T` cannot be formatted with the default formatter
  |
  = help: the trait `std::fmt::Display` is not implemented for `T`
  = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
  = help: consider adding a `where T: std::fmt::Display` bound
  = note: required by `std::fmt::Display::fmt`

کامپایلر به ما خطا می‌دهد. دلیل این خطا چیست؟ تنها داده‌هایی که ویژگی Display را پیاده‌سازی کرده‌اند قابلیّت چاپ‌شدن توسّط !println دارند.

حالا کامپایلر از کجا مطمئن باشد که این type نامعلومی که ما با اسم T مشخّص کرده‌ایم هم این ویژگی را پیاده‌سازی کرده است؟

از آنجایی که در کد ما راهی برای اثبات این موضوع نیست، کامپایلر جلوی کامپایل شدن این کد را می‌گیرد.

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

برای این کار کافی است که تابع را به این شکل تغییر بدهیم:

fn print_anything<T: std::fmt::Display>(value: T) {
    println!("مقداری که برای چاپ ارسال کرده‌اید: {}", value);
}

همانطوری که می‌بینید ما مقابل پارامتر T یک علامت : گذاشته‌ایم و بعد از این علامت اسم ویژگی (در اینجا std::fmt::Display) را نوشته‌ایم.

حالا اگر برنامه را اجرا کنیم می‌بینم که با موفّقیّت خروجی چاپ می‌شود.

مقداری که برای چاپ ارسال کرده‌اید: 10
مقداری که برای چاپ ارسال کرده‌اید: 0

ما می‌توانیم برای یک پارامتر تعیین نوع هر تعداد که دلمان خواست محدودیّت تعریف کنیم. مثلاً فرض‌کنید که می‌خواهیم یک تابع بنویسیم که دو مقدار را بگیرد و مقدار بزرگ‌تر را برایمان چاپ کند.

در Rust هر نوع داده‌ای که قابلیّت مقایسه شدن را دارد باید ویژگی Ord را پیاده‌سازی کند. همانطوری هم که دیدیم اگر قرار است چیزی با !println چاپ شود باید ویژگی Display را پیاده‌سازی کرده باشد.

تابع ما این شکلی خواهد بود:

fn print_maximum<T: Ord + Display>(value1: T, value2: T) {
    let maximum = if value1 > value2 {
        value1
    } else {
        value2
    };
    println!("maximum value: {}", maximum);
}

همان‌طوری که می‌بینید ما با گذاشتن علامت + بین ویژگی‌ها، می‌توانیم برای پارامتر تعیین نوع محدودیّت‌های جدید تعریف کنیم. (برای اینکه هر بار std::fmt::Display را ننویسیم، ابتدای فایل کد: use std::fmt::Display; را اضافه‌کرده‌ام. اینطوری برای اشاره به ویژگی Disply لازم نیست تمام آن رشته‌ی طولانی را بنویسیم).

حالا اگر تعداد این محدودیّت‌ها زیاد شود گذاشتن این + ها درون تعریف تابع باعث کاهش خوانایی کد می‌شود. برای جلوگیری از این مشکل ما می‌توانیم از کلمه‌ی کلیدی where استفاده کنیم. کافی است پس از تعریف پارامترهای تابع و پیش از شروع بدنه‌ی آن، کلمه‌ی کلیدی where را بنویسیم و بعد محدودیّت‌های پارامتر را مشخّص کنیم.

fn print_maximum<T>(value1: T, value2: T)
where T: Display + Ord
{
    let maximum = if value1 > value2 {
        value1
    } else {
        value2
    };
    println!("maximum value: {}", maximum);
}

این کد دقیقاً مشابه همان کد قبلی است. با این تفاوت که محدودیّت‌های پارامتر T را با استفاده از where مشخّص کرده‌ایم.

حواس‌تان باشد که اگر چند پارامتر تعیین نوع داشتیم، در هر دو سینتکس وقتی تعریف یکی تمام شد، باید ابتدا یک علامت , بگذاریم و بعد بعدی را تعریف کنیم.

ساخت ویژگی‌های Generic

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

trait SampleTrait<T> {
    // Trait codes
}

حالا می‌توان از نوع T در تمامی بخش‌های درون ویژگی SampleTrait استفاده کرد.

تعیین مقدار پیش‌فرض برای پارامتر تعیین نوع

ما می‌توانیم برای پارامتر تعیین نوع مقدار پیش‌فرض تعیین کنیم. مثلاً همین ویژگی بالا را می‌توانیم اینطوری بنویسیم:

trait SampleTrait<T=Self> {
    // Trait codes
}

حالا موقع استفاده از این ویژگی، اگر نوع T را مشخّص نکنیم، به صورت پیش‌فرض نوع داده‌ای که این ویژگی دارد برای آن پیاده‌سازی می‌شود جای T قرار می‌گیرد.

یعنی اگر ما SampleTrait را برای نوع داده‌ی Point پیاده‌سازی کنیم و برای پارامتر نوع مقداری را در نظر نگیریم، به صورت خودکار T برابر با Point قرار می‌گیرد.

Overload کردن عملگرها

Operator Overloading یا بازتعریف عملگرها یکی از کارهای رایج است. ما با استفاده از این ویژگی می‌توانیم رفتار عملگرها را برای داده‌های مختلف تعیین کنیم.

کردن عملگرهای منطقی و ریاضی

مثلاً فرض‌کنید که ما یک ساختار Point داریم:

struct Point {
    x: u8,
    y: u8
}

حالا ما می‌خواهیم عملگر ضرب را برای این نوع تعیین کنیم. یعنی اگر یک نمونه از این ساختار را ساختیم و آن را در یک عدد ضرب کردیم، مقادیر x و y آن نقطه درون آن عدد ضرب شوند.

بیایید اوّل کد نهایی را ببینیم:

use std::ops::Mul;

impl Mul<u8> for Point {
    type Output = Point;

    fn mul(self, rhs: u8) -> Self::Output {
        Point {
            x: self.x * rhs,
            y: self.y * rhs
        }
    }
}

Mul یکی از ویژگی‌ای هایی است که به صورت پیش‌فرض در زبان Rust وجود دارند. وقتی که ما می‌نویسیم: x * y اتّفاقی که واقعاً می‌افتد این است که متد mul مربوط به x فراخوانی می‌شود و عملوند سمت راست (یعنی y) به عنوان ورودی به آن داده می‌شود.

هر نوع‌داده‌ای که عملگر * روی آن کار می‌کند این ویژگی (trait) را پیاده‌سازی کرده است. حالا ما در برنامه‌ی بالا این ویژگی را برای ساختار Point که خودمان آن را نوشته‌ایم پیاده‌سازی کرده‌ایم.

یک بخش مهمی که در این ویژگی وجود دارد، مقدار Output است. همانطوری که می‌بینید ما پیش از اسم این مقدار کلمه‌ی کلیدی type را نوشته‌ایم و آن را برابر یک نوع (Type) قرار داده ایم. این مقدار ویژه دارد مشخّص می‌کند که نوع خروجی عمل ضرب قرار است چه چیزی باشد.

ما به Output در زبان Rust می‌گوییم: Associated type. در بخش بعدی بیشتر درمورد انواع مرتبط یا همان Associated types یاد می‌گیریم.

حالا من می‌توانم یک نمونه از ساختار Point را درون یک عدد از نوع u8 ضرب کنم و این کار باعث می‌شود که مقادیر x و y آن در آن عدد ضرب شوند.

بیایید این کد را امتحان کنیم:

fn main() {
    let my_point = Point{
        x: 10,
        y: 11
    };
    let new_point = my_point * 10;
    println!("new point. x: {}, y: {}", new_point.x, new_point.y);
}

خروجی این کد می‌شود مقدار زیر:

new point. x: 100, y: 110

ما می‌توانیم عملگر ضرب را برای هر نوع داده‌ی دیگری که دلمان خواست پیاده‌سازی کنیم.

در جدول زیر عملگرهای مختلف و اسم ویژگی (trait) مربوط به آن‌ها را آورده‌ام تا هروقت خواستید بتوانید عملگرهای دیگر را هم برای انواع داده‌ها overload کنید.

حواستان باشد که هرکدام از این بخش‌ها را ابتدا باید مثل مثال بالا با کلمه‌ی کلیدی use معرّفی کنید. کافی است که اسم ویژگی (trait) را به جای Mul در این مثال قرار بدهید.

نام ویژگی عملگر متناظر متدی که باید overload شود
Add + add
AddAssign =+ add_assign
BitAnd & bitand
BitAndAssign =& bitand_assign
BitOr \| bitor
BitOrAssign =\| bitor_assign
BitXor ^ bitxor
BitXorAssign =^ bitxor_assign
Deref * (برای dereference کردن) deref
DerefMut * (برای dereference کردن به صورت mutable) deref_mut
Div / div
DivAssign =/ div_assign
Drop زمانی که مقدار از scope خارج می‌شود و قرار است پاک شود، این متد فراخوانی می‌شود. drop
Index برای ایندکس دهی استفاده می‌شود. مثلاً می‌توانیم بنویسیم: [value[index index
IndexMut برای ایندکس دهی در شرایط mutable استفاده می‌شود. مثلاً: [value[index index_mut
Mul * mul
MulAssign =* mul_assign
Neg – (علامت منفی کردن که تنها یک ورودی دارد. با علامت منها اشتباه نکنید.) neg
Not ! not
Rem % rem
RemAssign =% rem_assign
Shr >> shl
ShlAssign =>> shl_assign
Shr << shr
ShrAssign =<< shr_assign
Sub sub
SubAssign =- sub_assign

Overload کردن عملگرهای مقایسه‌ای

علاوه بر این عملگرها، ما می‌توانیم عملگرهای مقایسه‌ای را هم overload کنیم. برای این کار به جای اینکه با استفاده از use ماژول opt را وارد برنامه کنیم، باید از ماژول cmp استفاده کنیم.

مثلاً فرض‌کنید که ما می‌خواهیم عملگر == را برای نوع داده‌ی Point پیاده‌سازی کنیم:

use std::cmp::PartialEq;

struct Point {
    x: u8,
    y: u8
}


impl PartialEq<Point> for Point {
    fn eq(&self, other: &Point) -> bool {
        self.x == other.x && self.y == other.y
    }
}

همانطوری که می‌بینید ما ابتدا PartialEq را با استفاده از use به برنامه معرّفی کرده‌ایم. سپس آن را برای ساختار Point پیاده‌سازی کرده‌ایم. حواستان باشد که پارامتر دومی که این عملگر می‌گیرد، در حقیقت یک رفرنس به داده است، نه خود داده. به همین خاطر باید other را از نوع &Point تعریف کنیم.

یا مثلاً ما می‌توانیم به شکل زیر عملگرهای کوچک‌تر و بزرگ‌تر را Overload کنیم:

use std::cmp::Ordering;

struct Point {
    x: u8,
    y: u8
}


impl PartialEq<Point> for Point {
    fn eq(&self, other: &Point) -> bool {
        self.x == other.x && self.y == other.y
    }
}


impl PartialOrd<Point> for Point {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        if self.x + self.y > other.x + other.y {
            return Some(Ordering::Greater);
        } else if self.x + self.y == other.x + other.y {
            return Some(Ordering::Equal);
        } else {
            return Some(Ordering::Less);
        }
    }
}

همان‌طوری که می‌بینید ما اینجا یک متد به نام partial_cmp را برای ویژگی PartialOrd پیاده‌سازی کرده‌ایم. این متد قرار است که عمل مقایسه را برای ما انجام بدهد.

اگر نتیجه‌ی مقایسه این بود که مقدار دوم (مقداری که سمت راست عملگر قرار می‌گیرد) از مقدار اوّل بزرگتر است، ما باید مقدار Ordering::Greater را به عنوان مقداری درونی enum معروف Some (که بعداً بیشتر درموردش حرف می‌زنیم) خروجی بدهیم.

اگر مقادیر برابر اند باید مقدار Ordering::Equal را برگردانیم و اگر مقدار دوم کوچک‌تر است، مقدار Ordering::Less را.

پیاده‌سازی متد partial_cmp ضروری است. بقیه‌ی متدهای این ویژگی پیاده‌‌سازی پیش‌فرض دارند و از این متد برای فهمیدن اینکه نتیجه‌ی عملگر چه چیزی خواهد بود استفاده می‌کنند. هرچند که خودتان هم می‌توانید پیاده‌سازی متدهای مربوط به عملگرهای مختلف را آنطوری که دوست‌دارید انجام بدهید.

به علاوه حواستان باشد که برای پیاده‌سازی این ویژگی، باید پیش از آن ویژگی PartialEq را هم پیاده‌سازی کنید. چون این ویژگی از PartialEq ارث‌بری می‌کند.

در جدول زیر عملگرهای مختلف و اسم ویژگی (trait) مربوط به آن‌ها را برایتان آورده‌ام:

نام ویژگی عملگر متناظر متدی که باید overload شود
PartialEq == eq
PartialEq =! ne (پیاده‌سازی این متد اختیاری است)
Eq ==
PartialOrd >,<,==, =<, =>, =! partial_cmp
PartialOrd < gt (پیاده‌سازی این متد اختیاری است)
PartialOrd > lt (پیاده‌سازی این متد اختیاری است)
PartialOrd => le (پیاده‌سازی این متد اختیاری است)
PartialOrd =< ge (پیاده‌سازی این متد اختیاری است)
Ord >, <, == cmp

این جدول یکم نیاز به توضیح دارد.

فرق Eq با PartialEq این است که اوّلی نشان‌دهنده‌ی رابطه‌ی هم‌ارزی جزئی (Partial equivalence) است و دومی نشان‌دهنده‌ی رابطه‌ی هم‌ارزی (Equivalence).

حالا فرق این دوتا در چیست؟ در Partial equivalence ما تقارن و تعدّی داریم. یعنی اگر رابطه را با حرف R نمایش‌بدهیم و a و b دو متغیّر باشند. روابط زیر برقرار است:

aRb => bRa
aRb && bRc => aRc

در رابطه‌ی هم‌ارزی هم این روابط برقرار است. امّا ما علاوه بر این‌ها، خاصیّت بازتابی هم داریم:

aRa

در زبان Rust برای اینکه نشان بدهیم رابطه‌ی ما هم‌ارزی کامل است، باید علاوه بر PartialEq، ویژگی Eq را هم برای آن پیاده‌سازی کنیم. امّا این ویژگی خودش هیچ متد یا مقداری ندارد. صرفاً برای اینکه شما موقع استفاده از کد حواس‌تان باشد که این ویژگی برقرار است به کار می‌آید.

موقع پیاده‌سازی باید حواس‌تان به تفاوت‌های PartialOrd و Ord هم باشد.

PartialOrd زمانی پیاده‌سازی می‌شود که برای نوع‌داده‌ی ما دو رابطه‌ی زیر صدق کند:

a < b => !(a > b)
a < b && b < c => a < c

رابطه‌ی اوّل باید برای رابطه‌ی بزرگ‌تر هم صدق کند. رابطه‌ی دوم هم باید هم برای بزرگ‌تر و هم مساوی صادق باشد.

Ord زمانی پیاده‌سازی می‌شود که برای نوع‌داده‌ی ما دو رابطه‌ی زیر صدق می‌کند:

a < b xor a == b xor a > b
a < b && b < c  => a < c

رابطه‌ی اوّل یعنی اینکه تنها یکی از روابط بزرگ‌تر، کوچک‌تر یا مساوی بین دو داده از این نوع درست خواهند بود. یعنی ما چیزی به نام بزرگ‌تر مساوی (=<) یا کوچک‌تر مساوی (=>) نداریم. رابطه‌ی دوم هم دقیقاً مثل PartialOrd است.

باز هم حواس‌تان باشد که اگر می‌خواهید Ord را پیاده‌سازی کنید، باید حتماً قبلش ویژگی‌های Eq و PartialOrd را پیاده‌سازی کنید.

این‌ها عملگرهایی بودند که ما می‌توانیم آن‌ها را در Rust برای انواعی که دوست‌داریم Overload کنیم. بقیه‌ی عملگرها، مثل && و || امکان Overload شدن ندارند، چون مختص به داده‌های بولی اند.

Associated Type چیست؟

یک نوع مرتبط یا همان Associated Type صرفاً اسمی است که موقع پیاده‌سازی یک ویژگی (trait)، با نوع مدنظر برنامه‌نویس جایگزین می‌شود و می‌توان آن را در تعریف متد و … استفاده کرد.

ما در بخش قبلی کاربرد Associated Type ها را در Operand Overloading دیدیم. امّا معروف‌ترین کاربرد Associated Type ها در ویژگی Iterator است (بعداً همه‌چیز را درمورد این ویژگی یادخواهیم گرفت. نگران نباشید).

trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}

ما در این ویژگی یک Associated Type به نام Item داریم. انواع مرتبط را با کلمه‌ی کلیدی type درون ویژگی تعریف می‌کنیم.

ما در تعریف متد next گفته‌ایم که نوع خروجی <Option<Self::Item است. حالا تفاوت این با همان Generic چیست؟

فرض‌کنید که ما همین ویژگی را به شکل Generic پیاده‌سازی کنیم:

trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}

الان چه اتّفاقی می‌افتد؟ اوّلین و البته کم‌اهمّیّت‌ترین تفاوت این است که باید برای هر پیاده‌سازی مقدار T را مشخّص کنیم.

امّا مسئله‌ی مهم‌تر این است که در حالت دوم ما می‌توانیم چندین پیاده‌سازی مختلف از Iterator را برای یک نوع داده‌ی خاص داشته باشیم.

یعنی من هم‌زمان می‌توانم برای ساختار X یک Iterator تعریف کنم که خروجی متد next آن از نوع u8 باشد و هم یک Iterator تعریف کنم که خروجی این متد در آن از نوع String باشد.

امّا وقتی که از Associated Type استفاده می‌کنیم، ما تنها یک پیاده‌سازی ممکن برای یک نوع خواهیم داشت و در همان پیاده‌سازی نوع Item را مشخّص می‌کنیم.

impl Iterator for X {
    type Item = u8;
    
    fn next(&mut self) -> Option<Self::Item> {
        // implementation
    }
}

ما اینجا نوع u8 را به وسیله‌ی Item به ویژگی Iterator مرتبط کرده‌ایم. اصلاً اسم این ویژگی به همین خاطر انتخاب شده است.

تأثیر استفاده از Generic در سرعت اجرای برنامه

حالا شاید این سؤال برایتان پیش آمده باشد که استفاده از Generic ها در سرعت اجرای برنامه چه تأثیری دارد؟

خبر خوب این است که استفاده از Generic هیچ سربار اضافی‌ای ندارد. یعنی سرعت اجرای برنامه زمانی که از Generic استفاده می‌کنید فرقی با وقتی که از آن استفاده نمی‌کنید ندارد.

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

مثلاً اگر ما در برنامه ساختار Point را یک‌بار برای نوع u8 و یک‌بار هم برای نوع i32 استفاده کرده باشیم، کامپایلر موقع کامپایل، دو ساختار مختلف را تولید می‌کند.

می‌توانیم فرض‌کنیم که در چنین حالتی، کد ما تبدیل به کد زیر می‌شود:

struct Point_u8 {
    x: u8,
    y: u8
}

struct Point_i32 {
    x: i32,
    y: i32
}

حالا هرجایی که از u8 استفاده کرده‌ایم، نوع داده با Point_u8 جایگزین می‌شود و هرجا که از i32 استفاده کرده‌ایم با Point_i32.

این یعنی اینکه سرعت اجرای برنامه مطلقاً تحت تأثیر قرار نمی‌گیرد، امّا حجم Code Segment بیشتر می‌شود که ممکن است در برنامه‌هایی که قرار است در سیستم‌های نهفته (embedded) اجرا شوند مشکل ایجاد کند. چون در بعضی از این سیستم‌ها ما محدودیّت زیادی برای حجم برنامه‌ی نهایی داریم. امّا در کاربردهای روزمره هیچ مشکلی برای ما ایجاد نمی‌شود.

کی از ویژگی‌ها استفاده کنیم و کی از Generic ها؟

ویژگی‌ها برای ما رابط (Interface) ایجاد می‌کنند، امّا Generic ها به ما این امکان را می‌دهند که بتوانیم یک پیاده‌سازی را برای نوع (Type) های مختلف داشته باشیم.

Generic ها هم یک راه برای پیاده‌سازی چندریختی (Polymorphism) اند. با این تفاوت که ما علاوه بر رابط، پیاده‌سازی را هم بین انواع مختلف به اشتراک می‌گذاریم.

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

به علاوه یادتان باشد که ویژگی‌ها (trait) از ساختار fat pointer استفاده می‌کنند. به همین خاطر هنگام اجرای برنامه کمی سربار زمانی ایجاد می‌کنند. البته این سربار آنقدر کم است که به جز موارد خیلی استثنایی اصلاً لازم نیست به آن فکر کنید.

اگر قسمت قبلی را از دست‌داده‌اید، با کلیک روی این نوشته خیلی سریع با مفهوم Generic در زبان Rust آشنا بشوید.

اوّلین بار است که این مجموعه‌ی آموزشی را می‌بینید؟ با کلیک روی این نوشته به اوّلین قسمت بروید و آموزش زبان Rust را به صورت کامل و ساده شروع کنید.

«نوشته‌های مرتبط»

پاسخی بگذارید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

«نوشته‌های ویژه»

«نوشته‌های محبوب»

«دیدگاه کاربران»