Trait Object

dyn Trait, dynamic dispatch, dan object safety.

Daftar isi

Topik ini membahas trait object, yaitu nilai bertipe dyn Trait yang mewakili tipe konkret mana pun yang mengimplementasikan trait tersebut pada saat runtime. Pembahasan mencakup bentuk &dyn Trait dan Box<dyn Trait>, mekanisme dynamic dispatch melalui vtable, pemakaian koleksi yang menampung tipe campuran, serta syarat dyn compatibility yang menentukan trait mana yang boleh dipakai sebagai trait object. Setelah membaca topik ini, kumpulan nilai bertipe berbeda dapat disimpan dalam satu struktur data, dan pilihan antara kode generik dan trait object dapat diambil berdasarkan kapan tipe concretnya diketahui.

Trait object dan penunjuk gemuk#

Tipe dyn Sensor menyatakan “suatu tipe yang mengimplementasikan Sensor” tanpa menyebut tipe konkretnya. Berbeda dengan parameter generik yang diganti tipe konkret saat kompilasi, trait object tetap menjadi satu tipe pada program jadi, dan tipe konkretnya baru ditentukan oleh nilai yang disimpan saat runtime. Karena ukuran tipe konkretnya tidak diketahui saat kompilasi, dyn Trait tidak dapat disimpan langsung pada variabel, melainkan harus berada di balik penunjuk seperti &dyn Trait atau Box<dyn Trait>.

Program berikut mendefinisikan fungsi dengan parameter &dyn Sensor, lalu memanggil fungsi tersebut dua kali dengan tipe yang berbeda.

rust referensi-dinamis.rs
trait Sensor {
    fn baca(&self) -> String;
}

struct SensorSuhu {
    celsius: f64,
}

struct SensorKelembapan {
    persen: u8,
}

impl Sensor for SensorSuhu {
    fn baca(&self) -> String {
        format!("suhu {} C", self.celsius)
    }
}

impl Sensor for SensorKelembapan {
    fn baca(&self) -> String {
        format!("kelembapan {}%", self.persen)
    }
}

fn laporkan(sensor: &dyn Sensor) {
    println!("pembacaan: {}", sensor.baca());
}

fn main() {
    let suhu = SensorSuhu { celsius: 36.5 };
    let lembap = SensorKelembapan { persen: 62 };

    laporkan(&suhu);
    laporkan(&lembap);
}
text Keluaran
pembacaan: suhu 36.5 C
pembacaan: kelembapan 62%

Satu parameter yang sama menangani dua tipe berbeda tanpa duplikasi fungsi. Pada tiap pemanggilan, referensi &SensorSuhu atau &SensorKelembapan dikonversi menjadi &dyn Sensor secara otomatis, dan metode baca yang dijalankan mengikuti tipe konkret nilai yang dikirim. Fungsi laporkan dikompilasi satu kali saja, tanpa salinan per tipe sebagaimana pada fungsi generik.

Dynamic dispatch lewat vtable#

Pemanggilan metode pada trait object berlangsung melalui dynamic dispatch, yaitu pengarahan pemanggilan yang diselesaikan saat runtime. Nilai &dyn Sensor adalah penunjuk gemuk (fat pointer) yang memuat dua alamat, yaitu alamat data dan alamat vtable. Vtable adalah tabel yang dibuat kompilator untuk setiap pasangan tipe konkret dan trait, dan setiap entrinya menunjuk implementasi metode yang sesuai untuk pasangan tersebut.

Pemanggilan sensor.baca() pada program di atas membaca entri baca dari vtable milik SensorSuhu atau SensorKelembapan, lalu memanggil fungsi yang ditunjuk entrinya. Biayinya berupa satu penelusuran tidak langsung per pemanggilan serta hilangnya kesempatan penyisipan kode (inlining) pada titik pemanggilan, karena kompilator tidak tahu fungsi mana yang akan terpilih. Sebagai gantinya, satu slot penyimpanan dapat menampung tipe apa pun yang mengimplementasikan trait tersebut, termasuk tipe yang baru ditambahkan tanpa mengubah kode pemanggilnya.

