Lewati ke konten

Trait Object (dyn) di Rust

Di halaman Trait, kamu menulis fungsi yang menerima “tipe apa saja yang mengimplementasikan Speak”. Di halaman Generik, kamu melihat bagaimana Rust membuat salinan terpisah dari fungsi generik untuk setiap tipe. Keduanya bekerja dengan baik kalau tipenya sudah diketahui saat kompilasi. Tapi bagaimana kalau kamu ingin satu daftar yang berisi banyak tipe berbeda?

Untuk itulah ada trait object (objek trait). Bayangkan seorang guru musik dengan satu ruangan penuh murid. Ada yang main piano, ada yang main biola, ada yang main drum. Sang guru tidak perlu tahu alat musik apa yang dipegang setiap murid. Ia cukup bilang “mainkan!”, dan setiap murid memainkan alatnya dengan caranya sendiri. Trait object membuat kodemu bisa melakukan hal yang sama: “Aku tidak tahu tipe persisnya, tapi aku tahu ia bisa melakukan ini.”

Misalnya kamu punya dua bentuk, dan kamu ingin menyimpannya dalam satu Vec:

struct Circle { radius: f64 }
struct Square { side: f64 }
fn main() {
let shapes = vec![Circle { radius: 1.0 }, Square { side: 2.0 }];
}

Kode ini tidak bisa dikompilasi:

error[E0308]: mismatched types
--> src/main.rs:5:47
|
5 | let shapes = vec![Circle { radius: 1.0 }, Square { side: 2.0 }];
| ^^^^^^^^^^^^^^^^^^^^ expected `Circle`, found `Square`

Compiler bilang tipenya tidak cocok: ia mengharapkan Circle, tapi menemukan Square. Sebuah Vec hanya bisa menampung satu tipe. Setiap item harus berukuran sama di memori, supaya Rust bisa menjajarkannya satu per satu. Circle dan Square adalah tipe yang berbeda, jadi keduanya tidak bisa berbagi Vec biasa.

Box<dyn Trait>: daftar berisi bentuk yang berbeda

Section titled “Box<dyn Trait>: daftar berisi bentuk yang berbeda”

Solusinya ada dua bagian. Pertama, beri kedua bentuk sebuah trait yang sama. Kedua, masukkan setiap bentuk ke dalam Box dan tulis tipenya sebagai Box<dyn Shape>:

trait Shape {
fn name(&self) -> String;
fn area(&self) -> f64;
}
struct Circle { radius: f64 }
struct Square { side: f64 }
impl Shape for Circle {
fn name(&self) -> String { String::from("lingkaran") }
fn area(&self) -> f64 { 3.14159 * self.radius * self.radius }
}
impl Shape for Square {
fn name(&self) -> String { String::from("persegi") }
fn area(&self) -> f64 { self.side * self.side }
}
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 2.0 }),
Box::new(Square { side: 3.0 }),
Box::new(Circle { radius: 1.0 }),
];
for shape in &shapes {
println!("{} punya luas {:.2}", shape.name(), shape.area());
}
}

Output:

lingkaran punya luas 12.57
persegi punya luas 9.00
lingkaran punya luas 3.14
▶ Coba sendiri

Mari kita baca Box<dyn Shape> bagian demi bagian:

  • dyn Shape artinya “suatu tipe yang mengimplementasikan Shape, dan tipe persisnya baru ketahuan saat program berjalan”. dyn adalah singkatan dari dynamic (dinamis).
  • Box menaruh nilai di heap dan menyimpan pointer ke sana (kamu sudah bertemu Box di halaman Smart Pointer). Setiap Box berukuran sama, apa pun isinya. Karena itulah Vec sekarang tidak protes lagi.

Nilai di dalam box (ditambah pointer tersembunyi yang bilang “yang ini adalah Circle”) itulah yang kita sebut trait object.

Kamu tidak selalu butuh Box. Kalau nilainya sudah disimpan di tempat lain, referensi juga bisa dipakai. &dyn Shape artinya “referensi pinjaman ke suatu tipe yang mengimplementasikan Shape”:

