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

การกำหนดอีนัม (Defining an Enum)

ในขณะที่โครงสร้างข้อมูล (structs) ช่วยให้คุณจัดกลุ่มฟิลด์และข้อมูลที่เกี่ยวข้องกันเข้าไว้ด้วยกัน เช่น Rectangle ที่มี width และ height อีนัม (enums) จะเป็นวิธีที่ช่วยระบุว่าค่านั้น ๆ เป็นค่าใดค่าหนึ่งจากกลุ่มของค่าที่เป็นไปได้ทั้งหมด ตัวอย่างเช่น เราอาจต้องการบอกว่า Rectangle เป็นหนึ่งในรูปร่างที่เป็นไปได้ทั้งหมดในกลุ่มซึ่งยังรวมถึง Circle และ Triangle อีกด้วย และเพื่อทำสิ่งนี้ Rust จึงอนุญาตให้เราเข้ารหัสความน่าจะเป็นเหล่านี้เป็นอีนัม

เรามาดูสถานการณ์ที่เราอาจต้องการแสดงออกมาในรูปของโค้ด และศึกษาดูว่าเหตุใดอีนัมจึงมีประโยชน์และเหมาะสมมากกว่า struct ในกรณีนี้ สมมติว่าเราจำเป็นต้องทำงานเกี่ยวกับที่อยู่ไอพี (IP addresses) ซึ่งในปัจจุบันนี้มีมาตรฐานหลัก ๆ สองอย่างที่ใช้กันทั่วไปคือ เวอร์ชัน 4 (IPv4) และเวอร์ชัน 6 (IPv6) เนื่องจากค่าเหล่านี้เป็นเพียงสองความเป็นไปได้เดียวสำหรับที่อยู่ไอพีที่โปรแกรมของเราจะพบเจอ เราจึงสามารถทำการแจงนับ (enumerate) รูปแบบที่เป็นไปได้ทั้งหมดออกมาได้ ซึ่งนั่นก็คือที่มาของคำว่าการแจงนับ (enumeration) หรืออีนัมนั่นเอง

ที่อยู่ไอพีใด ๆ จะเป็นได้เพียงเวอร์ชัน 4 หรือไม่ก็เวอร์ชัน 6 เท่านั้น แต่จะไม่สามารถเป็นทั้งสองเวอร์ชันพร้อมกันได้ คุณสมบัติดังกล่าวของที่อยู่ไอพีทำให้อีนัมเป็นโครงสร้างข้อมูลที่เหมาะสมอย่างยิ่ง เนื่องจากค่าของอีนัมสามารถเป็นได้เพียงรูปแบบใดรูปแบบหนึ่ง (variant) ของตัวมันเองเท่านั้น ทั้งที่อยู่ไอพีเวอร์ชัน 4 และเวอร์ชัน 6 ต่างก็ยังเป็นที่อยู่ไอพีโดยพื้นฐานเหมือนกัน ดังนั้นโค้ดของเราจึงควรจัดการกับพวกมันเสมือนว่าเป็นชนิดข้อมูลเดียวกัน เมื่อจำเป็นต้องจัดการกับสถานการณ์ทั่วไปที่ใช้ได้กับที่อยู่ไอพีทุกประเภท

เราสามารถแสดงแนวคิดนี้ในโค้ดได้โดยการกำหนดอีนัม IpAddrKind และระบุประเภทที่เป็นไปได้ทั้งหมดของที่อยู่ไอพี นั่นคือ V4 และ V6 ซึ่งสิ่งเหล่านี้คือรูปแบบที่เป็นไปได้ (variants) ของอีนัมดังกล่าว:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

ตอนนี้ IpAddrKind กลายเป็นชนิดข้อมูลแบบกำหนดเองที่เราสามารถนำไปใช้งานที่อื่นในโค้ดของเราได้แล้ว

ค่าต่าง ๆ ของอีนัม (Enum Values)

เราสามารถสร้างอินสแตนซ์ของทั้งสองรูปแบบใน IpAddrKind ได้ดังนี้:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

