Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

การควบคุมลำดับการทำงานอย่างกระชับด้วย 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 กัน