Trait Objects
Static vs Dynamic Dispatch
หัวข้อที่มีชื่อว่า “Static vs Dynamic Dispatch”Rust มีสองวิธีในการใช้ trait polymorphism:
- Static dispatch (ผ่าน generics): compiler รู้ exact type ณ compile time และสร้าง code เฉพาะสำหรับแต่ละ type ไม่มี runtime overhead
- Dynamic dispatch (ผ่าน
dyn Trait): concrete type ไม่ทราบจนกว่าจะถึง runtime compiler จะเก็บ vtable — ตารางของ function pointer — คู่กับค่านั้น เพื่อให้หา method ที่ถูกต้องได้ ณ runtime
Syntax dyn Trait
หัวข้อที่มีชื่อว่า “Syntax dyn Trait”เมื่อคุณเขียน dyn Animal คุณกำลังขอให้ Rust ใช้ dynamic dispatch เนื่องจาก size ของ concrete type ไม่ทราบ ณ compile time คุณต้องวาง dyn Trait ไว้หลัง pointer รูปแบบที่พบบ่อยสามแบบคือ:
&dyn Trait— trait object แบบ borrowed (ไม่ allocate heap)Box<dyn Trait>— trait object แบบ owned ที่เก็บบน heapRc<dyn Trait>— trait object แบบ shared ที่นับ reference (หลายเจ้าของ single-threaded)
trait Animal { fn sound(&self) -> &str; fn name(&self) -> &str;}
fn make_sound(animal: &dyn Animal) { println!("{} says: {}", animal.name(), animal.sound());}ฟังก์ชันนี้รับ type ใดก็ได้ ที่ implement Animal — คุณไม่จำเป็นต้องรู้ concrete type ณ compile time
Compiler พูดว่าอย่างไร: dyn Trait โดยไม่มี Pointer
หัวข้อที่มีชื่อว่า “Compiler พูดว่าอย่างไร: dyn Trait โดยไม่มี Pointer”dyn Trait เดี่ยว ๆ ไม่ใช่ type ที่คุณถือแบบ by-value ได้ — size ของมันขึ้นอยู่กับว่า concrete type ไหนอยู่ข้างหลัง และนั่นไม่รู้จนกว่าจะถึง runtime:
// @expect-error E0277trait Animal { fn sound(&self) -> &str;}
fn make_sound(animal: dyn Animal) { println!("{}", animal.sound());}
fn main() {}error[E0277]: the size for values of type `(dyn Animal + 'static)` cannot be known at compilation time --> src/main.rs:5:23 |5 | fn make_sound(animal: dyn Animal) { | ^^^^^^^^^^ doesn't have a size known at compile-time | = help: the trait `Sized` is not implemented for `(dyn Animal + 'static)`help: you can use `impl Trait` as the argument type |5 - fn make_sound(animal: dyn Animal) {5 + fn make_sound(animal: impl Animal) { |help: function arguments must have a statically known size, borrowed types always have a known size |5 | fn make_sound(animal: &dyn Animal) { | +Function parameter ทุกตัวต้องมี size ที่รู้แน่นอน ณ compile time โดยค่าเริ่มต้น (generic parameter ทุกตัวมี Sized bound แบบ implicit) และ dyn Animal ไม่ผ่านเงื่อนไขนั้น — Dog กับ struct ที่ใหญ่กว่ามากที่ implement Animal มีขนาดต่างกัน ดังนั้น “a dyn Animal” จึงเป็น stack value ขนาดคงที่ไม่ได้ Pointer (&dyn Animal, Box<dyn Animal>) มีขนาดคงที่เสมอไม่ว่าจะชี้ไปที่อะไร นี่คือเหตุผลพอดีที่ dyn Trait ต้องอยู่หลัง pointer เสมอ
dyn Trait + 'a: Lifetime ที่ซ่อนอยู่
หัวข้อที่มีชื่อว่า “dyn Trait + 'a: Lifetime ที่ซ่อนอยู่”ทุก dyn Trait type จริง ๆ แบก lifetime bound อยู่ ปกติถูก elide: Box<dyn Animal> คือ Box<dyn Animal + 'static> จริง ๆ และ &'a dyn Animal คือ &'a (dyn Animal + 'a) จริง ๆ ค่าเริ่มต้นคือ “นานเท่าที่ container ต้องการ” — 'static สำหรับ Box/Rc แบบ owning (ไม่มีอะไรที่ borrow มาจะมีชีวิตนานกว่า box ได้ เว้นแต่บอกไว้เป็นอื่น) และ lifetime ของ pointer เองสำหรับ &dyn Trait แบบ borrowed คุณจะเขียน + 'a อย่างชัดเจนก็ต่อเมื่อค่าเริ่มต้นผิดสำหรับกรณีของคุณ ซึ่งบ่อยที่สุดคือเมื่อ trait object ต้อง borrow data ที่ไม่ใช่ 'static:
trait Animal { fn sound(&self) -> &str;}
struct Dog<'a> { tag: &'a str,}
impl<'a> Animal for Dog<'a> { fn sound(&self) -> &str { self.tag }}
fn make_animal<'a>(tag: &'a str) -> Box<dyn Animal + 'a> { Box::new(Dog { tag }) // the trait object borrows `tag`, so it cannot be `+ 'static`}
fn main() { let tag = String::from("Woof"); let animal = make_animal(&tag); println!("{}", animal.sound());}เก็บหลาย Type ใน Collection เดียว
หัวข้อที่มีชื่อว่า “เก็บหลาย Type ใน Collection เดียว”การใช้งานที่ทรงพลังที่สุดของ trait object คือการเก็บค่าที่มี concrete type ต่างกันไว้ใน Vec หรือ collection อื่นร่วมกัน ถ้าใช้ generics เพียงอย่างเดียวจะทำแบบนี้ไม่ได้ — Vec<T> เก็บได้แค่ concrete type เดียวในแต่ละครั้ง
trait Animal { fn sound(&self) -> &str; fn name(&self) -> &str;}struct Dog;struct Cat;impl Animal for Dog { fn sound(&self) -> &str { "Woof" } fn name(&self) -> &str { "Dog" }}impl Animal for Cat { fn sound(&self) -> &str { "Meow" } fn name(&self) -> &str { "Cat" }}
let animals: Vec<Box<dyn Animal>> = vec![ Box::new(Dog), Box::new(Cat), Box::new(Dog),];แต่ละ element เป็น Box<dyn Animal> — pointer แบบ owned ไปยัง type ใดก็ตามที่ implement Animal vtable ที่เก็บคู่กับแต่ละค่ารับประกันว่า sound() และ name() ที่ถูกต้องจะถูกเรียก
&dyn Trait vs Box<dyn Trait> vs Rc<dyn Trait>
หัวข้อที่มีชื่อว่า “&dyn Trait vs Box<dyn Trait> vs Rc<dyn Trait>”ทั้งสามคือ trait object พวกมันต่างกันแค่เรื่อง ownership เหมือนกับคู่ที่ไม่ใช่ dyn ของมันทุกประการ:
| รูปแบบ | Ownership | ควรใช้เมื่อ |
|---|---|---|
&dyn Trait | Borrowed ไม่ allocate | Function แค่ต้องเรียก method ไม่ต้องเก็บค่าไว้ |
Box<dyn Trait> | Owned เจ้าของเดียว heap-allocated | เก็บ trait object หนึ่งตัวที่ scope นี้เป็นเจ้าของ (Vec<Box<dyn Trait>>, field ของ struct) |
Rc<dyn Trait> | Shared นับ reference heap-allocated | หลายเจ้าของต้องการ trait object เดียวกัน single-threaded (Arc<dyn Trait> คือคู่เทียบสำหรับ multi-threaded) |
impl Trait vs dyn Trait
หัวข้อที่มีชื่อว่า “impl Trait vs dyn Trait”ทั้ง &impl Animal และ &dyn Animal รับ “type ใดก็ได้ที่ implement Animal” เป็น parameter ของฟังก์ชัน แต่ compile ออกมาต่างกันมาก:
trait Animal { fn sound(&self) -> &str;}struct Dog;impl Animal for Dog { fn sound(&self) -> &str { "Woof" } }
// Static dispatch -- compiler generates specialized code per concrete typefn notify_static(item: &impl Animal) { println!("{}", item.sound());}
// Dynamic dispatch -- resolved at runtime via vtablefn notify_dyn(item: &dyn Animal) { println!("{}", item.sound());}
fn main() { notify_static(&Dog); notify_dyn(&Dog);}&impl Animal เป็น shorthand ของ generic type parameter (fn notify_static<T: Animal>(item: &T)) — compiler จะ monomorphize มันเหมือนกับ generics ที่คุณเห็นในบทก่อนหน้า ส่วน &dyn Animal คือ trait object — ฟังก์ชันเดียว ที่ dispatch ผ่าน vtable ณ runtime ใช้ impl Trait เมื่อรู้ type ณ compile time และต้องการประสิทธิภาพสูงสุด ใช้ dyn Trait เมื่อต้องการความยืดหยุ่น ณ runtime เช่นเก็บหลาย type ใน Vec หรือ return type ต่างกันจากแต่ละ branch
วัดขนาด Fat Pointer
หัวข้อที่มีชื่อว่า “วัดขนาด Fat Pointer”dyn Trait reference คือ fat pointer: มันแบก data pointer และ vtable pointer ดังนั้นมันมีขนาดเป็นสองเท่าของ reference ธรรมดา นี่ไม่ใช่ข้อกล่าวอ้างที่ต้องเชื่อเฉย ๆ:
trait Animal { fn sound(&self) -> &str;}
struct Dog;impl Animal for Dog { fn sound(&self) -> &str { "Woof" }}
fn main() { let d = Dog; let r: &Dog = &d; let dyn_r: &dyn Animal = &d; println!("&Dog: {} bytes", std::mem::size_of_val(&r)); println!("&dyn Animal: {} bytes", std::mem::size_of_val(&dyn_r));}&Dog: 8 bytes&dyn Animal: 16 bytes&Dog คือ machine word เดียว (8 byte บน 64-bit target) — แค่ address &dyn Animal คือสอง word: address ข้อมูลเดียวกัน บวก pointer ไปยัง vtable ของ Animal สำหรับ Dog Box<dyn Animal> และ Rc<dyn Animal> ก็ fat แบบเดียวกันด้วยเหตุผลเดียวกัน
flowchart LR
subgraph FatPointer["&dyn Animal (16 bytes)"]
DataPtr["data pointer"]
VtablePtr["vtable pointer"]
end
DataPtr --> DogData["Dog value on the stack or heap"]
VtablePtr --> Vtable["Dog's Animal vtable"]
Vtable --> SoundFn["fn sound(&self) -> &str"]
Vtable --> DropFn["fn drop_in_place"] Trait Objects และ Dyn Compatibility
หัวข้อที่มีชื่อว่า “Trait Objects และ Dyn Compatibility”ไม่ใช่ทุก trait จะใช้เป็น dyn Trait ได้ trait จะ dyn compatible ถ้าโดยคร่าวๆ:
- ไม่มี method ที่ return
Self - ไม่มี generic type parameter บน method ของมัน
(เอกสารเก่า — รวมถึงประวัติศาสตร์ของ Rust เอง — เรียกสิ่งนี้ว่า object safety; rustc ปัจจุบันใช้คำว่า dyn compatible / dyn compatibility ใน diagnostic ดังนั้นบทนี้จึงใช้คำนี้)
trait มาตรฐานอย่าง Display, Debug และ Iterator เป็น dyn compatible ส่วน trait อย่าง Clone (ที่ method clone return Self) ไม่ใช่ — การใช้มันเป็น &dyn Trait คือ compile error:
// @expect-error E0038trait Animal { fn sound(&self) -> &str; fn clone_animal(&self) -> Self; // returns Self -- not dyn compatible}
struct Dog;
impl Animal for Dog { fn sound(&self) -> &str { "Woof" } fn clone_animal(&self) -> Self { Dog }}
fn make_sound(animal: &dyn Animal) { println!("{}", animal.sound());}
fn main() { make_sound(&Dog);}error[E0038]: the trait `Animal` is not dyn compatible --> src/main.rs:14:24 |14 | fn make_sound(animal: &dyn Animal) { | ^^^^^^^^^^ `Animal` is not dyn compatible |note: for a trait to be dyn compatible it needs to allow building a vtable for more information, visit <https://doc.rust-lang.org/reference/items/traits.html#dyn-compatibility> --> src/main.rs:4:31 | 2 | trait Animal { | ------ this trait is not dyn compatible... 3 | fn sound(&self) -> &str; 4 | fn clone_animal(&self) -> Self; // returns Self -- not dyn compatible | ^^^^ ...because method `clone_animal` references the `Self` type in its return type = help: consider moving `clone_animal` to another trait = help: only type `Dog` implements `Animal`; consider using it directly instead.สังเกตคำที่ใช้: rustc พูดว่า “is not dyn compatible” — ไม่มีคำว่า “object safety” หรือ “object-safe” ปรากฏใน diagnostic เลย
ทางออก where Self: Sized
หัวข้อที่มีชื่อว่า “ทางออก where Self: Sized”คุณไม่จำเป็นต้องเลิกใช้ trait ทั้งตัวเพียงเพราะ method เดียวไม่ dyn compatible การเพิ่ม bound where Self: Sized ให้ method นั้นจะเอามันออกจากข้อกำหนดของ vtable — method จะใช้ผ่าน dyn Trait ไม่ได้ แต่ trait เองกลับมา dyn compatible อีกครั้ง และ method ยังใช้งานได้เมื่อเรียกบน concrete type ที่รู้แน่ชัด ณ compile time:
trait Animal { fn sound(&self) -> &str; fn clone_animal(&self) -> Self where Self: Sized; // excluded from the vtable -- only callable when Self is statically known}
struct Dog;
impl Animal for Dog { fn sound(&self) -> &str { "Woof" } fn clone_animal(&self) -> Self { Dog }}
fn make_sound(animal: &dyn Animal) { println!("{}", animal.sound());}
fn main() { make_sound(&Dog); // fine: dyn dispatch only needs sound() let d2 = Dog.clone_animal(); // fine: called on the concrete type, not through dyn make_sound(&d2);}dyn Any และ Downcasting
หัวข้อที่มีชื่อว่า “dyn Any และ Downcasting”std::any::Any คือ trait พิเศษที่ทุก 'static type implement ให้อัตโนมัติ มันให้คุณ type-erase ค่าได้อย่างสมบูรณ์ แล้วถามภายหลัง ณ runtime ว่า “นี่คือ Dog จริงหรือเปล่า” ผ่าน downcast_ref::<T>():
use std::any::Any;
struct Dog;struct Cat;
fn describe(animal: &dyn Any) { if let Some(_d) = animal.downcast_ref::<Dog>() { println!("it's a dog"); } else if let Some(_c) = animal.downcast_ref::<Cat>() { println!("it's a cat"); } else { println!("unknown animal"); }}
fn main() { describe(&Dog); describe(&Cat);}it's a dogit's a catการใช้แบบนี้มีความชอบธรรม — ระบบที่ type-erased จริง ๆ เช่น plugin registry หรือ heterogeneous event bus ที่ชุด type ที่เป็นไปได้ไม่ได้ถูกกำหนดตายตัวโดยโค้ดนี้เลย มันเป็นกลิ่นของปัญหาทันทีที่ชุด type ทั้งหมด รู้แล้ว และตายตัว อย่างในกรณีนี้: Dog และ Cat คือสอง struct ที่ hard-code ไว้ในไฟล์เดียวกันนี้ และ chain if/else if ของ downcast กำลังทำ ด้วยมือและไม่มี exhaustiveness checking สิ่งที่ match บน enum Animal { Dog, Cat } จะทำได้อัตโนมัติและปลอดภัยกว่า
กรอบคิดเรื่อง Performance
หัวข้อที่มีชื่อว่า “กรอบคิดเรื่อง Performance”การเรียก dyn Trait มีค่าใช้จ่ายเพิ่มคือ pointer indirection หนึ่งครั้งเพื่อค้นหา function ใน vtable บวกกับว่าการเรียกมักจะ inline ไม่ได้ (compiler ไม่รู้ ณ compile time ว่าจะกระโดดไป function ไหน) สำหรับโค้ดส่วนใหญ่นี่น้อยมากจนแทบไม่มีนัย — เทียบเท่ากับ memory load เพิ่มหนึ่งครั้ง — แต่มันไม่ได้ฟรีจริง ๆ แบบที่ monomorphized generic call เป็น #[inline] บน concrete method ไม่มีผลกับการเรียก ผ่าน dyn Trait เพราะ inlining ต้องรู้ function ที่แน่ชัด ณ compile time — มันช่วยได้แค่ path ที่ dispatch แบบ static โดยตรงเท่านั้น
เมื่อใดควรใช้แบบไหน
หัวข้อที่มีชื่อว่า “เมื่อใดควรใช้แบบไหน”| แนวทาง | รู้ type ณ compile time? | Runtime cost | เก็บหลาย type ใน collection เดียวได้? |
|---|---|---|---|
Generic (impl Trait หรือ T: Trait) | ใช่ | ไม่มี | ไม่ได้ |
Trait object (dyn Trait) | ไม่ | vtable indirection เล็กน้อย | ได้ |
ใช้ generics เมื่อรู้ type ณ compile time และต้องการประสิทธิภาพสูงสุด ใช้ trait object เมื่อต้องทำงานกับ collection ที่มี concrete type ปนกัน หรือเมื่อ type ถูกกำหนด ณ runtime จาก input หรือ configuration ของผู้ใช้
trait Animal { fn sound(&self) -> &str; fn name(&self) -> &str;}
struct Dog;struct Cat;
impl Animal for Dog { fn sound(&self) -> &str { "Woof" } fn name(&self) -> &str { "Dog" }}
impl Animal for Cat { fn sound(&self) -> &str { "Meow" } fn name(&self) -> &str { "Cat" }}
fn make_sound(animal: &dyn Animal) { println!("{} says: {}", animal.name(), animal.sound());}
fn main() { let animals: Vec<Box<dyn Animal>> = vec![ Box::new(Dog), Box::new(Cat), Box::new(Dog), ];
for animal in &animals { make_sound(animal.as_ref()); }}Compiling…
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”Trait object คือค่าที่จงใจลืม concrete type ของตัวเอง เพื่อแลกกับรูปร่างที่คงที่และเหมือนกันเสมอ: สอง word (data pointer, vtable pointer) ไม่ว่าจะมีอะไรอยู่ข้างหลังจริง ๆ ทุกการเรียก method ผ่านมันคือการค้นหา — “ไปที่ vtable นี้ หา slot ของ method นี้ กระโดดไปที่นั่น” — แทนที่จะเป็นการเรียกตรงที่ compiler resolve ไว้ล่วงหน้า การค้นหานั้นคือค่าใช้จ่าย runtime ทั้งหมด และมันก็เป็นเหตุผลทั้งหมดที่ Vec<Box<dyn Animal>> เก็บ Dog กับ Cat เคียงข้างกันได้ ในขณะที่ Vec<Dog> ทำแบบนั้นไม่ได้เลยโดยพื้นฐาน
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”dyn Trait ให้ความยืดหยุ่นจริง ๆ — heterogeneous collection, extensibility แบบ plugin, compiled code ชุดเดียวไม่ว่าจะมี type กี่ตัว implement trait นั้นในที่สุด — แลกกับค่าใช้จ่ายเล็กน้อยแต่จริง: vtable indirection ต่อการเรียก, ไม่มี inlining ข้ามการเรียกนั้น, และข้อจำกัดเรื่องขนาดกับ dyn-compatibility ที่ generics ไม่ต้องคิดถึงเลย dyn Any ผลักดันสิ่งนี้ไปไกลกว่า โดยแลก exhaustiveness checking ของ type system ทั้งหมดเพื่อ type erasure ที่แท้จริง — คุ้มค่าสำหรับ plugin system เป็นความเสี่ยงจริงเมื่อ type ทั้งหมดรู้อยู่แล้วตั้งแต่ต้น
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “
dyn Traitกับimpl Traitเป็น syntax sugar แบบเดียวกันโดยพื้นฐาน” พวกมัน compile ไปคนละกลยุทธ์:impl Traitresolve ต่อ call site ณ compile time (monomorphization);dyn Traitresolve ครั้งเดียว ณ runtime ผ่าน function เดียวที่ dispatch ด้วย vtable ร่วมกัน - “
dyn Traitvalue ส่งผ่านแบบ by value ได้เหมือน type อื่น” ทำไม่ได้ —dyn Traitเดี่ยว ๆ ไม่มี size ที่รู้ ณ compile time (error[E0277]ที่แสดงข้างบน) จึงต้องมี pointer ล้อมรอบเสมอ - “
dyn Anyกับ downcasting คือวิธี Rust ‘ที่แท้จริง’ ในการตรวจสอบ type ของค่า ณ runtime” พวกมันคือ escape hatch ที่จงใจสำหรับ type erasure จริง ๆ ไม่ใช่ตัวแทนทั่วไปของ pattern matching บน enum — หันไปใช้ enum ก่อนเสมอเมื่อชุด type ทั้งหมดรู้อยู่แล้ว - “Object safety กับ dyn compatibility เป็นคนละแนวคิดกัน” มันคือข้อกำหนดเดียวกันภายใต้สองชื่อ — “object safety” คือชื่อเก่า — diagnostic ของ rustc ปัจจุบันพูดแค่ “is not dyn compatible” เท่านั้น
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”โค้ดที่ generate มาบางครั้งหยิบ dyn Any บวกกับ chain ของ downcast_ref มาแยกความแตกต่างระหว่าง type ที่ hardcode ไว้แค่ไม่กี่ตัว แทนที่จะใช้ enum ที่มีอยู่ให้ใช้อยู่แล้วตลอดเวลา
🐛 หา bug ในโค้ดนี้
use std::any::Any;
struct Cat;struct Dog;
fn feed(animal: &dyn Any) { if animal.downcast_ref::<Cat>().is_some() { println!("give it fish"); } else if animal.downcast_ref::<Dog>().is_some() { println!("give it kibble"); }}
fn main() { feed(&Cat); feed(&Dog);}เฉลย
โค้ดนี้ compile ผ่านและรันได้ถูกต้อง — give it fish แล้ว give it kibble — ซึ่งเป็นเหตุผลพอดีที่มันรอดจากการ review ปัญหาคือสิ่งที่มันเสียไป: dyn Any type-erase ทุกอย่าง แล้ว downcast_ref ไล่กลับทีละ type ที่ hardcode ไว้ด้วย if/else if เป็นรูปร่างเดียวกับ match ที่ compiler จะ exhaustiveness-check ให้ได้ ไม่มีอะไรในนี้จับ animal type ที่สามที่เพิ่มเข้ามาทีหลังและลืมจัดการใน feed — chain if แค่ไม่ทำอะไรเงียบ ๆ สำหรับมัน enum Animal { Cat, Dog } ธรรมดาพร้อม match ใน feed ทำงานเหมือนกันวันนี้ และเปลี่ยน “ลืมจัดการ variant ใหม่” ให้เป็น compile error ในวันพรุ่งนี้ dyn Any มีไว้สำหรับสถานการณ์ที่ type-erased จริง ๆ — plugin system ที่เก็บ type ที่ไม่เกี่ยวข้องกันตามอำเภอใจ — ไม่ใช่ตัวแทนของ enum ที่คุณรู้รูปร่างอยู่แล้ว