เนื้อหา

Structs, Enums และ Pattern Matching

ทำไมเราต้องสร้างประเภทข้อมูลแบบกำหนดเอง

ในการเขียนโปรแกรม เรามักพบกับข้อมูลที่มีโครงสร้างซับซ้อนซึ่งไม่สามารถอธิบายได้ด้วยประเภทพื้นฐานเช่น integer หรือ string เพียงอย่างเดียว ตัวอย่างเช่น ในโปรเจกต์ rs-wsProxy เราต้องจัดการกับการตั้งค่าเซิร์ฟเวอร์พร็อกซีและผลลัพธ์จากการตรวจสอบการเชื่อมต่อ ซึ่งต้องการประเภทข้อมูลที่สามารถเก็บค่าหลายประเภทและสถานะที่แตกต่างกันได้ การใช้ structs และ enums ช่วยให้เราสามารถจำลองข้อมูลเหล่านี้ได้อย่างชัดเจนและปลอดภัย พร้อมทั้งใช้ประโยชน์จากระบบ type system ของ Rust เพื่อจับข้อผิดพลาดตั้งแต่เวลาคอมไพล์ นอกจากนี้ pattern matching ยังช่วยให้เราจัดการกับทุกกรณีที่เป็นไปได้ของ enum ได้อย่างครบถ้วนและกระชับ ลดความเสี่ยงจากการละเลยกรณีบางกรณี ในบทความนี้เราจะเรียนรู้การสร้างและใช้งาน structs, enums และ pattern matching อย่างลึกซึ้ง โดยมีตัวอย่างจากโค้ดจริงใน rs-wsProxy เพื่อแสดงให้เห็นการประยุกต์ใช้ในสถานการณ์จริง ก่อนเริ่มต้น แนะนำให้อ่าน Part 2 เกี่ยวกับ Ownership, Borrowing และ Lifetimes เพื่อความเข้าใจที่สมบูรณ์ยิ่งขึ้น

Structs: การกำหนดโครงสร้างข้อมูล

Structs เป็นวิธีการกำหนดประเภทข้อมูลที่มีฟิลด์หลายฟิลด์ โดยแต่ละฟิลด์สามารถมีประเภทต่างกันได้ มันคล้ายกับคลาสในภาษาอื่นๆ แต่ใน Rust structs ใช้เพื่อเก็บข้อมูลเท่านั้น ไม่มีพฤติกรรมติดมาด้วย (พฤติกรรมจะถูกเพิ่มผ่าน impl block แยกต่างหาก) ตัวอย่างเช่น เราสามารถสร้าง struct เพื่อแทนผู้ใช้ในระบบโดยเก็บชื่อ อีเมล และอายุ การสร้าง instance ของ struct ทำได้โดยการระบุค่าของแต่ละฟิลด์ และเราสามารถเข้าถึงฟิลด์ผ่านจุด notation นอกจากนี้ Rust ยังมีฟีเจอร์ที่เรียกว่า field init shorthand ซึ่งช่วยให้เราสามารถกำหนดค่าฟิลด์โดยใช้ชื่อตัวแปรที่ตรงกับชื่อฟิลด์ได้โดยตรง นอกจาก structs ที่มีฟิลด์ที่มีชื่อแล้ว เรายังมี tuple structs ซึ่งเหมาะสำหรับกรณีที่เราต้องการประเภททูเพิลที่มีชื่อ และ unit structs ซึ่งไม่มีข้อมูลใดๆ เลยและมักใช้เป็น marker หรือในการ implement traits

// Struct ธรรมดาที่มีฟิลด์ที่มีชื่อ
struct User {
    username: String,
    email: String,
    age: u32,
    active: bool,
}

// Tuple struct - เหมือนทูเพิลแต่มีชื่อ
struct Color(i32, i32, i32);

// Unit struct - ไม่มีข้อมูลใดๆ
struct AlwaysEqual;

fn main() {
    // สร้าง instance ของ User โดยระบุค่าฟิลด์ทั้งหมด
    let user1 = User {
        username: String::from("kawin"),
        email: String::from("kawin@example.com"),
        age: 30,
        active: true,
    };

    // การเข้าถึงฟิลด์ด้วยจุด notation
    println!("ชื่อผู้ใช้: {}", user1.username);
    println!("อีเมล: {}", user1.email);

    // การสร้าง tuple struct
    let black = Color(0, 0, 0);
    println!("สีดำ: RGB({}, {}, {})", black.0, black.1, black.2);

    // การสร้าง unit struct
    let subject = AlwaysEqual;
    // เราสามารถใช้ subject เป็น marker ได้ เช่น ในการ implement trait
}

จากตัวอย่างข้างต้น เราเห็นว่าการสร้าง struct นั้นตรงไปตรงมา เราสามารถกำหนดประเภทของแต่ละฟิลด์ได้อย่างอิสระ เมื่อต้องการสร้าง instance เราต้องระบุค่าสำหรับทุกฟิลด์ (เว้นแต่จะกำหนดค่าเริ่มต้นใน struct definition) การเข้าถึงฟิลด์ทำได้โดยใช้จุด notation เช่น user1.username สำหรับ tuple structs เราเข้าถึงฟิลด์โดยใช้ดัชนีเช่น black.0 ส่วน unit structs นั้นไม่มีข้อมูลให้เข้าถึง แต่ยังมีประโยชน์ในการเป็นประเภทที่แตกต่างกันเพื่อใช้ในการ overloading ฟังก์ชันหรือเป็น marker ใน type system ในการพัฒนาจริง เรามักจะกำหนดค่าเริ่มต้นให้กับบางฟิลด์โดยใช้ Default trait หรือสร้างฟังก์ชัน constructor ผ่าน associated function ใน impl block ซึ่งจะอธิบายในส่วนถัดไป