Trait object memuat dua penunjuk: data dan vtableBox<dyn Sensor>penunjuk datapenunjuk vtabledata di heapSensorSuhu { celsius: 36.5 }vtable untuk SensorSuhubaca() → SensorSuhu::bacapenurunan nilai dan informasi ukuranPemanggilan baca() mengikuti entri vtable pada saat runtime.
Trait object menyimpan penunjuk data dan penunjuk vtable berdampingan

Koleksi heterogen dengan Box#

Koleksi seperti Vec<T> menuntut seluruh elemennya bertipe sama, sehingga Vec<SensorSuhu> tidak dapat menampung SensorKelembapan. Trait object mengatasi halangan itu karena Box<dyn Sensor> adalah satu tipe tunggal yang isinya boleh berganti-ganti. Pemakaian Box sekaligus memindahkan nilai ke heap, dan ukuran penunjuknya seragam, sehingga setiap elemen vektor memakan ruang yang sama meskipun tipe konkretnya berbeda-beda.

rust koleksi-dinamis.rs
trait Sensor {
    fn baca(&self) -> String;
}

struct SensorSuhu {
    celsius: f64,
}

struct SensorKelembapan {
    persen: u8,
}

impl Sensor for SensorSuhu {
    fn baca(&self) -> String {
        format!("suhu {} C", self.celsius)
    }
}

impl Sensor for SensorKelembapan {
    fn baca(&self) -> String {
        format!("kelembapan {}%", self.persen)
    }
}

fn main() {
    let alat: Vec<Box<dyn Sensor>> = vec![
        Box::new(SensorSuhu { celsius: 36.5 }),
        Box::new(SensorKelembapan { persen: 62 }),
        Box::new(SensorSuhu { celsius: 37.1 }),
    ];

    for item in &alat {
        println!("- {}", item.baca());
    }

    println!("Jumlah alat: {}", alat.len());
}
text Keluaran
- suhu 36.5 C
- kelembapan 62%
- suhu 37.1 C
Jumlah alat: 3

Vektor tersebut menampung urutan tipe campuran, yaitu suhu, kelembapan, lalu suhu lagi, dan perulangan memanggil baca pada setiap elemen tanpa memedulikan tipe konkretnya. Penulisan Box::new(...) memindahkan setiap nilai ke heap, dan anotasi tipe pada let alat memberi tahu kompilator bahwa elemen vektor berupa Box<dyn Sensor>. Nilai alat.len() menghitung jumlah elemen tanpa terlibat dengan tipe concretnya, karena informasi itu disimpan vektor sebagai bagian dari datanya sendiri.

Pola ini menjadi dasar arsitektur plugin, antrean pesan bertipe campuran, dan daftar komponen yang tumbuh saat program berjalan. Syarat yang menyertainya adalah menjaga agar trait yang dipakai tetap sederhana, karena seluruh interaksi dengan elemen hanya dapat berjalan melalui metode yang tersedia pada trait tersebut.

Dyn compatibility#

Tidak setiap trait dapat menjadi trait object. Syaratnya dikenal sebagai dyn compatibility, dan pada dokumentasi lama disebut object safety. Intinya, kompilator harus dapat membangun satu vtable yang entrinya dapat dipanggil tanpa mengetahui tipe konkretnya. Dua hal utama yang menghalangi pembangunan itu adalah penggunaan Self di luar penerima metode, misalnya sebagai nilai kembalian, serta metode yang memiliki parameter tipe generik sendiri.

bash
export PATH="$HOME/.cargo/bin:$PATH"
# trait yang mengembalikan Self tidak dapat menjadi trait object
printf 'trait Pengganda {\n    fn gandakan(&self, faktor: f64) -> Self;\n}\n\nstruct Jarak {\n    km: f64,\n}\n\nimpl Pengganda for Jarak {\n    fn gandakan(&self, faktor: f64) -> Self {\n        Jarak { km: self.km * faktor }\n    }\n}\n\nfn ambil_contoh() -> Box<dyn Pengganda> {\n    Box::new(Jarak { km: 2.0 })\n}\n\nfn main() {\n    let contoh = ambil_contoh();\n    let _tidak_dipakai = contoh;\n}\n' > bukan-trait-object.rs
rustc --edition=2021 bukan-trait-object.rs 2>&1 | sed -n '1,16p'
text Keluaran
error[E0038]: the trait `Pengganda` is not dyn compatible
  --> bukan-trait-object.rs:15:26
   |
