Lewati ke konten

Trait Lanjutan di Rust

Di halaman Trait, kamu sudah belajar cara mendefinisikan dan mengimplementasikan trait. Di halaman Generik dan Trait yang sering dipakai, kamu memakai trait seperti Display, From, dan Add. Itu sudah cukup untuk sebagian besar kode sehari-hari. Tapi saat kamu membaca standard library atau crate populer, kamu akan melihat fitur trait yang awalnya terlihat agak aneh: type Item, Add<Rhs = Self>, trait Pretty: Display, impl<T: Display> ... for T.

Halaman ini menjelaskan fitur-fitur itu satu per satu. Tidak ada yang ajaib. Masing-masing menyelesaikan satu masalah kecil yang nyata, dan begitu kamu paham masalahnya, sintaksnya akan masuk akal. Pelan-pelan saja. Tidak apa-apa membaca satu bagian, mencoba kodenya, lalu kembali lagi nanti.

Kamu sebenarnya sudah pernah bertemu associated type: Iterator punya type Item. Associated type (tipe terkait) adalah tipe pengganti (placeholder) di dalam trait. Setiap implementasi mengisinya sekali.

Berikut trait Container yang kecil. Sebuah playlist berisi String, dan daftar skor berisi u32, jadi masing-masing memilih Item-nya sendiri:

use std::fmt::Debug;
trait Container {
type Item;
fn get(&self, index: usize) -> Option<&Self::Item>;
fn size(&self) -> usize;
fn first(&self) -> Option<&Self::Item> {
self.get(0)
}
}
struct Playlist {
songs: Vec<String>,
}
struct Scores {
values: Vec<u32>,
}
impl Container for Playlist {
type Item = String;
fn get(&self, index: usize) -> Option<&String> {
self.songs.get(index)
}
fn size(&self) -> usize {
self.songs.len()
}
}
impl Container for Scores {
type Item = u32;
fn get(&self, index: usize) -> Option<&u32> {
self.values.get(index)
}
fn size(&self) -> usize {
self.values.len()
}
}
// Bisa dipakai untuk Container apa saja yang item-nya bisa dicetak dengan {:?}
fn describe<C: Container>(c: &C)
where
C::Item: Debug,
{
println!("{} item, yang pertama {:?}", c.size(), c.first());
}
fn main() {
let playlist = Playlist { songs: vec![String::from("Intro"), String::from("Outro")] };
let scores = Scores { values: vec![90, 72, 85] };
describe(&playlist);
describe(&scores);
}

Output:

2 item, yang pertama Some("Intro")
3 item, yang pertama Some(90)
▶ Coba sendiri

Di dalam trait, Self::Item artinya “tipe apa pun yang dipilih oleh implementasi ini”. C::Item di describe artinya sama, tapi untuk tipe C. (Baris where hanyalah tempat yang lebih rapi untuk menulis bound. Penjelasannya ada di akhir halaman ini.)

Kenapa tidak pakai parameter generik saja? Kamu bisa saja menulis trait Container<T>. Tapi dengan begitu satu tipe bisa mengimplementasikannya berkali-kali, Container<String> dan Container<u32>, dan setiap pemanggil harus menyebutkan yang mana yang dimaksud. Untuk playlist, itu tidak masuk akal: isinya lagu, titik.

Itulah alasan Iterator memakai type Item. Iterator dari Vec<i32> menghasilkan i32 dan tidak ada yang lain, jadi for x in v.iter() tidak pernah butuh anotasi tipe. Patokan sederhananya:

  • Satu jawaban yang wajar per tipe → associated type (Iterator::Item, Add::Output).
  • Banyak jawaban yang sah per tipe → parameter generik (From<T>: sebuah String bisa dibuat dari &str, char, Box<str>, …).

Di halaman Trait yang sering dipakai, kamu membuat + bisa dipakai untuk Point dengan mengimplementasikan Add. Begini definisi Add yang sebenarnya di standard library:

trait Add<Rhs = Self> {
type Output;
fn add(self, rhs: Rhs) -> Self::Output;
}