Methods ด้วย impl

ใน Rust เราไม่สามารถกำหนดเมธอดภายใน struct definition ได้โดยตรง แต่เราใช้ impl block เพื่อเพิ่มเมธอดและฟังก์ชันที่เกี่ยวข้องกับ struct นั้น เมธอดใน Rust แตกต่างจากภาษาอื่นตรงที่เราต้องระบุประเภทของ self อย่างชัดเจน ซึ่งกำหนดว่าเมธอดนั้นยืม instance แบบใด มีสามรูปแบบหลัก ได้แก่ &self (ยืมแบบ immutable), &mut self (ยืมแบบ mutable) และ self (รับ ownership) นอกจากนี้ยังมี associated functions ซึ่งไม่มีพารามิเตอร์ self และมักใช้เป็น constructor หรือฟังก์ชันยูทิลิตี้ที่เกี่ยวข้องกับประเภทนั้นๆ เราสามารถมีหลาย impl block สำหรับ struct เดียวกันได้ ซึ่งช่วยให้เราจัดกลุ่มเมธอดตามความรับผิดชอบได้ ตัวอย่างต่อไปนี้แสดงการใช้งาน struct Rectangle พร้อมเมธอดต่างๆ เพื่อคำนวณพื้นที่และปรับขนาด

struct Rectangle {
    width: f64,
    height: f64,
}

impl Rectangle {
    // Associated function (constructor) - ไม่มี self
    fn new(width: f64, height: f64) -> Self {
        Rectangle { width, height }
    }

    // Method ที่ยืม self แบบ immutable
    fn area(&self) -> f64 {
        self.width * self.height
    }

    // Method ที่ยืม self แบบ mutable
    fn scale(&mut self, factor: f64) {
        self.width *= factor;
        self.height *= factor;
    }

    // Method ที่รับ ownership ของ self
    fn square(size: f64) -> Self {
        Rectangle { width: size, height: size }
    }
}

fn main() {
    // ใช้ associated function เพื่อสร้าง instance
    let mut rect = Rectangle::new(10.0, 5.0);
    println!("พื้นที่เริ่มต้น: {}", rect.area()); // พื้นที่เริ่มต้น: 50

    // ใช้ method ที่ยืม self แบบ mutable เพื่อปรับขนาด
    rect.scale(2.0);
    println!("พื้นที่หลังปรับขนาด: {}", rect.area()); // พื้นที่หลังปรับขนาด: 200

    // ใช้ associated function อีกตัวเพื่อสร้างสี่เหลี่ยมจัตุรัส
    let sq = Rectangle::square(7.0);
    println!("พื้นที่สี่เหลี่ยมจัตุรัส: {}", sq.area()); // พื้นที่สี่เหลี่ยมจัตุรัส: 49
}

จากตัวอย่าง เราเห็นว่า new เป็น associated function ที่ใช้สร้าง instance ของ Rectangle โดยไม่ต้องมี self ซึ่งเป็นรูปแบบที่พบบ่อยสำหรับ constructor ใน Rust เมธอด area ใช้ &self หมายถึงมันยืม instance แบบ immutable จึงสามารถอ่านค่าฟิลด์ได้แต่ไม่สามารถแก้ไขได้ เมธอด scale ใช้ &mut self ซึ่งอนุญาตให้แก้ไขค่าฟิลด์ได้ นอกจากนี้เรายังมี associated function square ที่สร้างสี่เหลี่ยมจัตุรัสจากขนาดด้านเดียว เราสามารถมีหลาย impl block ได้ ตัวอย่างเช่น เราอาจแยกเมธอดที่เกี่ยวข้องกับการแสดงผลออกไปอยู่ใน impl block อื่นเพื่อความชัดเจน ในการใช้งานจริง เราควรเลือกประเภทของ self ตามว่าเมธอดนั้นต้องการอ่านข้อมูลเท่านั้น เปลี่ยนแปลงข้อมูล หรือต้องการทำลาย instance หลังใช้งาน การใช้ &self และ &mut self ช่วยป้องกันข้อผิดพลาดทั่วไปเช่นการแก้ไขข้อมูลขณะที่มีการอ่านอยู่จากที่อื่น เนื่องจากระบบ ownership และ borrowing ของ Rust จะตรวจสอบสิ่งเหล่านี้ที่เวลาคอมไพล์

Enums: การกำหนดประเภทข้อมูลแบบสหภาพ

