مقدمه

Rust یک ساختار کنترلی بسیار قدرتمند و منعطف با نام match دارد که به ما امکان می‌دهد یک مقدار را با چند الگو (pattern) مقایسه کنیم. کد متناظر با اولین الگویی که با مقدار مورد نظر مطابقت داشته باشد، اجرا خواهد شد. الگوها می‌توانند از مقادیر لیترال، متغیرها، کاراکترهای wildcard و خیلی چیزهای دیگر تشکیل شده باشند. بخش مهمی از قدرت match از این حقیقت ناشی می‌شود که کامپایلر بررسی می‌کند که همه‌ی حالات ممکن هندل شده باشد و در غیر این صورت، اجازه‌ی کامپایل برنامه را نمی‌دهد. جزئیات مربوط به این موضوع را در ادامه خواهیم دید.

معرفی match با یک مثال

در آمریکا سکه‌های 1 سنتی penny نام دارند، سکه‌ی 5 سنتی را nickel می‌گویند، سکه‌ی 10 سنتی dime نام دارد و سکه‌ی 25 سنتی quarter نامیده می‌شود. می‌خواهیم تابغی بنویسیم که نام یک سکه را دریافت کند و عدد معادلش به سنت را برگرداند.

Copy Icon src/main.rs
fn main() {}

enum Coin {
  Penny,
  Nickel,
  Dime,
  Quarter,
}
            
fn value_in_cents(coin: Coin) -> u8 {
  match coin {
    Coin::Penny => 1,
    Coin::Nickel => 5,
    Coin::Dime => 10,
    Coin::Quarter => 25,
  }
}

سینتکس match به قدری خوانا هست که عملکرد کد بالا نیازی به توضیح نداشته باشد. با این حال، همانطور که می‌بینید، ابتدا یک شمارشی با نام Coin تعریف شده که دارای 4 آیتم، حالت یا variant است. در واقع، ما می‌خواهیم موجودیت سکه را مدل کنیم و چون هر سکه می‌تواند یکی از 4 نوع سکه‌ای که نام بردیم، باشد، بهتر است که این موجودیت را در قالب یک شمارشی تعریف کنیم.

در ادامه، تابعی تعریف شده که یک پارامتر از نوع Coin می‌گیرد. اما اتفاق مهم در بدنه‌ی این تابع رخ داده؛ جایی که از یک عبارت match استفاده کرده‌ایم. یادآوری می‌کنم که توابع، آخرین عبارت موجود در بدنه‌ی خودشان را ارزیابی کرده و مقدارش را برمی‌گردانند. در اینجا عبارت match بسته به اینکه آرگومانش کدام آیتم شمارشی باشد، یکی از اعداد مشخص شده را برمی‌گرداند.

به بیان دقیق‌تر، در اینجا مقدار ورودی تابع یعنی coin مقداری است که باید بررسی شود. درون بلاک match بازوهای match دیده می‌شوند. هر بازوی match از یک بخش الگو و یک بخش کد تشکیل می‌شود که با => از هم جدا می‌شوند. کد هر بازویی که الگویش با مقدار coin مطابقت داشته باشد، اجرا می‌شود. پس، وقتی یک عبارت match اجرا می‌شود، مقداری را که بعد از کلمه کلیدی match آورده شده با الگوی هر بازو مقایسه می‌کند. اگر یک الگو با مقدار مطابقت داشته باشد، کد مربوط به آن الگو اجرا می‌شود. در عیر این صورت، الگوی بعدی بررسی می‌شود.

در مثال بالا، کد هر بازو فقط از یک مقدار عددی تشکیل شده اما اگر کد مربوط به بازو بیش از یک خط باشد، باید از آکلادها استفاده کنیم. برای مثال، در کد زیر، اگر تابع با مقدار Coin::Penny فراخوانی شود، ابتدا یک پیام نمایش داده شده و سپس، مقدار 1 برگردانده می‌شود.

Copy Icon src/main.rs
fn value_in_cents(coin: Coin) -> u8 {
  match coin {
    Coin::Penny => {
      println!("Lucky penny!");
      1
    }
    Coin::Nickel => 5,
    Coin::Dime => 10,
    Coin::Quarter => 25,
  }
}

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

الگوهای دارای مقدار

یک ویژگی مفید دیگر بازوهای match این است که می‌توانند مقدار نگه دارند. اجازه دهید این موضوع را با توسعه‌ی مثال بالا بررسی کنیم. از سال ۱۹۹۹ تا ۲۰۰۸ ایالات متحده برای هر ایالت یک سکه‌ی 25 سنتی با طرح منفاوت ضرب کرد. این ویژگی فقط مختص سکه‌های 25 سنتی بود و سایر سکه‌ها در همه‌ی ایالت‌ها طرح یکسانی داشتند. برای پیاده‌سازی این ویژگی در کد، ابتدا یک شمارشی با نام UsState برای ایالت‌ها تعریف می‌کنیم و سپس، ترتیبی می‌دهیم که آیتم Quarter در شمارشی Coin بتواند مقداری از نوع UsState را نگه دارد.