โปรดสังเกตว่าแต่ละรูปแบบ (variants) ของอีนัมจะอยู่ภายใต้ขอบเขตชื่อ (namespace) ของตัวระบุของอีนัมนั้น ๆ และเราจะใช้เครื่องหมายโคลอนคู่ (::) ในการแยกทั้งสองออกจากกัน ซึ่งสิ่งนี้มีประโยชน์มากเนื่องจากในตอนนี้ทั้งค่า IpAddrKind::V4 และ IpAddrKind::V6 ต่างก็มีชนิดข้อมูลเดียวกัน นั่นคือ IpAddrKind ดังนั้น ตัวอย่างเช่น เราจึงสามารถกำหนดฟังก์ชันที่รับพารามิเตอร์เป็นชนิดข้อมูล IpAddrKind ใด ๆ ก็ได้:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

และเราสามารถเรียกใช้งานฟังก์ชันนี้ด้วยรูปแบบใดรูปแบบหนึ่งของอีนัมได้ดังนี้:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

การใช้งานอีนัมยังมีข้อดีอื่น ๆ อีกมาก หากเราคิดทบทวนเพิ่มเติมเกี่ยวกับชนิดข้อมูลที่อยู่ไอพีของเรา ในขณะนี้เรายังไม่มีวิธีสำหรับจัดเก็บ ข้อมูล ที่อยู่ไอพีจริง ๆ เลย เราเพียงแค่รู้ว่ามันเป็น ประเภท ใดเท่านั้น เมื่อพิจารณาว่าคุณเพิ่งจะได้เรียนรู้เกี่ยวกับโครงสร้างข้อมูล (structs) ไปในบทที่ 5 คุณอาจอยากจะแก้ปัญหานี้ด้วยการใช้ struct ดังที่แสดงในรายการโค้ดที่ 6-1

fn main() {
    enum IpAddrKind {
        V4,
        V6,
    }

    struct IpAddr {
        kind: IpAddrKind,
        address: String,
    }

    let home = IpAddr {
        kind: IpAddrKind::V4,
        address: String::from("127.0.0.1"),
    };

    let loopback = IpAddr {
        kind: IpAddrKind::V6,
        address: String::from("::1"),
    };
}

ในที่นี้เรากำหนด struct ชื่อ IpAddr ที่มีสองฟิลด์ด้วยกัน ได้แก่ ฟิลด์ kind ซึ่งมีชนิดข้อมูลเป็น IpAddrKind (อีนัมที่เราเพิ่งกำหนดไปก่อนหน้านี้) และฟิลด์ address ซึ่งมีชนิดข้อมูลเป็น String จากนั้นเราได้สร้างสองอินสแตนซ์ของ struct นี้ โดยอินสแตนซ์แรกคือ home ซึ่งมีค่าใน kind เป็น IpAddrKind::V4 พร้อมข้อมูลที่อยู่ไอพีที่เชื่อมโยงกันเป็น 127.0.0.1 ส่วนอินสแตนซ์ที่สองคือ loopback ซึ่งมีค่า kind เป็นอีกหนึ่งรูปแบบของ IpAddrKind นั่นคือ V6 และมีที่อยู่ไอพีเป็น ::1 เชื่อมโยงอยู่ด้วย เราได้ใช้ struct เพื่อมัดรวมค่าประเภท (kind) และที่อยู่ (address) เข้าไว้ด้วยกัน ดังนั้นตอนนี้รูปแบบของไอพีจึงมีความสัมพันธ์เชื่อมโยงกับตัวข้อมูลแล้ว

อย่างไรก็ตาม การนำเสนอแนวคิดเดียวกันนี้โดยการใช้แค่อีนัมเพียงอย่างเดียวนั้นมีความกระชับมากกว่า กล่าวคือ แทนที่จะต้องใช้อีนัมซ้อนอยู่ภายใน struct เราสามารถนำข้อมูลไปจัดเก็บไว้ภายในแต่ละรูปแบบ (variants) ของอีนัมได้โดยตรง การกำหนดนิยามใหม่ของอีนัม IpAddr นี้ระบุว่า ทั้งรูปแบบ V4 และ V6 จะมีข้อมูลชนิด String เชื่อมโยงติดไปด้วย:

fn main() {
    enum IpAddr {
        V4(String),
        V6(String),
    }

    let home = IpAddr::V4(String::from("127.0.0.1"));

    let loopback = IpAddr::V6(String::from("::1"));
}