Enums ใน Rust ช่วยให้เราสามารถกำหนดประเภทข้อมูลที่สามารถเป็นหนึ่งในหลายๆ ตัวเลือกได้ ซึ่งแตกต่างจาก structs ที่ enum ในหลายภาษาอาจเป็นเพียงตัวแปรจำนวนเต็มที่มีชื่อ enum ใน Rust สามารถเก็บข้อมูลที่แตกต่างกันได้ในแต่ละตัวเลือก ทำให้มีประสิทธิภาพและแสดงเจตนาได้ชัดเจน ตัวอย่างที่ง่ายที่สุดคือ enum ที่แสดงทิศทางสี่ทิศ โดยแต่ละตัวเลือกไม่มีข้อมูลเพิ่มเติม อย่างไรก็ตาม ความแข็งแกร่งที่แท้จริงของ enum ใน Rust ปรากฏเมื่อเราให้แต่ละตัวเลือกสามารถเก็บข้อมูลประเภทต่างกันได้ ตัวอย่างเช่น เราอาจต้องการแสดงที่อยู่ IP ซึ่งอาจเป็นแบบ IPv4 (สี่ตัวเลข) หรือ IPv6 (แปดกลุ่มของเลขฐานสิบหก) นอกจากนี้ enum ยังสามารถใช้เพื่อแสดงข้อความหรือคำสั่งต่างๆ ในการประมวลผลข้อความได้อย่างปลอดภัย เนื่องจากแต่ละตัวเลือกมีประเภทข้อมูลที่ชัดเจน จึงเป็นไปไม่ได้ที่จะสับสนระหว่างประเภทข้อมูลที่แตกต่างกัน การจัดการกับ enum จะทำผ่าน pattern matching ซึ่งจะบังคับให้เราจัดการกับทุกตัวเลือกที่เป็นไปได้ จึงช่วยป้องกันข้อผิดพลาดจากการละเลยกรณีบางกรณี

// Enum ที่ไม่มีข้อมูลเพิ่มเติมในตัวเลือก
enum Direction {
    Up,
    Down,
    Left,
    Right,
}

// Enum ที่มีข้อมูลประเภทต่างกันในแต่ละตัวเลือก
enum IpAddr {
    V4(String),
    V6(String),
}

// Enum ที่ซับซ้อนมากขึ้นที่แสดงข้อความหรือคำสั่งต่างๆ
enum Message {
    NewLine,              // ไม่มีข้อมูลเพิ่มเติม
    Text(String),         // เก็บข้อความหนึ่งสตริง
    Move { x: i32, y: i32 }, // เก็บพิกัดเป็น struct แบบมีชื่อ
    Write(String),        // เก็บข้อความที่ต้องเขียน
    ChangeColor(i32, i32, i32), // เก็บสีเป็นสามค่าจำนวนเต็ม
}

fn main() {
    // การใช้งาน Direction
    let dir = Direction::Up;
    match dir {
        Direction::Up => println!("กำลังเคลื่อนที่ขึ้น"),
        Direction::Down => println!("กำลังเคลื่อนที่ลง"),
        Direction::Left => println!("กำลังเคลื่อนที่ซ้าย"),
        Direction::Right => println!("กำลังเคลื่อนที่ขวา"),
    }

    // การสร้างและใช้งาน IpAddr
    let home = IpAddr::V4(String::from("127.0.0.1"));
    let loopback = IpAddr::V6(String::from("::1"));

    match home {
        IpAddr::V4(addr) => println!("ที่อยู่ IPv4: {}", addr),
        IpAddr::V6(addr) => println!("ที่อยู่ IPv6: {}", addr),
    }

    // การใช้งาน Message ที่ซับซ้อน
    let msg = Message::Move { x: 10, y: 20 };
    match msg {
        Message::NewLine => println!("ขึ้นบรรทัดใหม่"),
        Message::Text(t) => println!("ข้อความ: {}", t),
        Message::Move { x, y } => println!("เคลื่อนที่ไปที่ ({}, {})", x, y),
        Message::Write(t) => println!("เขียนข้อความ: {}", t),
        Message::ChangeColor(r, g, b) => println!("เปลี่ยนสีเป็น RGB({}, {}, {})", r, g, b),
    }
}

จากตัวอย่างข้างต้น เราเห็นว่า enum นั้นมีความยืดหยุ่นสูงมาก เราสามารถกำหนดตัวเลือกที่ไม่มีข้อมูลเพิ่มเติมเช่น Direction::Up หรือตัวเลือกที่เก็บข้อมูลประเภทต่างกันเช่น IpAddr::V4(String) ซึ่งเก็บที่อยู่ IPv4 เป็นสตริง ในกรณีที่ต้องการเก็บข้อมูลหลายค่าที่มีความหมาย เราสามารถใช้ struct แบบมีชื่อภายในตัวเลือกได้เช่นใน Message::Move { x: i32, y: i32 } ซึ่งทำให้โค้ดอ่านเข้าใจได้ง่ายกว่าการใช้ทูเพิลธรรมดา ความแตกต่างที่สำคัญระหว่าง enum ใน Rust และในภาษาอื่นคือ แต่ละตัวเลือกของ enum คือประเภทที่แตกต่างกันอย่างสิ้นเชิง ซึ่งหมายความว่าเราไม่สามารถสับสนระหว่าง IpAddr::V4 และ IpAddr::V6 ได้เลย เนื่องจากพวกมันเป็นค่าที่มีประเภทต่างกันโดยสิ้นเชิง นอกจากนี้เมื่อเราใช้ pattern matching กับ enum คอมไพเลอร์จะบังคับให้เราจัดการกับทุกตัวเลือกที่เป็นไปได้ หากเราลืมกรณีใดกรณีหนึ่ง คอมไพเลอร์จะแสดงข้อผิดพลาด ทำให้โค้ดของเรามีความปลอดภัยสูงและครอบคลุมทุกกรณี ในการพัฒนาจริง เช่นในโปรเจกต์ rs-wsProxy เราใช้ enum เพื่อแสดงผลลัพธ์จากการตรวจสอบการเชื่อมต่อ ซึ่งอาจเป็นการยอมรับหรือปฏิเสธพร้อมเหตุผล ซึ่งเราจะเห็นในส่วนถัดไป

