Mochidaz
Mochidaz Professional Tidurers

Bahasa Pemrograman Rust 9: Generics

Bahasa Pemrograman Rust 9: Generics

Saya sudah pernah menjelaskan tentang generics pada Bab Result, Option, dan Pattern Matching. Dengan generics, kita dapat menuliskan tipe data abstrak dengan placeholder types untuk struct, enum, dan juga fungsi. Kita menggunakan placeholder types daripada mendefinisikan tipe datanya secara eksplisit. Kita dapat menuliskan kode yang bekerja untuk tipe-tipe data yang berbeda sehingga menambah fleksibilitas kode kita. Dengan generics, kita dapat mengurangi duplikasi kode. Ingat bahwa generics tidak memiliki runtime cost - Generics dibuat saat compile time sehingga performa runtime akan tetap sama, dengan saat anda tidak menggunakan generics. Mari kita langsung masuk ke masalah pertama!

Sekarang saya akan membuat sebuah struct bernama Apalah yang berisi field x dan y yang keduanya memiliki tipe i32.

1
2
3
4
struct Apalah {
	x: i32,
	y: i32,
}

Nah, sekarang saya akan coba mencetak kedua field tersebut.

1
2
3
4
5
6
7
8
fn main() {
	let a = Apalah {
		x: 10,
		y: 20,
	};
	
	println!("x = {}, y = {}", a.x, a.y);
}

Kode tersebut akan berjalan dengan baik dan akan mencetak kedua field yang bertipe i32 tersebut. Namun bagaimana bila kita ingin mengisi struct tersebut dengan f64 misalnya? Tentunya kita tidak boleh melakukan ini:

1
2
3
4
5
6
7
8
9
struct Apalah {
	x: i32,
	y: i32,
}

struct Apalah {
	x: f64,
	y: f64,
}

Sehingga kita akan terpaksa menggunakan nama yang berbeda untuk dua struct yang sama dan hanya dibedakan oleh tipe field.

1
2
3
4
5
6
7
8
9
struct ApalahI32 {
	x: i32,
	y: i32,
}

struct ApalahF64 {
	x: f64,
	y: f64,
}

Terjadi duplikasi kode disini. Kita membuat dua struct sama yang hanya berbeda pada tipe, dengan nama yang berbeda. Karena inilah solusi yang membuat kode lebih fleksibel - generics diperlukan!

Mari kita hapus kedua struct diatas dan mendefinisikan struct Apalah lagi, namun kali ini berbeda.

1
2
3
4
struct Apalah<T> {
	x: T,
	y: T,
}

Nah, seperti yang kalian tahu, T merupakan tipe placeholder yang dapat diganti dengan tipe data apapun - compiler yang akan mengurusnya. Dengan T, kita akan dapat menggunakan struct Apalah untuk tipe data yang berbeda-beda. Mari kita coba.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn main() {
	let a = Apalah {
		x: 10,
		y: 20,	
	};
	
	println!("x = {}, y = {}", a.x, a.y);
	
	let b = Apalah {
		x: 10.5,
		y: 15.77,	
	};
	
	println!("x = {}, y = {}", b.x, b.y);
}

Daaaan kode akan tercompile dengan baik.

Apa yang terjadi disini? Disini, compiler akan secara otomatis mengisi tipe T dengan i32 disaat kita menggunakan tipe i32 saat kita membuat variabel bilangan bulat dari struct tersebut, dan juga f64 bila kita menggunakan desimal! Terima kasih, compiler. Namun, bila kita ingin menggunakan f32 untuk float misalnya, dan bukan f64, kita juga dapat mendefinisikan tipe data kita secara eksplisit pada saat pendeklarasian ataupun assignment seperti ini:

1
2
3
4
let b: Apalah<f32> = Apalah {
	x: 10.5,
	y: 15.77,	
};

Sangat berguna, bukan?

Lalu, kenapa kita menggunakan T? Dan bukan huruf lain? Sebenarnya, T hanyalah sebuah placeholder. Seperti sebuah identifier variabel, kalian bisa mengganti T dengan apapun sesuka kalian. Namun, T merupakan naming convention yang baik untuk generics. T merupakan kependekan dari Type atau tipe. Biasanya, naming convention untuk generics setelah T dilanjutkan dengan huruf sesudah T yaitu U. Namun tentu saja penamaan itu relatif.