Di sini ada dua fitur sekaligus. Output adalah associated type (hasil dari +). Dan Rhs = Self adalah default type parameter (parameter tipe bawaan): “sisi kanan bertipe sama dengan sisi kiri, kecuali kamu bilang lain”. Saat kamu menulis impl Add for Point, kamu mendapat nilai bawaannya. Saat kamu ingin menjumlahkan tipe yang berbeda, kamu menulis impl Add<OtherType> for Point.

use std::ops::Add;
#[derive(Debug, Clone, Copy)]
struct Meters(f64);
#[derive(Debug, Clone, Copy)]
struct Centimeters(f64);
// Memakai bawaan Rhs = Self, jadi ini Meters + Meters
impl Add for Meters {
type Output = Meters;
fn add(self, other: Meters) -> Meters {
Meters(self.0 + other.0)
}
}
// Mengganti bawaannya: Meters + Centimeters
impl Add<Centimeters> for Meters {
type Output = Meters;
fn add(self, other: Centimeters) -> Meters {
Meters(self.0 + other.0 / 100.0)
}
}
fn main() {
let a = Meters(2.0);
let b = Meters(3.5);
let c = Centimeters(50.0);
println!("{:?}", a + b);
println!("{:?}", a + c);
}

Output:

Meters(5.5)
Meters(2.5)
▶ Coba sendiri

Perhatikan bahwa Add memakai kedua alat itu: parameter generik untuk sisi kanan (banyak jawaban yang sah, kamu bisa menjumlahkan Meters atau Centimeters) dan associated type untuk hasilnya (satu jawaban untuk setiap pasangan). Default type parameter paling berguna saat kamu merancang trait: kasus yang umum tetap singkat, tanpa menghalangi kasus yang jarang.

Supertrait: “kamu juga wajib mengimplementasikan ini”

Section titled “Supertrait: “kamu juga wajib mengimplementasikan ini””

Kadang trait-mu hanya masuk akal kalau tipenya sudah bisa melakukan hal lain. Supertrait berarti “untuk mengimplementasikan trait-ku, kamu juga wajib mengimplementasikan trait yang itu”. Kamu menulisnya dengan titik dua, seperti bound:

use std::fmt;
// Apa pun yang Pretty juga wajib Display
trait Pretty: fmt::Display {
fn pretty(&self) -> String {
format!("*** {} ***", self)
}
}
struct Temperature {
celsius: f64,
}
impl fmt::Display for Temperature {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{:.1}°C", self.celsius)
}
}
impl Pretty for Temperature {}
fn main() {
let t = Temperature { celsius: 21.456 };
println!("{}", t);
println!("{}", t.pretty());
}

Output:

21.5°C
*** 21.5°C ***
▶ Coba sendiri

Method bawaan pretty bisa memakai {} pada self karena supertrait-nya menjamin Display tersedia. Tanpa : fmt::Display, Rust akan menolak format! itu, karena ia tidak bisa tahu apakah tipenya bisa dicetak. impl Pretty for Temperature {} memang kosong: isinya hanya “ya, pakai method bawaannya”.

Contoh nyata dari standard library: Copy: Clone (setiap tipe Copy juga wajib Clone) dan Eq: PartialEq.

Blanket implementation: satu impl untuk banyak tipe

Section titled “Blanket implementation: satu impl untuk banyak tipe”

Blanket implementation (implementasi menyeluruh) mengimplementasikan trait untuk setiap tipe yang memenuhi suatu syarat, sekaligus. Sintaksnya impl<T: SomeBound> MyTrait for T:

use std::fmt::Display;
trait Shout {
fn shout(&self) -> String;
}
// Setiap tipe yang mengimplementasikan Display otomatis dapat Shout
impl<T: Display> Shout for T {
fn shout(&self) -> String {
format!("{}!", self.to_string().to_uppercase())
}
}
fn main() {
println!("{}", "halo".shout());
println!("{}", 42.shout());
println!("{}", 'a'.shout());
println!("{}", String::from("rust itu seru").shout());
}

Output:

HALO!
42!
A!
RUST ITU SERU!
▶ Coba sendiri