Option: การจัดการกับค่าที่อาจไม่มีอยู่

ในภาษาโปรแกรมหลายภาษา เราใช้ค่า null หรือ nil เพื่อแสดงถึงการไม่มีค่า แต่สิ่งนี้มักนำไปสู่ข้อผิดพลาดที่พบบ่อยที่สุดอย่าง NullPointerException ใน Rust ไม่มีแนวคิดของ null แทนที่จะใช้ประเภท Option<T> ซึ่งเป็น enum ที่มีสองตัวเลือก ได้แก่ Some(T) ซึ่งบ่งบอกว่ามีค่าอยู่และเก็บค่าที่มีประเภท T ไว้ และ None ซึ่งบ่งบอกว่าไม่มีค่า การใช้ Option<T> ทำให้เราต้องจัดการกับกรณีที่ไม่มีค่าอย่างชัดเจนผ่าน pattern matching ซึ่งช่วยป้องกันข้อผิดพลาดจากการสมมติว่ามีค่าอยู่เมื่อจริงๆ แล้วไม่มี นอกจากนี้ยังทำให้เจตนาของโค้ดชัดเจนขึ้นว่าเราตระหนักถึงความเป็นไปได้ที่จะไม่มีค่า ตัวอย่างต่อไปนี้แสดงการใช้งาน Option<T> ในฟังก์ชันที่ค้นหาตัวอักษรแรกในสตริง ซึ่งอาจคืนค่าไม่พบหากสตริงว่างเปล่า

fn first_character(s: &str) -> Option<char> {
    // คืนค่า Some(char) หากพบตัวอักษรแรก หรือ None หากสตริงว่าง
    s.chars().next()
}

fn main() {
    let text = "สวัสดีครับ";
    match first_character(text) {
        Some(ch) => println!("ตัวอักษรแรกคือ: '{}'", ch),
        None => println!("สตริงว่างเปล่า ไม่มีตัวอักษรแรก"),
    }

    let empty = "";
    match first_character(empty) {
        Some(ch) => println!("ตัวอักษรแรกคือ: '{}'", ch),
        None => println!("สตริงว่างเปล่า ไม่มีตัวอักษรแรก"), // จะพบกรณีนี้
    }

    // การใช้ if let เพื่อจัดการกรณีเดียวอย่างกระชับ
    if let Some(ch) = first_character(text) {
        println!("พบตัวอักษรแรก: '{}'", ch);
    } else {
        println!("ไม่พบตัวอักษรแรก");
    }
}

จากตัวอย่าง เราเห็นว่าฟังก์ชัน first_character คืนค่าเป็น Option<char> ซึ่งบ่งบอกว่าอาจคืนค่าตัวอักษรหรือไม่คืนค่าเลยก็ได้ การใช้ match เพื่อจัดการกับ Option ทำให้เราต้องพิจารณาทั้งสองกรณีอย่างชัดเจน นอกจากนี้เรายังสามารถใช้ if let เมื่อเราสนใจเพียงกรณีเดียว (เช่นกรณีที่มีค่า) และต้องการจัดการกรณีที่เหลือในอีกทางหนึ่งได้อย่างกระชับ การใช้ Option<T> แทนที่จะใช้ null ช่วยขจัดคลาสของข้อผิดพลาดทั้งหมดที่เกี่ยวข้องกับการอ้างอิงถึงค่าว่าง เนื่องจากคอมไพเลอร์จะบังคับให้เราตรวจสอบว่าเราได้จัดการกับกรณี None แล้วหรือไม่ก่อนที่จะเข้าถึงค่าที่อยู่ภายใน Some ในการพัฒนาจริง เราจะเห็นการใช้ Option อย่างแพร่หลาย เช่นในโครงสร้างข้อมูลของ rs-wsProxy ที่ฟิลด์บางฟิลด์อาจไม่มีค่าที่กำหนดไว้ เช่น allowed_servers ซึ่งเป็น Option<Vec<String>> ที่ใช้แยกแยะระหว่างการไม่มีการจำกัด (None) กับการมีรายการเซิร์ฟเวอร์ที่อนุญาตว่างเปล่า (Some(vec![])) ซึ่งมีความหมายแตกต่างกันอย่างสิ้นเชิงในตรรกะของพร็อกซี

Pattern Matching: การจับคู่รูปแบบเพื่อจัดการข้อมูล

