مقدمه

یک تابع (function) در Rust یک بلاک از دستورات است که نامی به آن اختصاص داده می‌شود تا بتوان در هر جا که لازم است، آن را فراخوانی کرد. به این ترتیب، می‌توانیم کدی را که فقط یک بار نوشته‌ایم، هر چند بار که بخواهیم اجرا کنیم و از تکرار بی‌مورد کدها جلوگیری کنیم. این یک نمونه‌ی ساده از چیزی است که Code Reusability یا قابلیت استفاده‌ی مجدد از کد گفته می‌شود. توابع می‌توانند فاقد پارامتر ورودی باشند و یا اینکه یک یا چند پارامتر ورودی را دریافت کنند. علاوه بر این، یک تابع می‌تواند با تولید یک خروجی هم همراه باشد. این مطالب را در این درس مورد بررسی قرار می‌دهیم.

تعریف تابع در Rust

کلمه کلیدی fn برای تعریف توابع در Rust کاربرد دارد. سینتکس تعریف تابع به این صورت است که بعد از کلمه کلیدی fn نام تابع و سپس، یک جفت پرانتز برای پارامترهای احنمالی و سپس، یک جفت آکلاد برای بدنه‌ی تابع ایجاد می‌شود. پس، یک تابع Rust دارای فرم کلی زیر است.

Copy Icon RUST
fn function_name() {
  //statements
}            

استایل قراردادی رایج در Rust برای نام‌گذاری توابع هم مثل متغیرها شیوه‌ی snake_case است؛ یعنی کلمات با حروف کوچک (lowercase) نوشته شده و با _ از هم جدا می‌شوند.

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

تابع main در Rust

تابع main که در درس‌های قبل هم آن را دیدیم، یک تابع خاص در Rust است که باید در هر برنامه‌ی اجرایی وجود داشته باشد. این تابع Entry point یا نقطه‌ی ورود برنامه‌های اجرایی Rust است. یعنی با اجرای برنامه، کدهای درون این تابع اجرا می‌شوند. یک پروژه با نام functions ایجاد کنید و کد زیر را در فایل main.rs وارد کنید.

Copy Icon src/main.rs
fn main() {
  println!("Welcome");
          
  say_hello();
}
          
fn say_hello() {
  println!("Hello, world!");
}

در کد بالا، تابعی با نام say_hello تعریف شده و از درون متد main فراخوانی شده است. اگر این کد را اجرا کنید، خواهید دید که دستورات درون متد main به‌ترتیب اجرا می‌شوند. بنابراین، ابتدا عبارت Welcome نمایش داده می‌شود و سپس، مقدار Hello, world! در نتیجه‌ی فراخوانی تابع say_hello چاپ می‌شود.

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.28s
     Running `target/debug/functions`
Welcome
Hello, world!
          

پارامترهای تابع

تابع say_hello که در بالا تعریف کردیم، دارای پارامتر ورودی نبود اما همانطور که قبلاً هم گفتیم، توابع می‌توانند یک یا چند پارامتر ورودی هم داشته باشند. پارامترها متغیرهایی هستند که بخشی از امضای یک تابع محسوب می‌شوند. وقتی یک تابع دارای پارامتر یا پارامترهایی باشد، باید هنگام فراخوانی تابع، مقادیری برای آن پارامترها فراهم شود؛ این مقادیر را از تظر فنی آرگومان (argument) می‌نامند. پس، پارامترها متغیرهایی هستند که هنگام تعریف تابع ایجاد می‌شوند و آرگومان‌ها مقادیری هستند که هنگام فراخوانی تابع برای پارامترها فراهم می‌شود. با این وجود، خیلی‌ها واژه‌های پارامتر و آرگومان را به جای هم به کار می‌برند.

حالا محتوای فایل main.rs را حذف کنید و کدهای زیر را در آن وارد کنید.

Copy Icon src/main.rs
fn main() {
  square(5);
}
          
fn square(x: i32) {
  println!("The square of {x} is: {x * x}");
}

در اینجا تابعی به نام square تعریف شده که یک پارامتر از نوع i32 دارد. ابتدا خروجی این برنامه را ببینید.

$ cargo run
  Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.21s
      Running `target/debug/functions`
The square of 5 is: 25         
          