Menuliskan dua tipe generic berbeda sangat mudah. Hanya tinggal menambah satu huruf yang berbeda seperti ini:

1
2
3
4
struct Apalah<T, U> {
	x: T,
	y: U,
}

Dan sekarang kita dapat membuat sebuah struct dengan dua tipe generic yang berbeda seperti ini:

1
2
3
4
let a = Apalah {
	x: 10,
	y: 15.8,	
};

Atau tentu saja misalnya

1
2
3
4
let a: Apalah<i32, f32> = Apalah {
	x: 10,
	y: 15.8,	
};

Generics juga berlaku untuk enumerasi. Mari sekarang kita bersihkan dan hapus struct Apalah kita, dan semua yang ada didalam fungsi main dan membuat sebuah enum generic baru.

1
2
3
4
5
enum EnumApalah<T> {
	OpsiA(T),
	OpsiB(T),
	OpsiC,
}

Kemudian kita gunakan pattern matching.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn main() {
	let apalah = EnumApalah::OpsiA(20);
	let apalah2 = EnumApalah::OpsiB(vec![1,2,3]);
	
	match apalah {
		EnumApalah::OpsiA(a) => {
			println!("OpsiA memiliki {}", a);
		}
		EnumApalah::OpsiB(b) => {
			println!("OpsiB memiliki {}", b);
		}
		EnumApalah::OpsiC => {
			println!("OpsiC tidak ada apa-apa");
		}
 	};
}

Dan tentu saja, generic juga bekerja pada tipe yang berupa struct, seperti apalah2 diatas yang memuat Vec atau vektor.

Sekarang kita akan masuk ke fungsi generic. Silahkan untuk clear atau hapus enum yang telah kita buat, dan juga semua yang ada didalam fungsi main.

Pembuatan fungsi generic juga mirip dengan struct, yaitu sebagai berikut:

1
2
3
fn fungsi_apalah<T>(a: T, b: T) -> T {
	a
}

Diatas, kita memakai T sebagai tipe untuk a dan b, dan juga return typenya. Ini hanyalah sebuah contoh membosankan untuk generic pada fungsi.

Kedua tipe pada parameter fungsi diatas adalah T sehingga bila kita memasukkan tipe i32 misalnya pada parameter a, namun memasukkan tipe f32 pada parameter b, maka akan terjadi error.

Sekarang, mari kita masuk pada bagian yang tidak membosankan dan juga bagian menarik utamanya: Trait bounds.

Generics pada Rust adalah trait-based generics yang berarti limitasi atau apa yang bisa kita lakukan dengan generics didasarkan pada traits yang dibatasi olehnya. Bingung? Mari kita lihat sebuah contoh.

Kita ganti fungsi_apalah menjadi seperti ini:

1
2
3
fn fungsi_apalah<T>(a: T, b: T) -> T {
	a + b
}

Kira-kira apa yang akan terjadi? Akan terjadi error! Kalau anda memakai template pada bahasa C++, maka anda akan langsung bisa menambahkan kedua parameter diatas. Namun, tidak dengan Rust. Generics pada Rust dibatasi, atau diikat oleh trait. Dengan kata lain, kita harus menggunakan trait untuk melakukan operasi tertentu untuk tipe yang ingin kita input.

Untuk pertambahan, Rust menyediakan sebuah trait builtin yaitu std::ops::Add. Mari kita tambahkan!

1
2
3
fn fungsi_apalah<T: std::ops::Add<Output=T>>(a: T, b: T) -> T {
	a + b
}

Nah, sekarang mari kita tes.

1
2
3
4
fn main() {
	let a = fungsi_apalah(10, 20);
	println!("Hasilnya adalah: {}", a);
}

Dan jreng! Sekarang parameter a akan dapat ditambahkan dengan parameter b dan kita akan mendapat output Hasilnya adalah: 30!

Sekarang saya akan menjelaskan tentang trait Add diatas. Jadi, untuk menggunakan generics pada Rust untuk suatu tipe, tipe yang akan digunakan harus mengimplementasi trait khusus, dan kemudian kita akan menggunakan trait yang diimplementasi oleh tipe tersebut dalam fungsi kita.