Pattern matching ใน Rust เป็นกลไกที่ทรงพลังสำหรับการทำลายข้อมูลและจัดการกับกรณีต่างๆ ของ enum หรือ struct มันมากกว่าแค่ switch statement ในภาษาอื่นๆ เนื่องจากสามารถทำลายโครงสร้างข้อมูลที่ซับซ้อน พันตรวจสอบเงื่อนไขเพิ่มเติม และผูกค่าที่ตรงกับรูปแบบไว้เป็นตัวแปรใหม่ได้ เราใช้คีย์เวิร์ด match เพื่อเริ่มต้นการจับคู่รูปแบบ โดยแต่ละแขนของ match จะประกอบด้วยรูปแบบ (pattern) ที่จะจับคู่กับค่าที่ป้อนเข้ามา และนิพจน์ที่จะดำเนินการเมื่อเกิดการจับคู่ รูปแบบสามารถเป็นได้ตั้งแต่ค่าคงที่ ตัวแปร โครงสร้าง struct หรือ enum ไปจนถึงการผสมผสานที่ซับซ้อน นอกจากนี้ยังมีรูปแบบพิเศษอย่าง _ ซึ่งทำหน้าที่เป็น wildcard จับคู่กับทุกค่าที่เหลือ และเรายังสามารถใช้ if let และ while let เมื่อเราต้องการจับคู่เพียงรูปแบบเดียวโดยไม่ต้องเขียน match ทั้งหมด ตัวอย่างต่อไปนี้แสดงการใช้งาน pattern matching กับ enum ต่างๆ รวมถึง Option และผลลัพธ์จากการดำเนินการที่อาจล้มเหลว

// ตัวอย่างการจับคู่กับ enum ที่ซับซ้อน
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn process_message(msg: Message) {
    match msg {
        Message::Quit => {
            println!("ออกจากโปรแกรม");
            // ในโปรแกรมจริงอาจเรียก std::process::exit หรือส่งสัญญาณออกไป
        }
        Message::Move { x, y } => {
            println!("เคลื่อนที่ไปที่พิกัด ({}, {})", x, y);
        }
        Message::Write(text) => {
            println!("ส่งข้อความ: {}", text);
        }
        Message::ChangeColor(r, g, b) => {
            println!("เปลี่ยนสีเป็น RGB({}, {}, {})", r, g, b);
        }
    }
}

// ตัวอย่างการจับคู่กับ Option
fn divide_integers(a: i32, b: i32) -> Option<i32> {
    if b == 0 {
        None
    } else {
        Some(a / b)
    }
}

fn main() {
    // ทดสอบฟังก์ชันหาร
    match divide_integers(10, 2) {
        Some(result) => println!("10 / 2 = {}", result),
        None => println!("ไม่สามารถหารด้วยศูนย์ได้"),
    }

    // การใช้ if let เพื่อจัดการกรณีที่สำเร็จเพียงอย่างเดียว
    if let Some(result) = divide_integers(10, 3) {
        println!("ผลลัพธ์ของการหาร: {}", result);
    }

    // การใช้ while let ในลูปเพื่อประมวลผลชุดข้อมูลจนกว่าจะหมด
    let mut stack = vec![1, 2, 3, 4, 5];
    while let Some(top) = stack.pop() {
        println!("นำออกจากกอง: {}", top);
    }
    
    // ตัวอย่างการใช้แมทช์กับโครงสร้างซ้อนกัน
    let complex = (
        Message::Write(String::จาก("สวัสดี")),
        Message::ChangeColor(255, 0, 0),
    );
    match complex {
        (Message::Write(text), Message::ChangeColor(r, g, b)) => {
            println!("เขียนข้อความ '{}' ด้วยสี RGB({}, {}, {})", text, r, g, b);
        }
        _ => println!("รูปแบบไม่ตรงกัน"),
    }
}

จากตัวอย่างข้างต้น เราเห็นว่า pattern matching นั้นมีความยืดหยุ่นและทรงพลังมาก ในการจับคู่กับ enum ที่มีข้อมูล เราสามารถทำลายโครงสร้างเพื่อดึงค่าภายในออกมาใช้ได้โดยตรง เช่นในกรณีของ Message::Move { x, y } เราจะได้ตัวแปร x และ y ที่มีค่าตามที่กำหนดใน enum สำหรับกรณีที่มีข้อมูลหลายประเภทเช่นใน tuple ที่ซ้อนกัน เราสามารถทำลายหลายระดับได้ในครั้งเดียว นอกจากนี้เรายังสามารถใช้เงื่อนไขเพิ่มเติมในรูปแบบได้โดยใช้ if guard ตัวอย่างเช่น Some(x) if x > 0 จะจับคู่เฉพาะเมื่อค่าใน Some มากกว่าศูนย์ รูปแบบ wildcard _ มีประโยชน์เมื่อเราต้องการจัดการกับทุกกรณีที่เหลือโดยไม่ต้องระบุชื่อตัวแปร การใช้ if let และ while let ช่วยให้โค้ดกระชับขึ้นเมื่อเราสนใจเพียงรูปแบบเดียว ในการพัฒนาจริง pattern matching เป็นหัวใจสำคัญของการเขียนโค้ดที่ปลอดภัยและอ่านเข้าใจได้ใน Rust เนื่องจากมันบังคับให้เราจัดการกับทุกกรณีที่เป็นไปได้และทำให้เจตนาของโค้ดชัดเจนว่าเราคาดหวังว่าข้อมูลจะมีรูปแบบใดบ้าง

