Traits: Mendefinisikan Perilaku Bersama
Sebuah trait mendefinisikan fungsionalitas yang dimiliki suatu tipe tertentu dan bisa dibagikan (di-share) dengan tipe lainnya. Kita bisa memakai traits buat mendefinisikan perilaku bersama (shared behavior) secara abstrak. Kita bisa memakai trait bounds buat menentukan kalau sebuah tipe generik bisa berupa tipe apa pun asalkan punya perilaku tertentu.
Catatan: Traits itu mirip sama fitur yang sering disebut interfaces di bahasa pemrograman lain, walaupun ada beberapa perbedaan.
Mendefinisikan sebuah Trait
Perilaku dari sebuah tipe terdiri dari methods yang bisa kita panggil pada tipe tersebut. Berbagai tipe bisa berbagi perilaku yang sama kalau kita bisa memanggil methods yang sama pada semua tipe itu. Definisi trait adalah cara buat mengelompokkan method signatures (tanda tangan metode) bersama-sama untuk mendefinisikan sekumpulan perilaku yang dibutuhkan untuk mencapai suatu tujuan.
Misalnya, katakanlah kita punya beberapa struct yang menampung berbagai
macam dan jumlah teks: sebuah struct NewsArticle yang menampung berita di
lokasi tertentu dan sebuah SocialPost yang maksimal isinya 280 karakter
beserta metadata yang menunjukkan apakah itu postingan baru, di-repost,
atau balasan buat postingan lain.
Kita mau bikin library crate agregator media bernama aggregator yang bisa
nampilin ringkasan data yang mungkin disimpan di dalam instance NewsArticle
atau SocialPost. Untuk melakukan ini, kita butuh ringkasan dari tiap tipe,
dan kita bakal minta ringkasan itu dengan memanggil method summarize di
tiap instance-nya. Listing 10-12 menunjukkan definisi trait publik Summary
yang mengekspresikan perilaku ini.
pub trait Summary {
fn summarize(&self) -> String;
}
Summary yang terdiri dari perilaku yang disediakan oleh method summarizeDi sini, kita mendeklarasikan sebuah trait memakai keyword trait lalu nama
trait-nya, yang mana adalah Summary di kasus ini. Kita juga mendeklarasikan
trait ini sebagai pub supaya crates yang bergantung pada crate ini
bisa memanfaatkan trait ini juga, seperti yang bakal kita lihat di beberapa
contoh nanti. Di dalam kurung kurawal, kita mendeklarasikan method signatures
yang menggambarkan perilaku tipe-tipe yang mengimplementasikan trait ini,
yang di kasus ini adalah fn summarize(&self) -> String.
Setelah method signature, bukannya ngasih implementasi di dalam kurung kurawal,
kita memakai titik koma. Tiap tipe yang mengimplementasikan trait ini harus
menyediakan perilaku khususnya sendiri buat body (isi) dari method ini.
Compiler bakal memastikan kalau tipe apa pun yang punya trait Summary
bakal punya method summarize yang didefinisikan dengan signature yang
persis sama kayak gini.
Sebuah trait bisa punya banyak method di dalamnya: method signatures didaftarkan satu baris satu, dan tiap baris diakhiri dengan titik koma.
Mengimplementasikan sebuah Trait pada suatu Tipe
Sekarang setelah kita mendefinisikan signatures yang diinginkan dari method
trait Summary, kita bisa mengimplementasikannya pada tipe-tipe di agregator
media kita. Listing 10-13 menunjukkan implementasi trait Summary pada
struct NewsArticle yang memakai judul (headline), penulis, dan lokasi buat
bikin nilai kembalian dari summarize. Buat struct SocialPost, kita
mendefinisikan summarize sebagai username diikuti sama seluruh teks postingannya,
dengan asumsi kalau konten postingan sudah dibatasi sampai 280 karakter.
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
Summary pada tipe NewsArticle dan SocialPostMengimplementasikan trait pada suatu tipe itu mirip dengan mengimplementasikan
method biasa. Bedanya adalah setelah impl, kita menaruh nama trait yang
mau kita implementasikan, lalu memakai keyword for, dan kemudian menentukan
nama tipe di mana kita mau mengimplementasikan trait tersebut. Di dalam blok
impl, kita menaruh method signatures yang sudah didefinisikan sama definisi
trait-nya. Alih-alih menambahkan titik koma setelah setiap signature, kita
memakai kurung kurawal dan mengisi isi method dengan perilaku spesifik yang
kita mau dari method trait tersebut untuk tipe khususnya.
Sekarang setelah library ini mengimplementasikan trait Summary pada
NewsArticle dan SocialPost, pengguna dari crate ini bisa memanggil
method dari trait tersebut pada instance NewsArticle dan SocialPost
dengan cara yang sama seperti memanggil method biasa. Bedanya cuma si pengguna
harus membawa trait tersebut ke dalam scope sekaligus membawa tipe-tipenya.
Ini contoh gimana sebuah binary crate bisa memakai library crate
aggregator kita:
use aggregator::{SocialPost, Summary};
fn main() {
let post = SocialPost {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
repost: false,
};
println!("1 new post: {}", post.summarize());
}
Kode ini bakal mencetak 1 new post: horse_ebooks: of course, as you probably already know, people.
Crates lain yang bergantung pada crate aggregator juga bisa membawa trait
Summary ke dalam scope untuk mengimplementasikan Summary di tipe mereka
sendiri. Satu batasan yang perlu dicatat adalah kita cuma bisa mengimplementasikan
sebuah trait pada suatu tipe kalau setidaknya trait-nya atau tipenya, atau
keduanya, berada di crate kita sendiri (local to our crate). Misalnya, kita
bisa mengimplementasikan trait dari standard library seperti Display
pada tipe kustom seperti SocialPost sebagai bagian dari fungsionalitas
crate aggregator kita karena tipe SocialPost itu ada di crate
aggregator kita. Kita juga bisa mengimplementasikan Summary pada Vec<T>
di crate aggregator kita karena trait Summary itu ada di crate
aggregator kita.
Tapi kita tidak bisa mengimplementasikan traits eksternal pada tipe eksternal.
Misalnya, kita tidak bisa mengimplementasikan trait Display pada Vec<T>
di dalam crate aggregator kita karena Display dan Vec<T> dua-duanya
didefinisikan di standard library dan bukan bagian dari crate aggregator
kita. Batasan ini adalah bagian dari properti yang disebut coherence
(koherensi), dan lebih spesifik lagi disebut orphan rule (aturan yatim piatu),
dinamai begitu karena tipe induknya tidak ada. Aturan ini memastikan kalau kode
milik orang lain tidak bisa merusak kode kita dan sebaliknya. Tanpa aturan ini,
dua crates bisa saja mengimplementasikan trait yang sama untuk tipe yang sama,
dan Rust tidak bakal tau implementasi mana yang harus dipakai.
Implementasi Default
Kadang-kadang akan berguna kalau kita punya perilaku default untuk beberapa atau semua method di sebuah trait daripada mewajibkan implementasi untuk semua method di setiap tipe. Dengan begitu, saat kita mengimplementasikan trait pada tipe tertentu, kita bisa tetap menyimpan atau menimpa (override) perilaku default dari tiap method.
Di Listing 10-14, kita menentukan string default buat method summarize
dari trait Summary alih-alih cuma mendefinisikan method signature-nya,
seperti yang kita lakukan di Listing 10-12.
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
Summary dengan implementasi default buat method summarizeUntuk memakai implementasi default buat meringkas instance dari NewsArticle,
kita cukup menentukan blok impl yang kosong dengan
impl Summary for NewsArticle {}.
Meskipun kita tidak lagi mendefinisikan method summarize di NewsArticle
secara langsung, kita sudah menyediakan implementasi default dan menentukan
kalau NewsArticle mengimplementasikan trait Summary. Hasilnya, kita tetap
bisa memanggil method summarize pada instance NewsArticle, seperti ini:
use aggregator::{self, NewsArticle, Summary};
fn main() {
let article = NewsArticle {
headline: String::from("Penguins win the Stanley Cup Championship!"),
location: String::from("Pittsburgh, PA, USA"),
author: String::from("Iceburgh"),
content: String::from(
"The Pittsburgh Penguins once again are the best \
hockey team in the NHL.",
),
};
println!("New article available! {}", article.summarize());
}
Kode ini mencetak New article available! (Read more...).
Membuat implementasi default tidak mengharuskan kita untuk mengubah apa pun
dari implementasi Summary pada SocialPost di Listing 10-13. Alasannya adalah
sintaks buat menimpa implementasi default itu persis sama kayak sintaks buat
mengimplementasikan method trait yang tidak punya implementasi default.
Implementasi default bisa memanggil method lain di trait yang sama,
bahkan kalau method lain itu tidak punya implementasi default. Dengan cara
ini, sebuah trait bisa menyediakan banyak fungsionalitas berguna dan cuma
mewajibkan si peng-implementasi buat menentukan sebagian kecil saja. Misalnya,
kita bisa mendefinisikan trait Summary agar punya method summarize_author
yang implementasinya wajib, lalu mendefinisikan method summarize yang
punya implementasi default yang memanggil method summarize_author:
pub trait Summary {
fn summarize_author(&self) -> String;
fn summarize(&self) -> String {
format!("(Read more from {}...)", self.summarize_author())
}
}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
}
Untuk memakai versi Summary ini, kita cuma perlu mendefinisikan
summarize_author pas kita mengimplementasikan trait-nya pada sebuah tipe:
pub trait Summary {
fn summarize_author(&self) -> String;
fn summarize(&self) -> String {
format!("(Read more from {}...)", self.summarize_author())
}
}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
}
Setelah kita mendefinisikan summarize_author, kita bisa memanggil summarize
pada instance dari struct SocialPost, dan implementasi default dari
summarize bakal memanggil definisi summarize_author yang sudah kita sediakan.
Karena kita sudah mengimplementasikan summarize_author, trait Summary
sudah ngasih kita perilaku dari method summarize tanpa mengharuskan kita
menulis kode tambahan lagi. Berikut contoh pemakaiannya:
use aggregator::{self, SocialPost, Summary};
fn main() {
let post = SocialPost {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
repost: false,
};
println!("1 new post: {}", post.summarize());
}
Kode ini mencetak 1 new post: (Read more from @horse_ebooks...).
Perhatikan kalau tidak mungkin untuk memanggil implementasi default dari dalam implementasi yang lagi menimpa (overriding) method yang sama.
Traits sebagai Parameter
Sekarang setelah kita tahu cara mendefinisikan dan mengimplementasikan traits,
kita bisa eksplor gimana cara memakai traits buat mendefinisikan fungsi yang
bisa menerima berbagai macam tipe. Kita bakal memakai trait Summary yang
sudah kita implementasikan di tipe NewsArticle dan SocialPost di Listing
10-13 untuk mendefinisikan fungsi notify yang memanggil method summarize
pada parameter item-nya, yang bertipe apa pun selama tipe itu
mengimplementasikan trait Summary. Buat melakukannya, kita memakai sintaks
impl Trait, seperti ini:
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
pub fn notify(item: &impl Summary) {
println!("Breaking news! {}", item.summarize());
}
Alih-alih tipe konkret buat parameter item, kita memakai keyword impl
bersama dengan nama trait-nya. Parameter ini bakal menerima tipe apa pun yang
mengimplementasikan trait yang ditentukan. Di dalam body dari notify,
kita bisa memanggil method apa pun pada item yang asalnya dari trait
Summary, contohnya summarize. Kita bisa memanggil notify dan memberikan
instance apa pun dari NewsArticle atau SocialPost. Kode yang memanggil
fungsi tersebut dengan tipe lain, misalnya String atau i32, tidak bakal bisa
di-compile karena tipe-tipe tersebut tidak mengimplementasikan Summary.
Sintaks Trait Bound
Sintaks impl Trait memang praktis buat kasus-kasus sederhana tapi sebenarnya
itu cuma syntax sugar (sintaks pemanis) dari bentuk yang lebih panjang yang
dikenal sebagai trait bound; bentuknya kayak gini:
pub fn notify<T: Summary>(item: &T) {
println!("Breaking news! {}", item.summarize());
}
Bentuk yang lebih panjang ini ekuivalen (sama) dengan contoh di bagian
sebelumnya, tapi lebih panjang (verbose). Kita menaruh trait bounds
bersamaan dengan deklarasi parameter tipe generik setelah tanda titik dua (:)
dan di dalam kurung sudut.
Sintaks impl Trait itu nyaman dan bikin kode lebih ringkas buat kasus-kasus
sederhana, sementara sintaks trait bound yang lebih lengkap bisa
mengekspresikan lebih banyak kerumitan buat kasus lain. Misalnya, kita bisa punya
dua parameter yang dua-duanya mengimplementasikan Summary. Kalau pakai sintaks
impl Trait, bentuknya bakal seperti ini:
pub fn notify(item1: &impl Summary, item2: &impl Summary) {
Memakai impl Trait cocok kalau kita mau fungsi ini mengizinkan item1 dan
item2 untuk punya tipe yang berbeda (asalkan dua-duanya mengimplementasikan
Summary). Tapi, kalau kita mau memaksa kedua parameter tersebut buat punya
tipe yang sama persis, kita harus memakai trait bound, seperti ini:
pub fn notify<T: Summary>(item1: &T, item2: &T) {
Tipe generik T yang ditentukan sebagai tipe dari parameter item1 dan item2
membatasi fungsi ini sehingga tipe konkret dari nilai yang diberikan buat argumen
item1 dan item2 itu harus sama.
Menentukan Beberapa Trait Bounds dengan Sintaks +
Kita juga bisa menentukan lebih dari satu trait bound. Katakanlah kita mau
notify bisa memakai display formatting di samping memanggil summarize
pada item: kita tentukan di definisi notify kalau item harus
mengimplementasikan Display sekaligus Summary. Kita bisa melakukannya
menggunakan sintaks +:
pub fn notify(item: &(impl Summary + Display)) {
Sintaks + ini juga valid buat dipakai sama trait bounds pada tipe generik:
pub fn notify<T: Summary + Display>(item: &T) {
Dengan dua trait bounds yang ditentukan, body dari notify bisa memanggil
summarize dan juga memakai {} buat memformat item.
Trait Bounds yang Lebih Rapi pake Klausa where
Memakai terlalu banyak trait bounds ada sisi negatifnya. Masing-masing generik
punya trait bounds-nya sendiri, jadi fungsi dengan banyak parameter tipe generik
bisa mengandung sangat banyak informasi trait bound di antara nama fungsi dan
daftar parameternya, yang mana bisa bikin signature fungsinya jadi susah
dibaca. Karena alasan ini, Rust punya sintaks alternatif buat menentukan
trait bounds di dalam sebuah klausa where setelah signature fungsinya.
Jadi, alih-alih nulis begini:
fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {
kita bisa pakai klausa where, kayak gini:
fn some_function<T, U>(t: &T, u: &U) -> i32
where
T: Display + Clone,
U: Clone + Debug,
{
unimplemented!()
}
Signature fungsinya jadi tidak terlalu penuh: nama fungsi, daftar parameter, dan tipe kembalian semuanya berdekatan, mirip seperti fungsi yang tidak punya banyak trait bounds.
Mengembalikan Tipe yang Mengimplementasikan Traits
Kita juga bisa memakai sintaks impl Trait di posisi kembalian (return position)
buat mengembalikan nilai dari suatu tipe yang mengimplementasikan sebuah trait,
kayak gini:
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
fn returns_summarizable() -> impl Summary {
SocialPost {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
repost: false,
}
}
Dengan memakai impl Summary buat tipe kembaliannya, kita menentukan kalau
fungsi returns_summarizable bakal mengembalikan suatu tipe yang
mengimplementasikan trait Summary tanpa harus menyebut nama tipe konkretnya.
Di kasus ini, returns_summarizable mengembalikan sebuah SocialPost, tapi
kode yang memanggil fungsi ini tidak perlu tau soal itu.
Kemampuan buat menentukan tipe kembalian hanya berdasarkan trait yang
diimplementasikannya itu sangat berguna, apalagi di konteks closures dan
iterators, yang bakal kita bahas di Bab 13. Closures dan iterators bikin
tipe-tipe yang cuma compiler doang yang tau, atau tipe-tipe yang namanya
kepanjangan buat ditulis. Sintaks impl Trait memudahkan kita menentukan secara
ringkas kalau sebuah fungsi mengembalikan tipe tertentu yang mengimplementasikan
trait Iterator tanpa perlu nulis tipe yang kepanjangan.
Tapi, kita cuma bisa memakai impl Trait kalau kita mengembalikan satu tipe
tunggal. Misalnya, kode ini, yang mengembalikan entah NewsArticle atau
SocialPost dengan tipe kembalian impl Summary, tidak bakal bisa jalan:
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
fn returns_summarizable(switch: bool) -> impl Summary {
if switch {
NewsArticle {
headline: String::from(
"Penguins win the Stanley Cup Championship!",
),
location: String::from("Pittsburgh, PA, USA"),
author: String::from("Iceburgh"),
content: String::from(
"The Pittsburgh Penguins once again are the best \
hockey team in the NHL.",
),
}
} else {
SocialPost {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
repost: false,
}
}
}
Mengembalikan entah NewsArticle atau SocialPost itu tidak diperbolehkan
karena adanya batasan dari gimana sintaks impl Trait diimplementasikan di
dalam compiler. Kita bakal bahas gimana cara nulis fungsi dengan perilaku
kayak gini di bagian “Memakai Trait Objects yang Mengizinkan Nilai Dari
Tipe yang Berbeda-beda”
di Bab 18.
Memakai Trait Bounds Buat Mengimplementasikan Method secara Bersyarat
Dengan memakai trait bound bareng sebuah blok impl yang memakai parameter
tipe generik, kita bisa mengimplementasikan methods secara bersyarat
(conditionally) buat tipe-tipe yang mengimplementasikan traits yang ditentukan.
Misalnya, tipe Pair<T> di Listing 10-15 selalu mengimplementasikan fungsi new
buat mengembalikan instance baru dari Pair<T> (ingat dari bagian
“Mendefinisikan Methods” di Bab 5 bahwa Self adalah alias tipe buat
tipe dari blok impl-nya, yang mana di kasus ini adalah Pair<T>). Tapi di
blok impl berikutnya, Pair<T> cuma mengimplementasikan method
cmp_display kalau tipe di dalamnya T mengimplementasikan trait
PartialOrd (yang memungkinkan perbandingan) dan trait Display (yang
memungkinkan untuk dicetak).
use std::fmt::Display;
struct Pair<T> {
x: T,
y: T,
}
impl<T> Pair<T> {
fn new(x: T, y: T) -> Self {
Self { x, y }
}
}
impl<T: Display + PartialOrd> Pair<T> {
fn cmp_display(&self) {
if self.x >= self.y {
println!("The largest member is x = {}", self.x);
} else {
println!("The largest member is y = {}", self.y);
}
}
}
Kita juga bisa mengimplementasikan secara bersyarat sebuah trait buat tipe apa
pun yang mengimplementasikan trait lain. Implementasi sebuah trait pada
tipe apa pun yang memenuhi trait bounds-nya disebut sebagai blanket
implementations (implementasi selimut) dan ini sangat banyak dipakai di standard
library Rust. Misalnya, standard library mengimplementasikan trait ToString
pada tipe apa pun yang mengimplementasikan trait Display. Blok impl di
standard library keliatan mirip kayak kode ini:
impl<T: Display> ToString for T {
// --snip--
}
Karena standard library punya blanket implementation ini, kita bisa
memanggil method to_string yang didefinisikan sama trait ToString
pada tipe apa pun yang mengimplementasikan trait Display. Misalnya, kita bisa
mengubah integer jadi nilai String miliknya seperti ini karena integer
mengimplementasikan Display:
#![allow(unused)]
fn main() {
let s = 3.to_string();
}
Blanket implementations ini biasanya muncul di dokumentasi buat suatu trait di bagian “Implementors”.
Traits dan trait bounds memungkinkan kita nulis kode yang memakai parameter tipe generik untuk mengurangi duplikasi, sekaligus ngasih tau compiler kalau kita maunya tipe generik itu punya perilaku tertentu. Compiler kemudian bakal memakai informasi trait bound tersebut buat mengecek apakah semua tipe konkret yang dipakai di kode kita sudah menyediakan perilaku yang benar. Di bahasa pemrograman yang dynamically typed (tipe dinamis), kita bakal dapat error pas runtime kalau kita memanggil method di suatu tipe yang sebenarnya tidak punya definisi method tersebut. Tapi Rust mindahin error-error ini ke fase compile time jadi kita dipaksa buat membenarkan masalah ini sebelum kode kita bahkan bisa dijalankan. Sebagai bonus, kita tidak perlu nulis kode buat ngecek perilaku saat runtime karena kita sudah mengeceknya pas compile time. Melakukan ini bakal meningkatkan performa tanpa harus mengorbankan fleksibilitas dari generik.