การที่เราผูกข้อมูลเข้ากับรูปแบบแต่ละอย่างของอีนัมได้โดยตรงเช่นนี้ ทำให้ไม่มีความจำเป็นต้องใช้ struct เพิ่มเติมเข้ามาอีก และจากตรงนี้จะช่วยให้เห็นรายละเอียดที่น่าสนใจอีกอย่างของการทำงานของอีนัม นั่นคือ ชื่อของแต่ละรูปแบบของอีนัมที่เรากำหนดขึ้นนั้น จะทำหน้าที่เป็นฟังก์ชันที่สร้างอินสแตนซ์ของอีนัมด้วยเช่นกัน ซึ่งหมายความว่า IpAddr::V4() เป็นการเรียกใช้ฟังก์ชันที่รับอาร์กิวเมนต์เป็นชนิด String และคืนค่าออกมาเป็นอินสแตนซ์ชนิดข้อมูล IpAddr เราจะได้รับฟังก์ชันสร้างอินสแตนซ์ (constructor function) นี้โดยอัตโนมัติเป็นผลมาจากการกำหนดอีนัม

นอกจากนี้ยังมีข้อดีอีกประการหนึ่งในการใช้อีนัมแทน struct คือ แต่ละรูปแบบของอีนัมสามารถเก็บข้อมูลที่มีชนิดข้อมูลและจำนวนข้อมูลที่แตกต่างกันออกไปได้ ที่อยู่ไอพีเวอร์ชัน 4 มักจะประกอบด้วยส่วนประกอบตัวเลขสี่ตัวที่มีค่าอยู่ระหว่าง 0 ถึง 255 เสมอ หากเราต้องการจัดเก็บที่อยู่ไอพีแบบ V4 เป็นข้อมูลชนิด u8 จำนวนสี่ค่า แต่ยังต้องการจัดเก็บที่อยู่ไอพีแบบ V6 เป็นข้อมูลชนิด String เพียงค่าเดียว เราจะไม่สามารถทำแบบนี้ได้หากใช้งาน struct แต่อีนัมสามารถจัดการกรณีนี้ได้อย่างง่ายดาย:

fn main() {
    enum IpAddr {
        V4(u8, u8, u8, u8),
        V6(String),
    }

    let home = IpAddr::V4(127, 0, 0, 1);

    let loopback = IpAddr::V6(String::from("::1"));
}

เราได้แสดงหลายวิธีในการกำหนดโครงสร้างข้อมูลเพื่อจัดเก็บที่อยู่ไอพีเวอร์ชัน 4 และเวอร์ชัน 6 ไปแล้ว อย่างไรก็ตาม ความต้องการในการจัดเก็บที่อยู่ไอพีและระบุว่ามันเป็นประเภทใดนั้น เป็นเรื่องที่พบบ่อยมากจนกระทั่งไลบรารีมาตรฐานมีคำจำกัดความให้เราสามารถนำไปใช้งานได้เลย! เรามาลองดูวิธีการที่ไลบรารีมาตรฐานกำหนด IpAddr กัน มันมีตัวอีนัมและรูปแบบต่าง ๆ ตรงกับที่เราได้กำหนดและใช้งานไปก่อนหน้านี้ทุกประการ เพียงแต่มันใช้วิธีฝังข้อมูลที่อยู่ไอพีไว้ข้างในรูปแบบต่าง ๆ ในรูปของโครงสร้างข้อมูลสองตัวที่แตกต่างกัน ซึ่งถูกกำหนดขึ้นมาเฉพาะสำหรับแต่ละรูปแบบ:

#![allow(unused)]
fn main() {
struct Ipv4Addr {
    // --snip--
}

struct Ipv6Addr {
    // --snip--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}
}

โค้ดนี้แสดงให้เห็นว่าคุณสามารถนำข้อมูลประเภทใดก็ได้มาจัดเก็บไว้ภายในรูปแบบของอีนัม ตัวอย่างเช่น ข้อความ (strings), ตัวเลข หรือโครงสร้างข้อมูล (structs) และคุณยังสามารถใส่อีนัมตัวอื่นเข้าไปได้ด้วย! นอกจากนี้ ชนิดข้อมูลต่าง ๆ ในไลบรารีมาตรฐานมักจะไม่ได้มีความซับซ้อนมากไปกว่าสิ่งที่คุณสามารถคิดค้นขึ้นมาเองได้เลย

โปรดทราบว่าแม้ไลบรารีมาตรฐานจะมีคำจำกัดความสำหรับ IpAddr อยู่แล้ว แต่เราก็ยังคงสามารถสร้างและใช้งานคำจำกัดความของเราเองได้โดยไม่เกิดความขัดแย้งใด ๆ เนื่องจากเราไม่ได้นำเข้า (import) นิยามของไลบรารีมาตรฐานเข้ามาในขอบเขตการทำงาน (scope) ของเรา เราจะพูดถึงรายละเอียดเกี่ยวกับการนำชนิดข้อมูลเข้ามาในขอบเขตการทำงานเพิ่มเติมในบทที่ 7

เรามาดูตัวอย่างอีนัมอีกตัวหนึ่งในรายการโค้ดที่ 6-2 ซึ่งอีนัมนี้จะมีประเภทของข้อมูลที่หลากหลายฝังอยู่ในรูปแบบต่าง ๆ ของมัน

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}

อีนัมนี้มีรูปแบบที่แตกต่างกันสี่ประเภทด้วยกัน:

  • Quit: ไม่มีข้อมูลใด ๆ เชื่อมโยงติดไปด้วยเลย
  • Move: มีฟิลด์ข้อมูลที่มีชื่อเหมือนอย่างที่ struct มี
  • Write: ประกอบด้วย String เพียงค่าเดียว
  • ChangeColor: ประกอบด้วยค่า i32 สามค่า

การกำหนดอีนัมที่มีรูปแบบแตกต่างกันดังที่เห็นในรายการโค้ดที่ 6-2 นั้นมีความคล้ายคลึงกับการเขียนกำหนด struct รูปแบบต่าง ๆ แยกจากกัน เพียงแต่อีนัมจะไม่ต้องใช้คีย์เวิร์ด struct และรูปแบบทั้งหมดจะถูกจัดกลุ่มเข้าไว้ด้วยกันภายใต้ชนิดข้อมูล Message เดียวกัน ซึ่งโครงสร้างข้อมูล (structs) ต่อไปนี้สามารถจัดเก็บข้อมูลแบบเดียวกับที่รูปแบบของอีนัมข้างต้นจัดเก็บได้เช่นกัน:

struct QuitMessage; // unit struct
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String); // tuple struct
struct ChangeColorMessage(i32, i32, i32); // tuple struct

fn main() {}

แต่ถ้าหากเราใช้ struct แยกกันในลักษณะนี้ แต่ละตัวก็จะมีชนิดข้อมูลเฉพาะเป็นของตัวเอง ซึ่งทำให้เราไม่สามารถเขียนกำหนดฟังก์ชันให้สามารถรับส่งข้อความประเภทใด ๆ เหล่านี้ได้ง่ายเหมือนกับการใช้ประโยชน์จากอีนัม Message ที่กำหนดไว้ในรายการโค้ดที่ 6-2 ซึ่งจัดเป็นชนิดข้อมูลเพียงชนิดเดียว

ความเหมือนกันอีกอย่างหนึ่งระหว่างอีนัมและ struct คือ เช่นเดียวกับที่เราสามารถกำหนดเมธอด (methods) บน struct ด้วยการใช้บล็อก impl ได้ เราก็สามารถเขียนกำหนดเมธอดบนอีนัมได้เช่นเดียวกัน ด้านล่างนี้คือตัวอย่างของเมธอดชื่อ call ที่เราสามารถสร้างขึ้นบนอีนัม Message ของเราได้:

fn main() {
    enum Message {
        Quit,
        Move { x: i32, y: i32 },
        Write(String),
        ChangeColor(i32, i32, i32),
    }

    impl Message {
        fn call(&self) {
            // method body would be defined here
        }
    }

    let m = Message::Write(String::from("hello"));
    m.call();
}