Result<T, E>: การจัดการกับข้อผิดพลาดอย่างมีแบบแผน

เช่นเดียวกับที่ Option<T> ใช้เพื่อแสดงถึงการมีหรือไม่มีค่า Result<T, E> ใช้เพื่อแสดงถึงผลลัพธ์ของการดำเนินการที่อาจสำเร็จหรือล้มเหลว มันเป็น enum ที่มีสองตัวเลือก ได้แก่ Ok(T) ซึ่งบ่งบอกว่าการดำเนินการสำเร็จและคืนค่าที่มีประเภท T และ Err(E) ซึ่งบ่งบอกว่าการดำเนินการล้มเหลวและคืนค่าข้อผิดพลาดที่มีประเภท E การใช้ Result ทำให้เราต้องจัดการกับกรณีความล้มเหลวอย่างชัดเจน ซึ่งแตกต่างจากการใช้ exception ในภาษาอื่นที่อาจถูกมองข้ามหรือจัดการไม่ถูกต้อง นอกจากนี้ Result ยังช่วยให้เราสามารถส่งต่อข้อผิดพลาดไปยังผู้เรียกใช้ได้อย่างง่ายดายโดยใช้ตัวดำเนินการ ? ซึ่งจะคืนค่าข้อผิดพลาดทันทีหากผลลัพธ์เป็น Err หรือแตกค่าออกจาก Ok หากผลลัพธ์เป็น success แม้ว่าในบทความนี้เราจะให้เพียงการแนะนำเบื้องต้นเท่านั้น เนื่องจากการจัดการกับข้อผิดพลาดอย่างละเอียดจะถูกอธิบายอย่างเต็มที่ใน Part 4 เกี่ยวกับ Collections, Iterators และ Error Handling อย่างไรก็ตาม ตัวอย่างต่อไปนี้แสดงการใช้งานพื้นฐานของ Result ในการอ่านไฟล์ซึ่งเป็นการดำเนินการที่มักล้มเหลวได้จากหลายสาเหตุ เช่นไฟล์ไม่มีอยู่หรือไม่มีสิทธิ์อ่าน

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    // เปิดไฟล์และคืนค่า Result ที่อาจเป็น File หรือ io::Error
    let mut file = File::open("hello.txt")?;
    
    // อ่านเนื้อหาไฟล์ลงในสตริงและคืนค่า Result ที่อาจเป็นสตริงหรือ io::Error
    let mut username = String::new();
    file.read_to_string(&mut username)?;
    
    // หากทุกขั้นตอนสำเร็จ เราจะคืนค่า Ok ที่มี username
    Ok(username)
}

fn main() {
    match read_username_from_file() {
        Ok(username) => println!("ชื่อผู้ใช้: {}", username),
        Err(e) => println!("เกิดข้อผิดพลาดในการอ่านไฟล์: {}", e),
    }

    // การใช้ตัวดำเนินการ ? เพื่อส่งต่อข้อผิดพลาดอย่างรวดเร็ว
    // ฟังก์ชันนี้จะคืนค่า Result ทันทีหากมีข้อผิดพลาดเกิดขึ้นในขั้นตอนใดขั้นตอนหนึ่ง
    fn read_username_shortcut() -> Result<String, io::Error> {
        let mut username = String::new();
        File::open("hello.txt")?.read_to_string(&mut username)?;
        Ok(username)
    }
}

จากตัวอย่าง เราเห็นว่า Result<T, E> ช่วยให้เราจัดการกับการดำเนินการที่อาจล้มเหลวได้อย่างชัดเจนและปลอดภัย การใช้ตัวดำเนินการ ? ทำให้การส่งต่อข้อผิดพลาดเป็นไปอย่างสะดวกโดยไม่ต้องเขียน match ซ้ำซ้อนในทุกขั้นตอน ซึ่งช่วยให้โค้ดอ่านเข้าใจได้ง่ายขึ้นและลดความเสี่ยงจากการลืมจัดการกับข้อผิดพลาด ในการพัฒนาจริง เราจะเห็นการใช้ Result อย่างแพร่หลายในไลบรารีมาตรฐานของ Rust และในไลบรารีบุคคลที่สาม เกือบทุกการดำเนินการที่เกี่ยวข้องกับ I/O การเครือข่าย หรือการแยกวิเคราะห์ข้อมูลจะคืนค่าเป็น Result เพื่อบังคับให้ผู้เรียกใช้ต้องพิจารณากรณีความล้มเหลว ในโปรเจกต์ rs-wsProxy เราใช้ Result อย่างมากในการจัดการกับข้อผิดพลาดจากการเชื่อมต่อเครือข่าย การวิเคราะห์ข้อความ และการเข้าถึงระบบไฟล์ ซึ่งจะอธิบายอย่างละเอียดใน Part 4

เชื่อมโยงกับ rs-wsProxy: การประยุกต์ใช้ในโครงการจริง