Copy Icon src/main.rs
#[derive(Debug)]
enum UsState {
  Alabama,
  Alaska,
  Arizona,
  // --snip--
}
            
enum Coin {
  Penny,
  Nickel,
  Dime,
  Quarter(UsState),
}

حالا یک متغیر با نام state را به الگویی که با مقدار Coin::Quarter مطابقت دارد، اضافه می‌کنیم. سپس، می‌توانیم از state در کد مربوط به آن الگو استفاده کنیم.

Copy Icon src/main.rs
fn value_in_cents(coin: Coin) -> u8 {
  match coin {
    Coin::Penny => 1,
    Coin::Nickel => 5,
    Coin::Dime => 10,
    Coin::Quarter(state) => {
      println!("State quarter from {state:?}!");
      25
    }
  }
}

حالا می‌توانیم با اجرای کد زیر ببینیم که فراخوانی تابع value_in_cents() با مثادیری که با الگوی Coin::Quarter مطابقت دارند، چه نتیجه‌ای دارد.

Copy Icon src/main.rs
fn main() {
  value_in_cents(Coin::Quarter(UsState::Alaska));
  value_in_cents(Coin::Quarter(UsState::Arizona));
}
            
#[derive(Debug)]
enum UsState {
  Alabama,
  Alaska,
  Arizona,
  // --snip--
}
            
enum Coin {
  Penny,
  Nickel,
  Dime,
  Quarter(UsState),
}

fn value_in_cents(coin: Coin) -> u8 {
  match coin {
    Coin::Penny => 1,
    Coin::Nickel => 5,
    Coin::Dime => 10,
    Coin::Quarter(state) => {
      println!("State quarter from {state:?}!");
      25
    }
  }
}

نتیجه‌ی اجرای این کد به صورت زیر خواهد بود.

State quarter from Alaska!
State quarter from Arizona!
          

استفاده از match به همراه Option

دیدیم که چطور می‌توانیم با استفاده از match، یک مقدار را با آیتم‌های یک شمارشی مقایسه کنیم. به علاوه، دیدیم که چطور می‌توانیم مقداری را که یک الگوی match نگه می‌دارد، استخراج کنیم و از این مقدار در کد مربوط به آن الگو استفاده کنیم. این دقیقاً همان چیزی است که در پایان درس قبل به دنبالش بودیم. یعنی روشی برای استخراج مقداری که هنگام استفاده از Option توسط Some نگه داشته می‌شود. کافیست کاری را که در بالا روی شمارشی Coin انجام دادیم، روی شمارشی Option انجام دهیم.

فرض کنید قصد داریم تابعی بنویسیم که یک Option<i32> دریافت کند و اگر مقداری در آن باشد، یک واحد به آن اضافه کند و در غیر این صورت، بدون اینکه کاری انجام دهد، یک مقدار None برگرداند.

Copy Icon src/main.rs
          
fn main() {
  let five = Some(5);
  let six = plus_one(five);
  let none = plus_one(None);
}
          
fn plus_one(x: Option<i32>) -> Option<i32> {
  match x {
    None => None,
    Some(i) => Some(i + 1),
  }
}
          

می‌بینید که به لطف استفاده‌ی ترکیبی از match و enum، نوشتن این تابع به سادگی ممکن می‌شود. این الگویی است که به دفعات در کدهای خود از آن استفاده خواهیم کرد: استفاده از match روی یک شمارشی، وابسته کردن یک متغیر به دیتایی که نگه می‌دارد و اجرای کد بر اساس آن. این الگو شاید در ابتدا کمی پیچیده به تظر برسد اما وقتی به آن عادت کردید، زندگی بدون آن برایتان سخت خواهد شد.

جامعیت عبارات match

یک ویژگی مهم عبارات match در Rust جامعیت (exhaustiveness) آنهاست. منظور از جامعیت عبارات match این است که همه‌ی حالات ممکن باید پردازش شوند. برای مثال، اگر روی یک شمارشی از match استفاده کنیم، بازوهای match باید همه‌ی حالات یا آیتم‌های شمارشی را پوشش دهند. یا اگر از match روی یک مقدار u8 استفاده کنیم، بازوهای match باید همه‌ی مقادیر u8 یعنی اعداد صفر تا 255 را پوشش دهند. ورژن زیر از تابع plus_one() این اصل را رعایت نکرده و بنابراین، چنین برنامه‌ای کامپایل نخواهد شد.

Copy Icon src/main.rs
fn plus_one(x: Option<i32>) -> Option<i32> {
  match x {
    Some(i) => Some(i + 1),
  }
}