เนื้อความการทำงานของเมธอดจะใช้คำว่า self ในการเข้าถึงค่าที่เราใช้เรียกเมธอดนั้น ในตัวอย่างนี้ เราได้สร้างตัวแปร m ซึ่งเก็บค่าของ Message::Write(String::from("hello")) และนั่นก็คือค่าที่ self จะได้รับภายในเนื้อหาการทำงานของเมธอด call เมื่อคำสั่ง m.call() ถูกประมวลผล

เรามาลองดูตัวอย่างอีนัมอีกตัวหนึ่งในไลบรารีมาตรฐานที่มีการใช้งานบ่อยและมีประโยชน์อย่างมาก นั่นคือ Option

อีนัม Option (The Option Enum)

ส่วนนี้จะศึกษาเกี่ยวกับกรณีการใช้งานของ Option ซึ่งเป็นอีนัมอีกตัวหนึ่งที่ถูกกำหนดไว้ในไลบรารีมาตรฐาน ชนิดข้อมูล Option นี้จะช่วยในการเขียนจำลองกรณีจำลองที่พบได้บ่อยมาก นั่นคือการที่ค่าใดค่าหนึ่งอาจจะเป็น “การมีอยู่ของค่าบางอย่าง” หรือไม่ก็อาจจะเป็น “ความว่างเปล่า (ไม่มีอะไรเลย)”

ตัวอย่างเช่น หากคุณต้องการไอเท็มแรกสุดในรายการที่มีข้อมูลอยู่ คุณจะได้รับค่าข้อมูลกลับมา แต่หากคุณขอไอเท็มแรกสุดจากรายการที่ว่างเปล่า คุณก็จะไม่ได้รับอะไรเลย การแสดงแนวคิดนี้ผ่านระบบชนิดข้อมูล (type system) จะช่วยให้คอมไพเลอร์สามารถตรวจสอบได้ว่าคุณได้จัดการครบทุกกรณีที่คุณควรระมัดระวังเรียบร้อยแล้วหรือไม่ ซึ่งความสามารถในลักษณะนี้จะช่วยปกป้องโปรแกรมของคุณจากบั๊กที่พบบ่อยมากในภาษาโปรแกรมอื่น ๆ ได้

การออกแบบภาษาโปรแกรมมักจะถูกคิดถึงในแง่ของฟีเจอร์ที่คุณใส่เพิ่มเข้ามา แต่ทว่าฟีเจอร์ที่คุณนำออกไปหรือไม่ยอมใส่เข้ามาก็มีความสำคัญไม่แพ้กันเลย ภาษา Rust ไม่มีฟีเจอร์ที่เรียกว่า ค่าว่าง (null) เหมือนอย่างที่ภาษาอื่นหลายภาษามีกัน Null เป็นค่าที่แสดงถึงการ “ไม่มีค่าอยู่ ณ ที่นั้น” ในภาษาที่มีค่า null ตัวแปรต่าง ๆ จะสามารถอยู่ในสถานะใดสถานะหนึ่งจากสองสถานะนี้ได้เสมอ คือ เป็น null หรือ ไม่เป็น null

ในการนำเสนอหัวข้อ “Null References: The Billion Dollar Mistake” ในปี ค.ศ. 2009 ทาง Tony Hoare ผู้ซึ่งเป็นผู้คิดค้นแนวคิดเรื่องค่า null ได้กล่าวไว้ว่า:

ผมขอนิยามมันว่าเป็นความผิดพลาดมูลค่าพันล้านดอลลาร์ของผม ณ เวลานั้น ผมกำลังออกแบบระบบชนิดข้อมูลสำหรับการอ้างอิงที่ครอบคลุมเป็นระบบแรกสำหรับภาษาเชิงวัตถุ (object-oriented language) เป้าหมายของผมคือต้องการรับประกันว่าการใช้งานการอ้างอิงทั้งหมดจะต้องปลอดภัยอย่างสมบูรณ์แบบ โดยมีคอมไพเลอร์ทำหน้าที่ตรวจสอบให้อย่างอัตโนมัติ แต่ผมไม่สามารถหักห้ามใจจากความเย้ายวนในการใส่ตัวชี้ค่าว่าง (null reference) เข้าไปได้ เพียงเพราะว่ามันเขียนระบบขึ้นมาได้ง่ายมาก และนั่นก็นำไปสู่ข้อผิดพลาด ช่องโหว่ความปลอดภัย และระบบล่มนับครั้งไม่ถ้วน ซึ่งน่าจะสร้างความเจ็บปวดและความเสียหายมูลค่าไม่ต่ำกว่าพันล้านดอลลาร์ตลอดช่วงเวลาสี่สิบปีที่ผ่านมา

