آموزش زبان برنامهنویسی 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 را به صورت کامل و ساده شروع کنید.یا حرفهای شو یا برنامهنویسی را رها کن.
چطور میشود در بازار کار خالی از نیروی سنیور ایران، بدون کمک دیگران و وقت تلفکردن خودت را تبدیل به یک نیروی باتجربه بکنی؟
پاسخ ساده است. باید حرفهای بشوی. چیزی که من در این کتاب به تو یاد خواهم داد.
سلام، کارتون خیلی عالیه. من چند ماه پیش یادگیری راست رو شروع کردم و تا حد کمی یاهاش آشنا شدم، ولی بخاطر زمانبندی پروژهام ازش دور شدم. خواندن مطالب شما مرور کاملی بود برام. شیوه نگارش داکیومنت زبان راست خیلی راحت و قابل درکه، شما حتی بهتر از داکیومنت خود راست توضیح دادید. از شما ممنونم و منتظر دیدن ادامه پستهاتون هستم. پیشنهاد میکنم بعد از اتمام آموزشهاتون 1 پروژه کوچیک تعریف کنیم و همراه سایر خواننده ها بصورت عملی آموزشهای شما رو پیاده کنیم.