ข้าม​ไป​ยัง​เนื้อหา

Struct, enum, pattern matching และ Option — model ข้อมูล​ที่​ไม่มี null

บท​ที่​แล้ว​เรา​วาง​รากฐาน ownership ไว้ — ใคร​เป็น​เจ้าของ​ค่า​ไหน ยืม​กัน​อย่างไร ย้าย​เมื่อไหร่ บท​นี้​เรา​เอา​รากฐาน​นั้น​มา ประกอบ​เป็น​รูป​เป็น​ร่าง: model ข้อมูล​จริง​ของ taskcli ถ้า​คุณ​มา​จาก C#/.NET คุณ​คุ้น​กับ class/record, enum, และ switch อยู่​แล้ว — แต่ Rust ให้​เครื่องมือ​ชุด​ที่ หน้าตา​คล้าย แต่​เข้ม​กว่า​ใน​จุด​ที่​สำคัญ: enum พก payload ได้, match บังคับ​ให้​ครบ​ทุก​กรณี​ตั้งแต่​ตอน compile, และ null หาย​ไป​จาก​ภาษา​โดย​สิ้นเชิง แทนที่​ด้วย​ชนิด​ที่ compiler บังคับ ให้​คุณ​เปิด​กล่อง​ก่อน​ใช้

📦 kaen-taskcli

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 / .1
struct 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(...)]` — ความ​สามารถ​ฟรี​ที่​ต้อง​ขอ

บรรทัด #[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 ว่า​ไม่มี == ให้​ใช้

นี่​คือ​จุด​ที่ 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 จะ​แทน “สิ่ง​ที่​ผู้​ใช้​สั่ง” ไว้​ใน​ค่า​เดียว

อย่า​คิด​ว่า enum ของ Rust แปลง​เป็น int ได้

เพราะ variant พก payload ได้ enum ที่ มี​ข้อมูล จึง​แปลง​เป็น​ตัวเลข​ด้วย as ไม่​ได้ — Command::Done(3) as u32 compile ไม่​ผ่าน มี​แต่ enum ที่​ทุก variant ไม่มี payload (fieldless เช่น Priority) เท่านั้น​ที่ cast ด้วย as ได้ ถ้า​ยัง​ติด​นิสัย “enum ก็​คือ int” จาก C# ตรง​นี้​จะ​สะดุด — มอง Command เป็น กล่อง​ที่​มี​ป้าย​กำกับ (tag) + ของ​ข้าง​ใน ไม่ใช่​ตัวเลข

พอ 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 ... ได้ตรงๆ

แต่​คุณสมบัติ​ที่​สำคัญ​ที่สุด​คือ exhaustivenessmatch ต้อง​ครอบ​ทุก​กรณี​ที่​เป็น​ไป​ได้ ไม่​งั้น 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# switchRust match
ไม่​ครบ​ทุก​กรณี → ได้​แค่ warning CS8509 (ถ้า​เปิด) แล้ว​ปล่อย​ผ่านไม่​ครบ​ทุก​กรณี → compile error E0004 build ไม่​ออก
ตก​ไป​เข้า​กรณี​ที่​ลืม → โยน SwitchExpressionException ตอน runtimeจับ​ได้​ตั้งแต่ compile ก่อน​ถึง​มือ​ผู้​ใช้
เพิ่ม enum ตัว​ใหม่ → code เก่า​เงียบ ไป​พัง​ทีหลังเพิ่ม variant ใหม่ → ทุก match ที่​ต้อง​แก้ สว่าง​ขึ้น​พร้อม​กัน

แถว​สุดท้าย​คือ​ของขวัญ​ตอน refactor: วัน​ที่ taskcli เพิ่ม​คำ​สั่ง Command::Delete(u32) compiler จะ​ไล่​ชี้​ทุก match ที่​ยัง​ไม่​รองรับ​ให้​คุณ​เห็น​ทันที คุณ​จะ​ไม่มี​วัน​ลืม​อัปเดต​จุด​ไหน — ตราบ​ใด​ที่​คุณ ไม่ ใส่ _ => แบบ​ครอบ​จับ​ทุก​อย่าง

เลี่ยง `_ =>` บน enum ที่​คุณ​เป็น​เจ้าของ

_ => ... คือ 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 เต็ม

มา​ถึง​หัวใจ​ของ​บท ใน​โลก 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 TSome(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 diverge

compiler ปฏิเสธ​ทันที — มัน​พิสูจน์​ให้​เห็น​ว่า​เส้นทาง None ต้อง จบ​ลง​ที่​นั่น ไม่มี​ทาง​หลุด​มา​ใช้​ค่าที่​ไม่มี​จริง

เมื่อ​การ​เปิด​กล่อง​เต็มๆ ดู​เกิน​จำเป็น Option ยัง​มี combinator สั้นๆ ให้​ใช้ เช่น .map() แปลง​ค่า​ข้าง​ใน​ถ้า​มี, .unwrap_or_else() ให้​ค่า​สำรอง​เมื่อ None — บท​นี้​เรา​แนะ​พอ​ให้​รู้จัก บท5 จะ​เจอ combinator เต็ม​ชุด​ตอน​ทำงาน​กับ iterator

`Option<T>` ต้อง​อยู่​ใน backtick เสมอ (เกร็ด​เวลา​พิมพ์​เอง)

เวลา​เขียน code คุณ​จะ​พิมพ์ Option<String>, Vec<Task>, Option<u32> เต็ม​ไป​หมด — เขียน​ใน file .rs ไม่มี​ปัญหา แต่​ถ้า​คุณ​จด​โน้ต​เป็น Markdown/MDX อย่า​ลืม​ครอบ​ด้วย backtick เสมอ ไม่​งั้น <String> จะ​ถูก​ตีความ​เป็น HTML/JSX tag แล้ว​พัง — เป็น​กับดัก​คลาสสิก​ของ​การ​เขียน​เอกสาร Rust

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 ไม่​ยอม​ให้​คุณ​เมิน​มัน​เงียบๆ


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

เช็กความเข้าใจ — บทที่ 3

ข้อ 1 / 3

enum ของ Rust ต่างจาก enum ของ C# ในแง่ไหนเป็นหลัก?