ในโปรเจกต์ rs-wsProxy ซึ่งเป็นเซิร์ฟเวอร์พร็อกซ์ WebSocket ที่เขียนด้วย Rust เราได้ประยุกต์ใช้แนวคิดของ structs, enums และ pattern matching อย่างเต็มที่เพื่อสร้างระบบที่ปลอดภัยและบำรุงรักษาได้ง่าย ตัวอย่างที่เห็นได้ชัดเจนคือการกำหนดประเภทข้อมูลสำหรับการตั้งค่าและผลลัพธ์จากการตรวจสอบการเชื่อมต่อ เราใช้ struct AppState เพื่อเก็บสถานะของแอปพลิเคชันซึ่งประกอบด้วยฟิลด์ต่างๆ เช่น รายการเซิร์ฟเวอร์ที่อนุญาต (ซึ่งใช้ Option เพื่อแยกแยะระหว่างไม่มีการจำกัดและการอนุญาตรายการว่าง) ตารางการเปลี่ยนเส้นทาง และเป้าหมายเริ่มต้น นอกจากนี้เรายังใช้ enum VerifyResult เพื่อแสดงผลลัพธ์จากการตรวจสอบว่าไคลเอนต์ควรได้รับอนุญาตให้เชื่อมต่อไปยังเซิร์ฟเวอร์เป้าหมายหรือไม่ โดยมีสองตัวเลือก ได้แก่ Accepted ที่เก็บข้อความยืนยันและ Rejected ที่เก็บเหตุผลการปฏิเสธ ต่อไปนี้แสดงโครงสร้างข้อมูลจริงจากโค้ดของ rs-wsProxy

use std::collections::HashMap;

// ผลลัพธ์จากการตรวจสอบการเชื่อมต่อ
pub enum VerifyResult {
    // การเชื่อมต่อได้รับการยอมรับพร้อมข้อความยืนยัน (เช่นเซิร์ฟเวอร์เป้าหมาย)
    Accepted(String),
    // การเชื่อมต่อถูกปฏิเสธพร้อมเหตุผล
    Rejected(RejectReason),
}

// เหตุผลในการปฏิเสธการเชื่อมต่อ
pub enum RejectReason {
    Unauthorized,           // ไม่ได้รับอนุญาต
    ServerNotFound,         // เซิร์ฟเวอร์เป้าหมายไม่พบในรายการที่อนุญาต
    InternalError,          // ข้อผิดพลาดภายในของพร็อกซี่
}

// สถานะของแอปพลิเคชันที่แบ่งปันระหว่างการเชื่อมต่อต่างๆ
pub struct AppState {
    // รายการเซิร์ฟเวอร์ที่อนุญาตให้เชื่อมต่อได้ หากเป็น None หมายถึงไม่มีการจำกัด (เปิดพร็อกซี่)
    // หากเป็น Some(vec![]) หมายถึงอนุญาตรายการว่าง (ปฏิเสธการเชื่อมต่อทั้งหมด)
    pub allowed_servers: Option<Vec<String>>,
    // ตารางการเปลี่ยนเส้นทางจากเส้นทางต้นทางไปยังเส้นทางปลายทาง
    pub redirects: HashMap<String, String>,
    // เป้าหมายเริ่มต้นที่จะใช้เมื่อไม่มีการระบุเฉพาะเจาะจง หากเป็น None หมายถึงไม่มีเป้าหมายเริ่มต้น
    pub default_target: Option<String>,
}

impl AppState {
    // เมธอดสำหรับตรวจสอบว่าเซิร์ฟเวอร์เป้าหมายได้รับอนุญาตให้เชื่อมต่อหรือไม่
    pub fn is_server_allowed(&self, server: &str) -> bool {
        match &self.allowed_servers {
            Some(servers) => servers.contains(server),
            None => true, // None หมายถึงไม่มีการจำกัด อนุญาตทุกเซิร์ฟเวอร์
        }
    }
}

// ตัวอย่างการใช้งาน VerifyResult ในตัวจัดการการเชื่อมต่อ
async fn handle_connection(state: AppState, target_server: String) -> VerifyResult {
    // ตรวจสอบว่าเซิร์ฟเวอร์เป้าหมายได้รับอนุญาตหรือไม่
    if !state.is_server_allowed(&target_server) {
        return VerifyResult::Rejected(RejectReason::ServerNotFound);
    }
    
    // ในโค้ดจริงอาจมีการตรวจสอบเพิ่มเติม เช่นการเชื่อมต่อไปยังเซิร์ฟเวอร์จริง
    // ที่นี่เราสมมติว่าการตรวจสอบผ่านทั้งหมด
    VerifyResult::Accepted(format!("เชื่อมต่อไปยัง {} สำเร็จ", target_server))
}