Kita tidak pernah menulis impl Shout for i32 atau impl Shout for char. Blanket impl itu sudah mencakup semuanya, termasuk tipe Display yang belum ada sekarang.

Tanpa sadar, kamu sudah sering memakai blanket impl. Standard library punya (kurang lebih) impl<T: Display> ToString for T. Itulah sebabnya setiap tipe yang punya Display juga punya method .to_string(). Trik yang sama memberimu Into secara gratis saat kamu mengimplementasikan From.

Ada satu batasan: begitu sebuah blanket impl ada, kamu tidak bisa menambah impl kedua yang lebih spesifik untuk tipe yang sudah dicakupnya (misalnya impl Shout for i32 khusus). Rust akan punya dua jawaban, jadi ia menolak dengan error “conflicting implementations” (implementasi yang bertabrakan).

Ini aturan yang pernah mengejutkan semua orang. Coba beri Vec<String> sebuah Display yang rapi:

use std::fmt;
impl fmt::Display for Vec<String> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.join(", "))
}
}
error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate
--> src/main.rs:3:1
|
3 | impl fmt::Display for Vec<String> {
| ^^^^^^^^^^^^^^^^^^^^^^-----------
| |
| `Vec` is not defined in the current crate
|
= note: define and implement a trait or new type instead

Compiler bilang: hanya trait yang didefinisikan di crate ini yang boleh diimplementasikan untuk tipe dari luar crate, dan Vec tidak didefinisikan di crate ini. Inilah orphan rule (aturan yatim): kamu hanya boleh mengimplementasikan trait untuk sebuah tipe kalau trait-nya atau tipenya didefinisikan di crate-mu sendiri. Display milik standard library, Vec juga, jadi tidak ada satu pun yang milikmu.

Kenapa aturan ini ada? Bayangkan dua crate yang kamu pakai sama-sama menulis impl Display for Vec<String> versi masing-masing. Rust harus pakai yang mana? Tidak ada jawaban yang bagus, jadi Rust membuat situasi itu mustahil terjadi.

Solusinya adalah newtype pattern (pola newtype): bungkus tipe asing itu di dalam struct kecil buatanmu sendiri. Sekarang tipe itu milikmu, dan kamu bisa mengimplementasikan apa saja untuknya:

use std::fmt;
// Pembungkus lokal untuk tipe dari luar
struct Shopping(Vec<String>);
impl fmt::Display for Shopping {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}
fn main() {
let list = Shopping(vec![String::from("susu"), String::from("telur"), String::from("roti")]);
println!("Yang perlu dibeli: {}", list);
println!("Totalnya {} barang", list.0.len());
}

Output:

Yang perlu dibeli: [susu, telur, roti]
Totalnya 3 barang
▶ Coba sendiri

self.0 mengakses Vec di dalamnya. Newtype tidak punya biaya saat program berjalan: di memori, Shopping persis sama dengan Vec<String>. Kekurangannya, method milik Vec tidak langsung tersedia di Shopping, jadi kamu menulis list.0.len() (atau menambahkan method yang kamu perlukan). Newtype berguna untuk lebih dari sekadar orphan rule; lihat Design pattern.

Fully qualified syntax: saat nama bertabrakan

Section titled “Fully qualified syntax: saat nama bertabrakan”

Dua trait bisa punya method dengan nama yang sama, dan tipenya sendiri juga bisa punya method dengan nama itu. Jadi, robot.greet() memanggil yang mana?

trait English {
fn greet(&self) -> String;
fn language() -> String;
}
trait Spanish {
fn greet(&self) -> String;
fn language() -> String;
}
struct Robot;
impl Robot {
fn greet(&self) -> String {
String::from("BEEP BOOP")
}
}
impl English for Robot {
fn greet(&self) -> String { String::from("Hello!") }
fn language() -> String { String::from("Inggris") }
}
impl Spanish for Robot {
fn greet(&self) -> String { String::from("¡Hola!") }
fn language() -> String { String::from("Spanyol") }
}
fn main() {
let r = Robot;
println!("{}", r.greet()); // method milik tipenya sendiri yang menang
println!("{}", English::greet(&r)); // pilih trait berdasarkan namanya
println!("{}", Spanish::greet(&r));
// Tidak ada `self` di sini, jadi Rust butuh tipe DAN trait-nya:
println!("{}", <Robot as English>::language());
println!("{}", <Robot as Spanish>::language());
}

Output:

BEEP BOOP
Hello!
¡Hola!
Inggris
Spanyol
▶ Coba sendiri

Ada tiga tingkat, dari yang paling singkat sampai yang paling eksplisit:

  • r.greet(): method milik tipenya sendiri (inherent method) dipilih lebih dulu.
  • English::greet(&r): “versi English”. Rust mencari tahu tipenya dari &r.
  • <Robot as English>::language(): fully qualified syntax (sintaks lengkap). Kamu membutuhkannya untuk fungsi tanpa self, karena tidak ada nilai yang bisa dilihat Rust. English::language() saja akan gagal dengan error[E0790]: cannot call associated function on trait without specifying the corresponding impl type, artinya Rust tidak bisa memanggil fungsi trait itu tanpa tahu impl untuk tipe yang mana.

Kamu tidak akan sering membutuhkan ini. Kalau suatu saat butuh, pesan error compiler biasanya sudah menyarankan perbaikan yang tepat.

impl Trait di argumen dan nilai kembalian, plus where

Section titled “impl Trait di argumen dan nilai kembalian, plus where”

Kamu bisa menulis impl Trait di dua tempat, dan artinya sedikit berbeda di masing-masing tempat:

  • Posisi argumen, fn f(x: impl Trait): pemanggil yang memilih tipenya. Ini cara singkat untuk menulis fungsi generik fn f<T: Trait>(x: T).
  • Posisi kembalian, fn f() -> impl Trait: fungsinya yang memilih satu tipe konkret dan menyembunyikannya. Pemanggil hanya tahu “ini sesuatu yang mengimplementasikan Trait”.
// Pemanggil yang memilih: iterator u32 apa saja boleh
fn print_all(items: impl Iterator<Item = u32>) {
let all: Vec<u32> = items.collect();
println!("{:?}", all);
}
// Fungsi yang memilih: tipe aslinya Filter<...> yang panjang, tidak perlu kita tulis
fn evens_up_to(limit: u32) -> impl Iterator<Item = u32> {
(0..=limit).filter(|n| n % 2 == 0)
}
fn main() {
print_all(vec![3, 1, 4].into_iter());
print_all(evens_up_to(10));
}

Output:

[3, 1, 4]
[0, 2, 4, 6, 8, 10]
▶ Coba sendiri

impl Trait di posisi kembalian sangat praktis untuk closure dan iterator. Tipe asli dari hasil evens_up_to mengandung tipe closure-nya, dan closure tidak punya nama yang bisa kamu ketik. Dengan impl Iterator, kamu tidak perlu menuliskannya.

Saat sebuah fungsi punya beberapa parameter generik dengan beberapa bound untuk masing-masing, isi kurung sudutnya jadi sesak. Klausa where memindahkan bound-bound itu ke setelah signature, sehingga bentuk fungsinya lebih mudah dibaca:

use std::fmt::{Debug, Display};
// Sesak: fn report<T: Display, U: Debug + PartialOrd>(label: T, a: U, b: U) -> String
// Lebih rapi:
fn report<T, U>(label: T, a: U, b: U) -> String
where
T: Display,
U: Debug + PartialOrd,
{
let bigger = if a > b { a } else { b };
format!("{}: yang lebih besar {:?}", label, bigger)
}
fn main() {
println!("{}", report("skor", 7, 12));
println!("{}", report(String::from("nama"), "ann", "bob"));
}

Output:

skor: yang lebih besar 12
nama: yang lebih besar "bob"
▶ Coba sendiri

Bagi compiler, kedua bentuk itu artinya persis sama. where juga tempat yang wajar untuk bound pada associated type, seperti C::Item: Debug di contoh pertama halaman ini.

Mengimplementasikan trait tapi lupa supertrait-nya. Kalau kamu menghapus blok impl fmt::Display for Temperature dari contoh supertrait, kamu akan mendapat:

error[E0277]: `Temperature` doesn't implement `std::fmt::Display`
--> src/main.rs:13:17
|
13 | impl Pretty for Temperature {}
| ^^^^^^^^^^^ unsatisfied trait bound
|
note: required by a bound in `Pretty`
--> src/main.rs:3:15
|
3 | trait Pretty: fmt::Display {
| ^^^^^^^^^^^^ required by this bound in `Pretty`

Compiler bilang Temperature tidak mengimplementasikan Display, padahal Pretty mewajibkannya. Solusinya, implementasikan juga supertrait-nya (di sini, Display). Bagian note menunjuk langsung ke : fmt::Display yang mewajibkannya.

Mengembalikan dua tipe berbeda dari -> impl Trait. impl Trait menyembunyikan satu tipe. Ia tidak bisa “kadang ini, kadang itu”:

fn numbers(evens: bool) -> impl Iterator<Item = u32> {
if evens {
(0..10).filter(|n| n % 2 == 0)
} else {
vec![1, 3, 5].into_iter()
}
}
error[E0308]: `if` and `else` have incompatible types
--> src/main.rs:5:9
|
2 | / if evens {
3 | | (0..10).filter(|n| n % 2 == 0)
| | ------------------------------
| | | |
| | | the expected closure
| | expected because of this
4 | | } else {
5 | | vec![1, 3, 5].into_iter()
| | ^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Filter<Range<{integer}>, {closure@...}>`, found `IntoIter<{integer}>`
6 | | }
| |_____- `if` and `else` have incompatible types
|
= note: expected struct `Filter<std::ops::Range<{integer}>, {closure@src/main.rs:3:24: 3:27}>`
found struct `std::vec::IntoIter<{integer}>`
help: you could change the return type to be a boxed trait object
|
1 - fn numbers(evens: bool) -> impl Iterator<Item = u32> {
1 + fn numbers(evens: bool) -> Box<dyn Iterator<Item = u32>> {
|
help: if you change the return type to expect trait objects, box the returned expressions
|
3 ~ Box::new((0..10).filter(|n| n % 2 == 0))
4 | } else {
5 ~ Box::new(vec![1, 3, 5].into_iter())
|

Compiler bilang cabang if dan else menghasilkan tipe yang berbeda, lalu menyarankan untuk mengubah tipe kembaliannya menjadi trait object di dalam Box. Kalau tipenya memang berubah saat program berjalan, kembalikan Box<dyn Trait>, seperti saran compiler. Lihat Trait object.

  • Associated type (type Item) memberi satu jawaban per implementasi. Pakai parameter generik kalau satu tipe bisa mengimplementasikan trait itu dengan banyak cara.
  • Default type parameter seperti Add<Rhs = Self> membuat kasus yang umum tetap singkat; impl Add<Other> for T menggantinya.
  • Supertrait (trait Pretty: Display) mewajibkan trait lain, dan membuat method bawaan bisa memakainya.
  • Blanket impl (impl<T: Display> MyTrait for T) mencakup setiap tipe yang cocok sekaligus. Begitulah cara kerja .to_string().
  • Orphan rule melarangmu mengimplementasikan trait asing untuk tipe asing. Bungkus tipenya dengan newtype untuk mengatasinya.
  • Pakai fully qualified syntax <Type as Trait>::function() saat nama bertabrakan; impl Trait di posisi kembalian menyembunyikan satu tipe konkret; where membuat bound yang panjang lebih mudah dibaca.

Kuis singkat

1. Kenapa Iterator memakai type Item, bukan Iterator<T>?

2. Apa arti Rhs = Self dalam trait Add<Rhs = Self>?

3. Kamu ingin menulis impl Display for Vec<String>, tapi Rust menolak. Apa solusi yang biasa dipakai?

4. Baris mana yang memanggil versi English dari fungsi tanpa parameter self?

5. Pada fn evens() -> impl Iterator<Item = u32>, siapa yang menentukan tipe iterator konkretnya?

Skor: 0 / 5