Contohnya, tipe seperti i32, f32, dan sebagainya telah mengimplementasi trait Add sehingga kita dapat menggunakannya pada fungsi generic tersebut. Namun, apa yang akan terjadi bila kita memasukkan tipe yang tidak mengimplementasi Add seperti string slice, misalnya? Tentu saja akan terjadi error! Lebih tepatnya seperti ini: help: the trait Add is not implemented for &str yang berarti trait Add tidak diimplementasikan untuk &str.

Trait yang digunakan seperti diatas dinamakan juga dengan constraint.

Sudah jelaskan? Lalu apa fungsi dari <Output=T>? Itu untuk memberitahu Rust bahwa output dari fungsi tersebut adalah T, bukan yang lain. Lalu mengapa ini dibutuhkan? Karena bisa saja saat kita menambahkan kedua tipe yang sama, misalnya T + T, pertambahan tersebut berujung kepada hasil yang memiliki tipe yang berbeda, misalnya U atau T + U = V. Karena itu, kita harus mendefinisikan tipe Output kita.

Sekarang, bagaimana kalau kita juga ingin memakai operator pengurangan pada fungsi kita? Simpel, kita tambahkan constraint lainnya dengan operator +. Trait untuk pengurangan adalah std::ops::Sub.

1
2
3
fn fungsi_apalah<T: std::ops::Add<Output=T> + std::ops::Sub<Output=T>>(a: T, b: T) -> T {
	a - b
}

Dan sekarang kita bisa melakukan pengurangan dalam fungsi generic kita.

Sekarang, kita akan menambahkan std::fmt::Debug agar kita dapat menggunakan println! dalam fungsi kita.

1
2
3
4
fn fungsi_apalah<T: std::ops::Add<Output=T> + std::ops::Sub<Output=T> + std::fmt::Debug>(a: T, b: T) -> T {
	println!("a memiliki: {:?}", a);
	a - b
}

Namun… constraint kita sekarang terlihat sangat berantakan. Oleh karena itu, Rust menyediakan alternatif lain yang berupa keyword where.

1
2
3
4
5
fn fungsi_apalah<T>(a: T, b: T) -> T 
where T: std::ops::Add<Output=T> + std::ops::Sub<Output=T> + std::fmt::Debug {
	println!("a memiliki: {:?}", a);
	a - b
}

Nah, kode kita sekarang terlihat lebih readable. Dan sekarang, kita akan menambah satu parameter lain yang bertipe berbeda. U misalnya lalu menambah constraint Debug.

1
2
3
4
5
6
7
fn fungsi_apalah<T, U>(a: T, b: T, c: U) -> T 
where T: std::ops::Add<Output=T> + std::ops::Sub<Output=T> + std::fmt::Debug,
      U: std::fmt::Debug {
	println!("a memiliki: {:?}", a);
	println!("c memiliki: {:?}", c);
	a - b
}

Untuk menambahkan constraint pada tipe lain, cukup dengan menambah koma saja di akhir constraint untuk satu tipe, bukan operator +.

1
2
3
4
fn main() {
	let a = fungsi_apalah(10, 20, "Halo");
	println!("Hasilnya adalah: {}", a);
}

Output dari println! kita sekarang akan berupa:

1
2
3
a memiliki: 10
c memiliki: "Halo"
Hasilnya adalah: -10

Sekarang, kita akan membuat sebuah trait baru dengan satu method bernama TraitApalah.

1
2
3
trait TraitApalah {
	fn apalah(&self, a: &str, b: &str) -> String;
}

Nah, sekarang kita akan membuat dua fungsi baru.

1
2
3
4
5
6
7
8
9
10
fn sesuatu<T>(x: &T) -> String 
where T: TraitApalah {
	println!("{:?}", x);
	x.apalah("satu", "dua")
}

fn sesuatu2(x: &dyn TraitApalah) -> String {
	println!("{:?}", x);
	x.apalah("satu", "dua")
}

Seperti yang kalian lihat, fungsi sesuatu merupakan fungsi generic dan sesuatu2 tidak. Mungkin kalian berpikir untuk apa repot-repot menggunakan generic dan constraint pada fungsi sesuatu. Fungsi sesuatu2 akan lebih simpel. Namun, kalian lihat, kedua fungsi tersebut memiliki error karena mereka memiliki println! namun tidak mengimplementasikan Debug. Akan ribet untuk memperbaiki itu pada fungsi sesuatu2, sedangkan pada fungsi sesuatu, kita hanya tinggal menambah constraint Debug.