این مثال ساده دو موضوع مهم را در ارتباط با توابع و پارمترهای توابع روشن می‌کند:

  • وقتی پارامتری برای یک تابع تعیین می‌کنیم، باید نوع آن را با استفاده از سینتکس type annotation مشخص کنیم. دقت داشته باشید که این یک اجبار است و اگر نوع پارامتری را تعیین نکنیم، با خطا مواجه خواهیم شد.
  • با استفاده از سینتکس {expression} درون رشته‌های متنی، می‌توانیم یک عبارت (مثل x یا x * x در مثال بالا) را درون رشته‌ها جاسازی کنیم تا کامپایلر آن عبارت را ارزیابی کرده و مقدار معادلش را در رشته قرار دهد.

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

Copy Icon src/main.rs
fn main() {
  multiply(2.5, 4.0);
}
              
fn multiply(x: f64, y: f64) {
  println!("The multiplication of {x} and {y} is: {x * y}");
}

اجازه دهید تا این کد را اجرا کنیم. کد موجود در فایل main.rs را با کد بالا جایگزین کنید و با استفاده از دستور cargo run آن را اجرا کنید.

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/functions`
The multiplication of 2.5 and 4.0 is: 10.0
          

گزاره‌ها و عبارت‌های Rust

در Rust بدنه‌ی یک تابع از مجموعه‌ای از گزاره‌ها (statements) و عبارت‌ها (expressions) تشکیل می‌شود. گزاره‌ها دستورالعمل‌هایی هستند که کاری را انجام می‌دهند و هیچ چیزی برنمی‌گردانند اما عبارت‌ها به یک مقدار ارزیابی می‌شوند. شاید این تعاریف برای گزاره‌ها و عبارت‌ها را در زبان‌های دیگر هم شنیده باشید. اما در Rust بر خلاف خیلی از زبان‌های دیگر، درک تمایز بین گزاره‌ها و عبارت‌ها یک موضوع بسیار کلیدی است. این جمله را همیشه به خاطر داشته باشید که Rust یک زبان Expression-based است.

تکرار می‌کنم که گزاره‌های Rust دستورالعمل‌هایی هستند که مقداری برنمی‌گردانند. برای مثال، ایجاد یک متغیر با استفاده از let و تخصیص یک مقدار به آن، یک گزاره است. پس، در کد زیر، تابع main از یک گزاره تشکیل شده است.

Copy Icon src/main.rs
fn main() {
  let y = 6;
}

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

گفتیم که گزاره‌ها چیزی برنمی‌گردانند. بنابراین، ما نمی‌توانیم یک گزاره‌ی let‌ را به یک متغیر دیگر تخصیص دهیم. به همین دلیل است که کد زیر با خطا مواجه می‌شود.

Copy Icon src/main.rs
fn main() {
  let x = (let y = 6);
}

پیام خطای ناشی از اجرای کد بالا از این قرار است:

$ cargo run
  Compiling functions v0.1.0 (file:///projects/functions)
error: expected expression, found `let` statement
 --< src/main.rs:2:14
  |
2 |     let x = (let y = 6);
  |              ^^^
  |
  = note: only supported directly in conditions of `if` and `while` expressions
         
warning: unnecessary parentheses around assigned value
  --< src/main.rs:2:13
  |
2 |     let x = (let y = 6);
  |             ^         ^
  |
  = note: `#[warn(unused_parens)]` on by default
help: remove these parentheses
  |
2 -     let x = (let y = 6);
2 +     let x = let y = 6;
  |
         
warning: `functions` (bin "functions") generated 1 warning
error: could not compile `functions` (bin "functions") due to 1 previous error; 1 warning emitted         
          

گزاره‌ی let y=6; مقداری برنمی‌گرداند و لذا چیزی برای تخصیص به x وجود ندارد. این امر با آنچه در زبان‌های دیگر نظیر C و Ruby وجود دارد، متفاوت است. در آن زبان‌ها می‌توانیم بنویسیم x = y = 6; و هر دوی x و y دارای مقدار ۶ خواهند بود اما چنین کاری در Rust مجاز نیست.

اما بر خلاف گزاره‌ها، عبارت‌ها به یک مقدار ارزیابی می‌شوند. یک عبارت ساده‌ی ریاضی مانند 5 + 6 را در نظر بگیرید که به مقدار 11 ارزیابی می‌شود. عبارت‌ها می‌توانند بخشی از گزاره‌ها باشند. در کد بالا 6 در گزاره‌ی let y = 6; یک عبارت است که به مقدار 6 ارزیابی می‌شود. اما عبارت‌های مستقل از گزاره هم داریم. برای مثال، فراخوانی یک تابع یا ماکرو یک عبارت است یا بلاکی که با استفاده از آکلادها ایجاد می‌شود، یک عبارت است. به مثال زیر دقت کنید.

