آموزش زبان برنامهنویسی Rust - قسمت ۲۲: برنامهنویسی Functional با Rust
زبان برنامهنویسی Rust یک زبان چندالگویی (Multi Paradigm) است. یکی از الگوهای برنامهنویسی که میتوان در این زبان از آن پیروی کرد، برنامهنویسی functional است.
در این قسمت میخواهیم با هم امکاناتی را که زبان Rust برای نوشتن برنامههای Functional به ما میدهد یاد بگیریم.
فهرست مطالب
زندگی بدون Null
حتماً شما هم فحش و لعنتهایی را که پشت سر Null (یا هراسمی که زبانی که به آن کد میزنید به این مقدار داده است) شنیده اید.
سر تونی هور، یکی از خدایگان علم رایانه، که این مفهوم را برای بار اوّل معرّفی کرد، از آن به عنوان اشتباه میلیارد دلاری یاد میکند (یادآوری این گفته جزو مناسک سخنگفتن از نوع دادهی Null است. یک اجماع بینالمللی برای یادآوری مداوم این موضوع و تلاش برای خفتدادن این پیرمرد بیچاره وجود. نتیجهی اخلاقی اینکه اگر آدم مهم و معروفی هستید درمورد چیزهایی که ساختهاید عذرخواهی نکنید. چشمشان کور، اگر چیز بدی بود استفاده نمیکردند).
وجود Null چه مصیبتهایی را به همراه دارد؟
اینجا نمیخواهیم درمورد مشکلات Null صحبت کنیم، ولی بزرگترین مشکل با Null این است که نشاندهندهی هیچی است. خب این خودش مشکل نیست، مشکل این است که Null میگوید که هیچی نیست، ولی این کار را طوری انجام میدهد که میتواند باعث خطا بشود.
مثلاً کد C زیر را ببینید:
void print_str(char *str) { char current_character = str; while (*char != '\0') // \0 specifies the end of string { printf("%c", *current_character); current_character++; } } int main() { char rael_string[] = "I am a string"; print_str(real_string); char *bad_string = NULL; print_str(bad_string); return 0; }
ما اینجا یک تابع داریم که یک اشارهگر به یک رشته را میگیرد و آن را کاراکتر به کاراکتر چاپ میکند. حالا در تابع main
ما ۲ اشارهگر مختلف را به آن پاس میدهیم.
دفعهی اوّل اشارهگر واقعاً دارد به یک رشته اشاره میکند. امّا بار دوم مقدار اشارهگر NULL
است. حالا ما درون تابع داریم تلاش میکنیم که به مقدار هیچی دسترسی پیدا کنیم. در نتیجه کد ما در زمان اجرا منفجر میشود.
البته اشارهگرها تنها مکان ایجاد مشکل نیستند. در خیلی از زبانها مقادیر معمولی (مثل int و…) هم میتوانند مقدار هیچی را بگیرند.
مثلاً به عنوان مثال آخر این بحث کد زیر را ببینید:
def get_number_from_user(): try: user_input = int(input()) except ValueError: user_input = None return user_input doubled = get_number_from_user() * 2 print(doubled)
اگر کاربر ورودی غیر عددی وارد کند، ما در خط نهم داریم «هیچی» را در ۲ ضرب میکنیم. نتیجه این است که برنامهی ما دچار خطا میشود و از کار میافتد. امّا همانطوری که میبینید در زبان هیچ چیزی جلوی ما را برای انجام این کار نمیگیرد.
حالا اگر این مثال را به اندازهی استفادههای واقعی پیچیده کنید، میبینید که خیلی راحت در برنامه باگ ایجاد میشود و ما تا زمانی که مشتری عصبانی از خرابی برنامه نگوید، نمیفهمیم که مشکلی وجود دارد.
به علاوه پیداکردن چنین مشکلی خودش مقدار زیادی زمان لازم از ما میگیرد.
استفاده از Option به جای Null
ما در زبان Rust چیزی به نام Null نداریم. و خب وقتی که Null وجود نداشته باشد، مشکلاتی که گفتیم هم وجود نخواهند داشت.
مسئله این است که مفهوم «هیچی» واقعاً در برنامهنویسی لازم است. اگر Null نباشد چطور میخواهیم این مفهوم را داشته باشیم؟
زبان Rust برای این کار یک enum به نام Option دارد. تعریف (کوتاهشدهی) این enum به شکل زیر است:
pub enum Option<T> { None, Some(T), }
قضیه خیلی ساده است. اگر مقداری وجود دارد، پس درون Some
قرار میگیرد. اگر هم عملیّات ما به مقداری نرسیده است و الان «هیچی» در دست نداریم، مقدار None
برگردانده میشود.
بیایید این بار یک مثال را در زبان Rust بررسی کنیم. فرض کنید که میخواهیم تابعی بنویسیم که یک آرایه را بگیرد و اولین عدد بزرگتر از صفر آن را برگرداند.
اگر چنین عددی وجود نداشت، میخواهیم به کسی که تابع را صدا زده بفهمانیم که جواب ما «هیچ» است.
fn first_greater_than_zero(array: &[i8]) -> Option<i8> { for num in array { if *num > 0 { return Some(*num); } } Option::None }
خب حالا میتوانیم خیلی راحت بفهمیم که آیا عدد مثبتی در آرایهی ما وجود دارد یا نه.
fn main() { let array1 = [-1, -2, -3, 0, 5]; let first_positive_number = first_greater_than_zero(&array1); match first_positive_number { Some(number) => println!("First positive number in the array is: {}", number), None => println!("There is no positive number here.") }; }
اگر این برنامه را اجرا کنیم خروجی زیر را دریافت میکنیم:
First positive number in the array is: 5
Option چطوری مشکلات ما را حل میکند؟
خوبی استفاده از Option
به جای null
این است که ما باید به صورت صریح در روند اجرای برنامه بودن و نبودن مقدار را در نظر بگیریم.
اینطوری دیگر خطاهای پنهانی که به خاطر Null
بودن مقادیر پیش میآیند در کد ما وجود نخواهند داشت. چون ما از قبل حالت نبودن مقدار را مدیریت کردهایم.
متدهای Option
حالا که اینجا آمدهایم بیایید متدهای پرکاربرد Option
را هم با هم ببینیم. استفاده از این متدها خیلی وقتها کد ما را کوتاهتر و خواناتر میکند.
برخلاف خیلی از انواع دادهی دیگر ما اینجا به متدهای Option
هم میپردازیم چون زیاد به کارمان میآید و اگر بخواهیم کد functional بنویسیم زیاد باید با اینها سر و کله بزنیم. البته اینجا ما تمام متدها را نمیبینیم. صرفاً آنهایی که پراستفادهتر هستند را با هم مرور میکنیم.
اگر میخواهید فهرست تمام متدها را ببینید روی این لینک کلیک کنید.
expect
اوّلین متد، متد expect
است. این متد رشتهای را به عنوان ورودی میگیرد. در صورتی که مقدار ما از نوع Some
باشد، آن مقدار را برمیگرداند. در غیر این صورت رشتهی ورودی را به عنوان پیام panic!
در نظر میگیرد و آن را برمیگرداند.
fn main() { let array1 = [-1, -2, -3, 0]; let first_positive = first_greater_than_zero(&array1).expect("No positive number"); }
این بار در آرایهی ما هیچ عدد مثبتی وجود ندارد. پس خروجی تابع first_greater_than_zero
مقدار None
خواهد بود.
حالا چون ما از متد expect
استفاده کردهایم، کد ما در خط سوم panic میکند و اجرای برنامه متوقّف میشود:
thread 'main' panicked at 'No positive number', src/main.rs:12:26 stack backtrace:
get_or_insert
این متد رفتار جالبی دارد. ابتدا نگاه میکند تا ببیند که مقدار ما Some
است یا None
. اگر مقدار None
بود، مقداری که به عنوان ورودی به آن دادهایم را به عنوان مقدار متغیّر ما قرار میدهد.
سپس یک رفرنس mutable را به مقدار متغیّر (که درون یک Some
قرار دارد) به ما برمیگرداند.
let array1 = [-1, -2, -3, 0]; let mut first_positive = first_greater_than_zero(&array1); println!("first_positive value is: {:?}", first_positive); let value_reference: &mut i8 = first_positive.get_or_insert(0); println!("value reference is: {}", value_reference); println!("first_positive value after insert: {:?}", first_positive); }
اینجا هم مثل مثال قبلی خروجی تابع first_greater_than_zero
مقدار None
خواهد بود. ما در خط پنجم یک متغیّر از نوع &mut i8
تعریف کردهایم.
دلیلش هم این است که مقادیر آرایهی ما از نوع i8
هستند (دلیلش این است که ما تنها آن را به عنوان ورودی یک تابع استفاده کردهایم که نوع پارامتر ورودیاش یک رفرنس به آرایهای از نوع i8
است. سیستم تشخیص نوع Rust به همین خاطر به صورت خودکار نوع مقادیر این آرایه را i8
تعیین میکند).
همانطوری که گفتیم متد get_or_insert
یک رفرنس قابل تغییر به دادهای که درون Option
ذخیره میشود برمیگرداند. پس نوع متغیّر value_reference
هم باید همینطوری باشد.
خب حالا ما به متد get_or_insert
مقدار صفر را به عنوان ورودی دادهایم. یعنی اینکه اگر مقدار first_positive
برابر None
بود، عدد صفر را درون این Option
قرار بده.
از طرفی همانطوری که گفتیم این متد پس از بررسی این شرط و گذاشتن مقدار، یک رفرنس به آن را برمیگرداند. پس مقداری که value_reference
میگیرد برابر یک رفرنس به عدد صفر خواهد بود.
حالا یادتان نرود که نوع متغیّر first_positive
از جنس Option
است. پس عدد صفر درون یک Some
درون آن ذخیره میشود.
خروجی برنامهی بالا با تمام این توضیحاتی که دادیم، این است:
first_positive value is: None value reference is: 0 first_positive value after insert: Some(0)
حواستان باشد که این متد مقدار متغیّر را تغییر میدهد. پس تنها روی متغیّرهای mutable میتوانیم آن را صدا بزنیم.
get_or_insert_with
اسم طول و دراز این متد شبیه به متد قبلی است. با این تفاوت که یک with آخرش اضافه دارد. این with میخواهد بگوید که ورودی این متد به جای اینکه مثل قبلی یک مقدار باشد، یک closure است (یکم دیگر میفهمیم که closure چیست). ولی رفتارش دقیقاً مثل همان قبلی است.
تنها تفاوت این دو متد در این است که در این یکی خروجی closure به عنوان مقدار مورد نظر ما قرار میگیرد.
take
این متد صاحب مقدار فعلی میشود و به جای آن یک None
قرار میدهد.
fn main() { let array1 = [-1, -2, -3, 0, 3]; let mut first_positive = first_greater_than_zero(&array1); println!("first_positive value is: {:?}", first_positive); let taken_value = first_positive.take(); println!("taken_value is: {:?}", taken_value); println!("first_positive value after take: {:?}", first_positive); }
خروجی این برنامه میشود این:
first_positive value is: Some(3) taken_value is: Some(3) first_positive value after take: None
اگر مقدار Option
ما None
هم باشد و این متد را روی آن فراخوانی کنیم مشکلی پیش نمیآید. ولی حواستان باشد که مقدار take
لزوماً به ما یک Some
برنمیگرداند و اگر مقدار اصلی None
باشد، خروجی آن هم None
خواهد بود.
replace
این متد مقدار Option
را با مقدار جدید جایگزین میکند و مقدار قبلی را برمیگرداند.
fn main() { let array1 = [-1, -2, -3, 0, 4]; let mut first_positive = first_greater_than_zero(&array1); println!("first_positive value is: {:?}", first_positive); let old_value = first_positive.replace(0); println!("old_value is: {:?}", old_value); println!("first_positive value after replace: {:?}", first_positive); }
خروجی این برنامه میشود این:
first_positive value is: Some(4) old_value is: Some(4) first_positive value after replace: Some(0)
اینجا باید به دو نکته توجّه کنید. اوّل اینکه اگر مقدار Option
ما برابر None
باشد، پس از فراخوانی متد repalce
روی آن، یک مقدار Some
که حاوی مقدار جدید است جایگزین آن میشود.
نکتهی دوم که باید خیلی به آن توجّه کنید این است که شما نمیتوانید یک مقدار را با None
جایگزین کنید.
zip
متد بهدردبخور بعدی، متد zip
است. اگر مقدار فعلی یک مقدار Some
باشد و ورودی این متد هم یک مقدار Some
باشد، این متد یک مقدار Some
جدید را میسازد که حاوی یک tuple است. مقدار اوّل این tuple، مقداری است که درون Some
متغیّری که متد را روی آن فراخوانی کردهایم قرار دارد.
مقدار دوم هم مقدار درون Some
پارامتر ورودی است.
fn main() { let array1 = [-1, -2, -3, 0, 4]; let mut first_positive = first_greater_than_zero(&array1); println!("first_positive value is: {:?}", first_positive); let zipped_value = first_positive.zip(Some(10)); println!("zipped_value is: {:?}", zipped_value); }
حالا اگر این برنامه را اجرا کنیم، خروجی میشود مقدار زیر:
first_positive value is: Some(4) zipped_value is: Some((4, 10))
همانطوری که میبینید نتیجه شد یک tuple که درون یک مقدار Some
قرار گرفته است.
and
این متد یک Option
را به عنوان ورودی میگیرد. حالا اگر مقداری که این متد روی آن فراخوانی شده است برابر None
نبود، مقدار ورودی را برمیگرداند.
fn main() { let my_value = Some(10); let other_value = Some(15); println!("my_value.and(other_value) = {:?}", my_value.and(other_value)); }
اگر این برنامه را اجرا کنیم، میبینیم که خروجی فراخوانی این متد برابر مقدار other_value
خواهد بود:
my_value.and(other_value) = Some(15)
شاید از خودتان بپرسید که این متد به چه دردی میخورد؟ خب اگر یکم صبرداشته باشید میبینید که موقع زنجیرهسازی (chaining) این متد و متدهای مشابه کد ما را تمیزتر و خواناتر میکنند.
and_then
این متد دقیقاً مثل همان متد قبلی است. با این تفاوت که به جای پذیرفتن یک Option
، یک closure که خروجیش یک Option
است را به عنوان ورودی قبول میکند.
or
متد or
ابتدا مقداری که روی آن فراخوانی شده است را بررسی میکند. اگر آن مقدار از نوع Some
بود، همان را برمیگرداند. امّا اگر آن مقدار از نوع None
بود، به جای آن مقداری که به عنوان پارامتر ورودی گرفته است را برمیگرداند.
fn main() { let none_value = None; println!("calling or on none: {:?}", none_value.or(Some(5))); println!("calling or on none with None parameter: {:?}", none_value.or(None)); let some_value = Some(5); println!("Calling or On Some Value: {:?}", some_value.or(Some(10))); }
اگر این برنامه را اجرا کنیم خروجی مقدار زیر خواهد بود:
calling or on none: Some(5) calling or on none with None parameter: None Calling or On Some Value: Some(5)
or_else
مانند or
عمل میکند ولی یک تابع یا closure را به عنوان ورودی میپذیرد.
filter
این متد یک پارامتر ورودی دارد. پارامتر ورودیای که باید ویژگی FnOnce
را پیادهسازی کرده باشد (پایینتر میبینم که این ویژگی چیست). پس این پارامتر ورودی میتواند یک تابع یا closure باشد.
حالا اگر مقداری که داریم این متد را روی آن فراخوانی میکنیم برابر None
باشد، بدون اینکه کاری به پارامتر ورودی داشته باشد، مقدار None
را برمیگرداند.
امّا اگر مقدار ما یک Some
باشد، یک رفرنس به مقدار درون Some
به عنوان ورودی به تابع فیلترکردن داده میشود. حالا اگر این تابع مقدار true
را برگرداند، متد ما مقداری که روی آن صدازده شده است را برمیگرداند.
در غیر این صورت خروجی ما مقدار None
خواهد بود.
fn lesser_than_ten(number: &i32) -> bool { *number < 10 } fn main() { let wrong_value = Some(50); println!("wrong_value after filtering: {:?}", wrong_value.filter(lesser_than_ten)); let desired_value = Some(5); println!("desired_value after filtering: {:?}", desired_value.filter(lesser_than_ten)); }
خب همانطوری که میبینید ما یک تابع به نام lesser_than_ten
را برای فیلترکردن مقادیر تعریف کردهایم. این تابع یک رفرنس به یک مقدار i32
را به عنوان ورودی میگیرد. اگر مقدار ورودی از ۱۰ کمتر بود، مقدار true
را برمیگرداند. اگر هم بزرگتر یا مساوی ۱۰ بود، مقدار false
را برمیگرداند.
حالا درون تابع main
ما دوتا متغیّر تعریف کردهایم. اوّلی یک Some
است که عدد ۵۰ را نگهداری میکند. درون دومی هم عدد ۵ را قرار دادهایم.
حالا بیایید کد را اجرا کنیم.
wrong_value after filtering: None desired_value after filtering: Some(5)
وقتی که ما میخواهیم مقدار wrong_value
را فیلتر کنیم، یک رفرنس به عدد ۵۰ به تابع lesser_than_ten
فرستاده میشود. از آنجایی که این عدد از ۱۰ بزرگتر است، خروجی این تابع مقدار false
خواهد بود.
به همین خاطر متد filter
مقدار None
را به عنوان خروجی برمیگرداند.
امّا قضیه برای desired_value
فرق میکند. در اینجا چون خروجی lesser_than_ten
برابر true
است، خروجی ما مقدار Some(5)
خواهد بود.
unwrap_or
این متد یک پارامتر ورودی دارد. اگر مقداری که روی آن فراخوانی شده است None
باشد، خروجی ما مقدار ورودی به متد خواهد بود. امّا اگر مقداری که روی آن این متد را فراخوانی کردهایم از نوع Some
باشد، مقدار درون Some
بازگردانده میشود. کد زیر را در نظر بگیرید:
fn main() { let none_value = None; println!("calling unwrap_or_else on None: {:?}", none_value.unwrap_or(10)); let some_value = Some(5); println!("Calling or On Some Value: {:?}", some_value.unwrap_or(10)); }
خروجی ما مقدار زیر خواهد بود:
calling unwrap_or_else on None: 10 Calling or On Some Value: 5
ما ابتدا متد unwrap_or
را روی یک مقدار None
فراخوانی میکنیم. پس در نتیجه، مقداری که ما به عنوان ورودی به unwrap_or
دادهایم، یعنی عدد ۱۰، برگردانده میشود. امّا در حالت دوم ما این متد را روی یک مقدار Some
فراخوانی کردهایم. در نتیجه بدون تغییر، مقدار اوّلیّهای که درون Somo
قرار داشته بازگردانده میشود.
unwrap_or_else
مانند unwrap_or
عمل میکند. با این تفاوت که به جای یک مقدار، یک closure را به عنوان ورودی میپذیرد.
closure ها: توابع بینام و نشان
خب حالا که از شر null خلاص شدیم و زندگی با Option
را یادگرفتیم، وقت این است که برویم سراغ اصل مطلب.
اکثر آدمها وقتی که اسم برنامهنویسی تابعی و زبان Rust را کنار هم میشنوند، اوّلین چیزی که به ذهنشان میرسد، Closure است (که البته برای اینکه به همه ثابت بشود که رئیس کیست من اینجا به عنوان مبحث اول به سراغش نرفتم).
در این بخش با هم مفهوم closure را یاد میگیریم و شیوهی کارکردن با آن را میبینیم.
تابع به عنوان موجودیّت سطح یک
در زبان Rust توابع شهروند درجه یک محسوب میشوند. ما میتوانیم آنها را درون متغیّرها ذخیره کنیم، به عنوان ورودی به یک تابع بدهیم یا اینکه یک تابع را به عنوان خروجی یک تابع قرار بدهیم.
به اصطلاح فرنگیها، زبان Rust یک زبان First-class function است.
این ویژگی کمک بزرگی در نوشتن برنامهها به ما میکند و با استفاده از آن میتوانیم مجموعهی بزرگی از الگوهای معماری را پیادهسازی کنیم.
مثلاً کد زیر را ببینید. ما تابع lesser_than_ten
را که بالاتر دیدیم را میخواهیم به عنوان ورودی به یک تابع دیگر بدهیم:
fn main() { let func_place_holder = lesser_than_ten; let result = another_function(20, func_place_holder); println!("result: {}", result); } fn lesser_than_ten(number: &i32) -> bool { *number < 10 } fn another_function(number: i32, func: fn(&i32) -> bool) -> bool { func(&number) }
ما اوّل تابع lesser_than_ten
را درون یک متغیّر به اسم func_place_holder
ذخیره کردیم. سپس یک تابع به نام another_function
را فراخوانی کردیم که دوتا ورودی میگیرد.
اوّلین ورودی یک عدد از نوع i32
است و دومی هم همین متغیّری که تابع ما را ذخیره کرده است.
اگر تعریف تابع another_function
را نگاه کنید میبینید که همهچیز مثل بقیهی توابعی است که تا امروز دیدهایم. تنها تفاوت نوع پارامتر ورودی دومی است.
ما برای اینکه data type نشاندهندهی یک تابع را مشخّص کنیم، باید ابتدا کلمهی کلیدی fn
را بنویسیم. این یعنی اینکه این نوع قرار است یک تابع را ذخیره کند.
بعد از fn
و داخل پرانتزها باید نوع ورودیهایی که این تابع قرار است بگیرد را بنویسیم. همانطوری که میبینید ما اینجا اسم پارامترها را نمینویسیم، تنها نوع آنها را مشخّص میکنیم.
بعد از همهی اینها ما درست مثل یک تابع معمولی علامت ->
را میگذاریم و نوع خروجی تابع را مشخّص میکنیم.
حالا که اینقدر مفصّل توضیح دادیم، بهتر است به عنوان یک تقلّب کوچک این را به خاطر بسپارید که صرفاً کافی است اسم تابع و پارامترهایش را پاک کنید تا به نوعدادهی مربوط به آن تابع برسید.
خب حالا میبینید که ما درون تابع another_func
، پارامتر func
را درست مثل یک تابع فراخوانی کردهایم و خروجیاش را بازگرداندهایم.
closure چیست؟
بهترین کار برای شروع یادگرفتن یک مفهوم جدید، خواندن تعریف رسمی و عموماً کمتر کارآمد آن است.
به همین خاطر ما هم با تعریف closure شروع میکنیم.
یک closure، یک تابع ناشناس است که میتواند به حوزهی (scope) خارج از خود دسترسی داشته باشد.
خب حالا تمام اینها یعنی چی؟
معنی ناشناس بودن
ما میگوییم که closureها ناشناس (anonymous) هستند چون برخلاف توابع، دارای یک نام مشخّص نیستند. مثال قبلی را در نظر بگیرید.
تابع lesser_than_ten
یک نام مشخّص دارد که با استفاده از آن ما میتوانیم به آن دسترسی داشته باشیم و آن را فراخوانی کنیم.
پس ما یک راهی برای شناسایی توابع (همان نام آنها) داریم. ولی closureها برای خودشان یک نام مشخّص ندارند. به همین خاطر شما نمیتوانید بدون دسترسی به محل ذخیرهشان، آنها را صدا بزنید.
معنی دسترسی به حوزهی خارجی
حالا میرسیم به بخش دوم تعریف closure. توابع عادی امکان دسترسی به حوزهی محل تعریفشدنشان را ندارند.
برای مثال کد زیر را ببینید:
fn main() { let x = 10; fn regular_function() { println!("getting x from outer scope: {x}"); } regular_function(); }
اگر این برنامه را بخواهید کامپایل کنید (بله. ما اجازهی تعریفکردن یک تابع را درون یک تابع دیگر داریم) با خطای زیر روبهرو میشوید:
| 5 | println!("getting x from outer scope: {x}"); | ^^^ There is no argument named `x`
ما یک متغیّر به نام x
تعریف کردیم و تلاش کردیم که به آن در تابع regular_function
دسترسی پیدا کنیم. حالا چرا کد خطا خورد؟ چون توابع امکان دسترسی به حوزهای (scope) که در آن تعریف میشوند را (در این مثال، تابع main
) ندارند.
و خب تفاوت closure با توابع در همین است که شما هنگام فراخوانی یک closure به حوزهای که در آن تعریف شده هم دسترسی دارید.
شیوهی تعریف closure
خب حالا ببینیم که چطوری میتوان یک closure را تعریف کرد.
سینتکس کلی (و به درد نخور) تعریف یک closure این است:
|param1: type, param2: type,...| { // your code }
تعریف یک closure با علامت |
شروع میشود. پارامترهای ورودی بین دو علامت |
قرار میگیرند. سپس کدهای مربوط به closure را درون آکولادها مینویسیم.
خب بیایید شروع کنیم. برای مثال ما میخواهیم یک closure بنویسیم که یک ورودی میگیرد و مقدار x
را که درون حوزه (scope) فراخوانی قراردارد با آن جمع میکند:
fn main() { let x = 10; let my_closure = |input: i32| { x + input }; println!("closure output: {}", my_closure(5)); }
همانطوری که میبینید ما یک متغیّر جدید به نام my_closure
تعریف کردهایم. امّا این متغیّر به جای اینکه در خود یک مقدار را ذخیره کند، یک closure را در خود ذخیره کرده است.
یعنی ما به وسیلهی این متغیّر میتوانیم به یک مجموعه از کدهای قابل اجرا دسترسی پیدا کنیم.
همانطوری که گفتیم تعریف closure با علامت |
شروع شده است. درون علامتهای |
ما مشخّص کردهایم که این closure یک ورودی به نام input
میگیرد که نوع آن i32
است.
حالا نوبت به تعریف بدنهی این closure میرسد. ما کدهای اجرایی مربوط به closure را درست مثل توابع درون آکولاد باز و بسته نوشتهایم.
منطق closure ما هم خیلی ساده است. مقدار x
که درون scope بیرونی باید وجود داشته باشد را با مقدار ورودی جمع میکنیم و به عنوان خروجی به کسی که closure را صدا زده پس میدهیم. برای فراخوانی colsure هم کافی است که مثل توابع، نام متغیّر را بنویسیم و پس از آن پرانتزهای باز و بسته را قرار بدهیم. پارامترهای ورودی درون پرانتز به ترتیب نوشته میشوند.
حالا بیایید این برنامه را اجرا کنیم:
closure output: 15
برخلاف توابع، ما اکثر اوقات میتوانیم هنگام تعریف closure بیخیال تعریف نوع ورودی و خروجی بشویم. چون closureها ذاتاً در ناحیهای کوچک کاربرد دارند، تشخیص نوع ورودی و خروجی برای کامپایلر ساده است و بدون نیاز به کمک ما، خودش میتواند این کار را بکند. یعنی ما میتوانیم colsure بالا را اینطوری هم تعریف کنیم:
|input| { x + input }
انواع مختلف closure ها
ما به صورت کلّی و براساس رفتاری که closure از نظر حافظه با حوزهی خارجی دارد میتوانیم آن را دستهبندی کنیم.
هنگامی که یک closure مقداری را از حوزهی تعریفش استفاده میکند، باید برای آن حافظه اختصاص بدهد. به همین خاطر ما در closureهایی که از حوزهی خارجی استفاده میکنند سربار حافظه داریم. سرباری که در توابع وجود ندارد.
هر نوع مختلف closure هم با یک ویژگی(trait) خاص مشخّص میشود:
FnOnce
این نوع مقادیری را که از حوزهی (scope) خارجی میگیرد با گرفتن مالکیّت آنها مصرف میکند. اصلاً کلمهی once به همین خاطر در اسم این ویژگی وجود دارد.
ما یک مقدار خارجی داریم که یکبار مالکیّتش را گرفتهایم و آن را مصرف کردهایم. حالا اگر بخواهید دوباره closure را اجرا کنید دیگر آن مقدار بیرونی وجود ندارد. پس این closureها را تنها یکبار میتوان اجرا کرد. برای نوشتن یک closure که مالکیّت متغیّرهای ناحیهی بالاسر خودش را میگیرد، ما باید از کلمهی کلیدی move
پیش از شروع تعریف closure استفاده کنیم. به مثال زیر توجّه کنید:
fn main() { let mut my_vector = vec![1, 2, 3]; println!("x before calling closure = {:?}", my_vector); let mut my_closure = move |input| my_vector.push(input); my_closure(10); println!("x after calling closure = {:?}", my_vector); }
در اینجا ما ابتدا یک وکتور تعریف کردهایم. سپس آن را چاپ نموندهایم. بعد از آن، ما یک closure تعریف کردهایم و پیش از شروع تعریفش با کلمهی کلیدی move
اعلام کردهایم که قصد داریم مالکیّت متغیّرهایی که درونش استفاده شدهاند را بگیریم. داخل closure ما مقدار ورودی را به وکتور اضافه کردهایم. آخر سر هم مجدداً وکتور را چاپ کردهایم. اگر این برنامه را اجرا کنید با خطای زیر روبهرو میشوید:
error[E0382]: borrow of moved value: `my_vector` --> src/main.rs:6:48 | 2 | let mut my_vector = vec![1, 2, 3]; | ------------- move occurs because `my_vector` has type `Vec<i32>`, which does not implement the `Copy` trait 3 | println!("x before calling closure = {:?}", my_vector); 4 | let mut my_closure = move |input| my_vector.push(input); | ------------ --------- variable moved due to use in closure | | | value moved into closure here 5 | my_closure(10); 6 | println!("x after calling closure = {:?}", my_vector); | ^^^^^^^^^ value borrowed here after move | = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
فراموش نکنید که این ویژگی را تمام closureها پیادهسازی میکنند. حتّی آنهایی که مالکیّت متغیّرها را نمیگیرند. انواع دیگر closure زیرویژگی (sub-trait)هایی از همین ویژگی هستند. یعنی اگر شما جایی ورودیای از این نوع بگیرید، هر نوع closureی را میتوانید استفاده کنید.
FnMut
این نوع مقادیر را به صورت mutable از حوزهی خارجی قرض میگیرد. به همین خاطر امکان تغییر آنها را دارد. برای این نوع، کافی است که موقع تعریف closure آن را mutable تعریف کنیم:
fn main() { let mut x = 5; println!("x before calling closure = {}", x); let mut my_closure = |input| x += input; my_closure(10); println!("x after calling closure = {}", x); }
ما ابتدا متغیّر قابل تغییر x
را تعریف کردهایم. سپس مقدار آن را برای اطمینان چاپ کردهایم. بعد از آن به سراغ تعریف closure رفتهایم. این closure خروجی ندارد و تنها مقدار x
را با ورودی خودش جمع میکند. سپس closure را فراخوانی کردهایم و مجدداً مقدار x
را چاپ نمودهایم. خروجی ما این است:
x before calling closure = 5 x after calling closure = 15
همانطوری که میبینید ما بدون مشکل توانستهایم مقدار متغیّر را عوض کنیم. مالکیّت متغیّر هم همچنان در اختیار ناحیهی فراخوانی مانده است.
Fn
این نوع مقادیر را به صورت Immutable از حوزهی خارجی قرض میگیرد. یعنی اینکه تنها میتواند مقادیر آنها را بخواند ولی نمیتواند مقداری را عوض کند. مثال این نوع closure را با هم دیدهایم:
fn main() { let x = 10; let my_closure = |input: i32| { x + input }; println!("closure output: {}", my_closure(5)); }
کامپایلر Rust به صورت خودکار تشخیص میدهد که closure شما چه تعداد از ویژگیهای بالا را باید پیادهسازی کند.
از آنجایی که هر closure میتواند حدّاقل یک بار فراخوانی شود، پس تمام closureها ویژگی FnOnce
را پیادهسازی میکنند.
اگر closureی نیاز به تغییر مقادیر خارجی دارند ولی مالکیّت آنها را تصاحب نمیکند، FnMut
را پیادهسازی میکنند و اگر closureی نیاز به تغییر مقادیر خارجی نداشته باشد، Fn
را هم پیادهسازی میکند.
شیوهی تعریف closure به شکل خلاصه
ما تا اینجا اشکال مختلف تعریف closure را دیدهایم. برای جمعبندی، اینجا تمام اشکال تعریف closure را با هم میبینیم.
تعریف کامل
سینتکس تعریف کامل یک closure این است:
let my_closure = |x: u32| -> u32 { x + 1 };
در این حالت ما نوع ورودیها و خروجیها را مشخّص میکنیم و کد اجرایی closure را داخل آکولاد قرار میدهیم.
تعریف خلاصه
در این حالت ما نوع ورودیها و خروجیها را مشخّص نمیکنیم و اجازه میدهیم که کامپایلر خودش نوع را مشخّص کند:
let my_closure = |x| { x + 1 };
تعریف تک عبارتی
اگر بدنهی closure تنها یک عبارت (expression) باشد، ما میتوانیم حتّی آکولادها را هم حذف کنیم:
let my_closure = |x| x + 1;
هرچند من خودم زیاد با این حالت موافق نیستم. چون تأثیر مخرّبی در خوانایی دارد.
Iterator ها در Rust
امکان دیگری که زبان Rust به ما میدهد، تعریف Iterableها است. چیزهایی که ما میتوانیم روی آنها گردش کنیم و روی هر بخش آنها به نوبت کاری را انجام بدهیم. ما تا اینجا با مفهوم Iterator برخورد داشتهایم. مثلاً اگر یادتان باشد، ما درقسمت ۱۱ دیدیم که چطوری میتوان روی عناصر یک آرایه به ترتیب گردش کرد. یک وکتور را در نظر بگیرید. ما میتوانیم با فراخوانی متد iter
یک iterator را دریافت کنیم:
let my_vector = vec![1, 2, 3]; let iterator = my_vector.iter();
حالا خوبی iterator چیست؟ ما میتوانیم هربار با فراخوانی متد next
روی آن، مقدار فعلی را مصرف کنیم. اینطوری دیگر درگیر پیچیدگیهای حلقهزدن روی آرایه نمیشویم و احتمال خطا در کد کاهش مییابد (همانطور که بعداً میبینیم، لازم نیست که همیشه خودمان دستی next
را صدا بزنیم). خوبی دیگر Iteratorها این است که تنبل اند. خوبیاش چیست؟ چند خط پایینتر میبینید.
ساخت یک نوع Iterable جدید
برای ساخت یک Iterable، ما باید ویژگی Iterator
را برای آن پیادهسازی کنیم. کد زیر را مشاهده کنید:
struct EvenNumberGenerator { value: u8 } impl Iterator for EvenNumberGenerator { type Item = u8; fn next(&mut self) -> Option<Self::Item> { if self.value % 2 == 1 { self.value += 1; } else { self.value += 2; } return Some(self.value) } } fn main() { let generator = EvenNumberGenerator{value: 0}; println!("first call: {}", generator.next().unwrap()); println!("second call: {}", generator.next().unwrap()); println!("third call: {}", generator.next().unwrap()); }
ما ابتدا یک enum به نام EvenNumberGenerator
تعریف کردهایم. این enum تنها یک مقدار value را داخل خود نگهداری میکند. سپس ویژگی Iterator
را برای آن پیادهسازی کردهایم. برای این کار باید ابتدا نوع وابستهی Item را تعریف کنیم (اگر یادتان نیست که نوع وابسته چه بود، روی این متن کلیک کنید). خروجی ما با هربار گردش روی این enum از نوع u8
خواهد بود. پس نوع Item
را هم برابر با u8
قرار میدهیم. درون متد next
ما بررسی میکنیم که آیا value
فرد است یا زوج. سپس عدد زوج بعد از آن را درون value
میریزیم و در نهایت هم آن را داخل یک Some
برمیگردانیم. اینجا یکی از رفتارهای iteratorها را مشاهده میکنید. نتیجهی گردش روی یک iterator همیشه یک Option
است. اگر مقداری وجود داشته باشد شما یک مقدار از نوع Some
دریافت میکنید. اگر هم ما تمام iterator را مصرف کرده باشیم، مقدار None
را دریافت میکنیم. حالا بیایید که این کد را اجرا کنیم:
error[E0596]: cannot borrow `generator` as mutable, as it is not declared as mutable --> src/main.rs:21:32 | 20 | let generator = EvenNumberGenerator{value: 0}; | --------- help: consider changing this to be mutable: `mut generator` 21 | println!("first call: {}", generator.next().unwrap()); | ^^^^^^^^^^^^^^^^ cannot borrow as mutable
همانطور که مشاهده میکنید کامپایلر به ما خطا میدهد. اگر متن خطا را بخوانید متوجّه اشتباه میشوید. همیشه مصرف یک iterable (چه مثل اینجا با فراخوانی مستقیم متد next و چه به روشهای دیگری که خواهیم دید) وضعیّت درونی ساختار را تغییر میدهد. پس باید iterator ما به صورت mutable تعریف شده باشد. با افزودن کلمهی کلیدی mut
به تعریف generator
خروجی زیر را دریافت میکنیم:
first call: 2 second call: 4 third call: 6
کار با یک Iterator
روشهای زیادی برای مصرف یک iterator وجود دارد.
ما اینجا با هم مثدهایی را میبینیم که یک iterator را مصرف میکنند یا از روی آن یک iterator جدید میسازند.
map
این متد یک closure را روی تکتک اعضای یک iterator فراخوانی میکند و همزمان با مصرف آن، یک iterator جدید با مقادیر جدید میسازد. مثلاً کد زیر را در نظر بگیرید:
fn main() { let my_vector = vec![1, 2, 3, 4]; let square_vector = my_vector.iter().map(|x| x.pow(2)); println!("{:?}", square_vector); }
ما اینجا یک وکتور تعریف کردهایم. سپس با فراخوانی متد iter
یک iterator از روی آن ساختهایم. حالا روی آن iterator متد map
را فراخوانی کردهایم. ورودی map
یک closure است که یک ورودی به نام x
را دریافت میکند و آن را به توان دو میرساند. با اجرای این کد، هرکدام از اعضای iterator مصرف میشوند و به عنوان ورودی به closure داده میشوند. خروجیهای این closure هم به ترتیب به iteratorی که حاصل میشود میریزند. حالا بیایید این کد را اجرا کنیم:
Map { iter: Iter([1, 2, 3, 4]) }
خروجی عجیبوغریب است. امّا چرا؟ چون همانطوری که گفتیم، iteratorها تنبل هستند. یعنی تا زمانی که آنها را مصرف نکنید، کاری را انجام نمیدهند. اینجا هم ما iterator حاصل از map
را مصرف نکردهایم. پس موقع چاپ Rust صرفاً به ما میگوید که شما یک iterator دارید. حالا چطوری این را مصرف کنیم؟
collect
این متد یک iterator را به یک collection تبدیل میکند. حالا این یعنی چی؟ بیایید مثال بالا را با فراخوانی collect
روی square_vector
تکرار کنیم:
fn main() { let my_vector = vec![1, 2, 3, 4]; let square_vector: Vec<i32> = my_vector.iter().map(|x| x.pow(2)).collect(); println!("{:?}", square_vector); }
ما یکم تغییرات دادیم و نوع متغیّر square_vector
را مشخّص کردیم. دلیل این کار این است که collect
میتواند به هر collectionی تبدیل شود و اگر نوع را دقیق مشخّص نکنیم، کامپایلر نمیداند که باید چه کدی را اجرا کند. به جز تغییر گفته شده، ما متد collect
را روی خروجی map
صدا زدهایم. حالا بیایید این برنامه را اجرا کنیم:
error[E0599]: no method named `pow` found for reference `&{integer}` in the current scope --> src/main.rs:27:62 | 27 | let square_vector: Vec<i32> = my_vector.iter().map(|x| x.pow(2)).collect(); | ^^^ method not found in `&{integer}`
اینجا خطایی گرفتیم که ربطی به iteratorها ندارد. ولی برای کار با سیستم انواع (type system) راست مهم است. نوع ورودی x
در closure برابر است با i32&
. ما نمیتوانیم روی رفرنس متد pow
را صدا بزنیم. پس باید ابتدا آن را به مقدار مناسبی که کامپایلر آن را میفهمد تبدیل کنیم:
fn main() { let my_vector = vec![1, 2, 3, 4]; let square_vector: Vec<i32> = my_vector.iter().map(|x| {i32::from(*x).pow(2)}).collect(); println!("{:?}", square_vector); }
به عبارت i32::from(*x)
توجّه کنید. ما اینجا گفتهایم که x
را تبدیل به مقدارش کن و سپس به i32
گفتهایم که این را تبدیل به یکی از اعضای نوع خودت بکن. این کار برای این است که کامپایلر گمراه نشود و بفهمد که ما دقیقاً داریم با یک عدد از نوع i32
کار میکنیم. حالا اگر برنامه را اجرا کنیم به خروجی زیر میرسیم:
[1, 4, 9, 16]
همانطوری که میبینید ما به یک وکتور جدید رسیدهایم که مقادیرش برابر با توان دو های مقادیر وکتور اصلی است.
filter
متد filter
هم مانند map
یک iterable جدید میسازد. این متد یک closure میگیرد که خروجیاش باید یک مقدار boolean باشد. اگر خروجی برابر با true
بود، مقدار مصرف شده در iterable جدید حضور خواهد داشت. امّا اگر false
بود ما آن را دور میریزیم. کد زیر را ببینید:
fn main() { let my_vector = vec![1, 2, 3, 4]; let square_vector: Vec<i32> = my_vector.iter().filter( |x| *x % 2 == 0 ).map(|x| {i32::from(*x).pow(2)}).collect(); println!("{:?}", square_vector); }
ما اینجا پیش از فراخوانی map
، متد filter
را صدا زدهایم و درون آن بررسی کردهایم که آیا مقدار زوج است یا نه. اگر زوج بود، مقدار به iterator بعدی اضافه میشود و مثل بخش پیش تابع map
روی آن فراخوانی میشود. خروجی را با هم ببینیم:
[4, 16]
reduce
متد reduce
یک iterator را مصرف میکند و آن را تبدیل به یک مقدار میکند. مثلاً فرض کنید که میخواهیم مجموع اعداد درون یک وکتور را محاسبه کنیم:
fn main() { let my_vector = [1, 2, 3, 4]; let sum: Option<i32> = my_vector.iter().copied().reduce( |accumulator, element| { accumulator + element } ); println!("sum: {}", sum.unwrap()); }
ما در reduce
یک closure دریافت میکنیم که دو ورودی دارد. یکی مجموع خروجیهای reduce
تا الان و یکی هم عنصری که میخواهیم الان آن را بررسی کنیم. ما اینجا گفتهایم که عنصر فعلی را با مجموع عناصر قبلی جمع کن. اینجا علاوه بر reduce
ما یک تغییر دیگر داریم و آن فراخوانی copied
روی iter
است. در حالت عادی موقع گشتن روی iter
ما رفرنسهایی را به مقادیر دریافت میکنیم. اینجا برای اینکه بتوانیم عملیات جمع را انجام بدهیم، نیازمند دسترسی به خود مقادیر هستیم. پس با فراخوانی copied
یک iterator جدید میسازیم که تمام عناصر iterator اصلی را کپی کرده است. اینطوری به جای رفرنس، ما به خود المانها دسترسی خواهیم داشت. بیایید خروجی کد را با هم ببینیم:
sum: 10
مجموع عدد ۱۰ شد. هرچند میتوانستیم با فراخوانی متد sum
هم به همین مقدار برسیم.
شما میتوانید دیگر متدهای iterator ها را اینجا ببینید.
حالا که کاربرد copied
را یاد گرفتیم، میتوانیم از آن در مسائل کاربردیتر هم استفاده کنیم. مثلاً فرض کنید که ما میخواهیم مقدار None
را از یک iterable حذف کنیم. برای این کار کافی است که کد زیر را بنویسیم:
fn main() { let my_vector: Vec<Option<i32>> = vec![Some(1), Some(2), None, Some(7), None]; let no_none_vector: Vec<Option<i32>> = my_vector.iter().copied().filter(|x| {x.is_some()}).collect(); println!("{:?}", no_none_vector) }
خب این قسمت هم تمام شد. در این قسمت ما دیدیم که چطور میتوان با زبان Rust کد تابعی (functional) نوشت. اگر به برنامهنویسی تابعی علاقه داشته باشید میتوانید کارهای بسیار زیادی را با امکاناتی که Rust در اختیارتان گذاشته است انجام بدهید.
یا حرفهای شو یا برنامهنویسی را رها کن.
چطور میشود در بازار کار خالی از نیروی سنیور ایران، بدون کمک دیگران و وقت تلفکردن خودت را تبدیل به یک نیروی باتجربه بکنی؟
پاسخ ساده است. باید حرفهای بشوی. چیزی که من در این کتاب به تو یاد خواهم داد.
با توجه به این که گفتین Iterator ها در Rust تنبل هستن و باید collect رو بعد از متد فراخوانی کنیم تا نتجیه رو برگردونه
دلیل اینکه خاصی داره که برای map اینکارو انجام دادین ولی برای reduce نه ؟
در کل این داستان lazy بودن برا من جا نیوفتاده ممنون میشم بیشتر توضیحاش بدین
دلیلش این است که
map
یکiterator
تولید میکند. ولیreduce
یکIterator
را مصرف کرده و یک مقدار عادی را بازمیگرداند. به همین خاطر نیازی نیست که روی دومی ماcollect
را صدا بزنیم.در کل lazy بودن به این معنا است که تا زمانی که واقعاً به خود آن مقدار نیاز نداشته باشیم سراغ محاسبهاش هم نخواهیم رفت.
توی باکس فهرست مطالب یه چک باکس هست که باعث اسکرول افقی سایت میشه
توی این مسیره span.ez-toc-title-toggle a.ez-toc-pull-right.ez-toc-btn.ez-toc-btn-xs.ez-toc-btn-default.ez-toc-toggle input#item-64e61e8f4f716
از انجایی که نیازی به دیده شدن نداره اگه به این شکل ویرایشاش کنی مشکل حل میشه
بخاطر مشکل چپ چین کد رو توی لینک زیر گذاشتم
https://pastebin.com/LuhAZRrP
ممنون.