در اینجا از match روی یک Option استفاده شده اما آیتم یا حالت None پردازش نشده و بازویی به آن اختصاص داده نشده است. بنابراین، این کد یک باگ دارد و اجرای آن به نمایش خطای زیر منجر می‌شود.

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0004]: non-exhaustive patterns: `None` not covered
 --> src/main.rs:3:15
  |
3 |         match x {
  |               ^ pattern `None` not covered
  |
note: `Option<i32>` defined here
 --> /rustc/129f3b9964af4d4a709d1383930ade12dfe7c081/library/core/src/option.rs:571:1
 ::: /rustc/129f3b9964af4d4a709d1383930ade12dfe7c081/library/core/src/option.rs:575:5
  |
  = note: not covered
  = note: the matched value is of type `Option<i32>
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
  |
4 ~             Some(i) => Some(i + 1),
5 ~             None => todo!(),
  |

For more information about this error, try `rustc --explain E0004`.
error: could not compile `enums` (bin "enums") due to 1 previous error
          

کامپایلر متوجه شده که ما همه‌ی حالات ممکن را پوشش نداده‌ایم و این امر به طور خاص در مورد Option به این معناست که Rust به ما اجازه نمی‌دهد که حالت None را فراموش کنیم و این یعنی اینکه ما باگ‌های مربوط به null را تجربه نخواهیم کرد.

الگوهای catch-all

در یک عبارت match می‌توانیم چند مقدار را هندل کنیم و برای سایر مقادیر، یک اقدام پیش‌فرض تعریف کنیم. یک بازی را در نظر بگیرید که در آن، کاربر دو تاس را به صورت همزمان می‌ریزد. اگر مجموع دو تاس برابر با 3 باشد، بازیکن حرکت نمی‌کند و در عوض، یک کلاه شیک دریافت می‌کند. اگر مجموع دو تاس 7 باشد، یک کلاه از دست می‌دهد و به ازای هر عدد دیگر، به تعداد آن در خانه‌های صفحه‌ی بازی حرکت می‌کند. کد زیر، این منطق را با استفاده از یک عبارت match پیاده‌سازی می‌کند. البته با توجه به هدفی که داریم، بدنه‌ی توابع را خالی می‌گذاریم و عدد نشان دهنده‌ی مجموع دو تاس را هم که قاعدتاً باید به صورت تصادفی تولید شود، مستقیماً وارد می‌کنیم.

Copy Icon src/main.rs
fn main() {
  let dice_roll = 9;
  match dice_roll {
    3 => add_fancy_hat(),
    7 => remove_fancy_hat(),
    other => move_player(other),
  }
}
          
fn add_fancy_hat() {}
fn remove_fancy_hat() {}
fn move_player(num_spaces: u8) {}

الگوهای دو بازوی اول، مقادیر لیترال هستند اما در بازوی سوم که سایر مقادیر ممکن را هندل می‌کند، برای الگو از متغیری با نام other استفاده شده است. همانطور که می‌بینید، کد مربوط به این بازو از این متغیر استفاده کرده است. با وجودی که در اینجا ما همه‌ی مقادیر ممکن برای نوع u8 را وارد نکرده‌ایم اما سایر مقادیر به جز 3 و 7 توسط بازوی آخر که از الگویی موسوم به catch-all استفاده می‌کند، هندل می‌شوند و شرط جامعیت برآورده می‌شود.

در مثال بالا اگر از متغیر other استفاده نکنیم، Rust در مورد اینکه یک متغیر بلااستفاده در برنامه داریم، به ما هشدار می‌دهد. برای خلاصی از دست این هشدار، می‌توانیم در مواردی که به مقداری که الگوی cath-all دریافت می‌کند، نیاز نداشته باشیم، از یک کاراکتر _ به جای نام یک متغیر مثل other استفاده کنیم. فرض کنید قصد داریم قانون بازی را به نحوی تغییر دهیم که اگر عددی غیر از 3 و 7 به عنوان مجموع دو تاس بدست آید، مجدداً تاس ریخته شود.

Copy Icon src/main.rs
fn main() {
  let dice_roll = 9;
  match dice_roll {
    3 => add_fancy_hat(),
    7 => remove_fancy_hat(),
    _ => reroll(),
  }
}
          
fn add_fancy_hat() {}
fn remove_fancy_hat() {}
fn reroll() {}

در ضمن، اگر بخواهیم ترتیبی دهیم که برای اعداد به جز 3 و 7 هیچ اتفاقی رخ ندهد، می‌توانیم مانند زیر از unit یا همان تاپل خالی استفاده کنیم.

Copy Icon src/main.rs
fn main() {
  let dice_roll = 9;
  match dice_roll {
    3 => add_fancy_hat(),
    7 => remove_fancy_hat(),
    _ => (),
  }
}
          
fn add_fancy_hat() {}
fn remove_fancy_hat() {}