ปัญหาสำคัญของค่า null คือ หากคุณพยายามนำค่าที่เป็น null ไปใช้งานเสมือนว่ามันเป็นค่าที่ใช้งานได้ปกติ (not-null) คุณจะได้รับข้อผิดพลาดบางอย่างกลับมา และเนื่องจากคุณสมบัติความเป็น null หรือไม่เป็น null นี้กระจัดกระจายอยู่ทั่วไปในโปรแกรม จึงเป็นเรื่องง่ายมากที่จะเกิดข้อผิดพลาดประเภทนี้ขึ้น

อย่างไรก็ตาม แนวคิดที่ null พยายามสื่อออกมานั้นยังคงเป็นแนวคิดที่มีประโยชน์ นั่นคือ การระบุว่าค่าดังกล่าวในปัจจุบันยังใช้งานไม่ได้ หรือยังขาดหายไปเนื่องจากเหตุผลบางประการ

ปัญหาจริง ๆ จึงไม่ได้อยู่ที่ตัวแนวคิด แต่อยู่ที่การนำแนวคิดนี้ไปสร้างระบบขึ้นมาใช้ (particular implementation) ด้วยเหตุนี้ Rust จึงไม่มีค่า null แต่มีอีนัมที่ช่วยจำลองแนวคิดของการมีอยู่หรือการไม่มีอยู่ของข้อมูลทดแทน อีนัมดังกล่าวก็คือ Option<T> ซึ่งถูกกำหนดไว้ในไลบรารีมาตรฐาน ดังต่อไปนี้:

#![allow(unused)]
fn main() {
enum Option<T> {
    None,
    Some(T),
}
}

อีนัม Option<T> มีประโยชน์มากจนถึงขั้นถูกนำมารวมไว้ใน prelude (สิ่งต่าง ๆ ที่นำเข้าให้พร้อมใช้งานโดยอัตโนมัติ) ดังนั้นคุณจึงไม่จำเป็นต้องนำมันเข้าสู่ขอบเขตการทำงาน (scope) อย่างเป็นทางการ ตัวรูปแบบที่เป็นไปได้ (variants) ของมันก็รวมอยู่ใน prelude ด้วยเช่นกัน คุณจึงสามารถเขียนใช้งาน Some และ None ได้โดยตรงโดยไม่ต้องใส่คำนำหน้า Option:: ทั้งนี้ อีนัม Option<T> ก็ยังคงเป็นเพียงอีนัมธรรมดาตัวหนึ่ง และ Some(T) กับ None ก็ยังคงเป็นรูปแบบย่อยของชนิดข้อมูล Option<T>

ไวยากรณ์ <T> เป็นคุณลักษณะเด่นของ Rust ที่เรายังไม่ได้พูดคุยกันมาก่อน มันคือพารามิเตอร์ชนิดข้อมูลทั่วไป (generic type parameter) ซึ่งเราจะศึกษาเรื่องเจเนอริกส์แบบเจาะลึกในบทที่ 10 สำหรับตอนนี้ สิ่งที่คุณจำเป็นต้องรู้มีเพียงแค่ว่า <T> หมายถึง รูปแบบ Some ของอีนัม Option จะสามารถเก็บข้อมูลหนึ่งชิ้นของชนิดข้อมูลใด ๆ ก็ได้ และชนิดข้อมูลจริงแต่ละตัวที่เข้ามาแทนที่ตำแหน่ง T จะส่งผลให้ชนิดข้อมูล Option<T> โดยรวมกลายเป็นชนิดข้อมูลที่ต่างกันออกไป ด้านล่างนี้คือตัวอย่างการใช้งานค่า Option เพื่อจัดเก็บข้อมูลประเภทตัวเลขและตัวอักษร (char):

fn main() {
    let some_number = Some(5);
    let some_char = Some('e');

    let absent_number: Option<i32> = None;
}

