A.36. Traits
Trait jika diartikan dalam bahasa Indonesia artinya adalah sifat. Chapter ini akan membahas tentang apa itu trait, apa kegunaannya, dan bagaimana cara penerapannya di Rust programming.
Pembahasan chapter ini cukup panjang. Makin mendekati akhir pembahasan, makin berat yang dibahas. Penulis anjurkan jika nantinya setelah section A.36.4. Trait sebagai tipe parameter dirasa cukup susah untuk dipahami, silakan lanjut ke chapter berikutnya dulu, dan nanti bisa kembali ke pembahasan chapter ini lagi.
Chapter ini butuh tambahan detail
A.36.1. Konsep traits
Di Rust kita bisa mendefinisikan trait/sifat, isinya adalah definisi header method yang bisa di-share ke banyak tipe data.
Trait isinya hanya definisi header method (bisa diartikan method tanpa isi). Ketika ada tipe data yang meng-implement suatu trait, maka tipe tersebut wajib untuk menuliskan implementasi method sesuai dengan header method yang ada di trait.
Ada dua bagian penting dalam trait yang harus diketahui:
- Deklarasi trait
- Implementasi trait ke tipe data
Perihal point pertama, intinya kita bisa menciptakan trait sesuai kebutuhan. Terlepas dari itu, Rust juga menyediakan cukup banyak traits yang di-implement ke banyak tipe data yang ada di Rust standard library. Beberapa di antaranya:
- Trait
std::fmt::Debug
, digunakan agar data bisa di-print menggunakan formatted print{:?}
. - Trait
std::iter::Enumerate
, digunakan agar data bisa di-iterasi menggunakan keywordfor
. - Trait
std::ops::Add
, di-implementasikan agar data bisa digunakan pada operasi aritmatik penambahan+
.
Pada bahasa pemrograman lain, contohnya Java, konsep trait mirip dengan
interface
Ok, biar lebih jelas, mari lanjut pembelajaran menggunakan contoh. Kita mulai dengan pembahasan tentang cara implementasi trait. Contoh yang digunakan adalah implementasi salah satu trait milik Rust standard library, yaitu trait std::fmt::Debug
.
A.36.2. Implementasi trait
Kita pilih trait std::fmt::Debug
milik Rust standard library untuk belajar cara implementasi trait pada tipe data.
Kegunaan dari trait ini adalah: jika di-implement ke tipe data tertentu maka data dengan tipe tersebut bisa di-print via macro println
atau macro printing lainnya, dengan menggunakan formatted print {:?}
.
Trait Debug
ini diimplementasikan ke pada banyak tipe data yang di Rust standard library, baik itu tipe primitif maupun non-primitif. Contohnya bisa dilihat pada kode berikut:
let number = 12;
println!("{:?}", number);
let text = String::from("hello");
println!("{:?}", text);
Dua variabel di atas sukses di-print tanpa error, karena tipe data i32
dan String
by default sudah implement trait std::fmt::Debug
.
Jika tertarik untuk pengecekan lebih lanjut, silakan lihat di halaman dokumentasi tipe data i32 dan String.
Bagaimana dengan custom type yang kita buat sendiri? Misalnya struct.
fn main() {
let circle_one = Circle{raidus: 6};
println!("{:?}", circle_one);
}
struct Circle {
raidus: i32,
}
Hasilnya error, karena struct Circle
yang dibuat tidak implement trait std::fmt::Debug
.
Solusi agar tidak error adalah dengan mengimplementasikan trait std::fmt::Debug
ke tipe Circle
, dengan itu semua data bertipe Circle
akan bisa di-print menggunakan formatted print {:?}
.
Selain via implementasi trait, tipe data custom bisa di-print dengan cara menambahkan atribut
#[derive(Debug)]
pada definisi tipe data-nya. Namun kita tidak membahas itu pada chapter ini.
Langkah pertama untuk implementasi trait adalah mencari tau terlebih dahulu spesifikasi trait yang ingin diimplementasikan. Trait std::fmt::Debug
adalah traits milik Rust standard library, maka harusnya spesifikasi bisa dilihat di dokumentasi Rust.
Pada URL dokumentasi bisa dilihat kalau trait Debug
memiliki struktur kurang lebih seperti berikut:
pub trait Debug {
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>;
}
Trait Debug
mempunyai satu spesifikasi method, bernama fmt
yang detail strukturnya bisa dilihat di atas.
Kita akan implement trait Debug
ini ke tipe Circle
, maka wajib hukumnya untuk menuliskan implementasi method sesuai dengan yang ada di trait Debug
.
Di bawah ini adalah contoh cara implementasi trait.
struct Circle {
raidus: i32,
}
impl std::fmt::Debug for Circle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Circle radius: {}", self.raidus)
}
}
fn main() {
let circle_one = Circle{raidus: 6};
println!("{:?}", circle_one);
}
Ketika program di-run, hasilnya sukses tanpa error. Artinya implementasi trait Debug
pada tipe data struct Circle
adalah sukses.
Cara implementasi trait ke struct Circle
memang step-nya agak panjang, tapi penulis yakin lama-kelamaan pasti terbiasa. Ok, sekarang kita bahas satu per satu kode di atas.
◉ Struct Circle
Block kode definisi struct Circle
cukup straightforward, isinya hanya 1 property bernama radius
bertipe i32
.
◉ Block kode impl X for Y
Notasi penulisan implementasi trait adalah impl X for Y
, dimana X
adalah trait yang ingin diimplementasikan dan Y
adalah tipe data tujuan implementasi.
Pada contoh di atas, trait Debug
diimplementasikan ke custom type struct Circle
. Maka statement-nya adalah:
impl std::fmt::Debug for Circle {
// ...
}
◉ Block kode method dalam impl
Block kode impl
harus diikuti dengan implementasi method. Pada contoh ini, method fmt
milik trait Debug
wajib untuk diimplementasikan. Spesifikasi method ini adalah fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>
(lebih jelasnya silakan lihat dokumentasi).
Silakan copy method tersebut kemudian paste ke dalam block kode impl
yang sudah ditulis, kemudian tambahkan block kurung kurawal.
impl std::fmt::Debug for Circle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// ...
}
}
Kemudian tulis implementasi method fmt
dalam block method. Tulis statement macro write
untuk data string (yang ingin di-print) dengan tujuan adalah variabel f
.
Di contoh, format Circle radius: {}
digunakan. Dengan ini nantinya saat printing data, yang muncul adalah text Circle radius: {}
.
impl std::fmt::Debug for Circle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Circle radius: {}", self.raidus)
}
}
Tips untuk pengguna visual studio code dengan rust-analyzer extension ter-install, setelah selesai menulis block kode
impl
, cukup jalankanctrl+space
ataucmd+space
untuk men-trigger autocomplete suggestion. Kemudian klik opsi method yang ada disitu, maka kode implementasi method langsung muncul dengan sendirinya.
◉ Macro write
Macro ini digunakan untuk menuliskan sebuah data ke object tertentu. Pada contoh kita gunakan untuk menulis string Circle radius: {}
ke variabel f
yang bertipe std::fmt::Formatter<'_>
.
Notasi penulisan macro write
:
// notasi penulisan
write!(variabel_tujuan, data_yang_ingin_di_print, arg1, arg2, ...);
// contoh penerapan
write!(f, "Circle radius: {}", self.raidus);
◉ Print data menggunakan formatted print {:?}
Step terakhir adalah print variabel circle
menggunakan macro println
. Hasilnya sukses, tidak error seperti sebelumnya.
◉ Print data menggunakan formatted print {}
Coba tambahkan statement println
, tetapi kali ini gunakan formatted print {}
, apakah hasilnya juga tidak error?
Hasilnya error, karena trait std::fmt::Debug
hanya berguna untuk formatted print {:?}
. Agar data bertipe Circle
bisa di-print menggunakan formatted print {}
maka trait std::fmt::Display
harus di-implementasikan juga.
Ubah kode dengan menambahkan implementasi trait Display
. Hasilnya kurang lebih seperti ini:
struct Circle {
raidus: i32,
}
impl std::fmt::Debug for Circle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Circle radius: {}", self.raidus)
}
}
impl std::fmt::Display for Circle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Circle radius: {}", self.raidus)
}
}
- Link dokumentasi trait
Debug
https://doc.rust-lang.org/std/fmt/trait.Debug.html- Link dokumentasi trait
Display
https://doc.rust-lang.org/std/fmt/trait.Display.html
A.36.3. Membuat custom trait
Pada section di atas kita telah membahas bagaimana cara implementasi traits ke tipe data. Pada bagian ini kita akan belajar tentang cara membuat definisi trait (membuat custom trait).
Masih sama dengan metode sebelumnya, pembelajaran dilakukan dengan praktik. Kita gunakan skenario praktik berikut pada program selanjutnya:
- Buat struct bernama
Circle
. - Buat struct bernama
Square
. - Buat trait bernama
Area
dengan isi satu buah method untuk menghitung luas bangun datar (calculate
). - Implementasikan trait
Area
ke dua struct tersebut.
Ok, mari mulai praktikkan skenario di atas. Pertama siapkan project dengan struktur berikut:
my_package
│─── Cargo.toml
└─── src
│─── calculation_spec.rs
│─── two_dimensional.rs
└─── main.rs
Module calculation_spec
berisi definisi trait Area
. Trait ini punya visibility akses publik, isinya hanya satu buah definisi method header bernama calculate
. Trait ini nantinya diimplementasikan ke struct Circle
dan juga Square
, agar nantinya kedua struct tersebut memiliki method calculate
yang berguna untuk kalkulasi luas bangun datar 2d.
pub trait Area {
fn calculate(&self) -> f64;
}
Kemudian siapkan file two_dimensional
, isinya dua buah struct: Circle
dan Square
. Pada file yang sama, siapkan juga block kode implementasi trait Area
. Dengan ini maka kedua struct tersebut wajib untuk memiliki method bernama calculate
dengan isi adalah operasi perhitungan aritmatika untuk mencari luas bangun datar.
pub struct Circle {
pub radius: i32,
}
impl crate::calculation_spec::Area for Circle {
fn calculate(&self) -> f64 {
// PI * (r ^ 2)
// ada operasi casting ke tipe f64 karena self.radius bertipe i32
3.14 * (self.radius.pow(2) as f64)
}
}
pub struct Square {
pub length: i32,
}
impl crate::calculation_spec::Area for Square {
fn calculate(&self) -> f64 {
// (s ^ 2)
// ada operasi casting ke tipe f64 karena self.length bertipe i32
self.length.pow(2) as f64
}
}
Bisa dilihat pada kode di atas, deklarasi struct beserta property memiliki visibility publik. Idealnya, saat sturct tersebut digunakan di fungsi main
nantinya tidak akan ada error terkait visibility akses.
Selanjutnya, pada file main.rs
siapkan kode yang isinya registrasi module calculate_spec
dan two_dimensional
, juga definisi fungsi main
dengan isi statement pembuatan 2 variabel object untuk masing-masing tipe data struct Circle
dan Square
.
mod calculation_spec;
mod two_dimensional;
use crate::calculation_spec::Area;
fn main() {
let circle_one = two_dimensional::Circle{ radius: 10 };
println!("circle area: {}", circle_one.calculate());
let square_one = two_dimensional::Square{ length: 5 };
println!("square area: {}", square_one.calculate());
}
Method calculate
milik object bertipe Circle
dan Square
diakses untuk kemudian di-print.
Coba jalankan program.
Hmm, error. Padahal trait Area
sudah publik, dan struct Circle
& Square
beserta property-nya juga sudah publik. Tapi masih error.
Error ini disebabkan oleh trait Area
yang belum di-import di crate root (main). Meskipun kita tidak mengakses trait tersebut secara langsung (melainkan via method calculate
milik struct Circle
dan Square
), diharuskan untuk meng-import-nya juga.
Detail error beserta solusi dari error ini sebenarnya bisa dilihat di error message. Bagaimana Rust menginformasikan error sangat luar biasa informatif.
Ok, sekarang ubah isi file main.rs
menjadi seperti ini, kemudian jalankan ulang program. Hasilnya tidak ada error.
mod calculation_spec;
mod two_dimensional;
use crate::calculation_spec::Area; // <------- tambahkan statement import module
fn main() {
let circle_one = two_dimensional::Circle{ radius: 10 };
println!("circle area: {}", circle_one.calculate());
let square_one = two_dimensional::Square{ length: 5 };
println!("square area: {}", square_one.calculate());
}
O iya, ada beberapa hal baru pada penerapan kode di atas, berikut adalah pembahasannya:
◉ Method pow
untuk operasi pangkat
Method pow
adalah item milik tipe data numerik (i8
, i16
, i32
, ...) yang fungsinya untuk operasi pangkat.
3.pow(2); // ===> 3 pangkat 2
8.pow(5); // ===> 8 pangkat 5
◉ Keyword as
untuk casting tipe data
Keyword as
digunakan untuk casting tipe data. Keyword ini bisa diterapkan pada beberapa jenis tipe data, salah satunya adalah semua tipe data numerik.
1024 as f32; // ===> 1024 dikonversi ke tipe f32, hasinya adalah 1024.0
3.14 as i32; // ===> 3.14 dikonversi ke tipe i32, hasinya 3 karena ada pembulatan
A.36.4. Trait sebagai tipe parameter
Trait bisa digunakan sebagai tipe data parameter sebuah fungsi, contoh notasi penulisannya bisa dilihat pada kode berikut:
fn calculate_and_print_result(name: String, item: &impl Area) {
println!("{} area: {}", name, item.calculate());
}
Manfaat penerapan trait sebagai tipe data parameter fungsi adalah saat pemanggilan fungsi, parameter tersebut bisa diisi dengan argument data bertipe apapun dengan catatan tipe dari data tersebut mengimplementasikan trait yang sama dengan yang digunakan pada parameter.
Misalnya, pada fungsi calculate_and_print_result
di atas yang parameter ke-2 bertipe &impl Area
, nantinya saat fungsi tersebut dipanggil, kita bisa sisipi parameter ke-2 dengan object circle_one
ataupun circle_two
.
let circle_one = two_dimensional::Circle{ radius: 10 };
calculate_and_print_result("circle".to_string(), &circle_one);
let square_one = two_dimensional::Square{ length: 5 };
calculate_and_print_result("square".to_string(), &square_one);
&impl Area
ini tipe data pointer ya, tipe non-pointer-nya adalahimpl Area
. Di sini digunakan tipe data pointer untuk antisipasi move semantics pada tipe data custom type (borrowing).
Dimisalkan, fungsi tersebut parameter item
-nya bisa menampung beberapa jenis traits, kira-kira apakah bisa dibuat seperti itu? Misalnya ada trait lain bernama Circumference
, dan parameter item
milik fungsi calculate_and_print_result
harus bisa menampung data baik dari tipe yang implement trait Area
ataupun trait Circumference
.
Hal seperti itu bisa, caranya dengan menggunakan notasi penulisan berikut:
fn calculate_and_print_result(name: String, item: &(impl Area + Circumference)) {
println!("{} area: {}", name, item.calculate());
println!("{} circumference: {}", name, item.calculateCircumference());
}
Tambahkan tanda ()
sebelum impl NamaTrait
, lalu ganti NamaTrait
dengan traits apa saja yang diinginkan dengan separator tanda +
.
A.36.5. Trait bound syntax
Penerapan trait sebagai parameter fungsi juga bisa dituliskan dalam notasi yang memanfaatkan generic. Teknik penulisan ini disebut dengan trait bound syntax.
Contohnya bisa dilihat pada kode berikut. Ada generic bernama T
yang merepresentasikan trait Area
, kemudian pada definisi parameter ke-2 fungsi (yaitu parameter item
) tipenya menggunakan &T
. Tipe &T
di sini adalah ekuivalen dengan &impl Area
.
fn calculate_and_print_result2<T: Area>(name: String, item: &T) {
println!("{} area: {}", name, item.calculate());
}
Dimisalkan jika ada lebih dari satu trait yang digunakan sebagai tipe data paramater (misalnya trait Area
dan Circumference
), maka penulisannya seperti ini:
fn calculate_and_print_result2<T: Area + Circumference>(name: String, item: &T) {
println!("{} area: {}", name, item.calculate());
println!("{} circumference: {}", name, item.calculateCircumference());
}
Satu tambahan contoh lagi untuk ilustrasi yang lebih kompleks:
fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {
// ...
}
Pada contoh di atas fungsi some_function
memiliki 2 generics param, yaitu T
dan U
.
T
merepresentasikan traitDisplay
danClone
U
merepresentasikan traitClone
danDebug
Lebih jelasnya mengenai generics dibahas pada chapter Generics
A.36.6. Trait where
clause
Ada lagi alternatif penulisan trait bound syntax, yaitu menggunakan keyword where
. Contoh pengaplikasiannya bisa dilihat pada kode berikut. Semua definisi fungsi di bawah ini adalah ekuivalen.
fn calculate_and_print_result(name: String, item: &(impl Area + Circumference)) {
println!("{} area: {}", name, item.calculate());
println!("{} circumference: {}", name, item.calculateCircumference());
}
fn calculate_and_print_result2<T: Area + Circumference>(name: String, item: &T) {
println!("{} area: {}", name, item.calculate());
println!("{} circumference: {}", name, item.calculateCircumference());
}
fn calculate_and_print_result3<T>(name: String, item: &T) where T: Area + Circumference {
println!("{} area: {}", name, item.calculate());
}
fn calculate_and_print_result4<T>(name: String, item: &T)
where
T: Area + Circumference,
// ... other generic params if exists
{
println!("{} area: {}", name, item.calculate());
}
Lebih jelasnya mengenai generics dibahas pada chapter Generics
A.36.7. Trait sebagai return type
Trait bisa juga digunakan sebagai tipe data return value. Caranya gunakan notasi penulisan impl NamaTrait
sebagai tipe data.
Contohnya bisa dilihat pada kode berikut. Ada dua fungsi baru dideklarasikan:
- Fungsi
new_circle
dengan return type adalahimpl Area
, dan data yang dikembalikan adalah bertipetwo_dimensional::Circle
. - Fungsi
new_square
dengan return type adalahimpl Area
, dan data yang dikembalikan adalah bertipetwo_dimensional::Square
.
fn main() {
let circle_one = new_circle(5);
calculate_and_print_result4("circle".to_string(), &circle_one);
let square_one = new_square(10);
calculate_and_print_result4("square".to_string(), &square_one);
}
fn new_circle(radius: i32) -> impl Area {
let data = two_dimensional::Circle{
radius
};
data
}
fn new_square(length: i32) -> impl Area {
two_dimensional::Square{
length
}
}
fn calculate_and_print_result4<T>(name: String, item: &T)
where
T: Area,
{
println!("{} area: {}", name, item.calculate());
}
Salah satu konsekuensi dalam penerapan trait sebagai return type adalah: tipe data milik nilai yang dikembalikan terdeteksi sebagai tipe trait. Contohnya variabel circle_one
di atas, tipe data-nya bukan Circle
, melainkan impl Area
.
Tipe data aslinya tetap bisa diakses, tapi butuh tambahan effort. Lebih jelasnya dibahas pada chapter Trait → Conversion (From & Into).
A.36.8. Associated types pada trait
Associated types adalah tipe data yang didefinisikan didalam suatu trait. Associated types tidak tidak memiliki tipe data konkret saat didefinisikan, namun ketika trait di-implementasikan maka tipe tersebut harus ditentukan tipe data konkritnya.
Lebih jelas silakan perhatikan kode berikut:
trait Shape {
type Area;
fn area(&self) -> Self::Area;
}
Pada definisi trait Shape
di atas, yang disebut dengan associated types adalah tipe Area
yang definisinya berada dalam block trait. Tipe didefinisikan tanpa assignment operator, jadi tidak ada tipe data konkretnya.
Associated types ini sering digunakan pada Rust programming. Contoh implementasinya bisa dilihat pada contoh di bawah ini:
my_package
│─── Cargo.toml
└─── src
│─── shape.rs
│─── circle.rs
│─── square.rs
└─── main.rs
- Disiapkan suatu trait bernama
shape::Shape
.- Trait ini memiliki satu associated types bernama
Area
. - Dan memiliki sebuah definisi method header
area
yang gunanya untuk menghitung luas bangun datar (shape).
- Trait ini memiliki satu associated types bernama
- Disiapkan struct
circle::Circle
yang mengadopsi traitshape::Shape
. - Disiapkan struct
square::Square
yang mengadopsi traitshape::Shape
.
pub trait Shape {
type Area;
fn area(&self) -> Self::Area;
}
Trait Shape
di atas spesifikasinya mirip seperti pada contoh sebelumnya, hanya saja kali ini trait-nya di set public agar bisa diakses dari main.rs
nantinya.
Trait Shape
kemudian di-implementasikan ke struct Circle
dan Square
, kode-nya bisa dilihat berikut:
pub struct Circle {
pub radius: f64,
}
impl crate::shape::Shape for Circle {
type Area = f64;
fn area(&self) -> Self::Area {
std::f64::consts::PI * self.radius * self.radius
}
}
pub struct Square {
pub side: i64,
}
impl crate::shape::Shape for Square {
type Area = i64;
fn area(&self) -> Self::Area {
self.side * self.side
}
}
Bisa dilihat pada kedua implementasi di atas, associated type Area
diisi dengan tipe concrete, yaitu:
- Tipe data
f64
sebagai tipe concretecircle:Circle:Area
- Tipe data
i64
sebagai tipe concretesquare:Square:Area
Contoh di atas adalah cara pengaplikasian associated types.
Lalu pada main.rs
, tipe data struct circle::Circle
dan square::Square
digunakan untuk membuat variabel baru, yang kemudian dari variabel tersebut, method .area()
milik diakses.
mod shape;
mod circle;
mod square;
use crate::shape::Shape;
fn main() {
let obj1 = circle::Circle{ radius: 10.0 };
println!("area of circle: {:.2}", obj1.area());
let obj2 = square::Square{ side: 10 };
println!("area of square: {:}", obj2.area());
}
Silakan jalankan program dan lihat hasilnya.
O iya, pada main.rs
, module item shape::Shape
perlu di-import meskipun kita tidak menggunakan trait
tersebut secara langsung. Jika tidak di-import, maka method .area()
milik Circle
dan Square
tidak bisa diakses.
A.36.9. Attribute derive
Ada cara lain untuk mengimplementasikan suatu trait ke dalam tipe data selain dengan menuliskan implementasinya secara eksplist, caranya menggunakan attribute derive
.
Lebih detailnya dibahas pada chapter Attributes.
Catatan chapter 📑
◉ Source code praktik
github.com/novalagung/dasarpemrogramanrust-example/../traits
◉ Work in progress
- Pembahasan tentang trait bounds untuk implementasi method kondisional
- Pembahasan tentang trait overloading