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

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

آموزش زبان برنامه‌نویسی Rust - قسمت ۲۲: برنامه‌نویسی Functional با Rust

آموزش زبان برنامه‌نویسی Rust برنامه‌نویسی تابعی functional

زبان برنامه‌نویسی 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 در اختیارتان گذاشته است انجام بدهید.

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

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

یا حرفه‌ای شو یا برنامه‌نویسی را رها کن.

چطور می‌شود در بازار کار خالی از نیروی سنیور ایران، بدون کمک دیگران و وقت تلف‌کردن خودت را تبدیل به یک نیروی باتجربه بکنی؟

پاسخ ساده است. باید حرفه‌ای بشوی. چیزی که من در این کتاب به تو یاد خواهم داد.

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

4 پاسخ به “آموزش زبان برنامه‌نویسی Rust – قسمت ۲۲: برنامه‌نویسی Functional با Rust”

  1. Freehan گفت:

    با توجه به این که گفتین Iterator ها در Rust تنبل هستن و باید collect رو بعد از متد فراخوانی کنیم تا نتجیه رو برگردونه
    دلیل اینکه خاصی داره که برای map اینکارو انجام دادین ولی برای reduce نه ؟

    در کل این داستان lazy بودن برا من جا نیوفتاده ممنون میشم بیشتر توضیح‌اش بدین

    • PerAdmin گفت:

      دلیلش این است که map یک iterator تولید می‌کند. ولی reduce یک Iterator را مصرف کرده و یک مقدار عادی را بازمی‌گرداند. به همین خاطر نیازی نیست که روی دومی ما collect را صدا بزنیم.
      در کل lazy بودن به این معنا است که تا زمانی که واقعاً به خود آن مقدار نیاز نداشته باشیم سراغ محاسبه‌اش هم نخواهیم رفت.

  2. Freehan گفت:

    توی باکس فهرست مطالب یه چک باکس هست که باعث اسکرول افقی سایت میشه

    توی این مسیره 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

دیدگاهتان را بنویسید

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

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

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

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