Tipe Data Generik
Kita memakai generik buat membuat definisi untuk item seperti signature fungsi atau struct, yang nantinya bisa kita pakai dengan berbagai macam tipe data konkret. Mari kita lihat dulu gimana cara mendefinisikan fungsi, struct, enum, dan method memakai generik. Setelah itu, kita bakal membahas gimana pengaruh generik terhadap performa kode.
Di Definisi Fungsi
Saat mendefinisikan fungsi yang memakai generik, kita menaruh generik itu di signature fungsi di tempat kita biasanya menentukan tipe data untuk parameter dan nilai kembalian. Melakukan hal ini bikin kode kita jadi lebih fleksibel dan memberikan lebih banyak fungsionalitas bagi pemanggil fungsi kita sekaligus mencegah duplikasi kode.
Melanjutkan fungsi largest kita, Listing 10-4 menunjukkan dua fungsi yang
keduanya mencari nilai paling besar di dalam sebuah slice. Kita bakal
menggabungkan kedua fungsi ini jadi satu fungsi tunggal yang memakai generik.
fn largest_i32(list: &[i32]) -> &i32 {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn largest_char(list: &[char]) -> &char {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest_i32(&number_list);
println!("The largest number is {result}");
assert_eq!(*result, 100);
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest_char(&char_list);
println!("The largest char is {result}");
assert_eq!(*result, 'y');
}
Fungsi largest_i32 adalah fungsi yang kita ekstrak di Listing 10-3 untuk
mencari i32 paling besar di dalam slice. Fungsi largest_char mencari
char paling besar di dalam slice. Body fungsinya punya kode yang persis
sama, jadi mari kita hilangkan duplikasi ini dengan memperkenalkan parameter
tipe generik di satu fungsi tunggal.
Buat memparameterisasi tipe di fungsi tunggal yang baru, kita harus menamai
parameter tipenya, sama seperti kita menamai parameter nilai buat sebuah fungsi.
Kita bisa memakai identifier (nama) apa saja sebagai nama parameter tipe. Tapi
kita bakal memakai T karena, secara konvensi, nama parameter tipe di Rust itu
pendek, sering kali cuma satu huruf, dan konvensi penamaan tipe di Rust adalah
CamelCase. Singkatan dari type (tipe), T adalah pilihan default buat
kebanyakan programmer Rust.
Pas kita memakai sebuah parameter di dalam body fungsi, kita harus
mendeklarasikan nama parameter itu di signature agar compiler tau apa makna
nama tersebut. Demikian juga, pas kita memakai nama parameter tipe di signature
fungsi, kita harus mendeklarasikan nama parameter tipe itu sebelum memakainya.
Untuk mendefinisikan fungsi largest yang generik, kita menaruh deklarasi nama
tipe di dalam kurung sudut, <>, di antara nama fungsinya dan daftar parameternya,
seperti ini:
fn largest<T>(list: &[T]) -> &T {
Kita ngebaca definisi ini sebagai: fungsi largest bersifat generik terhadap
suatu tipe T. Fungsi ini punya satu parameter bernama list, yang merupakan
sebuah slice berisi nilai bertipe T. Fungsi largest bakal mengembalikan
referensi ke nilai dengan tipe T yang sama.
Listing 10-5 menunjukkan definisi fungsi largest gabungan yang memakai tipe
data generik di signature-nya. Listing ini juga menunjukkan gimana kita bisa
memanggil fungsi tersebut baik dengan slice nilai i32 maupun nilai char.
Perhatikan bahwa kode ini belum bisa di-compile.
fn largest<T>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest(&number_list);
println!("The largest number is {result}");
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest(&char_list);
println!("The largest char is {result}");
}
largest yang memakai parameter tipe generik; kode ini belum bisa di-compileKalau kita men-compile kode ini sekarang, kita bakal dapat error ini:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
--> src/main.rs:5:17
|
5 | if item > largest {
| ---- ^ ------- &T
| |
| &T
|
help: consider restricting type parameter `T` with trait `PartialOrd`
|
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
| ++++++++++++++++++++++
For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
Teks bantuannya menyebutkan std::cmp::PartialOrd, yang mana itu adalah sebuah
trait, dan kita bakal membahas traits di bagian selanjutnya. Buat sekarang,
ketahuilah bahwa error ini menyatakan kalau body dari largest tidak bakal jalan
untuk semua tipe yang mungkin bakal mengisi T. Karena kita mau membandingkan
nilai-nilai bertipe T di dalam body-nya, kita cuma bisa memakai tipe-tipe
yang nilainya bisa diurutkan. Buat memungkinkan perbandingan, standard library
punya trait std::cmp::PartialOrd yang bisa kita implementasikan di tipe-tipe
tertentu (lihat Lampiran C buat info lebih lanjut soal trait ini). Buat
memperbaiki Listing 10-5, kita bisa mengikuti saran di teks bantuannya dan
membatasi tipe-tipe yang valid buat T hanya pada tipe-tipe yang
mengimplementasikan PartialOrd. Listing ini kemudian bakal bisa di-compile,
karena standard library mengimplementasikan PartialOrd buat i32 dan char.
Di Definisi Struct
Kita juga bisa mendefinisikan struct agar memakai parameter tipe generik di
satu atau lebih field-nya menggunakan sintaks <>. Listing 10-6 mendefinisikan
sebuah struct Point<T> buat menampung nilai koordinat x dan y dari tipe
apa pun.
struct Point<T> {
x: T,
y: T,
}
fn main() {
let integer = Point { x: 5, y: 10 };
let float = Point { x: 1.0, y: 4.0 };
}
Point<T> yang menampung nilai x dan y bertipe TSintaks buat memakai generik di definisi struct itu mirip kayak yang dipakai di definisi fungsi. Pertama kita deklarasikan nama parameter tipenya di dalam kurung sudut persis setelah nama struct-nya. Kemudian kita pakai tipe generik itu di definisi struct-nya di tempat kita biasanya memasukkan tipe data konkret.
Perhatikan bahwa karena kita cuma pakai satu tipe generik buat mendefinisikan
Point<T>, definisi ini berarti struct Point<T> bersifat generik terhadap
suatu tipe T, dan field x serta y itu keduanya memiliki tipe yang sama
tersebut, tidak peduli apa tipe aslinya. Kalau kita bikin instance dari
Point<T> yang punya nilai dengan tipe yang berbeda, seperti di Listing 10-7,
kode kita tidak bakal bisa di-compile.
struct Point<T> {
x: T,
y: T,
}
fn main() {
let wont_work = Point { x: 5, y: 4.0 };
}
x dan y harus punya tipe yang sama karena keduanya punya tipe data generik yang sama yaitu T.Di contoh ini, saat kita nge-assign nilai integer 5 ke x, kita memberitahu
compiler kalau tipe generik T bakal jadi integer untuk instance Point<T>
ini. Lalu saat kita memberikan 4.0 untuk y, yang mana sebelumnya kita
definisikan punya tipe yang sama dengan x, kita bakal dapat error ketidakcocokan
tipe (type mismatch) seperti ini:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
--> src/main.rs:7:38
|
7 | let wont_work = Point { x: 5, y: 4.0 };
| ^^^ expected integer, found floating-point number
For more information about this error, try `rustc --explain E0308`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
Buat mendefinisikan struct Point di mana x dan y keduanya adalah generik
tapi bisa punya tipe yang berbeda, kita bisa memakai banyak parameter tipe generik.
Misalnya, di Listing 10-8, kita mengubah definisi Point agar bersifat generik
terhadap tipe T dan U, di mana x bertipe T dan y bertipe U.
struct Point<T, U> {
x: T,
y: U,
}
fn main() {
let both_integer = Point { x: 5, y: 10 };
let both_float = Point { x: 1.0, y: 4.0 };
let integer_and_float = Point { x: 5, y: 4.0 };
}
Point<T, U> yang generik terhadap dua tipe sehingga x dan y bisa menampung nilai dengan tipe yang berbedaSekarang semua instance dari Point yang ditunjukkan itu diperbolehkan! Kita
bisa memakai sebanyak apa pun parameter tipe generik di sebuah definisi, tapi
memakai lebih dari beberapa bakal bikin kode kita jadi susah dibaca. Kalau kita
merasa butuh banyak tipe generik di kode kita, itu bisa jadi pertanda kalau
kode kita butuh direstrukturisasi jadi bagian-bagian yang lebih kecil.
Di Definisi Enum
Sama seperti struct, kita bisa mendefinisikan enum buat menampung tipe
data generik di dalam variannya. Mari kita lihat lagi enum Option<T> yang
disediakan oleh standard library, yang kita pakai di Bab 6:
#![allow(unused)]
fn main() {
enum Option<T> {
Some(T),
None,
}
}
Definisi ini seharusnya sekarang jadi lebih masuk akal. Seperti yang bisa kita
lihat, enum Option<T> bersifat generik terhadap tipe T dan punya dua varian:
Some, yang menampung satu nilai bertipe T, dan varian None yang tidak
menampung nilai apa pun. Dengan memakai enum Option<T>, kita bisa
mengekspresikan konsep abstrak dari nilai yang opsional (bisa ada isinya atau
tidak), dan karena Option<T> itu generik, kita bisa memakai abstraksi ini
apa pun tipe dari nilai opsional tersebut.
Enum juga bisa memakai banyak tipe generik. Definisi dari enum Result
yang kita pakai di Bab 9 adalah salah satu contohnya:
#![allow(unused)]
fn main() {
enum Result<T, E> {
Ok(T),
Err(E),
}
}
Enum Result bersifat generik terhadap dua tipe, T dan E, dan punya dua
varian: Ok, yang menampung nilai bertipe T, dan Err, yang menampung nilai
bertipe E. Definisi ini membuatnya sangat nyaman untuk memakai enum Result
di mana pun kita punya operasi yang mungkin berhasil (mengembalikan nilai bertipe
T) atau gagal (mengembalikan error bertipe E). Kenyataannya, inilah yang kita
pakai buat membuka file di Listing 9-3, di mana T diisi dengan tipe
std::fs::File saat filenya berhasil dibuka dan E diisi dengan tipe
std::io::Error pas ada masalah saat membuka file tersebut.
Kalau kita mengenali situasi di kode kita di mana banyak definisi struct atau enum yang cuma berbeda di tipe nilai yang mereka tampung, kita bisa menghindari duplikasi dengan memakai tipe generik.
Di Definisi Method
Kita bisa mengimplementasikan method di struct dan enum (seperti yang kita
lakukan di Bab 5) dan memakai tipe generik di definisinya juga. Listing 10-9
menunjukkan struct Point<T> yang kita definisikan di Listing 10-6 dilengkapi
sebuah method bernama x yang diimplementasikan di atasnya.
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
fn main() {
let p = Point { x: 5, y: 10 };
println!("p.x = {}", p.x());
}
x di struct Point<T> yang bakal mengembalikan referensi ke field x bertipe TDi sini, kita sudah mendefinisikan sebuah method bernama x pada Point<T>
yang mengembalikan referensi ke data di field x.
Perhatikan bahwa kita harus mendeklarasikan T persis setelah impl supaya kita
bisa memakai T buat menentukan kalau kita lagi mengimplementasikan method di
tipe Point<T>. Dengan mendeklarasikan T sebagai tipe generik setelah impl,
Rust bisa mengidentifikasi kalau tipe di dalam kurung sudut di Point itu adalah
tipe generik, bukan tipe konkret. Kita bisa saja memilih nama yang berbeda buat
parameter generik ini daripada parameter generik yang dideklarasikan di definisi
struct, tapi memakai nama yang sama adalah konvensi yang umum. Kalau kita menulis
method di dalam impl yang mendeklarasikan tipe generik, method itu bakal
didefinisikan pada instance tipe apa pun, tidak peduli tipe konkret apa yang
akhirnya menggantikan tipe generiknya.
Kita juga bisa ngasih batasan pada tipe generik saat mendefinisikan method
pada suatu tipe. Misalnya, kita bisa mengimplementasikan method hanya pada
instance Point<f32> saja bukannya pada instance Point<T> dengan tipe generik
apa pun. Di Listing 10-10 kita memakai tipe konkret f32, yang artinya kita
tidak mendeklarasikan tipe apa pun setelah impl.
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
impl Point<f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
fn main() {
let p = Point { x: 5, y: 10 };
println!("p.x = {}", p.x());
}
impl yang hanya berlaku buat struct dengan tipe konkret tertentu buat parameter tipe generik TKode ini berarti tipe Point<f32> bakal punya method distance_from_origin;
instance Point<T> lain di mana T bukan tipe f32 tidak bakal punya method
ini. Method ini mengukur seberapa jauh titik kita dari titik origin di
koordinat (0.0, 0.0) dan memakai operasi matematika yang hanya tersedia buat tipe
floating-point.
Parameter tipe generik di definisi struct tidak selalu sama dengan parameter
yang kita pakai di signature method pada struct yang sama. Listing 10-11
memakai tipe generik X1 dan Y1 buat struct Point serta X2 Y2 buat
signature method mixup buat bikin contohnya jadi lebih jelas. Method ini
bikin instance Point baru dengan nilai x dari Point yang mewakili self
(bertipe X1) dan nilai y dari Point yang di-pass masuk (bertipe Y2).
struct Point<X1, Y1> {
x: X1,
y: Y1,
}
impl<X1, Y1> Point<X1, Y1> {
fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> {
Point {
x: self.x,
y: other.y,
}
}
}
fn main() {
let p1 = Point { x: 5, y: 10.4 };
let p2 = Point { x: "Hello", y: 'c' };
let p3 = p1.mixup(p2);
println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}
Di main, kita sudah mendefinisikan sebuah Point yang punya i32 buat x
(dengan nilai 5) dan f64 buat y (dengan nilai 10.4). Variabel p2
adalah struct Point yang punya string slice buat x (dengan nilai
"Hello") dan char buat y (dengan nilai c). Memanggil mixup pada p1
dengan argumen p2 bakal menghasilkan p3, yang bakal punya i32 buat x
karena x-nya datang dari p1. Variabel p3 bakal punya char buat y karena
y-nya datang dari p2. Pemanggilan macro println! bakal mencetak
p3.x = 5, p3.y = c.
Tujuan dari contoh ini adalah buat mendemonstrasikan situasi di mana beberapa
parameter generik dideklarasikan bersama impl dan beberapa lainnya dideklarasikan
bersama definisi method-nya. Di sini, parameter generik X1 dan Y1
dideklarasikan setelah impl karena mereka ditujukan buat definisi struct-nya.
Parameter generik X2 dan Y2 dideklarasikan setelah fn mixup karena mereka
cuma relevan buat method itu aja.
Performa Kode yang Memakai Generik
Kita mungkin bertanya-tanya apakah ada biaya performa saat runtime kalau kita pakai parameter tipe generik. Kabar baiknya adalah memakai tipe generik tidak bakal bikin program kita berjalan lebih lambat dibanding kalau kita memakai tipe konkret.
Rust mencapai ini dengan melakukan monomorphization pada kode yang memakai generik di saat compile time. Monomorphization adalah proses mengubah kode generik jadi kode spesifik dengan mengisi tipe-tipe konkret yang dipakai pas di-compile. Dalam proses ini, compiler melakukan hal kebalikan dari langkah- langkah yang kita ambil buat bikin fungsi generik di Listing 10-5: compiler melihat semua tempat di mana kode generik itu dipanggil dan menghasilkan kode buat tipe-tipe konkret tempat kode generik itu dipanggil.
Mari kita lihat gimana proses ini bekerja dengan menggunakan enum generik
Option<T> dari standard library:
#![allow(unused)]
fn main() {
let integer = Some(5);
let float = Some(5.0);
}
Pas Rust men-compile kode ini, dia melakukan monomorphization. Selama proses
itu, compiler membaca nilai-nilai yang udah dipakai di instance Option<T>
dan mengenali dua jenis Option<T>: satu buat i32 dan satu lagi buat f64.
Oleh karena itu, dia memperluas definisi generik dari Option<T> jadi dua
definisi yang dikhususkan buat i32 dan f64, sehingga mengganti definisi
generik dengan yang spesifik.
Versi kode hasil monomorphization terlihat mirip seperti berikut (compiler sebenarnya memakai nama yang berbeda dari apa yang kita pakai di sini buat ilustrasi):
enum Option_i32 {
Some(i32),
None,
}
enum Option_f64 {
Some(f64),
None,
}
fn main() {
let integer = Option_i32::Some(5);
let float = Option_f64::Some(5.0);
}
Generik Option<T> digantikan dengan definisi spesifik yang dibikin sama
compiler. Karena Rust men-compile kode generik jadi kode yang menentukan tipe
asli di masing-masing instance, kita tidak harus membayar biaya apa pun saat
runtime akibat pemakaian generik. Pas kodenya jalan, performanya sama persis
seperti kalau kita menduplikasi masing-masing definisi pakai tangan sendiri.
Proses monomorphization ini bikin generik di Rust jadi sangat efisien pas
runtime.