trait Shape {
fn area(&self) -> f64;
}
struct Circle { radius: f64 }
struct Square { side: f64 }
impl Shape for Circle {
fn area(&self) -> f64 { 3.14159 * self.radius * self.radius }
}
impl Shape for Square {
fn area(&self) -> f64 { self.side * self.side }
}
fn print_area(shape: &dyn Shape) {
println!("Luas: {:.1}", shape.area());
}
fn main() {
let circle = Circle { radius: 1.0 };
let square = Square { side: 4.0 };
print_area(&circle);
print_area(&square);
let both: Vec<&dyn Shape> = vec![&circle, &square];
println!("Ada {} bentuk", both.len());
}

Output:

Luas: 3.1
Luas: 16.0
Ada 2 bentuk
▶ Coba sendiri

Aturan sederhananya: pakai Box<dyn Trait> kalau daftarnya harus memiliki (own) nilai-nilainya, dan &dyn Trait kalau kamu hanya perlu meminjamnya sebentar.

“Dispatch” artinya “memutuskan method mana yang dipanggil”. Rust bisa melakukannya dengan dua cara.

Static dispatch (dispatch statis) terjadi pada generics dan impl Trait. Compiler tahu tipe persisnya, jadi ia membuat salinan khusus fungsi itu untuk setiap tipe (seperti yang kamu lihat di halaman Generik). Pemanggilannya langsung menuju method yang tepat. Sangat cepat.

Dynamic dispatch (dispatch dinamis) terjadi pada dyn Trait. Compiler tidak tahu tipe persisnya. Karena itu, setiap trait object membawa tabel kecil tersembunyi (namanya vtable) yang berisi catatan “untuk nilai ini, area ada di sini”. Saat program berjalan, Rust mencari method di tabel itu, lalu memanggilnya. Pencarian itu menambah sedikit sekali biaya.

// Static dispatch: Rust membuat satu salinan fungsi ini untuk setiap tipe
fn static_area(shape: &impl Shape) -> f64 {
shape.area()
}
// Dynamic dispatch: satu fungsi, method-nya dicari saat program berjalan
fn dynamic_area(shape: &dyn Shape) -> f64 {
shape.area()
}

Kedua fungsi memberi jawaban yang sama untuk bentuk yang sama. Bedanya hanya cara Rust menemukan method area yang tepat. Jadi, mana yang sebaiknya kamu pilih?

Pakai generics / impl Trait kalau… Pakai dyn Trait kalau…
Setiap pemanggilan bekerja dengan satu tipe yang sudah diketahui Kamu perlu mencampur tipe yang berbeda dalam satu koleksi
Kamu ingin kode secepat mungkin Tipenya ditentukan saat program berjalan (misalnya dari input pengguna)
Kamu tidak keberatan program sedikit lebih besar Kamu ingin kode lebih kecil dan kompilasi lebih cepat

Pilihan awal yang bagus untuk pemula: mulailah dengan generics atau impl Trait. Pakai dyn saat kamu bertemu masalah “tipe berbeda dalam satu daftar”. Jangan terlalu pusing soal perbedaan kecepatannya. Di kebanyakan program, kamu tidak akan pernah merasakannya.

Kadang sebuah fungsi perlu memutuskan saat program berjalan tipe mana yang dikembalikan. Kamu mungkin mencoba -> impl Shape, tapi itu hanya bisa kalau fungsinya selalu mengembalikan tipe yang sama. Kalau cabang yang berbeda mengembalikan tipe yang berbeda, kembalikan Box<dyn Shape>:

trait Shape {
fn describe(&self) -> String;
}
struct Circle { radius: f64 }
struct Square { side: f64 }
impl Shape for Circle {
fn describe(&self) -> String { format!("lingkaran dengan jari-jari {}", self.radius) }
}
impl Shape for Square {
fn describe(&self) -> String { format!("persegi dengan sisi {}", self.side) }
}
fn make_shape(kind: &str, size: f64) -> Box<dyn Shape> {
if kind == "circle" {
Box::new(Circle { radius: size })
} else {
Box::new(Square { side: size })
}
}
fn main() {
let a = make_shape("circle", 1.5);
let b = make_shape("square", 2.0);
println!("Aku membuat {}", a.describe());
println!("Aku membuat {}", b.describe());
}

Output:

Aku membuat lingkaran dengan jari-jari 1.5
Aku membuat persegi dengan sisi 2
▶ Coba sendiri

Ini pola yang umum untuk fungsi “pabrik” (factory), sistem plugin, dan kode yang diatur lewat konfigurasi, yaitu di mana pun tipe persisnya bergantung pada data yang baru terlihat saat program berjalan.

