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

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#

📦 kaen-taskcli

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 — แต่​มี​สอง​อย่าง​ที่ C# interface ทำ​ไม่​ได้ (หรือ​เพิ่ง​ทำได้​ทีหลัง):

  1. Default method body — trait ใส่ code จริง ให้ method ได้ ชนิด​ที่ impl แล้ว​จะ override หรือ​ใช้​ของ default ก็ได้ (C# เพิ่ง​ได้ default interface method ใน C# 8.0 และ​ยัง​ใช้​ไม่​แพร่หลาย)
  2. 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 ได้​ตาม

สะพาน ไม่ใช่​ของ​เท่า​กัน​เป๊ะ

traitinterface ช่วย​จับ​ภาพ​ได้​เร็ว — ยกเว้น สอง​ข้อ​ข้าง​บน และ​อีก​จุด​ที่​จะ​เห็นถัดๆ ไป: การ​เรียก​ผ่าน interface ใน C# เป็น virtual dispatch เสมอ ส่วน Rust ให้​คุณ​เลือก​ได้​ระหว่าง static (ผ่าน generics) กับ dynamic (ผ่าน dyn) — และ default คือ static ตรง​ข้าม​กับ C#

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 ติดกับ generic
fn 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());
}
เทียบ C#: constraint บน generic

<T: Summary>where T : ISummary ใน C# — ยกเว้น ผลลัพธ์​ตอน runtime: C# generic ผ่าน constraint ที่​เป็น interface มัก​ยัง​จบ​ลง​ที่ virtual call ส่วน Rust monomorphize จน call กลาย​เป็น direct call ที่ inline ได้ ไม่มี metadata ของ generic เหลือ​ตอน runtime เลย

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&lt;T: Summary&gt;(item: &amp;T)"]
    G -->|"compile: monomorphize"| A["notify::&lt;Article&gt;<br/>สำเนาเฉพาะชนิด"]
    G -->|"compile: monomorphize"| B["notify::&lt;Tweet&gt;<br/>สำเนาเฉพาะชนิด"]
  end
  subgraph DYN["dynamic dispatch — dyn (opt-in)"]
    direction TB
    D["Box&lt;dyn Summary&gt;"]
    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

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)" ฟรีจาก Display

จำ ? 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 ขึ้น​ไป — วงกลม​ปิด​ครบ

อย่า​เขียน Into เอง

ถ้า​คุณ impl ทั้ง From และ Into ให้​คู่​ชนิด​เดียวกัน จะชน blanket impl ของ std แล้ว compile ไม่​ผ่าน — เขียน From ทาง​เดียว​เสมอ แล้ว​ปล่อย​ให้ Into มา​เอง นี่​คือ​ทิศทาง​ที่ std บังคับ และ​เป็น​เหตุผล​ที่ ? ทำงาน​ได้​สม่ำเสมอ​ทุก​ที่

การ​เทียบเท่า​และ​การ​เรียง​ลำดับ​ก็​เป็น 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

พลัง​ของ 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 จริง):

  1. reference parameter แต่ละ​ตัว​ได้ lifetime ของ​ตัวเอง
  2. ถ้า​มี input lifetime เดียว มัน​ถูก​ยก​ให้ output ทุก​ตัว
  3. ถ้า​มี &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 ผ่าน dynVec<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.rstaskcli จะ​กลาย​เป็น​เครื่องมือ​ที่​ใช้ได้​จริง​เป็น​ครั้ง​แรก


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

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

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

ข้อ 1 / 3

เมื่อคุณเรียก `notify<T: Summary>(item: &T)` ด้วย `Article` แล้วด้วย `Tweet` compiler ทำอะไร และมันต่างจาก interface call ใน C# อย่างไร?