Traits และ generics — polymorphism แบบ Rust
ในโลก C#/.NET เวลาคุณอยากให้หลายชนิด “ทำสิ่งเดียวกันได้” คุณเอื้อมไปหา interface แล้ว inject มันเข้าไป — และแทบทุกครั้งการเรียกผ่าน interface reference คือ virtual call ผ่าน vtable Rust ให้เครื่องมือคู่ขนานชื่อ traittraitนิยามพฤติกรรมร่วม (คล้าย interface) แต่มี default method และ impl ให้ชนิดที่เราไม่ได้เขียนเองได้ แต่พลิกค่า default: การใช้ trait ผ่าน generics นั้น ไม่มี ต้นทุน dispatch เลย compiler สร้าง code เฉพาะต่อชนิดให้ตอน compile ส่วน dynamic dispatch (แบบที่ C# ทำเป็นปริยาย) กลายเป็นสิ่งที่คุณต้อง opt-in ด้วย keyword dyn บทนี้คือจุดที่ reflex “ทุกอย่างเป็น interface + DI” ของคุณต้องเรียนรู้ที่จะแยกว่า เมื่อไรควร generic เมื่อไรควร dyn — และทำไม Rust ถึงตั้งค่า default กลับด้านกับ C#
code ลงมือของคอร์สนี้อยู่ใน repo kaen-taskcli (code ตัวอย่างกำลังจัดทำ) — บทนี้เป็นบทเครื่องมือ: trait, generics, และ std traits (Display/Debug/From/Iterator) คือชิ้นส่วนที่ taskcli จะใช้ตอนพิมพ์ task ออกจอ (Display) แปลง error (From — ต่อยอดจาก ? ในบท4) และวนสมาชิก ตัวอย่างในบทนี้เป็น drill แยกที่รันได้จริงด้วย std ล้วน (compile ผ่าน cargo check บน rustc 1.97.1 / edition 2024) แล้วค่อยหลอมเข้า CLI จริงในบท7
trait คือ interface — ที่ต่างสองจุดสำคัญ
หัวข้อที่มีชื่อว่า “trait คือ interface — ที่ต่างสองจุดสำคัญ”trait นิยาม พฤติกรรมร่วม เหมือน interface — แต่มีสองอย่างที่ C# interface ทำไม่ได้ (หรือเพิ่งทำได้ทีหลัง):
- Default method body — trait ใส่ code จริง ให้ method ได้ ชนิดที่ impl แล้วจะ override หรือใช้ของ default ก็ได้ (C# เพิ่งได้ default interface method ใน C# 8.0 และยังใช้ไม่แพร่หลาย)
- impl ให้ชนิดที่เราไม่ได้เขียนเอง — คุณ impl trait ของคุณให้
StringหรือVec<T>ได้ ตราบใดที่ อย่างน้อยหนึ่ง ในสอง (trait หรือ type) เป็นของ crate คุณ กฎนี้ชื่อ orphan rule — มันคือของจริง ไม่ใช่ extension method ปลอมๆ ที่ C# เอามาแปะข้างนอก
trait Summary { fn summarize_author(&self) -> String; fn summarize(&self) -> String { format!("(read more from {}...)", self.summarize_author()) }}struct Article { headline: String, author: String }struct Tweet { username: String, content: String }
impl Summary for Article { fn summarize_author(&self) -> String { format!("@{}", self.author) } fn summarize(&self) -> String { format!("{} by {}", self.headline, self.summarize_author()) }}impl Summary for Tweet { fn summarize_author(&self) -> String { format!("@{}", self.username) }}fn notify<T: Summary>(item: &T) { println!("Breaking! {}", item.summarize()); }Article เขียน summarize เอง ส่วน Tweet ปล่อยให้ใช้ default body — ทั้งคู่ให้เพียง summarize_author เท่านั้น นี่คือ “default method = พลังฟรี”: นิยามครั้งเดียว ทุกชนิดที่ impl ได้ตาม
trait ≈ interface ช่วยจับภาพได้เร็ว — ยกเว้น สองข้อข้างบน และอีกจุดที่จะเห็นถัดๆ ไป: การเรียกผ่าน interface ใน C# เป็น virtual dispatch เสมอ ส่วน Rust ให้คุณเลือกได้ระหว่าง static (ผ่าน generics) กับ dynamic (ผ่าน dyn) — และ default คือ static ตรงข้ามกับ C#
generics ถูก monomorphize — dispatch ต้นทุนศูนย์
หัวข้อที่มีชื่อว่า “generics ถูก monomorphize — dispatch ต้นทุนศูนย์”fn notify<T: Summary>(item: &T) ข้างบนใช้ genericsgenericsparameter ชนิดที่ถูก monomorphize — สร้าง code เฉพาะต่อชนิด ต้นทุน dispatch = ศูนย์ พร้อม trait bound T: Summary — อ่านว่า “T ชนิดใดก็ได้ที่ impl Summary” ตอน compile Rust ทำ monomorphization: มันดูว่าคุณเรียก notify ด้วยชนิดจริงอะไรบ้าง แล้ว สร้างสำเนา code เฉพาะต่อชนิด ออกมา — notify::<Article> หนึ่งตัว, notify::<Tweet> อีกตัว ราวกับคุณเขียน2 function แยกด้วยมือ ผลคือ ไม่มีต้นทุน dispatch ตอน runtime เลย และ inline ได้เต็มที่
หนังสือ The Rust Programming Language (ch10) พูดตรงๆ ว่า monomorphization ทำให้ generic code เร็ว “เท่ากับที่เราเขียน code เฉพาะทางด้วยมือ” แลกกับ binary ที่ใหญ่ขึ้น (มีหลายสำเนา) — ต่างจาก C# ที่ generics ของ reference type ใช้ code ชุดเดียวร่วมกันและ resolve ตอน JIT
trait bound เขียนได้สามแบบที่ เทียบเท่ากันเป๊ะ — เลือกตามความอ่านง่าย:
fn notify<T: Summary>(item: &T) { println!("{}", item.summarize()); } // 1. bound ติดกับ genericfn notify_impl(item: &impl Summary) { println!("{}", item.summarize()); } // 2. impl Trait (สั้นสุด)fn notify_where<T>(item: &T) where T: Summary { // 3. where clause (bound เยอะ ๆ อ่านง่ายกว่า) println!("{}", item.summarize());}<T: Summary> ≈ where T : ISummary ใน C# — ยกเว้น ผลลัพธ์ตอน runtime: C# generic ผ่าน constraint ที่เป็น interface มักยังจบลงที่ virtual call ส่วน Rust monomorphize จน call กลายเป็น direct call ที่ inline ได้ ไม่มี metadata ของ generic เหลือตอน runtime เลย
static vs dynamic dispatch — และ dyn ที่ต้องเขียนให้ครบ
หัวข้อที่มีชื่อว่า “static vs dynamic dispatch — และ dyn ที่ต้องเขียนให้ครบ”generics เก่งเมื่อ collection เป็น ชนิดเดียวล้วน — &[Article] ทั้งก้อน แต่เมื่อคุณอยากได้ List<ISummary> แบบ C# ที่ยัด Article กับ Tweet ปนกันได้ใน list เดียว คุณต้องการ trait objecttrait object`dyn Trait` หลัง pointer (`Box<dyn T>`) — dynamic dispatch ผ่าน vtable สำหรับ collection ต่างชนิด: Box<dyn Summary> — pointer ที่ชี้ไปทั้ง ข้อมูล และ vtable สำหรับ lookup method ตอน runtime (นี่คือ dynamic dispatch แบบเดียวกับ interface ของ C# — แต่ที่นี่คุณจงใจ opt-in)
fn print_all<T: Summary>(items: &[T]) { // static: homogeneous, เร็ว for it in items { println!("{}", it.summarize()); }}fn make_feed() -> Vec<Box<dyn Summary>> { // dynamic: heterogeneous, 1 vtable ต่อชนิด vec![ Box::new(Article { headline: "Rust 2024".into(), author: "team".into() }), Box::new(Tweet { username: "rustlang".into(), content: "released".into() }), ]}keyword dyn ไม่ใช่ของประดับ — ใน edition 2024 การเขียน trait object โดยไม่มี dyn เป็น hard error (E0782) ไม่ใช่แค่ warning:
// ❌ version ดิบ — compile ไม่ผ่านfn make_feed() -> Vec<Box<Summary>> { // error[E0782]: expected a type, found a trait vec![]}// ✅ ใส่ dyn ให้ครบfn make_feed() -> Vec<Box<dyn Summary>> { vec![]}E0782 คือ compiler-ครูอีกครั้ง: มันกันความกำกวมระหว่าง “ชนิดหนึ่งๆ ที่ impl Summary” (generic, static) กับ “อะไรก็ได้ที่ impl Summary หลัง pointer” (dyn, dynamic) — สองความหมายนี้ compile ออกมาคนละแบบสิ้นเชิง Rust จึงบังคับให้คุณ พูดออกมาให้ชัด คำแนะนำภาคปฏิบัติ: generics เป็น default อย่าเอื้อมหา Box<dyn> โดยอัตโนมัติ — reflex “ทุกอย่างเป็น interface” ของ C# คือ anti-pattern ที่นี่ ใช้ dyn เฉพาะตอนต้องการ collection ต่างชนิดจริงๆ
flowchart TB
subgraph STATIC["static dispatch — generics (default)"]
direction TB
G["notify<T: Summary>(item: &T)"]
G -->|"compile: monomorphize"| A["notify::<Article><br/>สำเนาเฉพาะชนิด"]
G -->|"compile: monomorphize"| B["notify::<Tweet><br/>สำเนาเฉพาะชนิด"]
end
subgraph DYN["dynamic dispatch — dyn (opt-in)"]
direction TB
D["Box<dyn Summary>"]
D -->|"ชี้ไป"| DATA["ข้อมูลจริง (Article หรือ Tweet)"]
D -->|"ชี้ไป"| VT["vtable<br/>lookup method ตอน runtime"]
end
classDef s fill:#fde68a,stroke:#92400e,color:#451a03;
classDef d fill:#fed7aa,stroke:#7c2d12,color:#431407;
class G,A,B s;
class D,DATA,VT d;
คำบรรยายภาพ: สองเลนของ polymorphism ใน Rust — ฝั่ง static (generics) compiler ปั๊มสำเนา code เฉพาะต่อชนิด (notify::<Article>, notify::<Tweet>) จน dispatch ต้นทุนเป็นศูนย์; ฝั่ง dynamic (dyn) มี Box<dyn Summary> ตัวเดียวชี้ไปทั้งข้อมูลและ vtable แล้ว lookup method ตอน runtime — ยืดหยุ่นรับหลายชนิด แลกกับ dispatch ที่ต้องเดินผ่าน vtable
Display กับ Debug — สองหน้าจอที่ Rust จงใจแยก
หัวข้อที่มีชื่อว่า “Display กับ Debug — สองหน้าจอที่ Rust จงใจแยก”C# ยุบทุกอย่างไว้ที่ ToString() เดียว Rust แยกเป็น2 trait โดยตั้งใจ:
Debug({:?}) — สำหรับ นักพัฒนา: derive ได้ด้วย#[derive(Debug)]เห็นโครงสร้างภายในดิบๆDisplay({}) — สำหรับ ผู้ใช้ปลายทาง: derive ไม่ได้ ต้องเขียนfmtเอง เพราะ “รูปแบบที่คนอ่าน” เป็นการตัดสินใจที่เครื่องเดาแทนไม่ได้
impl Display แล้วคุณได้ .to_string() ฟรี (มาจาก blanket impl) สังเกต signature ใช้ Formatter<'_> — '_ คือ lifetime ที่ถูก elide (เดี๋ยวเราคุยกฎ elision ท้ายบท):
use std::fmt;#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]struct Point { x: i32, y: i32 }impl fmt::Display for Point { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "({}, {})", self.x, self.y) }}// println!("{p} / {p:?}") -> (1, 2) / Point { x: 1, y: 2 }// p.to_string() -> "(1, 2)" ฟรีจาก DisplayFrom/Into — และความลับของ ? จากบท4
หัวข้อที่มีชื่อว่า “From/Into — และความลับของ ? จากบท4”จำ ? operator? operatorตัวช่วยส่งต่อ error: คืนค่า Ok/Some หรือ early-return Err/None พร้อมแปลง error ด้วย `From` ในบท4 ที่ “แปลง error ให้อัตโนมัติ” ได้ไหม — กลไกนั้นคือ trait ชื่อ FromFromtrait แปลงชนิด; impl `From` แล้วได้ `Into` ฟรี และเป็นกลไกที่ `?` ใช้แปลง error นี่เอง กฎมีข้อเดียวที่ต้องจำ: impl From แล้วคุณได้ Into ฟรี (มาจาก blanket impl ใน std) คุณจึง ไม่ควร เขียน Into เองเด็ดขาด — เขียนแค่ From ทางเดียว:
struct Celsius(f64);struct Fahrenheit(f64);impl From<Celsius> for Fahrenheit { fn from(c: Celsius) -> Self { Fahrenheit(c.0 * 9.0 / 5.0 + 32.0) }}fn greet<T: Into<String>>(name: T) -> String { let name: String = name.into(); // ได้ฟรีจาก impl From ข้างบน + blanket impl ของ std format!("hi {name}")}// Fahrenheit::from(Celsius(100.0)).0 -> 212.0// greet("Kaen") และ greet(String::from("Kaen")) ผ่านทั้งคู่fn greet<T: Into<String>>(name: T) คือสำนวนที่พบบ่อย — รับได้ทั้ง &str และ String เพราะทั้งคู่ impl Into<String> ตอนนี้ย้อนกลับไปดูบท4: #[from] ของ thiserror ก็แค่ generate impl From<io::Error> for ConfigError ให้ แล้ว ? เรียก .into() ใช้ blanket impl เดียวกันนี้แปลง error ขึ้นไป — วงกลมปิดครบ
ถ้าคุณ impl ทั้ง From และ Into ให้คู่ชนิดเดียวกัน จะชน blanket impl ของ std แล้ว compile ไม่ผ่าน — เขียน From ทางเดียวเสมอ แล้วปล่อยให้ Into มาเอง นี่คือทิศทางที่ std บังคับ และเป็นเหตุผลที่ ? ทำงานได้สม่ำเสมอทุกที่
PartialEq/Eq และ PartialOrd/Ord — ทำไมต้องมีสองชั้น
หัวข้อที่มีชื่อว่า “PartialEq/Eq และ PartialOrd/Ord — ทำไมต้องมีสองชั้น”การเทียบเท่าและการเรียงลำดับก็เป็น trait — และ Rust แยกเป็น partial กับ total ด้วยเหตุผลที่ C# มองข้าม: f64 เท่ากับตัวเองไม่เสมอไป เพราะ NaN != NaN ตามมาตรฐาน IEEE-754 float จึง impl ได้แค่ PartialEq/PartialOrd ไม่ใช่ Eq/Ord (total) — นี่คือความจริงที่ C# ปล่อยให้ double.NaN == double.NaN คืน false เงียบๆ โดยไม่มีชั้นชนิดกันไว้
ผลเชิงปฏิบัติ: การ derive Ord ต้อง derive Eq และ PartialOrd มาด้วยครบชุด (ดูใน Point ข้างบนที่มี PartialEq, Eq, PartialOrd, Ord ครบ) — ถ้าลืมตัวใดตัว1 compiler จะทักทันที และคุณ derive Eq/Ord ให้ struct ที่มี field เป็น f64 ไม่ได้ เพราะ f64 เองก็ไม่ใช่ Eq
custom Iterator — และกฎ lifetime elision สามข้อ
หัวข้อที่มีชื่อว่า “custom Iterator — และกฎ lifetime elision สามข้อ”พลังของ default method เห็นชัดที่สุดกับ IteratorIteratortrait ที่มีเมท็อดจำเป็นเดียว `next()`; adapter ทุกตัวขี้เกียจ (lazy) จนกว่าจะ consume: คุณ impl เพียง next() (บวก associated type type Item) แล้วได้ adapter ทั้งหมด — map, filter, sum, collect — มาฟรีทันที:
struct Counter { count: u32 }impl Iterator for Counter { type Item = u32; fn next(&mut self) -> Option<Self::Item> { if self.count < 5 { self.count += 1; Some(self.count) } else { None } }}// Counter { count: 0 }.map(|n| n * 2).sum::<u32>() -> 30 (2+4+6+8+10)type Item คือ associated type ไม่ใช่ generic parameter — เขียน impl Iterator for Counter { type Item = u32; ... } ไม่ใช่ impl Iterator<u32> แต่ละชนิดผูกกับ Item ได้แบบเดียว ซึ่งพอดีกับความหมาย “iterator นี้ให้ค่าชนิดอะไร”
Lifetime elision — ทำไม signature ส่วนใหญ่ไม่ต้องเขียน 'a: ในบท2 เราเจอ lifetimelifetimeคำอธิบายความสัมพันธ์ของอายุ reference เพื่อกันการชี้ค้าง (dangling); ส่วนใหญ่ถูก elide ครั้งแรกกับ longest<'a> ที่ต้องกำกับ 'a ด้วยมือ แต่คุณคงสังเกตว่า ส่วนใหญ่ ของ signature ในคอร์สนี้ไม่มี 'a เลย — นั่นเพราะ compiler เดาให้ตามกฎ elision สามข้อ (ทั้งหมดเป็นแค่การเดา ไม่ใช่ inference จริง):
- reference parameter แต่ละตัวได้ lifetime ของตัวเอง
- ถ้ามี input lifetime เดียว มันถูกยกให้ output ทุกตัว
- ถ้ามี
&self/&mut self(method) lifetime ของselfถูกยกให้ output ทุกตัว
fn fmt(&self, f: &mut Formatter<'_>) ผ่านกฎ 3: output ผูกกับ self โดยไม่ต้องเขียนอะไร — และ Formatter<'_> ใช้ '_ บอก “มี lifetime อยู่ตรงนี้ แต่ให้ compiler เดา” นั่นคือเหตุผลที่ longest (2 input reference, ไม่มี self) ต้อง เขียน 'a เอง — มันตกทั้งสามกฎ
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”บทนี้ให้ polymorphism ทั้งสองหน้าของ Rust: trait คือ interface ที่ต่างสองจุด (default method body + impl ให้ชนิดที่เราไม่ได้เขียนเองภายใต้ orphan rule); generics ถูก monomorphize เป็น static dispatch ต้นทุนศูนย์ (สามแบบเขียน bound: <T: Bound>, impl Trait, where); dynamic dispatch เป็น opt-in ผ่าน dyn — Vec<Box<dyn Summary>> รับหลายชนิด ส่วน bare trait object ไม่มี dyn เป็น hard error E0782 ใน edition 2024 (default ของ Rust ตรงข้าม C#: static ก่อน, dyn เมื่อจำเป็น); std traits — Debug (derive ได้) vs Display (เขียนเอง, ให้ .to_string() ฟรี, ใช้ Formatter<'_>); From แล้วได้ Into ฟรี และนี่คือกลไกที่ ? ใช้แปลง error มาตั้งแต่บท4; PartialEq/PartialOrd (partial, เพราะ NaN != NaN) แยกจาก Eq/Ord (total, derive Ord ต้องมาครบชุด); ปิดท้ายด้วย custom Iterator (impl แค่ next + type Item ได้ adapter ครบ) และกฎ lifetime elision สามข้อที่อธิบายว่าทำไม signature ส่วนใหญ่ไม่ต้องเขียน 'a
บทหน้าเราเอาทุกชิ้นมาประกอบเป็น CLI จริง: clap สำหรับ parse argument, serde สำหรับ JSON, std::fs สำหรับ file, cargo test และการแยก lib.rs + main.rs — taskcli จะกลายเป็นเครื่องมือที่ใช้ได้จริงเป็นครั้งแรก
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- The Rust Programming Language — ch10-01 Generic Data Types (เข้าถึง 2026-07-24) — generics + monomorphization “as fast as … by hand”
- The Rust Programming Language — ch10-02 Traits: Defining Shared Behavior (เข้าถึง 2026-07-24) — trait def/impl, default method, orphan rule, trait bound สามแบบ
- The Rust Programming Language — ch10-03 Validating References with Lifetimes (เข้าถึง 2026-07-24) — กฎ lifetime elision สามข้อ
- The Rust Programming Language — ch18-02 Using Trait Objects (เข้าถึง 2026-07-24) —
Box<dyn Trait>ชี้ไปข้อมูล + vtable, dynamic dispatch - The Rust Programming Language — ch13-02 Processing a Series of Items with Iterators (เข้าถึง 2026-07-24) —
Iteratorมี method จำเป็นเดียวnext+ associated typeItem - std::convert — From/Into (blanket impl) (เข้าถึง 2026-07-24) — impl
Fromแล้วได้Intoฟรี - rustc Error Codes — E0782 (เข้าถึง 2026-07-24) — bare trait object ไม่มี
dynเป็น error ใน edition 2024
เช็กความเข้าใจ — บทที่ 6
ข้อ 1 / 3เมื่อคุณเรียก `notify<T: Summary>(item: &T)` ด้วย `Article` แล้วด้วย `Tweet` compiler ทำอะไร และมันต่างจาก interface call ใน C# อย่างไร?