ชนิดข้อมูลของ some_number จะเป็น Option<i32> ส่วนชนิดข้อมูลของ some_char จะเป็น Option<char> ซึ่งเป็นชนิดข้อมูลที่แตกต่างกันอย่างสิ้นเชิง ทาง Rust สามารถทำการอนุมานชนิดข้อมูล (infer types) เหล่านี้ได้เนื่องจากเราได้ระบุข้อมูลไว้ภายในรูปแบบ Some แล้ว แต่สำหรับตัวแปร absent_number ทาง Rust บังคับให้เราต้องเขียนระบุชนิดข้อมูลของ Option ไว้ด้วยเสมอ เนื่องจากคอมไพเลอร์ไม่สามารถเดาได้ว่ารูปแบบ Some ที่คู่กันจะเก็บข้อมูลชนิดใดจากการดูเพียงแค่ค่า None ในตัวอย่างนี้เราจึงระบุให้ Rust รู้ว่าเราตั้งใจให้ absent_number มีชนิดข้อมูลเป็น Option<i32>

เมื่อเรามีค่า Some เราจะรู้ว่ามีค่าข้อมูลอยู่ และค่านั้นจะถูกจัดเก็บอยู่ข้างใน Some แต่เมื่อเราได้ค่า None ในมุมหนึ่งจะมีความหมายเหมือนกับค่า null นั่นคือเราไม่มีค่าข้อมูลที่ใช้การได้ แล้วเหตุใดการมีอยู่ของ Option<T> จึงดีกว่าการใช้ระบบค่าว่างแบบ null?

พูดสั้น ๆ ก็คือ เนื่องจาก Option<T> และ T (โดยที่ T สามารถเป็นชนิดข้อมูลใด ๆ ก็ได้) เป็นชนิดข้อมูลที่แตกต่างกันอย่างสิ้นเชิง คอมไพเลอร์จึงไม่ยินยอมให้เรานำค่าของ Option<T> ไปใช้เสมือนว่ามันเป็นค่าที่ถูกต้องและใช้งานได้ชัวร์ ๆ ตัวอย่างเช่น โค้ดด้านล่างนี้จะคอมไพล์ไม่ผ่าน เนื่องจากพยายามนำค่าชนิด i8 ไปบวกเข้ากับค่าชนิด Option<i8>:

fn main() {
    let x: i8 = 5;
    let y: Option<i8> = Some(5);

    let sum = x + y;
}

หากเรารันโค้ดชุดนี้ เราจะได้รับข้อความแจ้งข้อผิดพลาดทำนองนี้:

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
 --> src/main.rs:5:17
  |
5 |     let sum = x + y;
  |                 ^ no implementation for `i8 + Option<i8>`
  |
  = help: the trait `Add<Option<i8>>` is not implemented for `i8`
help: the following other types implement trait `Add<Rhs>`
 --> /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/ops/arith.rs:98:8
  |
  = note: `i8` implements `Add`
 ::: /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/ops/arith.rs:113:0
  |
  = note: in this macro invocation
 --> /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/internal_macros.rs:22:8
  |
  = note: `&i8` implements `Add<i8>`
 ::: /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/internal_macros.rs:33:8
  |
  = note: `i8` implements `Add<&i8>`
 ::: /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/internal_macros.rs:44:8
  |
  = note: `&i8` implements `Add`
  = note: this error originates in the macro `add_impl` (in Nightly builds, run with -Z macro-backtrace for more info)

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

เข้มงวดมาก! ผลลัพธ์ของข้อผิดพลาดนี้หมายความว่า Rust ไม่เข้าใจวิธีการนำ i8 ไปบวกกับ Option<i8> เพราะว่าพวกมันเป็นคนละชนิดข้อมูลกัน เมื่อเรามีค่าที่เป็นชนิดข้อมูลประเภทธรรมดา เช่น i8 ในภาษา Rust คอมไพเลอร์จะรับประกันให้เราเสมอว่าค่านั้นจะมีค่าที่ถูกต้องและใช้งานได้จริงแน่นอน ทำให้เราสามารถเขียนโค้ดต่อได้ด้วยความมั่นใจโดยไม่ต้องเขียนเช็ค null ก่อนนำค่านั้นไปใช้ แต่เมื่อใดที่เราต้องรับมือกับ Option<i8> (หรือชนิดข้อมูลใด ๆ ภายใต้ Option) เมื่อนั้นเราจึงต้องตระหนักถึงความเสี่ยงที่ข้อมูลอาจจะไม่มีอยู่ และคอมไพเลอร์จะบังคับให้เราจัดการกับกรณีดังกล่าวก่อนที่จะนำค่านั้นไปประมวลผล