Ini contoh yang lebih mirip aplikasi sungguhan. Programmu perlu mengirim notifikasi ke pengguna, tapi setiap pengguna memilih saluran yang berbeda: email, SMS, atau keduanya. Setiap saluran punya tipenya sendiri, dan semuanya berbagi trait Notifier:

trait Notifier {
fn send(&self, message: &str) -> String;
}
struct Email { address: String }
struct Sms { phone: String }
impl Notifier for Email {
fn send(&self, message: &str) -> String {
format!("Email ke {}: {}", self.address, message)
}
}
impl Notifier for Sms {
fn send(&self, message: &str) -> String {
format!("SMS ke {}: {}", self.phone, message)
}
}
fn notify_all(channels: &[Box<dyn Notifier>], message: &str) {
for channel in channels {
println!("{}", channel.send(message));
}
}
fn main() {
let channels: Vec<Box<dyn Notifier>> = vec![
Box::new(Email { address: String::from("[email protected]") }),
Box::new(Sms { phone: String::from("+62-812-555-0100") }),
];
notify_all(&channels, "Pesananmu sudah dikirim!");
}

Output:

Email ke [email protected]: Pesananmu sudah dikirim!
SMS ke +62-812-555-0100: Pesananmu sudah dikirim!
▶ Coba sendiri

Bagian enaknya: kalau nanti kamu menambah notifier Push, kamu cukup menulis satu struct baru dan satu impl Notifier for Push. Fungsi notify_all sama sekali tidak berubah.

Tidak semua trait bisa menjadi trait object. Sebuah trait harus dyn compatible (buku dan artikel blog yang lebih lama menyebutnya object safety). Aturan lengkapnya panjang, tapi dua aturan ini mencakup sebagian besar kasus nyata:

  • Method tidak boleh mengembalikan Self. (Di balik dyn, Rust tidak tahu Self itu apa, jadi ia tidak bisa tahu seberapa besar nilai yang dikembalikan.)
  • Method tidak boleh punya parameter tipe generik sendiri, seperti fn process<T>(&self, item: T).

Ini yang terjadi kalau kamu melanggar aturan pertama:

