سازه جریان کنترلی match
زبان Rust دارای یک سازه جریان کنترلی بسیار قدرتمند به نام match است که به شما اجازه میدهد تا یک مقدار را با یک سری الگوها مقایسه کنید و سپس بر اساس الگویی که مطابقت دارد، کد مربوطه را اجرا کنید. الگوها میتوانند شامل مقادیر ثابت، نام متغیرها، wildcardها و چیزهای دیگر باشند. فصل 19 انواع مختلف الگوها و عملکرد آنها را پوشش میدهد. قدرت match از بیانپذیری الگوها و این واقعیت ناشی میشود که کامپایلر تأیید میکند که همه حالتهای ممکن مدیریت شدهاند.
میتوانید یک عبارت match را مانند یک دستگاه مرتبکننده سکه تصور کنید: سکهها در یک مسیر با سوراخهایی با اندازههای مختلف قرار میگیرند و هر سکه از اولین سوراخی که در آن جا میشود عبور میکند. به همین ترتیب، مقادیر از هر الگو در یک match عبور میکنند و در اولین الگویی که مقدار “جا میشود”، مقدار به بلوک کد مرتبط میافتد و برای اجرا استفاده میشود.
حال بیایید از یک مثال واقعی با سکهها استفاده کنیم! میتوانیم تابعی بنویسیم که یک سکه ناشناخته از ایالات متحده را بگیرد و به شیوهای مشابه دستگاه شمارنده سکهها، تعیین کند که آن سکه کدام نوع است و ارزش آن را به سنت برگرداند، همانطور که در فهرست 6-3 نشان داده شده است.
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, } } fn main() {}
match که حالتهای enum را به عنوان الگوهای خود داردبازبینی تابع value_in_cents
ابتدا کلمه کلیدی match و سپس یک عبارت را فهرست میکنیم که در این مورد مقدار coin است. این کار بسیار مشابه یک عبارت شرطی که با if استفاده میشود به نظر میرسد، اما تفاوت بزرگی دارد: با if، شرط باید به یک مقدار بولین ارزیابی شود، اما اینجا میتواند هر نوعی باشد. نوع coin در این مثال enum Coin است که در اولین خط تعریف کردیم.
بازوهای match دو قسمت دارند: یک الگو و مقداری کد. اولین بازو در اینجا دارای الگویی است که مقدار Coin::Penny است و سپس اپراتور => که الگو و کد اجرایی را از هم جدا میکند. کد در اینجا فقط مقدار 1 است. هر بازو با یک کاما از بازوی بعدی جدا میشود.
هنگامی که عبارت match اجرا میشود، مقدار حاصل را با الگوی هر بازو به ترتیب مقایسه میکند. اگر الگویی با مقدار مطابقت داشته باشد، کدی که با آن الگو مرتبط است اجرا میشود. اگر آن الگو با مقدار مطابقت نداشته باشد، اجرا به بازوی بعدی ادامه مییابد، همانطور که در یک دستگاه مرتبکننده سکهها عمل میکند. ما میتوانیم به هر تعداد بازو که نیاز داریم داشته باشیم: در فهرست 6-3، match ما چهار بازو دارد.
کد مرتبط با هر بازو یک عبارت است و مقدار حاصل از عبارت در بازوی منطبق شده، مقداری است که برای کل عبارت match بازگردانده میشود.
معمولاً اگر کد بازوی match کوتاه باشد، از آکولاد استفاده نمیکنیم، همانطور که در فهرست 6-3 که هر بازو فقط یک مقدار را برمیگرداند. اگر بخواهید چندین خط کد را در یک بازو اجرا کنید، باید از آکولاد استفاده کنید، و در این صورت کاما پس از بازو اختیاری است. به عنوان مثال، کد زیر هر بار که متد با یک Coin::Penny فراخوانی میشود، “Lucky penny!” را چاپ میکند، اما همچنان آخرین مقدار بلوک یعنی 1 را بازمیگرداند:
enum Coin { Penny, Nickel, Dime, Quarter, } fn value_in_cents(coin: Coin) -> u8 { match coin { Coin::Penny => { println!("Lucky penny!"); 1 } Coin::Nickel => 5, Coin::Dime => 10, Coin::Quarter => 25, } } fn main() {}
الگوهایی که به مقادیر متصل میشوند
یکی دیگر از ویژگیهای مفید بازوهای match این است که میتوانند به بخشهایی از مقادیر که با الگو مطابقت دارند متصل شوند. این همان روشی است که میتوانیم مقادیر را از حالتهای enum استخراج کنیم.
به عنوان مثال، بیایید یکی از حالتهای enum خود را تغییر دهیم تا دادههایی را درون خود نگه دارد. از سال 1999 تا 2008، ایالات متحده ربعهایی با طرحهای مختلف برای هر یک از 50 ایالت در یک طرف ضرب کرد. هیچ سکه دیگری طرح ایالتی نداشت، بنابراین فقط ربعها این مقدار اضافی را دارند. میتوانیم این اطلاعات را به enum خود با تغییر حالت Quarter به گونهای که یک مقدار UsState درون آن ذخیره شود اضافه کنیم، همانطور که در فهرست 6-4 انجام دادیم.
#[derive(Debug)] // so we can inspect the state in a minute enum UsState { Alabama, Alaska, // --snip-- } enum Coin { Penny, Nickel, Dime, Quarter(UsState), } fn main() {}
Coin که حالت Quarter آن همچنین یک مقدار UsState را نگه میداردبیایید تصور کنیم که یک دوست ما سعی دارد تمام 50 ربع ایالتی را جمعآوری کند. در حالی که ما پولهای خود را بر اساس نوع سکه مرتب میکنیم، همچنین نام ایالتی که با هر ربع مرتبط است را اعلام میکنیم تا اگر این یکی از آنهایی باشد که دوست ما ندارد، بتوانند آن را به مجموعه خود اضافه کنند.
در عبارت match برای این کد، یک متغیر به نام state به الگو اضافه میکنیم که مقادیری از حالت Coin::Quarter را تطبیق میدهد. وقتی که یک مقدار Coin::Quarter منطبق میشود، متغیر state به مقدار ایالت آن ربع متصل خواهد شد. سپس میتوانیم از state در کد بازوی آن استفاده کنیم، به این صورت:
#[derive(Debug)] enum UsState { Alabama, Alaska, // --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 } } } fn main() { value_in_cents(Coin::Quarter(UsState::Alaska)); }
اگر ما value_in_cents(Coin::Quarter(UsState::Alaska)) را فراخوانی کنیم، مقدار coin برابر با Coin::Quarter(UsState::Alaska) خواهد بود. هنگامی که آن مقدار را با هر بازوی match مقایسه میکنیم، هیچکدام از آنها مطابقت ندارند تا اینکه به Coin::Quarter(state) برسیم. در این نقطه، اتصال برای state مقدار UsState::Alaska خواهد بود. سپس میتوانیم از آن اتصال در عبارت println! استفاده کنیم و به این ترتیب مقدار داخلی ایالت را از حالت Quarter enum Coin استخراج کنیم.
تطبیق با Option<T>
در بخش قبلی، ما میخواستیم مقدار داخلی T را از حالت Some استخراج کنیم زمانی که از Option<T> استفاده میکردیم؛ همچنین میتوانیم با استفاده از match حالتهای Option<T> را مدیریت کنیم، همانطور که با enum Coin انجام دادیم! به جای مقایسه سکهها، حالتهای Option<T> را مقایسه میکنیم، اما روش کار عبارت match همان باقی میماند.
بیایید فرض کنیم که میخواهیم تابعی بنویسیم که یک Option<i32> بگیرد و اگر یک مقدار درون آن باشد، مقدار 1 را به آن اضافه کند. اگر هیچ مقداری درون آن نباشد، تابع باید مقدار None را بازگرداند و هیچ عملیاتی را انجام ندهد.
نوشتن این تابع با استفاده از match بسیار آسان است و به این صورت خواهد بود:
fn main() { fn plus_one(x: Option<i32>) -> Option<i32> { match x { None => None, Some(i) => Some(i + 1), } } let five = Some(5); let six = plus_one(five); let none = plus_one(None); }
اجازه دهید اولین اجرای plus_one را با جزئیات بیشتری بررسی کنیم. وقتی که plus_one(five) را فراخوانی میکنیم، متغیر x در بدنه plus_one مقدار Some(5) خواهد داشت. سپس آن را با هر بازوی match مقایسه میکنیم:
fn main() {
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
}
مقدار Some(5) با الگوی None مطابقت ندارد، بنابراین به بازوی بعدی میرویم:
fn main() {
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
}
آیا Some(5) با Some(i) مطابقت دارد؟ بله! ما همان حالت را داریم. مقدار i به مقدار داخل Some متصل میشود، بنابراین i مقدار 5 میگیرد. سپس کد موجود در بازوی match اجرا میشود، بنابراین مقدار 1 به مقدار i اضافه میکنیم و یک مقدار جدید Some با مقدار کل 6 ایجاد میکنیم.
حالا اجازه دهید اجرای دوم plus_one را در فهرست 6-5 در نظر بگیریم، جایی که مقدار x برابر با None است. ما وارد match میشویم و آن را با اولین بازو مقایسه میکنیم:
fn main() {
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
}
این بار مطابقت دارد! هیچ مقداری برای اضافه کردن وجود ندارد، بنابراین برنامه متوقف میشود و مقدار None در سمت راست => را بازمیگرداند. از آنجا که اولین بازو مطابقت داشت، بازوهای دیگر بررسی نمیشوند.
ترکیب match و enumها در بسیاری از موقعیتها مفید است. این الگو را در کد Rust زیاد خواهید دید: match روی یک enum، اتصال یک متغیر به داده داخل، و سپس اجرای کد بر اساس آن. ممکن است در ابتدا کمی سخت باشد، اما وقتی به آن عادت کنید، آرزو خواهید کرد که در همه زبانها وجود داشته باشد. این سازه همواره یکی از ویژگیهای مورد علاقه کاربران است.
تطابقها Exhaustive هستند
یکی دیگر از جنبههای عبارت match این است که الگوهای بازوها باید تمام حالتهای ممکن را پوشش دهند. به این نسخه از تابع plus_one که یک باگ دارد و کامپایل نمیشود توجه کنید:
fn main() {
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
}
ما حالت None را مدیریت نکردهایم، بنابراین این کد باعث بروز یک باگ خواهد شد. خوشبختانه، این یک باگ است که Rust میتواند آن را تشخیص دهد. اگر تلاش کنیم این کد را کامپایل کنیم، این خطا را دریافت خواهیم کرد:
$ 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/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/option.rs:572:1
::: /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/option.rs:576: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
Rust میداند که ما هر حالت ممکن را پوشش ندادهایم و حتی میداند که کدام الگو را فراموش کردهایم! تطابقها در Rust exhaustive هستند: ما باید هر حالت ممکن را مدیریت کنیم تا کد معتبر باشد. به ویژه در مورد Option<T>، وقتی که Rust از فراموش کردن مدیریت صریح حالت None جلوگیری میکند، از فرض نادرست وجود مقدار زمانی که ممکن است null باشد محافظت میکند و به این ترتیب اشتباه میلیارد دلاری که قبلاً بحث شد را غیرممکن میسازد.
الگوهای Catch-all و Placeholder _
با استفاده از Enumها، میتوانیم اقدامات ویژهای برای چند مقدار خاص انجام دهیم، اما برای تمام مقادیر دیگر یک عمل پیشفرض داشته باشیم. تصور کنید که در حال پیادهسازی یک بازی هستید که اگر بازیکن عدد 3 روی تاس بیاورد، حرکت نمیکند اما یک کلاه زیبا جدید میگیرد. اگر عدد 7 بیاورد، بازیکن یک کلاه زیبا از دست میدهد. برای تمام مقادیر دیگر، بازیکن به اندازه عدد روی تخته بازی حرکت میکند. در اینجا یک عبارت match آورده شده است که این منطق را پیادهسازی میکند. نتیجهی پرتاب تاس به جای مقدار تصادفی، به صورت هاردکد شده قرار داده شده است، و تمام منطق دیگر با توابعی بدون بدنه نشان داده شدهاند زیرا پیادهسازی آنها خارج از محدوده این مثال است:
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) {} }
برای دو بازوی اول، الگوها مقادیر ثابت 3 و 7 هستند. برای بازوی آخر که تمام مقادیر ممکن دیگر را پوشش میدهد، الگو یک متغیر است که ما آن را other نامیدهایم. کدی که برای بازوی other اجرا میشود، متغیر را با استفاده از تابع move_player میفرستد.
این کد کامپایل میشود، حتی اگر تمام مقادیر ممکن یک u8 را فهرست نکرده باشیم، زیرا بازوی آخر همه مقادیر ذکر نشده را تطبیق میدهد. این الگوی catch-all نیاز تطابق exhaustive را برآورده میکند. توجه داشته باشید که باید بازوی catch-all را در آخر قرار دهیم زیرا الگوها به ترتیب ارزیابی میشوند. اگر بازوی catch-all را زودتر قرار دهیم، بازوهای دیگر هرگز اجرا نخواهند شد، بنابراین Rust به ما هشدار میدهد اگر بعد از یک بازوی catch-all بازوهای دیگری اضافه کنیم!
Rust همچنین یک الگو به نام _ دارد که میتوانیم از آن استفاده کنیم وقتی که میخواهیم یک catch-all داشته باشیم اما نمیخواهیم مقدار در الگوی catch-all را استفاده کنیم. این به Rust میگوید که ما قصد نداریم مقدار را استفاده کنیم، بنابراین Rust درباره یک متغیر استفاده نشده به ما هشدار نمیدهد.
بیایید قوانین بازی را تغییر دهیم: حالا اگر بازیکن هر چیزی به غیر از 3 یا 7 بیاورد، باید دوباره تاس بیندازد. دیگر نیازی به استفاده از مقدار catch-all نیست، بنابراین میتوانیم کد خود را بهجای متغیری به نام other از _ استفاده کنیم:
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() {} }
این مثال نیز نیاز تطابق exhaustive را برآورده میکند زیرا ما صریحاً تمام مقادیر دیگر را در بازوی آخر نادیده گرفتهایم و چیزی را فراموش نکردهایم.
در نهایت، قوانین بازی را یک بار دیگر تغییر میدهیم، بنابراین اگر بازیکن هر چیزی غیر از 3 یا 7 بیاورد، هیچ کار دیگری در نوبت او انجام نمیشود. میتوانیم این موضوع را با استفاده از مقدار واحد (نوع tuple خالی که قبلاً در بخش “نوع Tuple” ذکر شد) به عنوان کدی که با بازوی _ همراه است بیان کنیم:
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() {} }
اینجا، ما به Rust صریحاً میگوییم که قصد نداریم هیچ مقدار دیگری را که با هیچ الگویی در بازوهای قبلی مطابقت ندارد استفاده کنیم و نمیخواهیم در این حالت کدی اجرا کنیم.
درباره الگوها و تطبیق آنها مطالب بیشتری در فصل 19 پوشش خواهیم داد. فعلاً به سینتکس if let میپردازیم که میتواند در مواقعی که عبارت match کمی طولانی به نظر میرسد، مفید باشد.