Copy Icon src/main.rs
fn main() {
  let y = {
    let x = 3;
    x + 1
  };
          
  println!("The value of y is: {y}");
}

در اینجا بلاکی که به y تخصیص داده شده، عبارتی است که به مقدار 4 ارزیابی می‌شود. این مقدار به عنوان بخشی از گزاره‌ی let به به y تخصیص داده می‌شود. توجه داشته باشید که خط x + 1 بر خلاف اکثر خطوطی که تا الان دیده‌ایم، به یک سمی‌کالن ختم نمی‌شود. عبارت‌ها به سمی‌کالن ختم نمی‌شوند. اگر به انتهای یک عبارت سمی‌کالن افزوده شود، به یک گزاره تبدیل می‌شود که مقداری را برنمی‌گرداند.

هرچقدر حلوتر برویم، ماهیت Expression-based زبان Rust را بیشتر و بهتر درک خواهید کرد.

مقدار بازگشتی توابع

توابعی که تا الان دیدیم، فاقد مقدار بازگشتی بودند اما همانطور که قبلاً هم گفته شد، یک تابع می‌تواند مقداری را هم برگرداند. نوع مقدار بازگشتی یک تابع باید بعد از یک arrow یا فلش ( -> ) آورده شود. برای تعیین مقداری که تابع برمی‌گرداند، دو روش وجود دارد:

  • روش ضمنی که در آن مقدار بازگشتی تابع از روی آخرین عبارت موجود در بدنه‌ی تابع مشخص می‌شود.
  • روش صریح که در آن از کلمه کلیدی return برای تعیین مقدار بازگشتی تابع استفاده می‌شود.

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

Copy Icon src/main.rs
fn five() -> i32 {
  5
}
          
fn main() {
  let x = five();
          
  println!("The value of x is: {x}");
}

در بدنه‌ی تابع five هیچ اثری از فراخوانی تابع، ماکرو یا حتی گزاره‌های let نیست و فقط مقدار 5 وجود دارد. این قبیل توابع در Rust کاملاً معتبر هستند. توجه کنید که نوع بازگشتی تابع نیز i32 نعیین شده است. اگر این کد را اجرا کنید، نتیجه‌ی زیر حاصل می‌شود.

$ cargo run
  Compiling functions v0.1.0 (file:///projects/functions)
   Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
    Running `target/debug/functions`
The value of x is: 5         
          

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

Copy Icon src/main.rs
fn main() {
  let x = plus_one(5);
          
  println!("The value of x is: {x}");
}
          
fn plus_one(x: i32) -> i32 {
  x + 1
}

اجرای این کد باعث چاپ عبارت the value of x Is 6. می شود اما اگر ما یک سمی کالن در انتهای خط شامل x + 1 قرار دهیم، این عبارت را به یک گزاره تبدیل کرده‌ایم و لذا با خطا مواجه خواهیم شد؛ چون این تابع دیگر چیزی برنمی‌گرداند و این خلاف امضای تابع است که از تولید یک مقدار بازگشتی از نوع i32 حکایت می‌کند. اجازه دهید این موضوع را در عمل ببینیم.

Copy Icon src/main.rs
fn main() {
  let x = plus_one(5);
          
  println!("The value of x is: {x}");
}
          
fn plus_one(x: i32) -> i32 {
  x + 1;
}

کامپایل این کد با گزارش خطای زیر همراه است.

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
  error[E0308]: mismatched types
   --< src/main.rs:7:24
           |
7 | fn plus_one(x: i32) -> i32 {
  |    --------            ^^^ expected `i32`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail or `return` expression
8 |     x + 1;
  |          - help: remove this semicolon to return this value
         
For more information about this error, try `rustc --explain E0308`.
error: could not compile `functions` (bin "functions") due to 1 previous error         
          

بررسی پیغام خطای بالا ما را به یک نتیجه‌ی جالب می رساند. در این پیغام گفته نشده که تابع plus_one چیزی برنمی‌گرداند؛ بلکه اشاره شده که این تابع یک () برمی‌گرداند که یک تاپل است و با نوع بازگشتی امضای تابع یعنی i32 متفاوت است و به همین دلیل خطای mismatched types رخ داده است. پس، نتیجه می‌گیریم که توابع Rust حتی اگر نوع بازگشتی نداشته باشند، باز هم یک مقدار () برمی‌گردانند که یک تاپل خالی است که unit نامیده می‌شود.