15 | fn ambil_contoh() -> Box<dyn Pengganda> {
   |                          ^^^^^^^^^^^^^ `Pengganda` 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>
  --> bukan-trait-object.rs:2:40
   |
 1 | trait Pengganda {
   |       --------- this trait is not dyn compatible...
 2 |     fn gandakan(&self, faktor: f64) -> Self;
   |                                        ^^^^ ...because method `gandakan` references the `Self` type in its return type
   = help: consider moving `gandakan` to another trait
   = help: only type `Jarak` implements `Pengganda`; consider using it directly instead.

Alasan penolakan tersebut dapat dibaca langsung dari pesannya. Entri vtable harus dapat dipanggil untuk tipe konkret mana pun. Metode gandakan mengembalikan Self, yaitu tipe dari nilai tempat metode itu dipanggil, sehingga tabel tidak dapat menyediakan satu bentuk pemanggilan yang berlaku untuk semua tipe. Aturan yang sama berlaku untuk metode dengan parameter tipe generik, seperti fn bandingkan<T: PartialOrd>(&self, lain: &T) -> bool, karena setiap pengisian T akan menuntut entrinya sendiri dan vtable tidak dapat memuatnya tanpa batas. Saran pemisahan pada pesan, yaitu memindahkan metode bermasalah ke trait lain, menjadi pola perbaikan yang lazim: trait utama tetap dapat menjadi trait object, sedangkan metode yang membutuhkan Self atau generik dipisahkan ke trait yang hanya dipakai pada tipe konkret.

Kesalahan umum#

Kesalahan pertama adalah mengharapkan metode khas tipe konkret tetap dapat dipanggil dari trait object. Nilai Box<dyn Sensor> hanya menampilkan metode pada trait Sensor, sehingga field dan metode milik SensorSuhu tidak terlihat dari variabel tersebut. Mengembalikan tipe konkret memerlukan konversi balik (downcast) yang pada umumnya memakai trait Any, dan kebutuhan itu sering menandakan desain yang lebih tepat dijawab dengan enum.

Kesalahan kedua adalah memakai trait object padahal seluruh tipe sudah diketahui saat kompilasi. Setiap pemanggilan metode menanggung penelusuran vtable dan kehilangan inlining, sehingga kode generik lebih tepat untuk kasus tersebut. Kesalahan ketiga adalah memakai Box<dyn Trait> tanpa memeriksa apakah trait yang dipakai memenuhi syarat dyn compatibility, sehingga error E0038 baru muncul jauh setelah desain berkembang. Memeriksa syarat ini sejak perancangan trait membuat trait tetap dapat dipakai sebagai batasan generik maupun sebagai trait object. Tiga kebiasaan yang menjaganya adalah memakai penerima referensi seperti &self, menghindari Self pada nilai kembalian, dan tidak memberi metode parameter tipe generik.

Ringkasan#

  • dyn Trait mewakili tipe konkret mana pun yang mengimplementasikan trait tersebut pada runtime, dan harus berada di balik penunjuk seperti &dyn Trait atau Box<dyn Trait>.
  • Trait object adalah penunjuk gemuk yang memuat penunjuk data dan penunjuk vtable, dan pemanggilan metodenya diarahkan saat runtime melalui entri vtable.
  • Vec<Box<dyn Trait>> menampung elemen bertipe campuran dalam satu koleksi dengan pemanggilan metode yang seragam.
  • Syarat dyn compatibility melarang Self di luar penerima metode dan metode dengan parameter tipe generik, dan pelanggarannya ditolak dengan error E0038.

Referensi#