การควบคุมลำดับการทำงานอย่างกระชับด้วย if let และ let...else (Concise Control Flow with if let and let...else)
ไวยากรณ์ if let ช่วยให้คุณสามารถรวม if และ let เข้าด้วยกันเพื่อย่อวิธีการเขียนโค้ดสำหรับรับมือกับค่าที่ตรงตามแพตเทิร์นเพียงแบบเดียว ในขณะที่ละเว้นการตรวจสอบกรณีอื่น ๆ ที่เหลือ ลองพิจารณาโปรแกรมในรายการโค้ดที่ 6-6 ซึ่งทำการเปรียบเทียบค่าชนิด Option<u8> ในตัวแปร config_max แต่ทว่ามีวัตถุประสงค์เพื่อต้องการประมวลผลโค้ดก็ต่อเมื่อค่านั้นเป็นรูปแบบ Some เท่านั้น
fn main() {
let config_max = Some(3u8);
match config_max {
Some(max) => println!("The maximum is configured to be {max}"),
_ => (),
}
}
หากค่าที่ได้เป็น Some เราจะพิมพ์ค่าที่อยู่ในรูปแบบ Some นั้นออกมาโดยการผูกค่านั้นเข้ากับตัวแปร max ภายใต้แพตเทิร์น โดยที่เราไม่ประสงค์จะทำงานใด ๆ หากพบค่าที่เป็น None และเพื่อให้เป็นไปตามกฎไวยากรณ์ของนิพจน์ match เราจำเป็นต้องใส่แขน _ => () เพิ่มเติมลงไปหลังจากประมวลผลรูปแบบย่อยแรกเสร็จสิ้น ซึ่งจัดว่าเป็นโค้ดสำเร็จรูปที่ซ้ำซาก (boilerplate code) และน่ารำคาญที่ต้องเขียนเพิ่มเข้ามา
แต่ถ้าเราเปลี่ยนมาใช้วิธีที่สั้นลงโดยใช้ if let โค้ดด้านล่างนี้จะทำงานเหมือนกับนิพจน์ match ในรายการโค้ดที่ 6-6 ทุกประการ:
fn main() {
let config_max = Some(3u8);
if let Some(max) = config_max {
println!("The maximum is configured to be {max}");
}
}
ไวยากรณ์ของ if let จะรับตัวแพตเทิร์นและตัวนิพจน์แยกกันด้วยเครื่องหมายเท่ากับ (=) มันมีลักษณะการทำงานแบบเดียวกับ match ที่ส่งค่าตัวนิพจน์เข้าไป และมีแพตเทิร์นเป็นแขนแรกของแมตช์ ในตัวอย่างนี้ แพตเทิร์นคือ Some(max) และตัวแปร max จะทำหน้าที่ผูกเข้ากับค่าข้อมูลที่อยู่ภายใน Some จากนั้นเราก็จะสามารถนำตัวแปร max ไปใช้งานภายในบล็อกของ if let ได้เช่นเดียวกับที่เราใช้ max ในแขนของ match ที่คู่กัน โค้ดในบล็อก if let จะรันเฉพาะเมื่อค่าข้อมูลที่ตรวจสอบจับคู่สอดคล้องกับแพตเทิร์นเท่านั้น
การใช้งาน if let ช่วยให้พิมพ์โค้ดน้อยลง ย่นระยะการย่อหน้าลง และลดการเขียนโค้ดที่ซ้ำซากจำเจ อย่างไรก็ตาม คุณจะเสียฟังก์ชันการตรวจสอบความครอบคลุมทุกกรณี (exhaustive checking) ที่เป็นระบบบังคับของ match เพื่อรับรองว่าคุณจะไม่ได้ตกหล่นกรณีใดไป การเลือกระหว่าง match และ if let จึงขึ้นอยู่กับลักษณะงานที่คุณทำในสถานการณ์นั้น ๆ และขึ้นอยู่กับว่าการแลกมาซึ่งความกระชับเหมาะสมคุ้มค่ากับการสูญเสียการตรวจสอบอย่างครอบคลุมหรือไม่
พูดง่าย ๆ ก็คือ คุณสามารถมอง if let ว่าเป็นไวยากรณ์อำนวยความสะดวก (syntax sugar) ของโครงสร้าง match ที่รันโค้ดเมื่อค่าตรงตามแพตเทิร์นเดียวที่กำหนด และละทิ้งการตรวจสอบค่าตัวแปรอื่น ๆ ทั้งหมดที่เหลือนั่นเอง
เราสามารถใช้บล็อก else ร่วมกับ if let ได้ด้วย โดยบล็อกโค้ดที่อยู่หลังคำว่า else จะทำหน้าที่เดียวกับบล็อกโค้ดในแขน _ ของนิพจน์ match ที่เทียบเท่ากัน ย้อนกลับไปพิจารณาอีนัม Coin ในรายการโค้ดที่ 6-4 ที่รูปแบบ Quarter จัดเก็บค่าชนิด UsState ไว้ภายใน หากเราต้องการนับจำนวนเหรียญอื่น ๆ ทั้งหมดที่ไม่ใช่เหรียญควอเตอร์ไปพร้อม ๆ กับการประกาศชื่อรัฐของเหรียญควอเตอร์ เราสามารถเขียนด้วยนิพจน์ match ได้ดังนี้:
#[derive(Debug)]
enum UsState {
Alabama,
Alaska,
// --snip--
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
fn main() {
let coin = Coin::Penny;
let mut count = 0;
match coin {
Coin::Quarter(state) => println!("State quarter from {state:?}!"),
_ => count += 1,
}
}
หรือเราจะเขียนโดยการใช้โครงสร้างนิพจน์ if let และ else ได้ดังนี้:
#[derive(Debug)]
enum UsState {
Alabama,
Alaska,
// --snip--
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
fn main() {
let coin = Coin::Penny;
let mut count = 0;
if let Coin::Quarter(state) = coin {
println!("State quarter from {state:?}!");
} else {
count += 1;
}
}
การคงการประมวลผลไว้บน “เส้นทางที่ราบรื่น” ด้วย let...else (Staying on the “Happy Path” with let...else)
รูปแบบที่พบได้ทั่วไปคือการคำนวณประมวลผลข้อมูลเมื่อมีค่าข้อมูลอยู่ และจะส่งค่าเริ่มต้น (default value) กลับไปในกรณีอื่น ๆ ตัวอย่างเช่น จากกรณีศึกษาเรื่องเหรียญที่มีค่า UsState หากเราต้องการจะพูดคำพูดตลก ๆ บางอย่างตามความเก่าแก่ของรัฐที่ปรากฏอยู่บนเหรียญควอเตอร์ เราอาจเพิ่มเมธอดบน UsState เพื่อให้ทำหน้าที่ตรวจสอบอายุของรัฐนั้น ๆ ดังนี้:
#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
Alabama,
Alaska,
// --snip--
}
impl UsState {
fn existed_in(&self, year: u16) -> bool {
match self {
UsState::Alabama => year >= 1819,
UsState::Alaska => year >= 1959,
// -- snip --
}
}
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
fn describe_state_quarter(coin: Coin) -> Option<String> {
if let Coin::Quarter(state) = coin {
if state.existed_in(1900) {
Some(format!("{state:?} is pretty old, for America!"))
} else {
Some(format!("{state:?} is relatively new."))
}
} else {
None
}
}
fn main() {
if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
println!("{desc}");
}
}
จากนั้น เราอาจจะหยิบ if let มาใช้เพื่อจับคู่ประเภทของเหรียญ พร้อมทั้งประกาศตัวแปร state ขึ้นมาใช้ต่อภายในขอบเขตการทำงานของเงื่อนไข ดังในรายการโค้ดที่ 6-7
#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
Alabama,
Alaska,
// --snip--
}
impl UsState {
fn existed_in(&self, year: u16) -> bool {
match self {
UsState::Alabama => year >= 1819,
UsState::Alaska => year >= 1959,
// -- snip --
}
}
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
fn describe_state_quarter(coin: Coin) -> Option<String> {
if let Coin::Quarter(state) = coin {
if state.existed_in(1900) {
Some(format!("{state:?} is pretty old, for America!"))
} else {
Some(format!("{state:?} is relatively new."))
}
} else {
None
}
}
fn main() {
if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
println!("{desc}");
}
}
การเขียนแบบนี้สามารถทำให้บรรลุเป้าหมายได้ก็จริง แต่ทว่ามันทำให้ภาระงานหลัก ๆ ถูกดันเข้าไปอยู่ภายในบล็อกของเงื่อนไข if let และถ้าหากงานที่ต้องประมวลผลมีความซับซ้อนมากกว่านี้ ก็อาจจะยากที่จะเข้าใจได้ว่าโครงสร้างเงื่อนไขในระดับบนสุดเชื่อมโยงกันอย่างไร เราอาจใช้ประโยชน์จากการที่นิพจน์สามารถคืนค่าได้ เพื่อกำหนดค่าให้กับตัวแปร state จากเงื่อนไข if let หรือใช้เพื่อยุติการทำงานและส่งค่ากลับล่วงหน้า (return early) ดังแสดงในรายการโค้ดที่ 6-8 (ซึ่งคุณสามารถเขียนโครงสร้างทำนองนี้ด้วย match ได้เช่นกัน)
#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
Alabama,
Alaska,
// --snip--
}
impl UsState {
fn existed_in(&self, year: u16) -> bool {
match self {
UsState::Alabama => year >= 1819,
UsState::Alaska => year >= 1959,
// -- snip --
}
}
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
fn describe_state_quarter(coin: Coin) -> Option<String> {
let state = if let Coin::Quarter(state) = coin {
state
} else {
return None;
};
if state.existed_in(1900) {
Some(format!("{state:?} is pretty old, for America!"))
} else {
Some(format!("{state:?} is relatively new."))
}
}
fn main() {
if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
println!("{desc}");
}
}
อย่างไรก็ตาม การเขียนแบบนี้ก็น่าหงุดหงิดในการทำความเข้าใจในแบบของมันเอง! เนื่องจากแขนฝั่งหนึ่งของ if let มีหน้าที่คืนค่าข้อมูลออกมา ในขณะที่อีกฝั่งหนึ่งกลับเป็นการสั่งสิ้นสุดการทำงานของฟังก์ชันทันที
เพื่อช่วยให้รูปแบบยอดนิยมนี้แสดงออกได้อย่างสวยงามยิ่งขึ้น ภาษา Rust จึงมีไวยากรณ์ let...else โดยไวยากรณ์ let...else จะรับแพตเทิร์นทางด้านซ้ายและรับนิพจน์ทางด้านขวา ซึ่งมีลักษณะคล้ายคลึงกับ if let มาก แต่จุดต่างคือมันจะไม่มีแขน if และจะมีเพียงแขน else เท่านั้น หากแพตเทิร์นจับคู่สำเร็จ มันจะผูกค่าข้อมูลจากแพตเทิร์นเข้ากับตัวแปรภายนอกขอบเขตของมันโดยอัตโนมัติ แต่ถ้าหากแพตเทิร์นจับคู่ ไม่ สำเร็จ การประมวลผลจะไหลเข้าสู่แขน else ซึ่งมีข้อบังคับว่าจำเป็นต้องสั่งหยุดและส่งค่ากลับออกจากฟังก์ชันเท่านั้น
ในรายการโค้ดที่ 6-9 คุณสามารถศึกษาดูว่าเมื่อเราเปลี่ยนรายการโค้ดที่ 6-8 มาเขียนโดยการใช้งาน let...else แทน if let จะได้รูปแบบโครงสร้างอย่างไร
#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
Alabama,
Alaska,
// --snip--
}
impl UsState {
fn existed_in(&self, year: u16) -> bool {
match self {
UsState::Alabama => year >= 1819,
UsState::Alaska => year >= 1959,
// -- snip --
}
}
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
fn describe_state_quarter(coin: Coin) -> Option<String> {
let Coin::Quarter(state) = coin else {
return None;
};
if state.existed_in(1900) {
Some(format!("{state:?} is pretty old, for America!"))
} else {
Some(format!("{state:?} is relatively new."))
}
}
fn main() {
if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
println!("{desc}");
}
}
สังเกตว่าการเขียนด้วยวิธีนี้จะช่วยให้โค้ดการทำงานหลักยังคงอยู่บน “เส้นทางที่ราบรื่น (happy path)” ภายในเนื้อความหลักของฟังก์ชันได้โดยตรง โดยไม่จำเป็นต้องใช้โครงสร้างควบคุมที่ต่างกันอย่างสุดขั้วสำหรับสองเงื่อนไขเหมือนดังที่เงื่อนไข if let ทำก่อนหน้านี้
หากคุณเจอกับสถานการณ์ที่ตรรกะในโปรแกรมดูเยิ่นเย้อและเขียนด้วย match ได้ยาก โปรดระลึกไว้เสมอว่าไวยากรณ์ if let และ let...else ก็เป็นเครื่องมือที่พร้อมให้คุณเลือกใช้งานในกล่องเครื่องมือของ Rust เช่นเดียวกัน
สรุป
ในตอนนี้เราได้เรียนรู้ถึงการใช้งานอีนัม (enums) เพื่อสร้างชนิดข้อมูลขึ้นมาใช้เอง ซึ่งสามารถเป็นค่าข้อมูลใดค่าหนึ่งจากชุดของค่าที่แจงนับไว้ทั้งหมด เราได้เห็นวิธีการที่ชนิดข้อมูล Option<T> ของไลบรารีมาตรฐานช่วยให้คุณใช้งานระบบชนิดข้อมูล (type system) เพื่อปกป้องโปรแกรมจากข้อผิดพลาด และเมื่อค่าข้อมูลของอีนัมมีข้อมูลภายในบรรจุอยู่ คุณก็สามารถเลือกใช้ match หรือ if let เพื่อดึงข้อมูลเหล่านั้นออกมาใช้งานได้ตามความเหมาะสมกับปริมาณกรณีที่คุณต้องการจัดการ
ตอนนี้โปรแกรมภาษา Rust ของคุณสามารถอธิบายแนวคิดจำเพาะเจาะจงของงานคุณได้โดยการเลือกใช้ structs และ enums การสร้างชนิดข้อมูลที่กำหนดขึ้นเองเพื่อเขียนอธิบายใน API จะช่วยรับประกันความปลอดภัยของชนิดข้อมูล (type safety) ได้ กล่าวคือ คอมไพเลอร์จะคอยดูแลรับรองว่าฟังก์ชันต่าง ๆ ของคุณจะได้รับค่าข้อมูลตรงตามประเภทที่ฟังก์ชันนั้น ๆ คาดหวังไว้เท่านั้น
เพื่อที่จะนำเสนอ API ที่จัดระเบียบมาอย่างดีให้กับผู้ใช้ ซึ่งใช้งานได้ง่ายและเปิดเผยเฉพาะสิ่งที่ผู้ใช้จำเป็นต้องใช้งานจริง ๆ เท่านั้น ลำดับต่อไปเราจะหันไปเรียนรู้เกี่ยวกับระบบโมดูล (modules) ของภาษา Rust กัน