1
2
3
4
5
6
7
8
9
10
fn sesuatu<T>(x: &T) -> String 
where T: TraitApalah + std::fmt::Debug {
	println!("{:?}", x);
	x.apalah("satu", "dua")
}

fn sesuatu2(x: &dyn TraitApalah) -> String {
	println!("{:?}", x);
	x.apalah("satu", "dua")
}

Dan error pada fungsi sesuatu akan hilang.

Sekarang, hapus fungsi sesuatu2 dan kita buat sebuah struct baru bernama StructApalah. Jangan lupa derive Debug.

1
2
3
4
#[derive(Debug)]
struct StructApalah {
	sesuatu: i32,
}

Lalu kita implement TraitApalah pada StructApalah.

1
2
3
4
5
impl TraitApalah for StructApalah {
	fn apalah(&self, a: &str, b: &str) -> String {
		self.sesuatu.to_string() + "|" + a + "|" + b
	}
}

Lalu kita coba gunakan fungsi sesuatu pada StructApalah.

1
2
3
4
5
6
7
8
fn main() {
    let a = StructApalah {
        sesuatu: 100
    };

    let b = sesuatu(&a);
    println!("{}", b);
}

Output yang akan keluar adalah:

1
2
StructApalah { sesuatu: 100 }
100|satu|dua

Sekarang, mari kita coba untuk mengimplementasikan TraitApalah pada tipe primitif - yaitu i32. Benar, kita bisa melakukannya.

1
2
3
4
5
impl TraitApalah for i32 {
	fn apalah(&self, a: &str, b: &str) -> String {
		"i32".to_string() + "|" + a + "|" + b
	}
}

Lalu kita coba pakai di fungsi main.

1
2
3
4
5
6
7
8
9
10
11
12
fn main() {
    let a = StructApalah {
        sesuatu: 100
    };

    let b = sesuatu(&a);
    println!("{}", b);
    
    let sebuahi32 = 10;
    let c = sesuatu(&sebuahi32);
    println!("{}", c);
}

Kode akan berjalan dengan sempurna. Kita dapat menggunakan fungsi generic sesuatu pada kedua tipe berbeda tersebut karena mereka mengimplementasikan TraitApalah dan fungsi tersebut memiliki constraint TraitApalah juga.

Mungkin implementasi diatas terlihat aneh, namun saya ingin menunjukkan bahwa tipe itu sekarang agaknya tidak relevan dalam generic programming - trait-lah yang bermain peran besar. Itulah yang dimaksud dengan trait-based generics.

Sekarang, bersihkan lagi semua yang tadi kita tulis dan kita akan merombak ulang StructApalah. Kita akan mempelajari cara untuk menggunakan implementasi pada struct generic. Kita akan membuat sebuah logger sederhana sebagai contoh.

1
2
3
4
5
6
7
8
9
10
11
12
13
struct StructApalah<T,U> {
	a: T,
	b: U,
}

impl<T,U> StructApalah<T,U>
where T: std::fmt::Debug,
      U: std::fmt::Debug {
	fn log(&self) {
		println!("Logging! a: {:?} b: {:?}", self.a, self.b);
	}
}

Kita menggunakan constraint untuk generics pada struct untuk method pada implementasi - dengan where juga, seperti pada fungsi.

Mari kita buat sebuah variabel dari StructApalah dan kemudian kita gunakan method log. Selalu ingat bahwa a dan b dapat diisi dengan tipe apapun yang mengimplementasi Debug.

1
2
3
4
5
6
7
8
fn main() {
	let a = StructApalah {
		a: 20,
		b: vec![0,0,0,0],
	};
	
	a.log();
}

Dan kode diatas akan berjalan dengan sempurna dengan output seperti ini:

1
Logging! a: 20 b: [0, 0, 0, 0]

Demikian akhir dari topik generics kita kali ini, terima kasih banyak sudah membaca, bila ada pertanyaan, silahkan kirim email ke rahmanhakim2435@pm.me :).

comments powered by Disqus