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

Trait Objects

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

เมื่อคุณเขียน 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 ที่เก็บบน heap
  • Rc<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

dyn Trait เดี่ยว ๆ ไม่ใช่ type ที่คุณถือแบบ by-value ได้ — size ของมันขึ้นอยู่กับว่า concrete type ไหนอยู่ข้างหลัง และนั่นไม่รู้จนกว่าจะถึง runtime:

// @expect-error E0277
trait 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 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());
}

การใช้งานที่ทรงพลังที่สุดของ 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() ที่ถูกต้องจะถูกเรียก

ทั้งสามคือ trait object พวกมันต่างกันแค่เรื่อง ownership เหมือนกับคู่ที่ไม่ใช่ dyn ของมันทุกประการ:

รูปแบบOwnershipควรใช้เมื่อ
&dyn TraitBorrowed ไม่ allocateFunction แค่ต้องเรียก 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 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 type
fn notify_static(item: &impl Animal) {
println!("{}", item.sound());
}
// Dynamic dispatch -- resolved at runtime via vtable
fn 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

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"]
สิ่งที่ fat pointer ของ trait object ชี้ไปจริง ๆ

ไม่ใช่ทุก trait จะใช้เป็น dyn Trait ได้ trait จะ dyn compatible ถ้าโดยคร่าวๆ:

  1. ไม่มี method ที่ return Self
  2. ไม่มี 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 E0038
trait 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 เลย

คุณไม่จำเป็นต้องเลิกใช้ 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);
}

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 dog
it'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 } จะทำได้อัตโนมัติและปลอดภัยกว่า

การเรียก 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());
}
}

Trait object คือค่าที่จงใจลืม concrete type ของตัวเอง เพื่อแลกกับรูปร่างที่คงที่และเหมือนกันเสมอ: สอง word (data pointer, vtable pointer) ไม่ว่าจะมีอะไรอยู่ข้างหลังจริง ๆ ทุกการเรียก method ผ่านมันคือการค้นหา — “ไปที่ vtable นี้ หา slot ของ method นี้ กระโดดไปที่นั่น” — แทนที่จะเป็นการเรียกตรงที่ compiler resolve ไว้ล่วงหน้า การค้นหานั้นคือค่าใช้จ่าย runtime ทั้งหมด และมันก็เป็นเหตุผลทั้งหมดที่ Vec<Box<dyn Animal>> เก็บ Dog กับ Cat เคียงข้างกันได้ ในขณะที่ Vec<Dog> ทำแบบนั้นไม่ได้เลยโดยพื้นฐาน

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 ทั้งหมดรู้อยู่แล้วตั้งแต่ต้น

  • “dyn Trait กับ impl Trait เป็น syntax sugar แบบเดียวกันโดยพื้นฐาน” พวกมัน compile ไปคนละกลยุทธ์: impl Trait resolve ต่อ call site ณ compile time (monomorphization); dyn Trait resolve ครั้งเดียว ณ runtime ผ่าน function เดียวที่ dispatch ด้วย vtable ร่วมกัน
  • “dyn Trait value ส่งผ่านแบบ 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” เท่านั้น

โค้ดที่ 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 ที่คุณรู้รูปร่างอยู่แล้ว

dyn Trait ใน Rust หมายความว่าอะไร?
vtable คืออะไร?
ทำไม dyn Trait ต้องวางไว้หลัง pointer เช่น Box<dyn Trait> หรือ &dyn Trait?
แนวทางใดที่รองรับการเก็บค่าของ concrete type ต่างกันไว้ใน Vec เดียวกัน?
สองบรรทัดนี้ print อะไร?
trait Animal { fn sound(&self) -> &str; }
struct Dog;
impl Animal for Dog { fn sound(&self) -> &str { "Woof" } }
fn main() {
    let d = Dog;
    println!("{}", std::mem::size_of_val(&(&d)));
    let dyn_r: &dyn Animal = &d;
    println!("{}", std::mem::size_of_val(&dyn_r));
}
ทำไม code นี้ compile ไม่ผ่าน?
trait Animal { fn sound(&self) -> &str; }
fn make_sound(animal: dyn Animal) {
    println!("{}", animal.sound());
}
ทำไม chain if/else if ของ downcast_ref บน dyn Any แบบเขียนมือถึงเป็นกลิ่นของปัญหาเมื่อชุด type ทั้งหมด (เช่น Cat กับ Dog) รู้อยู่แล้ว?