Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

Filename: src/main.rs
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');
}
Listing 10-4: Dua fungsi yang cuma beda di nama dan tipe di signature-nya

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.

Filename: src/main.rs
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}");
}
Listing 10-5: Fungsi largest yang memakai parameter tipe generik; kode ini belum bisa di-compile

Kalau 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.

Filename: src/main.rs
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 };
}
Listing 10-6: Sebuah struct Point<T> yang menampung nilai x dan y bertipe T

Sintaks 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.

Filename: src/main.rs
struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}
Listing 10-7: Field 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.

Filename: src/main.rs
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 };
}
Listing 10-8: Sebuah Point<T, U> yang generik terhadap dua tipe sehingga x dan y bisa menampung nilai dengan tipe yang berbeda

Sekarang 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.

Filename: src/main.rs
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());
}
Listing 10-9: Mengimplementasikan sebuah method bernama x di struct Point<T> yang bakal mengembalikan referensi ke field x bertipe T

Di 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.

Filename: src/main.rs
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());
}
Listing 10-10: Blok impl yang hanya berlaku buat struct dengan tipe konkret tertentu buat parameter tipe generik T

Kode 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).

Filename: src/main.rs
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);
}
Listing 10-11: Sebuah method yang memakai tipe generik yang berbeda dari definisi struct-nya

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):

Filename: src/main.rs
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.