آموزش زبان برنامهنویسی Rust - قسمت ۱۸ : شروع کار با Generic ها
در دو قسمت قبلی ابتدا با همدیگر مفهوم چندریختی (Polymorphism) را مرورکردیم. بعدش شیوهی اوّلی که Rust برای پیادهسازی آن پیشگرفته است، یعنی ویژگی (trait)، را دیدیم. حالا در این قسمت میخواهیم دومین راه حل Rust را برای پیادهسازی مفهوم چندریختی یادبگیریم.
فهرست مطالب
Generic چیست؟
اوّل ببینیم که generic چه چیزی است. شما فرض کنید که ما در یک جای کدمان میخواهیم میانگین آرایهای که ۳ عنصر ۱، ۲ و ۳ را دارد به دست بیاوریم:let my_first_array = [1, 2, 3]; let mut sum = 0; for array_item in my_first_array.iter() { sum += *array_item; } println!("Average value of my_first_array is: {}", sum / my_first_array.len());
حالا در یک بخش دیگر از کد یک آرایهی چهارتایی داریم که میخواهیم میانگین آن را هم حساب کنیم:
let my_second_array = [1, 2, 3, 4]; let mut sum2 = 0; for array_item in my_second_array.iter() { sum2 += *array_item; } println!("Average value of my_second_array is: {}", sum2 / my_second_array.len());
خب ما برای اینکه از تکرار شدن این کد مشابه در تمام برنامهمان جلوگیری کنیم چه کاری میکنیم؟ میآییم و یک تابع برای گرفتن میانگین یک آرایه مینویسیم:
fn get_average(array: &[usize]) -> usize { let mut count = 0; for array_item in array.iter() { count += *array_item; } return count / array.len(); } fn main() { println!("Average value of my_first_array is: {}", get_average(&my_first_array)); println!("Average value of my_secondP_array is: {}", get_average(&my_second_array)); }
الان این کد دقیقاً همان کار قبلی را میکند، با این تفاوت که به جای آنکه دو بار تمام کد مرتبط با محاسبهی میانگین را بنویسیم، با یک بار نوشتن آن در قالب یک تابع همان کار را انجام میدهیم.
اینطوری چون کد کمتری نوشتهایم، هم برنامه سریعتر نوشته میشود و هم احتمال خطا در آن کاهش مییابد. چون هرچقدر که کدهای بیشتری بنویسیم احتمال وجود باگ لابهلای آن خطها بیشتر میشود.
خب حالا تابعی که ما نوشتیم تنها میتواند میانگین آرایههایی که دادههایشان از نوع usize
است را به دست بیاورد.
اگر بخواهیم میانگین آرایههایی که از نوع i32 هستند را محاسبه کنیم باید چه کار کنیم؟ مجبوریم که یک تابع جدید عیناً شبیه به همین تابع بنویسیم که تنها نوع ورودیاش متفاوت است.
fn get_average_of_i32(array: &[i32]) -> i32 { let count = 0; for array_item in array.iter() { count += *array_item; } return count / array.len() as i32; }
این تابع دقیقاً شبیه به همان تابع قبلی است. تفاوتش با آن تابع در دو تا چیز است. اوّل اینکه ورودی و خروجی آن از جنس i32
است. دومی هم اینکه در خط آخر تابع، ما مقدار ()array.len
را به نوع i32
تبدیل (cast) کردهایم.
هرچند که این کار ربطی به مسئلهی Generic ندارد، ولی قبل از اینکه به سراغ همان بحث اصلیمان برویم این موضوع را هم با هم بررسی میکنیم.
فعلاً مسئلهی اصلی این است که ما دوباره به همان مشکلی برخوردیم که برای رهایی از آن به تعریف توابع روی آورده بودیم.
ما داریم کد تکراری مینویسیم چون نوع ورودی تابع هر بار تغییر میکند. حالا نمیشود که همان کاری که دفعهی پیش برای جلوگیری از تکرار کردیم را تکرار کنیم؟ یعنی توابع را طوری بنویسیم که برای انواع مختلف دادهها کار کنند؟ معلوم است که میشود. اسم این کار Generic است.
تبدیل دادهها به انواع مختلف (Type Casting)
قبل از اینکه حرفزدن درمورد Generic ها را شروع کنیم برویم سراغ آن کلمهی as
آخر مثال قبلی. ما در Rust تبدیل نوع ضمنی نداریم. یعنی اینطوری نیست که شما بتوانید یک کاراکتر را در یک u32 بریزید. اگر میخواهید کاری شبیه به این انجام بدهید، باید به صورت دقیق در کد مشخّصکنید که قرار است چه نوع دادهای به چه نوعی تبدیل شود.
برای این کار در زبان Rust کلمهی کلیدی as
قرار داده شده است. برای تبدیل یک نوع به نوع دیگر کافی است پس از مقدار، کلمهی as
را قرار بدهیم و نوعی را که میخواهیم دادهمان به آن تبدیل شود بنویسم.
مثلاً شما فرضکنید که میخواهیم یک مقدار u8
را به یک عدد i32
تبدیل کنیم:
fn main() { let a: u8 = 10; let b: i32 = a as i32; println!("a value is: {}", a); println!("b value is: {}", b); }
اگر این برنامه را اجرا کنیم میبینم که هر دو متغیّر مقدار ۱۰ را در خود دارند.
محدودیّتهای تبدیل نوع
تبدیل نوعهای مختلف داده به هم همیشه اینقدر آسان نیست. خیلی وقتها داده پس از تغییر نوع خراب میشود. پس ما باید حواسمان موقع تبدیل نوع خیلی جمع باشد.
ما چند محدودیّت عمده هنگام تبدیل نوع داریم:
۱- تبدیل اعداد علامتدار به عدد بدون علامت
شما فرضکنید که میخواهید یک عدد منفی را به یک نوعدادهی بدون علامت تبدیل کنید. حالا تکلیف آن علامت منفی چه میشود؟
fn main() { let a: i8 = -10; let b: u8 = a as u8; println!("a value is: {}", a); println!("b value is: {}", b); }
اگر این برنامه را اجرا کنیم خروجی چه چیزی خواهد بود؟
a value is: -10 b value is: 246
همانطوری که میبیند مقداری که پس از تبدیل نوع (type casting) در متغیّر b ریخته شده است اشتباه است. حالا این مقدار اشتباه از کجا میآید؟
ما اعداد منفی را به صورت مکمّل ۲ نمایش میدهیم. یعنی اوّل معادل مثبت آن عدد را به صورت باینری مینویسیم. بعد تمام بیتهای آن عدد را برعکس میکنیم (یعنی ۱ ها را به ۰ و ۰ ها را به ۱ تبدیل میکنیم). بعدش هم مقدار جدید را با ۱ جمع میکنیم.
معادل دودویی عدد ۱۰ میشود مقدار زیر:
00001010
حالا ما باید تمام بیتها را برعکس کنیم:
11110101
و حالا باید این مقدار را با ۱ جمع کنیم:
11110110
این مقدار آخر چیزی است که به عنوان عدد −10
در حافظه قرار میگیرد. حالا ما میخواهیم این مقدار را به یک عدد بدون علامت تبدیل کنیم. چه اتّفاقی میافتد؟ بیت آخر (سمت چپترین بیت) به جای اینکه علامت را نشان بدهد، خودش هم بخشی از عدد به حساب میآید. پس عدد به جای اینکه ۱۰− درنظر گرفته شود، تبدیل میشود به عدد ۲۴۶+.
۲-تبدیل از نوعدادهی بزرگتر به نوعدادهی کوچکتر
مشکل دیگری که ممکن است رخبدهد زمانی است که ما میخواهیم از یک نوعدادهی بزرگ مقدارمان را به نوع دادهای کوچک تبدیل کنیم. مثلاً عددی از نوع u64
را به u8
تبدیل کنیم. اگر عدد در بازهی اعداد قابل نمایش برای u8
نباشد، دادهی ما خراب میشود و پس از تبدیل مقدار عدد تغییر میکند.
fn main() { let a: u64 = 1_000_000; let b: u8 = a as u8; println!("a value is: {}", a); println!("b value is: {}", b); }
اگر این برنامه را اجرا کنیم خروجی زیر را دریافت میکنیم:
a value is: 1000000 b value is: 64
دلیل این تغییر چیست؟ ما صرفاً ۸ بیت داده را میتوانیم در نوع u8
نگهداریکنیم. این یعنی بزرگترین عدد در این نوع عدد ۲۵۵ است. حالا اگر بخواهیم عددی که برای نمایشش به بیشتر از ۸ بیت داده احتیاج است را در آن بریزیم، باید بیتهای اضافی دور ریخته بشوند(اگر نحوهی نمایش عدد یک میلیون برایتان عجیب به نظر میرسد، یعنی قسمت مربوط به مقادیر عددی را فراموشکردهاید. با کلیک روی این نوشته خیلی سریع آن را مرور کنید).
۳ تبدیل انواع غیر بنیادین
ما با کلمهی کلیدی as
تنها میتوانیم نوعدادههایی را تبدیل کنیم که به عنوان انواع بنیادین (Primitive Types) شناخته میشوند. هرچند که در اصطلاحات زبان Rust انواع بنیادین بیشتر از اینها هستند، امّا برای تبدیل نوع ما صرفاً میتوانیم با اعداد و مقادیر بولی کار کنیم.
اگر بخواهیم از انواع دیگر استفاده کنیم، یا باید با raw pointer کار کنیم یا با ویژگی From
. بعداً به این دو حالت هم میپردازیم.
خب حالا وقت این است که برویم سراغ مبحث اصلی خودمان.
ساخت یک ساختار Generic
شما فرضکنید که ما میخواهیم یک ساختار (struct) به نام Point
داشته باشیم. این ساختار گاهی اوقات برای نمایش یک نقطه روی صفحهی نمایش استفاده میشود و گاهی اوقات برای نمایش یک نقطه روی کاغذ توسّط چاپگر.
طول و عرض نقطههایی که چاپگر آنها را میفهمد در مقیاس سانتیمتر نوشتهشدهاند. بنابراین مقادیر x
و y
آن میتوانند اعداد اعشاری هم باشند. امّا نقطههایی که برای صفحهی نمایش استفاده میشوند در مقیاس پیکسل هستند، پس یک عدد صحیح بدون علامت هم میتواند آنها را مشخّص کند.
طبق چیزی که تا الان یادگرفتهایم باید برای این کار دو struct مجزا تعیین کنیم:
struct PointForScreen { x: u16, y: u16 } struct PointForPrinter { x: f32, y: f32 }
هر دوی این ساختارها برای ما یک مفهوم واحد، یعنی نقطه، را نمایش میدهند و نحوهی رفتار ما با موجودیّتهایی که از این دو نوع ساخته شده اند یکسان است.
به همین خاطر ما باید متدهای مربوط به نقطه را برای هر دو ساختار عیناً تکرار کنیم. مثلاً فرضکنید که ما یک متد داریم که جای مقادیر x و y را عوض میکند. برای کد فعلی باید یک کد ثابت را برای هردو ساختار تکرار کنیم:
impl PointForPrinter { fn swap_coordinates(&mut self) { let old_y = self.y; self.y = self.x; self.x = old_y; } } impl PointForScreen { fn swap_coordinates(&mut self) { let old_y = self.y; self.y = self.x; self.x = old_y; } }
گفتیم که ما از Generic برای جلوگیری از همین تکرار میخواهیم استفاده کنیم. امّا چطوری؟
بیایید اوّل ساختار Generic را ببینیم و بعد درمورد بخشهای مختلفش صحبت کنیم:
struct Point<T> { x: T, y: T }
همانطوری که میبینید درست مثل یک ساختار عادی ابتدا کلمهی کلیدی struct
را نوشتهایم. بعد از آن هم اسم ساختار را قرار دادهایم. تفاوت اصلی درست بعد از نام ساختار (struct) شروع میشود.
ما درست بعد از نام ساختار یک علامت بزرگتر و کوچکتر قرار دادهایم و بین آنها حرف T
را نوشتهایم. T
یک پارامتر مشخّصکنندهی نوع (Type Parameter) است. همانطوری که ما هنگام فراخوانی یک تابع میتوانیم مقادیر مختلفی را به پارامترهای ورودی آن نسبت بدهیم و هنگام فراخوانی آن تابع، آن مقادیر جایگزین پارامترها میشوند، مقدار پارامتر نوع هم با توجّه به مقادیری که درون ساختار ریخته میشوند تعیین میشود و هنگام کامپایل با نوع مرتبط جایگزین میشود.
مثلاً ما اگر یک نمونه از ساختار Point
را مثل کد زیر بسازیم، به جای T
مقدار u8
قرار میگیرد. این یعنی اینکه نوع مقادیر x
و y
میشود u8
.
let pointer_u8 = Point::<u8> { x: 10, y: 12 };
همانطوری که میبینید برای مشخّص کردن T
کافی است که بعد از اسم ساختار علامت ::
بگذاریم و بعد داخل علامتهای کوچکتر و بزرگتر نوع دلخواهمان را بنویسیم.
شما الان میتوانید هر مدل نقطهای که دوستدارید را با همین ساختار (struct) پیادهسازی کنید.
let float_pointer = Point::<f32> { x: 0.0, y: 666.32 };
حالا ما میتوانیم اصلاً نوع نقطه را هم مشخّص نکنیم و اجازه بدهیم که خود کامپایلر از مقادیری که میدهیم آن را تشخیص بدهد.
مثلاً در کد زیر نوع مقادیر x
و y
میشود i32
:
let detect_my_type = Point { x: 10, y: 11 };فقط حواستان باشد که
T
یک مثال است و شما هر اسمی که دوستدارید را میتوانید بنویسید. ولی خب استفاده از حروف بزرگ الفبای انگلیسی (عموماً آن آخریها مثل T و W و…) معمولتر است.
ساخت توابع Generic
حالا ما عین همین کار را میتوانیم با توابع هم انجام بدهیم. یعنی تابعی داشته باشیم که پارامترها و خروجیاش نوعهای مختلفی داشته باشند.
فرضکنید که میخواهیم یک تابع بنویسیم که یک نقطه از جنس Point
را بگیرد و به عنوان خروجی یک نقطهی دیگر را بدهد که جای x
و y
آن برعکس نقطهی ورودی است.
کد ما این شکلی خواهد بود:
fn swap_point<T>(original_point: Point<T>) -> Point<T> { let Point {x, y} = original_point; return Point { x: y, y: x } }
ما ابتدا مثل توابع معمولی کلمهی کلیدی fn
و نام تابع را نوشتهایم. سپس مقابل نام و بین علامتهای کوچکتر و بزرگتر، پارامتر مشخّصکنندهی نوع (Type Parameter) را قرار داده ایم. ما از این پارامتر میتوانیم برای تعیین نوع پارامترهای ورودی، مقدار خروجی و یا مقادیری که درون بدنهی تابع تعریف میشوند استفاده کنیم.
مثلاً در همین مثال، ما گفتهایم که پارامتر ورودی ما یک نمونه از ساختار Point
است که جنس مقادیرش از نوع T
است. درست مثل همان چیزی که در مثال بخش قبل در مورد ساختارها دیدیم.
بعدش هم مشخّص کردهایم که خروجی تابع ما هم یک نمونه از ساختار Point
است که مقادیرش از جنس T
هستند.
داخل تابع هم ابتدا با یک الگو (Pattern) مقادیر x
و y
پارامتر ورودی را استخراج کردهایم و درون دو تا متغیّر محلّی با نامهای x
و y
ریختهایم. حواستان باشد که این x
و y
را با مقادیر همنام در ساختار Point
اشتباه نگیرید (اگر نحوهی استخراج مقادیر درون ساختارها را با استفاده از الگوها از یاد بردهاید، با کلیک روی این نوشته خیلی سریع همه چیز را به خاطر بیاورید).
آخر سر هم یک نمونهی جدید از Point
ساختهایم. مقدار x
آن را برابر متغیّر y
درون الگو قرار دادهایم و برای مقدار y
هم عکس این کار را انجام دادهایم. اینطوری هر نمونهای از ساختار Point
را که به این تابع بدهیم به ما یک نقطهی دیگر را بر میگردد که مقادیر x
و y
آن حاصل جابهجا کردن مقادیر مشابه در نقطهی ورودی است.
شما میتوانید با همین ساختار هر تابعی که خواستید را به صورت Generic بنویسید.
حالا چطوری از این تابع استفاده کنیم؟
fn main() { let my_point = Point::<u8> { x: 10, y: 12 }; let swapped_point = swap_point(my_point); println!("swapped point x: {}, y: {}", swapped_point.x, swapped_point.y); }
همانطوری که میبینید ما ابتدا یک نمونه از ساختار Point
را از نوع u8
تعریف کردهایم. سپس نقطهای که ساختهایم را به تابع swap_point
دادهایم تا مقادیرش را برایمان جابهجا کند و یک نقطهی جدید بسازد. حالا اگر برنامه را اجرا کنیم میتوانیم مقادیر نقطهی جدید را ببینیم:
swapped point x: 12, y: 10
(نکته: فقط حواستان باشد که ما با فرستادن خود مقدار my_point
به تابع در حقیقت مقدار این متغیّر را move کردهایم و دیگر نمیتوانیم از آن استفاده کنیم. اگر این موضوع را فراموش کردهاید با کلیک روی این نوشته به قسمت مربوط به آن بروید و خیلی سریع این مفهوم را مرور کنید.)
ما توی این مثال از حالت خلاصهی فراخوانی یک تابع Generic استفاده کردهایم. اگر به هر دلیلی مجبور شدید که به صورت دقیق نوع پارامتر تعیینِ نوع را مشخّص کنید، میتوانید از این سینتکس استفاده کنید:
swap_point::<u8>(my_point)
داخل علامتهای کوچکتر و بزرگتر نوعی که جایگزین T
میشود قرار میگیرد.
ساخت متدهای Generic
ساخت یک متد Generic خیلی خیلی شبیه به ساخت یک تابع Generic است. تنها تفاوتش در این است که ما باید تمام پارامترهای تعیین نوع را هنگام تعریف بخش imp
هم معرّفی کنیم. (نحوهی تعریف متدها را فراموش کردهاید؟)
impl<Type> Point<Type> { fn swap_coordinates(&mut self) { mem::swap(&mut self.x, &mut self.y); } }
خب اینجا برای اینکه ببینید میشود به جای T
هر عبارت دیگری را هم قرار داد از کلمهی Type
استفاده کردهایم. بعد داخل متد swap_coordinates
میخواهیم که مقادیر x
و y
ساختار فعلی را عوض کنیم.
کاری که اینجا داریم میکنیم با قبلی متفاوت است. ما داریم از تابع swap برای جابهجایی این دو مقدار استفاده میکنیم که درون کتابخانهی استاندارد Rust از قبل وجود دارد. کاری که این تابع میکند این است که دو تا رفرنس به عنوان ورودی میگیرد و مقادیر این دو رفرنس را با هم عوض میکند. یعنی اگر یک رفرنس به خانهی شمارهی ۱۰ اشاره میکند و یکی به ۱۱، این تابع مقداری که درون خانهی شمارهی ۱۱ است را در خانهی ۱۰ میریزد و مقداری که درون خانهی ۱۰ قرار دارد را درون خانهی ۱۱. استفاده از این تابع به جای اینکه خودمان دستی این کد را بنویسیم به خوانایی و نگهداری کد کمک میکند.
اگر پیادهسازی این تابع را نگاه کنید میبینید که پیادهسازیاش دقیقاً همان کاری است که ما در متدهایی که ابتدای این قسمت نوشتهایم کردهایم. حالا هروقت به کدهای unsafe
رسیدیم با هم پیادهسازی این تابع را هم بررسی میکنیم.
موقع استفاده از این تابع صرفاً باید دو مورد را حتماً به خاطر داشته باشید:
اوّل اینکه برای استفاده از این تابع باید با نوشتن این خط در ابتدای فایلتان به کامپایلر بفهمانید که از کجا باید این تابع را پیدا کند (بعداً که به ماژولها و … رسیدیم بیشتر در مورد این میفهمیم):
use std::mem;
دوم اینکه ورودیهای این تابع باید دو رفرنس mutable آن هم از یک نوع باشند.
خب حالا بیایید کدی که نوشتیم را امتحان کنیم:
fn main() { let mut my_point = Point::<u8> { x: 10, y: 12 }; println!("Before swap x: {}, y: {}", my_point.x, my_point.y); my_point.swap_coordinates(); println!("Before swap x: {}, y: {}", my_point.x, my_point.y); }
حالا اگر این برنامه را اجرا کنیم میبینم که خروجی زیر را به ما میدهد:
Before swap x: 10, y: 12 Before swap x: 12, y: 10
ساخت enum های Generic
ساخت enum به صورت Generic هم درست مثل قبلیها است. بیایید مثلاً یک نمونهی ساده از enum پرکاربرد Result
را که در قسمتهای آینده با آن آشنا میشویم بسازیم.
enum Result<T> { OK(T), Err(()) }
قرار است که اگر اجرای تابع موفّقیّتآمیز بود، نتیجهی اجرای آن درون مقدار OK
قرار بگیرد و اگر هم ناموفّق بود، مقدار Err
این enum برگردانده شود.
همانطوری که میبینید ما نوعی که درون OK
قرار میگیرد را به صورت Generic تعریف کردهایم تا هرچیزی بتواند درون آن قرار بگید.
حالا بیایید یک مثال به ظاهر پیچیده را ببینیم:
fn main() { let mut my_point = Point::<u8> { x: 10, y: 12 }; let that_is_ok = Result::OK::<Point<u8>>(my_point); match that_is_ok { Result::OK(Point {x, y}) => println!("x: {}, y: {}", x, y), Result::Err(_) => println!("An Error happened"), _ => {} }; }
ما ابتدا مثل قبل یک نقطه از نوع ساختار Point
ساختهایم. سپس یک مقدار OK
ساختهایم. برای این کار، بعد از Result::OK
ما یک علامت ::
گذاشتهایم و مقابلش نوعی را که قرار است جایگزین پارامتر تعیینِ نوعِ T
بشود مشخّص کردهایم.
قرار است OK
حامل یک نمونه از ساختار Point
باشد که مقادیرش از نوع u8
هستند. سپس مقدار my_point
را هم درون OK
ریختهایم تا بعداً بتوانیم به وسیلهی یک الگو (pattern) آن را استخراج کنیم.
حالا به بخش match
میرسیم. در الگوی اوّل، اگر مقداری که درون that_is_ok
قرار داشت با الگو همخوانی داشت، مقادیر x
و y
نقطهای که درون OK
نگهداری میشود را درون دو متغیّر جدید با نامهای x
و y
استخراج میکنیم. سپس این مقادیر را چاپ میکنیم.
اگر مقدار that_is_ok
از نوع Err
بود پیام خطا چاپ میشود و اگر مقدار هرچیز دیگری بود هیچ اتّفاقی نمیافتد.
این حالتهای تو در تو در یک کد خوب و تمیز که هر روز دست و رویش را میشوید و مادرش ازش راضی است کمتر رخ میدهند. امّا مطمئناً با این شرایط سر و کله خواهید زد. مهم این است که بدانید در اوایل کار قاطی کردن بعضی از این ترتیبها عادی است و با یکم تمرین خیلی زود به آنها عادت میکنید.
The form you have selected does not exist.
خب این هم از این قسمت ما. در قسمت بعدی باقی مباحث مربوط به Generic ها را با هم یادمیگیریم.
دریافت کدهای این قسمت
یا حرفهای شو یا برنامهنویسی را رها کن.
چطور میشود در بازار کار خالی از نیروی سنیور ایران، بدون کمک دیگران و وقت تلفکردن خودت را تبدیل به یک نیروی باتجربه بکنی؟
پاسخ ساده است. باید حرفهای بشوی. چیزی که من در این کتاب به تو یاد خواهم داد.
سلام
من میخوام C++رو کامل یاد بگیرم و کمی بلدمش اما با c اشناییت ندارم بنظرتون یادگیری هر دو زبان لازمه هست ؟ یا نه یکشون رو یاد بگیریم به صورت کامل کافیه برای یادگیری زبان راست؟