Struct, enum, pattern matching และ Option — model ข้อมูลที่ไม่มี null
บทที่แล้วเราวางรากฐาน ownership ไว้ — ใครเป็นเจ้าของค่าไหน ยืมกันอย่างไร ย้ายเมื่อไหร่ บทนี้เราเอารากฐานนั้นมา ประกอบเป็นรูปเป็นร่าง: model ข้อมูลจริงของ taskcli ถ้าคุณมาจาก C#/.NET คุณคุ้นกับ class/record, enum, และ switch อยู่แล้ว — แต่ Rust ให้เครื่องมือชุดที่ หน้าตาคล้าย แต่เข้มกว่าในจุดที่สำคัญ: enum พก payload ได้, match บังคับให้ครบทุกกรณีตั้งแต่ตอน compile, และ null หายไปจากภาษาโดยสิ้นเชิง แทนที่ด้วยชนิดที่ compiler บังคับ ให้คุณเปิดกล่องก่อนใช้
code ลงมือของคอร์สนี้อยู่ใน repo kaen-taskcli (code ตัวอย่างกำลังจัดทำ) — บทนี้คือจุดที่ taskcli เริ่มมี ชนิดข้อมูลของตัวเอง เป็นครั้งแรก: Task (หนึ่งรายการงาน), Priority (ระดับความสำคัญ) และ Command (สิ่งที่ผู้ใช้สั่ง) ทั้งสามตัวนี้จะอยู่กับเราไปยาวๆ — บท4 ห่อ error รอบมัน, บท7 parse argument CLI มาเป็น Command, บท8 เอา Vec<Task> ไปประมวลผลแบบขนาน code ในบทนี้เป็น std ล้วน ไม่มี dependency ภายนอกสักตัว
struct — โครงข้อมูลสามแบบ แยกข้อมูลออกจากพฤติกรรม
หัวข้อที่มีชื่อว่า “struct — โครงข้อมูลสามแบบ แยกข้อมูลออกจากพฤติกรรม”structstructโครงข้อมูล 3 แบบ: named-field, tuple, unit; ผูกพฤติกรรมด้วย `impl` แยกจากตัวข้อมูล คือหน่วยประกอบข้อมูลของ Rust เทียบได้กับ class/record ฝั่ง C# — แต่มีจุดต่างที่เห็นทันทีคือ Rust แยก ตัวข้อมูล (struct) ออกจากพฤติกรรม (impl) เป็นคนละ block ไม่ยัด field กับ method ไว้ปนกันในวงเล็บเดียว struct มีสามรูปแบบ:
#[derive(Debug, Clone, PartialEq)]struct Task { id: u32, title: String, done: bool,}
impl Task { fn new(id: u32, title: String) -> Self { Self { id, title, done: false } } fn summary(&self) -> String { let mark = if self.done { "x" } else { " " }; format!("[{mark}] #{} {}", self.id, self.title) } fn complete(&mut self) { self.done = true; }}
struct Point(i32, i32); // tuple struct — เข้าถึงด้วย .0 / .1struct Marker; // unit-like struct — ไม่มี field เลย- named-field (
Task) — แบบที่ใช้บ่อยที่สุด แต่ละ field มีชื่อ - tuple struct (
Point) — มีชนิดแต่ไม่ตั้งชื่อ field เข้าถึงด้วย.0,.1เหมาะกับ wrapper บางๆ (บท6 จะเจอCelsius(f64)แบบนี้อีก) - unit-like struct (
Marker) — ไม่มีข้อมูลเลย มีไว้ผูก trait เข้าไปทีหลัง
ใน impl block สังเกต receiver ของแต่ละ method — มันคือสิ่งที่บทที่แล้วปูมาเป๊ะๆ:
Task::newไม่มีselfเลย → เป็น associated function (constructor) เรียกด้วยTask::new(...)เทียบได้กับ static factory method / constructor ฝั่ง C#summary(&self)ยืม ตัวTaskมาอ่านเฉยๆ — เรียกกี่ครั้งก็ได้ ไม่แตะข้อมูลcomplete(&mut self)ยืมแบบแก้ไข — ต้องการสิทธิ์เขียน จึงต้องเรียกบนตัวแปรที่ประกาศmut
นี่คือความต่างที่ C# ซ่อนไว้: ในฝั่ง C# ทุก method รับ this เหมือนกันหมด คุณมองไม่ออกจากลายเซ็นว่า method ไหนอ่านอย่างเดียว method ไหนแก้ไข (ต้องไปอ่านเนื้อใน หรือหวังพึ่ง readonly) แต่ใน Rust &self กับ &mut self ประกาศเจตนานั้นในลายเซ็น และ borrow checker รักษากฎนั้นให้ — receiver บอกทุกอย่าง
บรรทัด #[derive(Debug, Clone, PartialEq)] เหนือ Task สั่งให้ compiler เขียน code สามอย่างนี้ให้อัตโนมัติ: Debug (พิมพ์ด้วย {:?} เพื่อ debug), Clone (คัดลอกลึกด้วย .clone()) และ PartialEq (เทียบด้วย ==) ทั้งสามเป็น built-in derive ของภาษา ไม่ต้องเพิ่ม crate ใดๆ ต่างจาก C# ที่ record แถม ToString/Equals/with ให้ทันทีโดยไม่ต้องขอ — Rust ให้คุณ เลือกเปิดทีละอย่าง คุณจ่ายเฉพาะสิ่งที่ใช้ ลบ PartialEq ออกแล้วลอง task == other ดู จะเจอ error ทันทีตอน compile ว่าไม่มี == ให้ใช้
enum เป็น sum type — ไม่ใช่แค่ค่าคงที่จำนวนเต็ม
หัวข้อที่มีชื่อว่า “enum เป็น sum type — ไม่ใช่แค่ค่าคงที่จำนวนเต็ม”นี่คือจุดที่ enumenumsum type: แต่ละ variant พก payload ของตัวเองได้ (ต่างจาก enum = ค่าคงที่จำนวนเต็มใน C#) ของ Rust แยกทางจาก C# อย่างเด็ดขาด ในฝั่ง C# enum คือ ชื่อของค่าคงที่จำนวนเต็ม — enum Priority { Low, Medium, High } เบื้องหลังคือ 0, 1, 2 และมันพก payload เพิ่มไม่ได้ ถ้าอยากให้แต่ละกรณีถือข้อมูลต่างกัน คุณต้องไปสร้างลำดับชั้น class + inheritance หรือ pattern แบบ discriminated union เอาเอง
Rust enum เป็น sum type — แต่ละ variant พก payload ของตัวเองได้ และแต่ละ variant พกคนละแบบก็ยังได้:
#[derive(Debug, Clone, PartialEq)]enum Priority { Low, Medium, High }
#[derive(Debug)]enum Command { Add { title: String, priority: Priority }, // struct-like variant Done(u32), // tuple variant List, // unit variant}Priority เป็น enum แบบ “ค่าคงที่” ตรงๆ (ไม่มี payload) — อันนี้ตรงกับ C# แต่ Command คือของจริงที่ C# ทำไม่ได้ในบรรทัดเดียว: Add พกทั้ง title และ priority, Done พก id หนึ่งตัว, List ไม่พกอะไรเลย — ทั้งสามเป็นชนิดเดียวกันคือ Command นี่คือวิธีที่ taskcli จะแทน “สิ่งที่ผู้ใช้สั่ง” ไว้ในค่าเดียว
เพราะ variant พก payload ได้ enum ที่ มีข้อมูล จึงแปลงเป็นตัวเลขด้วย as ไม่ได้ — Command::Done(3) as u32 compile ไม่ผ่าน มีแต่ enum ที่ทุก variant ไม่มี payload (fieldless เช่น Priority) เท่านั้นที่ cast ด้วย as ได้ ถ้ายังติดนิสัย “enum ก็คือ int” จาก C# ตรงนี้จะสะดุด — มอง Command เป็น กล่องที่มีป้ายกำกับ (tag) + ของข้างใน ไม่ใช่ตัวเลข
match — จับคู่ทุกกรณี ไม่งั้น compile ไม่ผ่าน
หัวข้อที่มีชื่อว่า “match — จับคู่ทุกกรณี ไม่งั้น compile ไม่ผ่าน”พอ enum พก payload ได้ เราต้องมีวิธี เปิดกล่อง ออกมาใช้ — นั่นคือ matchmatchการจับคู่ pattern แบบ exhaustive (ครอบทุกกรณี ไม่งั้น compile ไม่ผ่าน E0004) มันจับคู่ค่ากับ pattern แล้วดึงข้อมูลข้างในออกมาผูกกับตัวแปรในคราวเดียว:
fn describe(cmd: &Command) -> String { match cmd { Command::Add { title, priority } => format!("add {title:?} ({priority:?})"), Command::Done(id) => format!("mark #{id} done"), Command::List => "list all".to_string(), }}สังเกตสองอย่าง: (1) แต่ละ arm แกะ payload ออกมาเป็นตัวแปร (title, priority, id) ให้ใช้ได้เลยในฝั่งขวา และ (2) ทุก arm ต้องคืนค่า ชนิดเดียวกัน (ที่นี่คือ String) เพราะ match เป็น expression — ทั้งก้อนมีค่าเป็นผลลัพธ์ เอาไปใส่ let x = match ... ได้ตรงๆ
แต่คุณสมบัติที่สำคัญที่สุดคือ exhaustiveness — match ต้องครอบทุกกรณีที่เป็นไปได้ ไม่งั้น compile ไม่ผ่าน ลองลบ arm Command::List ออก:
fn describe(cmd: &Command) -> String { match cmd { Command::Add { title, priority } => format!("add {title:?} ({priority:?})"), Command::Done(id) => format!("mark #{id} done"), // ลบ arm List ออก }}error[E0004]: non-exhaustive patterns: `&Command::List` not coveredนี่คือ compiler-ครูอีกครั้ง — E0004 ไม่ได้แค่บ่น มัน หยุด build ให้เลย เทียบกับ C# ตรงๆ:
C# switch | Rust match |
|---|---|
| ไม่ครบทุกกรณี → ได้แค่ warning CS8509 (ถ้าเปิด) แล้วปล่อยผ่าน | ไม่ครบทุกกรณี → compile error E0004 build ไม่ออก |
ตกไปเข้ากรณีที่ลืม → โยน SwitchExpressionException ตอน runtime | จับได้ตั้งแต่ compile ก่อนถึงมือผู้ใช้ |
| เพิ่ม enum ตัวใหม่ → code เก่าเงียบ ไปพังทีหลัง | เพิ่ม variant ใหม่ → ทุก match ที่ต้องแก้ สว่างขึ้นพร้อมกัน |
แถวสุดท้ายคือของขวัญตอน refactor: วันที่ taskcli เพิ่มคำสั่ง Command::Delete(u32) compiler จะไล่ชี้ทุก match ที่ยังไม่รองรับให้คุณเห็นทันที คุณจะไม่มีวันลืมอัปเดตจุดไหน — ตราบใดที่คุณ ไม่ ใส่ _ => แบบครอบจับทุกอย่าง
_ => ... คือ arm ครอบจับ “ที่เหลือทั้งหมด” มันสะดวก แต่มัน ปิดสวิตช์ ความปลอดภัยตอน refactor ที่เพิ่งคุยกัน — เพราะเมื่อคุณเพิ่ม variant ใหม่ _ จะกลืนมันเงียบๆ แทนที่จะบังคับให้คุณไปคิดว่าควรทำอย่างไร บน enum ที่ คุณเป็นเจ้าของเอง ให้เขียนทุก arm ออกมาตรงๆ เก็บ _ ไว้ใช้กับกรณีที่ตัวเลือกเยอะจนแจงไม่ไหวจริงๆ เท่านั้น
นอกจากจับ variant ตรงๆ pattern ยังมี OR-pattern (A | B), guard (if เงื่อนไข) และการ bind ได้ในตัว:
fn label(p: &Priority, urgent: bool) -> &'static str { match p { Priority::High if urgent => "drop everything", Priority::High => "high", Priority::Medium | Priority::Low => "can wait", }}fn is_high(p: &Priority) -> bool { matches!(p, Priority::High)}Priority::High if urgent คือ arm ที่ match เฉพาะเมื่อ urgent เป็นจริง, Medium | Low รวมสองกรณีเป็นบรรทัดเดียว, และ matches! คือ macro ทางลัดสำหรับตอนที่คุณอยากได้แค่ bool ว่า “match หรือไม่” ไม่ต้องเขียน match เต็ม
Option — null ที่หายไปจากภาษา
หัวข้อที่มีชื่อว่า “Option — null ที่หายไปจากภาษา”มาถึงหัวใจของบท ในโลก C# ทุก reference เป็น null ได้ และ NullReferenceException คือ bug ที่เจอบ่อยที่สุดตัวหนึ่งในประวัติศาสตร์ nullable reference type (C# 8+) ช่วยได้ระดับหนึ่งแต่เป็นแค่ warning ที่ปิดได้ ข้ามได้ และไม่ครอบคลุม code เก่า
Rust ไม่มี null เลย แทนที่ด้วยชนิดใน library มาตรฐานชื่อ OptionOption`Some(T)` หรือ `None` — แทน null; compiler บังคับให้จัดการกรณีว่างก่อนใช้ค่า:
enum Option<T> { Some(T), None,}Option เป็นแค่ enum ธรรมดา (ที่เราเพิ่งเรียน!) มี generic T — Some(T) คือ “มีค่า” และ None คือ “ไม่มีค่า” กุญแจอยู่ตรงนี้: ถ้า function คืน String แปลว่าคุณ รับประกัน ได้ว่ามีค่า string จริงๆ “ไม่มีค่า” เป็นไปไม่ได้เลย เว้นแต่ชนิดจะเป็น Option<String> โดยตรง และเมื่อมันเป็น Option compiler จะ บังคับ ให้คุณจัดการกรณี None ก่อนเข้าถึงค่าข้างใน — ลืมไม่ได้ เพราะค่าข้างในถูกล็อกไว้หลัง match/if let
ทางที่ตรงไปตรงมาที่สุดคือ match ทั้งสองกรณี แต่บ่อยครั้งเราสนใจแค่เส้นทางที่ “มีค่า” — Rust มีสำนวน let ... else สำหรับตรงนี้ (stable ตั้งแต่ Rust 1.65):
fn normalized_title(input: Option<String>) -> String { let Some(title) = input else { return String::from("(untitled)"); }; title.trim().to_string()}อ่านเป็นภาษาคน: “ดึง title ออกจาก Some ให้ที — ถ้าเป็น None ให้ทำ block else แล้วออกจาก function ไปเลย” ข้อดีคือหลังบรรทัดนั้น title เป็น String ธรรมดา (ไม่ใช่ Option แล้ว) ใช้ต่อได้ยาวๆ โดยไม่ต้องเลื่อน code เข้าไปใน block ซ้อนลึกเรื่อยๆ แบบ if let
กฎเหล็กของ let ... else คือ block else ต้อง diverge — คือต้องออกจากเส้นทางปกติ (ด้วย return, break, continue, หรือ panic!) ห้ามไหลตกลงมาข้างล่าง เพราะถ้าไหลลงมาได้ title จะไม่มีค่าให้ใช้ ลองเปลี่ยน return ... เป็นแค่ String::from("(untitled)"); เฉยๆ:
error[E0308]: `else` clause of `let...else` does not divergecompiler ปฏิเสธทันที — มันพิสูจน์ให้เห็นว่าเส้นทาง None ต้อง จบลงที่นั่น ไม่มีทางหลุดมาใช้ค่าที่ไม่มีจริง
เมื่อการเปิดกล่องเต็มๆ ดูเกินจำเป็น Option ยังมี combinator สั้นๆ ให้ใช้ เช่น .map() แปลงค่าข้างในถ้ามี, .unwrap_or_else() ให้ค่าสำรองเมื่อ None — บทนี้เราแนะพอให้รู้จัก บท5 จะเจอ combinator เต็มชุดตอนทำงานกับ iterator
เวลาเขียน code คุณจะพิมพ์ Option<String>, Vec<Task>, Option<u32> เต็มไปหมด — เขียนใน file .rs ไม่มีปัญหา แต่ถ้าคุณจดโน้ตเป็น Markdown/MDX อย่าลืมครอบด้วย backtick เสมอ ไม่งั้น <String> จะถูกตีความเป็น HTML/JSX tag แล้วพัง — เป็นกับดักคลาสสิกของการเขียนเอกสาร Rust
รวมภาพ: enum + match + Option ทำงานร่วมกัน
หัวข้อที่มีชื่อว่า “รวมภาพ: enum + match + Option ทำงานร่วมกัน”flowchart TB E["enum Command<br/>หนึ่งชนิด สามหน้า (sum type)"] E --> V1["List<br/>unit variant — ไม่พก payload"] E --> V2["Done(u32)<br/>tuple variant — พก id"] E --> V3["Add title priority<br/>struct variant — พกหลาย field"] V1 --> M["match cmd แกะ payload ของแต่ละ variant"] V2 --> M V3 --> M M --> R["ต้องครอบครบทุกกรณี<br/>ขาด arm ไหน = E0004 ตอน compile"] classDef head fill:#fde68a,stroke:#92400e,color:#451a03; classDef variant fill:#fed7aa,stroke:#7c2d12,color:#431407; classDef gate fill:#fecaca,stroke:#991b1b,color:#450a0a; class E head; class V1,V2,V3 variant; class M,R gate;
คำบรรยายภาพ: enum เป็น sum type — variant สามแบบ (unit List, tuple Done(u32), struct-like Add) ต่างพก payload คนละอย่างแต่เป็นชนิด Command เดียวกัน; match แกะ payload ของแต่ละ variant ออกมาใช้ และ compiler บังคับให้จับครบทุกกรณี ขาด arm ไหน = E0004 ตั้งแต่ตอน compile
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”บทนี้ taskcli ได้ model ข้อมูลของตัวเองเป็นครั้งแรก: struct สามแบบ (named/tuple/unit) ที่แยกข้อมูลออกจากพฤติกรรมใน impl โดย receiver (&self/&mut self/self) ประกาศเจตนาอ่าน-แก้-ย้ายไว้ในลายเซ็น; enum เป็น sum type ที่แต่ละ variant พก payload ต่างกัน (Command::Add/Done/List) ต่างจาก enum = ค่าคงที่จำนวนเต็มของ C#; match ที่บังคับ exhaustiveness ด้วย E0004 ตอน compile (เทียบกับ switch ที่ได้แค่ CS8509 แล้วโยนตอน runtime) พร้อม OR-pattern, guard และ matches!; และ Option<T> ที่แทน null ทั้งหมด โดย compiler บังคับให้เปิดกล่องกรณี None ก่อนใช้ค่า — ผ่าน match, if let และ let ... else ที่ block else ต้อง diverge (ไม่งั้น E0308) ทั้งหมดนี้เป็น std ล้วน ใช้ #[derive(Debug, Clone, PartialEq)] โดยไม่ต้องพึ่ง crate ใดๆ
บทหน้าเราจะให้ taskcli รู้จัก ล้มเหลวอย่างมีระเบียบ: การจัดการ error ด้วย Result<T, E>, ตัวดำเนินการ ?, panic! และ thiserror — เราจะเห็นว่า Rust ทำให้ error เป็น ค่าปกติ ในลายเซ็น function ไม่ใช่ exception ที่โผล่มาจากไหนก็ได้ และ compiler ไม่ยอมให้คุณเมินมันเงียบๆ
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- The Rust Programming Language — ch05 Using Structs to Structure Related Data (เข้าถึง 2026-07-24) — struct สามรูปแบบ (named/tuple/unit) และ method syntax ใน
impl - The Rust Programming Language — ch06 Enums and Pattern Matching (เข้าถึง 2026-07-24) — enum เป็น sum type,
Option<T>แทน null,matchexhaustive และif let/let ... else - The Rust Programming Language — ch18 OOP: Trait Objects (บริบท sum type vs class hierarchy) (เข้าถึง 2026-07-24) — ทำไม enum + match แทนลำดับชั้น class ได้
- The Rust Programming Language — ch19 Patterns and Matching (เข้าถึง 2026-07-24) — OR-pattern, match guard, การ bind ใน pattern
- rustc Error Codes — E0004 (non-exhaustive match) (เข้าถึง 2026-07-24) — match ที่ไม่ครอบทุกกรณีคือ compile error
เช็กความเข้าใจ — บทที่ 3
ข้อ 1 / 3enum ของ Rust ต่างจาก enum ของ C# ในแง่ไหนเป็นหลัก?