trait Shape {
fn area(&self) -> f64;
fn duplicate(&self) -> Self;
}
// ... lalu di bagian lain:
let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 1.0 })];
error[E0038]: the trait `Shape` is not dyn compatible
|
14 | let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 1.0 })];
| ^^^^^ `Shape` is not dyn compatible
|
1 | trait Shape {
| ----- this trait is not dyn compatible...
2 | fn area(&self) -> f64;
3 | fn duplicate(&self) -> Self;
| ^^^^ ...because method `duplicate` references the `Self` type in its return type
= help: consider moving `duplicate` to another trait

Pesan error-nya jujur dan membantu: trait Shape tidak dyn compatible karena method duplicate memakai Self sebagai tipe kembalian. Pilihanmu adalah memindahkan method itu ke trait lain, atau menambahkan where Self: Sized pada method tersebut. Itu memberi tahu Rust “method ini hanya ada di tipe nyata yang sudah diketahui, bukan di trait object”:

trait Shape {
fn area(&self) -> f64;
fn duplicate(&self) -> Self where Self: Sized;
}

Ini juga alasan kenapa beberapa trait standar, seperti Clone, tidak bisa dipakai sebagai dyn Clone: clone() mengembalikan Self.

Lupa Box. Rasanya ingin langsung menulis Vec<dyn Shape>:

let shapes: Vec<dyn Shape> = Vec::new();
error[E0277]: the size for values of type `dyn Shape` cannot be known at compilation time
--> src/main.rs:3:17
|
3 | let shapes: Vec<dyn Shape> = Vec::new();
| ^^^^^^^^^^^^^^ doesn't have a size known at compile-time
|
= help: the trait `Sized` is not implemented for `dyn Shape`

Compiler bilang ukuran dyn Shape tidak bisa diketahui saat kompilasi. Sebuah dyn Shape bisa berukuran berapa saja: Square yang kecil, atau struct besar dengan banyak field. Rust butuh ukuran yang tetap, jadi trait object harus selalu berada di balik pointer: Box<dyn Shape>, &dyn Shape, atau Rc<dyn Shape>.

Memakai dyn di mana-mana. Kalau sebuah fungsi hanya pernah menerima satu tipe dalam satu waktu, impl Trait atau generics lebih sederhana dan lebih cepat (halaman Trait lanjutan membahas impl Trait lebih jauh). Simpan dyn untuk saat kamu benar-benar perlu mencampur tipe.

Definisikan trait Animal dengan fn name(&self) -> String dan fn sound(&self) -> String. Implementasikan untuk Dog, Cow, dan Duck, masukkan masing-masing satu ke dalam Vec<Box<dyn Animal>>, lalu lakukan perulangan untuk mencetak suara setiap hewan. Petunjuk: struct-nya boleh kosong (struct Dog;), dan setiap hewan dimasukkan dengan Box::new(...).

Lihat solusi
trait Animal {
fn name(&self) -> String;
fn sound(&self) -> String;
}
struct Dog;
struct Cow;
struct Duck;
impl Animal for Dog {
fn name(&self) -> String { String::from("Anjing") }
fn sound(&self) -> String { String::from("guk guk") }
}
impl Animal for Cow {
fn name(&self) -> String { String::from("Sapi") }
fn sound(&self) -> String { String::from("mooo") }
}
impl Animal for Duck {
fn name(&self) -> String { String::from("Bebek") }
fn sound(&self) -> String { String::from("kwek kwek") }
}
fn main() {
let zoo: Vec<Box<dyn Animal>> = vec![Box::new(Dog), Box::new(Cow), Box::new(Duck)];
for animal in &zoo {
println!("{} bersuara {}", animal.name(), animal.sound());
}
}

Output:

Anjing bersuara guk guk
Sapi bersuara mooo
Bebek bersuara kwek kwek
▶ Coba sendiri

Definisikan trait Greeter dengan fn hello(&self, name: &str) -> String, dan dua tipe: English dan Spanish. Tulis fn make_greeter(lang: &str) -> Box<dyn Greeter> yang mengembalikan Spanish untuk "es" dan English untuk kode lainnya. Sapa "Mia" untuk setiap kode di ["en", "es", "fr"]. Petunjuk: kedua cabang if harus mengembalikan tipe yang sama, jadi bungkus masing-masing dengan Box::new(...).

Lihat solusi
trait Greeter {
fn hello(&self, name: &str) -> String;
}
struct English;
struct Spanish;
impl Greeter for English {
fn hello(&self, name: &str) -> String {
format!("Hello, {name}!")
}
}
impl Greeter for Spanish {
fn hello(&self, name: &str) -> String {
format!("¡Hola, {name}!")
}
}
fn make_greeter(lang: &str) -> Box<dyn Greeter> {
if lang == "es" {
Box::new(Spanish)
} else {
Box::new(English)
}
}
fn main() {
for lang in ["en", "es", "fr"] {
let greeter = make_greeter(lang);
println!("{lang}: {}", greeter.hello("Mia"));
}
}

Output:

en: Hello, Mia!
es: ¡Hola, Mia!
fr: Hello, Mia!
▶ Coba sendiri

Sapaan “Hello” dan “¡Hola” sengaja dibiarkan dalam bahasa Inggris dan Spanyol, karena memang itulah bahasa yang diwakili kedua tipe ini.

  • Sebuah Vec hanya bisa menampung satu tipe. Untuk mencampur tipe, pakai trait object seperti Box<dyn Trait>.
  • Box<dyn Trait> memiliki nilainya; &dyn Trait meminjamnya. Trait object selalu berada di balik pointer.
  • Generics dan impl Trait memakai static dispatch (cepat, diputuskan saat kompilasi). dyn Trait memakai dynamic dispatch (mencari di vtable saat program berjalan).
  • Kembalikan Box<dyn Trait> kalau fungsi memilih di antara tipe yang berbeda saat program berjalan.
  • Hanya trait yang dyn compatible yang bisa menjadi trait object: tidak ada method yang mengembalikan Self dan tidak ada method generik (kecuali method itu punya where Self: Sized).

Kuis singkat

1. Kenapa kamu tidak bisa menaruh Circle dan Square dalam satu Vec biasa?

2. Apa yang disampaikan dyn dalam Box<dyn Shape> kepada Rust?

3. Mana yang memakai static dispatch?

4. Kenapa Vec<dyn Shape> (tanpa Box) menghasilkan error?

5. Method trait mana yang membuat sebuah trait tidak dyn compatible?

Skor: 0 / 5