fn main() {
    // ตัวอย่างการสร้าง AppState และการใช้งาน
    let state = AppState {
        allowed_servers: Some(vec![
            String::from("เซิร์ฟเวอร์A.example.com"),
            String::from("เซิร์ฟเวอร์B.example.com"),
        ]),
        redirects: HashMap::from([
            (String::from("/เก่า"), String::from("/ใหม่")),
        ]),
        default_target: Some(String::from("เซิร์ฟเวอร์C.example.com")),
    };

    // ตรวจสอบว่าเซิร์ฟเวอร์หนึ่งได้รับอนุญาตหรือไม่
    println!(
        "เซิร์ฟเวอร์A ได้รับอนุญาต: {}",
        state.is_server_allowed("เซิร์ฟเวอร์A.example.com")
    ); // จะพิมพ์ true

    println!(
        "เซิร์ฟเวอร์C ได้รับอนุญาต: {}",
        state.is_server_allowed("เซิร์ฟเวอร์C.example.com")
    ); // จะพิมพ์ false เนื่องจากไม่อยู่ในรายการ

    // จำลองการจัดการการเชื่อมต่อ
    let result = handle_connection(state, String::from("เซิร์ฟเวอร์A.example.com"));
    match result {
        VerifyResult::Accepted(msg) => println!("ผลลัพธ์: {}", msg),
        VerifyResult::Rejected(reason) => {
            println!("การเชื่อมต่อถูกปฏิเสธด้วยเหตุผล: {:?}", reason);
        }
    }
}

จากตัวอย่างข้างต้น เราเห็นว่าการใช้ Option<T> ในฟิลด์ allowed_servers ช่วยให้เราสามารถแยกแยะสามสถานะได้อย่างชัดเจน: None หมายถึงไม่มีการจำกัด (พร็อกซี่เปิดให้เชื่อมต่อไปยังเซิร์ฟเวอร์ใดๆ ก็ได้), Some(vec![]) หมายถึงอนุญาตรายการว่าง (ปฏิเสธการเชื่อมต่อทั้งหมดเนื่องจากไม่มีเซิร์ฟเวอร์ใดที่ได้รับอนุญาต), และ Some ที่มีองค์ประกอบหนึ่งหรือมากกว่าหมายถึงอนุญาตเฉพาะเซิร์ฟเวอร์ในรายการนั้น การออกแบบเช่นนี้ทำให้ตรรกะในการตรวจสอบว่าเซิร์ฟเวอร์ได้รับอนุญาตหรือไม่ (is_server_allowed) มีความชัดเจนและปลอดภัย เนื่องจากเราต้องจัดการกับทั้งสองกรณีของ Option อย่างชัดเจน นอกจากนี้การใช้ enum VerifyResult พร้อมกับ pattern matching ใน handle_connection ทำให้เราสามารถจัดการกับผลลัพธ์สองประเภทได้อย่างครบถ้วนโดยไม่มีความเสี่ยงที่จะละเลยกรณีใดกรณีหนึ่ง ในการพัฒนาจริง โครงสร้างข้อมูลเหล่านี้ถูกใช้ทั่วทั้งโค้ดฐานของ rs-wsProxy เพื่อจัดการกับสถานะ การตั้งค่า และผลลัพธ์จากการดำเนินการต่างๆ ซึ่งทำให้โค้ดมีความทนทานต่อข้อผิดพลาดและง่ายต่อการเข้าใจ

สรุป

ในบทความนี้เราได้เรียนรู้เกี่ยวกับการสร้างและใช้งาน structs และ enums ใน Rust เพื่อกำหนดประเภทข้อมูลของเราเอง ซึ่งช่วยให้เราสามารถจำลองข้อมูลที่ซับซ้อนได้อย่างปลอดภัยและชัดเจน เราได้เห็นว่า structs เหมาะสำหรับการเก็บข้อมูลที่มีฟิลด์หลายฟิลด์ที่เกี่ยวข้องกัน ในขณะที่ enums ช่วยให้เราสามารถกำหนดประเภทข้อมูลที่สามารถเป็นหนึ่งในหลายๆ ตัวเลือกได้ โดยแต่ละตัวเลือกสามารถเก็บข้อมูลประเภทต่างกันได้ เราได้เรียนรู้เกี่ยวกับการเพิ่มเมธอดให้กับ structs ผ่าน impl block และความแตกต่างระหว่าง &self, &mut self และ self นอกจากนี้เราได้สำรวจ pattern matching ซึ่งเป็นกลไกที่ทรงพลังสำหรับการจัดการกับทุกกรณีที่เป็นไปได้ของ enum หรือ struct อย่างครบถ้วน และเราได้เห็นการแนะนำเบื้องต้นเกี่ยวกับ Result<T, E> สำหรับการจัดการกับข้อผิดพลาด ซึ่งจะถูกอธิบายอย่างละเอียดใน Part 4 สุดท้ายเราได้เห็นการประยุกต์ใช้แนวคิดเหล่านี้ในโครงการจริงอย่าง rs-wsProxy ซึ่งแสดงให้เห็นว่าแนวคิดเหล่านี้ไม่ใช่เพียงทฤษฎีเท่านั้น แต่เป็นเครื่องมือที่จำเป็นในการสร้างซอฟต์แวร์ที่ทนทานและบำรุงรักษาได้ง่ายใน Rust

หากคุณต้องการทบทวนเนื้อหาก่อนหน้าเกี่ยวกับ Ownership, Borrowing และ Lifetimes โปรดดูที่: Ownership, Borrowing และ Lifetimes

หากคุณต้องการเรียนรู้ต่อไปเกี่ยวกับ Collections, Iterators และ Error Handling ใน Rust โปรดดูที่: Collections, Iterators และ Error Handling