กล่าวอีกนัยหนึ่งคือ คุณจำเป็นต้องทำการแปลงค่า Option<T> ไปเป็นค่า T เสียก่อน จึงจะสามารถทำกิจกรรมหรือคำนวณแบบเฉพาะเจาะจงของ T กับมันได้ ซึ่งโดยทั่วไปแล้วสิ่งนี้จะช่วยดักจับหนึ่งในปัญหาที่พบได้บ่อยที่สุดของระบบ null นั่นคือ การทึกทักเอาเองว่าค่านั้นไม่เป็น null ทั้งที่จริง ๆ แล้วมันกำลังเป็น null อยู่

การกำจัดความเสี่ยงจากการทึกทักเอาเองว่าข้อมูลไม่เป็น null ช่วยให้คุณมั่นใจในโค้ดของคุณมากยิ่งขึ้น ในการที่จะอนุญาตให้ค่าใด ๆ สามารถเป็นค่าว่างได้ คุณต้องแสดงความจำนงอย่างชัดเจน (explicitly opt in) ด้วยการกำหนดให้ค่าดังกล่าวเป็นชนิดข้อมูล Option<T> และหลังจากนั้นเมื่อคุณต้องการใช้งานค่านั้น คุณจะถูกบังคับให้เขียนจัดการกรณีที่ค่านั้นเป็น null อย่างชัดเจน ส่วนที่อื่น ๆ ทั้งหมดที่ตัวแปรมีชนิดข้อมูลปกติที่ไม่ได้อยู่ในรูปแบบ Option<T> คุณก็สามารถสรุปได้อย่างปลอดภัยอย่างยิ่งว่าค่านั้นไม่มีทางเป็น null ได้อย่างแน่นอน นี่คือการตัดสินใจออกแบบอย่างตั้งใจของทีมผู้สร้าง Rust เพื่อจำกัดความแพร่หลายของระบบ null และเพื่อเพิ่มระดับความปลอดภัยให้กับโค้ดของ Rust

แล้วคุณจะนำค่า T ออกมาจากรูปแบบ Some ของชนิดข้อมูล Option<T> เพื่อนำไปใช้งานได้อย่างไร? อีนัม Option<T> มีเมธอดจำนวนมากที่ออกแบบมาเพื่อช่วยอำนวยความสะดวกในสถานการณ์ต่าง ๆ ซึ่งคุณสามารถเปิดดูรายละเอียดได้ในเอกสารอ้างอิงของมัน การสร้างความคุ้นเคยกับเมธอดต่าง ๆ บน Option<T> จะเป็นประโยชน์ต่อเส้นทางการเรียนรู้ภาษา Rust ของคุณอย่างมาก

โดยทั่วไป ในการใช้งานค่าชนิด Option<T> คุณจะต้องเขียนโค้ดที่สามารถรับมือกับรูปแบบที่เป็นไปได้แต่ละแบบของมัน คุณจะต้องการให้โค้ดส่วนหนึ่งรันเฉพาะเมื่อคุณมีค่าเป็น Some(T) และโค้ดส่วนนี้จะได้รับอนุญาตให้ใช้ค่า T ที่อยู่ด้านในได้ และคุณก็ต้องการให้โค้ดอีกส่วนหนึ่งรันเฉพาะเมื่อเจอค่า None ซึ่งโค้ดส่วนนี้จะไม่สามารถเรียกใช้ค่า T ได้ นิพจน์ match คือโครงสร้างควบคุมลำดับการทำงาน (control flow construct) ที่ทำหน้าที่ในลักษณะดังกล่าวเมื่อใช้งานร่วมกับอีนัม โดยมันจะแยกการทำงานของโค้ดตามรูปแบบของอีนัมที่ตรวจพบ และโค้ดส่วนนั้นก็จะสามารถหยิบยกข้อมูลที่จัดเก็บไว้ในค่านั้นมาใช้ต่อได้