Ciri-ciri Bahasa Pemrograman Berorientasi Objek
Tidak ada konsensus (kesepakatan umum) di komunitas pemrograman mengenai fitur- fitur apa aja yang wajib dimiliki oleh sebuah bahasa supaya bisa dianggap berorientasi objek. Rust dipengaruhi oleh banyak paradigma pemrograman, termasuk OOP; misalnya, kita sudah mengeksplorasi fitur-fitur yang datang dari pemrograman fungsional (functional programming) di Bab 13. Walaupun bisa diperdebatkan (arguably), bahasa pemrograman OOP biasanya berbagi ciri-ciri umum tertentu, yakni objek, encapsulation (enkapsulasi), dan inheritance (pewarisan). Mari kita lihat apa arti dari masing-masing ciri tersebut dan apakah Rust mendukungnya atau tidak.
Objek Mengandung Data dan Perilaku (Behavior)
Buku Design Patterns: Elements of Reusable Object-Oriented Software karangan Erich Gamma, Richard Helm, Ralph Johnson, dan John Vlissides (Addison-Wesley, 1994), yang secara santai sering disebut sebagai buku The Gang of Four, adalah sebuah katalog berisi desain pola berorientasi objek. Buku itu mendefinisikan OOP dengan cara ini:
Program berorientasi objek dibikin dari objek-objek. Sebuah objek membungkus (packages) baik data maupun prosedur-prosedur yang beroperasi pada data tersebut. Prosedur-prosedur tersebut biasanya disebut methods atau operations.
Memakai definisi ini, Rust itu berorientasi objek: structs dan enums punya
data, dan blok impl menyediakan methods pada structs dan enums tersebut.
Meskipun structs dan enums yang dilengkapi methods tidak disebut sebagai
objek, mereka menyediakan fungsionalitas yang sama, menurut definisi objek dari
the Gang of Four.
Encapsulation yang Menyembunyikan Detail Implementasi
Aspek lain yang umumnya dikaitkan dengan OOP adalah ide tentang encapsulation (enkapsulasi), yang berarti kalau detail implementasi dari sebuah objek itu tidak bisa diakses (accessible) oleh kode yang memakai objek tersebut. Oleh karena itu, satu-satunya cara buat berinteraksi dengan sebuah objek adalah melalui API public-nya; kode yang memakai objek itu tidak seharusnya bisa menjangkau bagian internal dari si objek lalu mengubah data atau perilakunya secara langsung. Hal ini memungkinkan programmer untuk mengubah dan me-refactor bagian internal dari sebuah objek tanpa perlu mengubah kode yang memakai objek tersebut.
Kita sudah membahas cara mengontrol encapsulation di Bab 7: kita bisa memakai
keyword pub buat menentukan modul, tipe, fungsi, dan method mana saja di kode
kita yang seharusnya bersifat public, sementara secara default segala hal
lainnya bersifat private. Misalnya, kita bisa mendefinisikan sebuah struct
AveragedCollection yang punya field yang berisi vector dari nilai i32.
Struct tersebut juga bisa punya sebuah field yang berisi nilai rata-rata
(average) dari nilai-nilai di vector tersebut, yang berarti nilai rata-ratanya
tidak harus dihitung seketika (on demand) kapan pun seseorang membutuhkannya.
Dengan kata lain, AveragedCollection bakal menge-cache nilai rata-rata
yang sudah dihitung tersebut buat kita. Listing 18-1 punya definisi dari struct
AveragedCollection.
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
AveragedCollection yang memelihara daftar (list) dari angka integer dan nilai rata-rata dari item-item di koleksi tersebutStruct ini ditandai sebagai pub supaya kode lain bisa memakainya, tapi field-
field di dalam struct tersebut tetap bersifat private. Hal ini penting di
kasus ini karena kita pengen memastikan bahwa kapan pun sebuah nilai
ditambahkan atau dihapus dari list, nilai average juga harus di-update.
Kita melakukan ini dengan mengimplementasikan method add, remove, dan
average pada struct tersebut, seperti yang ditunjukkan di Listing 18-2.
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
impl AveragedCollection {
pub fn add(&mut self, value: i32) {
self.list.push(value);
self.update_average();
}
pub fn remove(&mut self) -> Option<i32> {
let result = self.list.pop();
match result {
Some(value) => {
self.update_average();
Some(value)
}
None => None,
}
}
pub fn average(&self) -> f64 {
self.average
}
fn update_average(&mut self) {
let total: i32 = self.list.iter().sum();
self.average = total as f64 / self.list.len() as f64;
}
}
add, remove, dan average pada AveragedCollectionPublic methods add, remove, dan average adalah satu-satunya cara buat
mengakses atau memodifikasi data di dalam sebuah instance AveragedCollection.
Saat sebuah item ditambahkan ke list memakai method add atau dihapus
memakai method remove, implementasi dari kedua method ini bakal memanggil
method private update_average yang juga menangani pembaruan pada field
average.
Kita membiarkan field list dan average tetap private sehingga tidak ada
cara buat kode eksternal (external code) buat menambahkan atau menghapus item
ke atau dari field list secara langsung; karena jika dibiarkan, field average
bisa jadi tidak sinkron saat list berubah. Method average mengembalikan nilai
yang ada di dalam field average, yang memungkinkan kode eksternal buat membaca
nilai average tersebut tapi tidak bisa memodifikasinya.
Karena kita udah mengenkapsulasi (encapsulated) detail implementasi dari struct
AveragedCollection, kita bisa gampang mengubah berbagai aspeknya, kayak
struktur datanya, di masa depan. Misalnya, kita bisa aja memakai sebuah
HashSet<i32> ketimbang Vec<i32> buat field list. Asalkan signature
dari public methods add, remove, dan average itu tetap sama, kode
yang memakai AveragedCollection tidak perlu berubah. Tapi, kalau seandainya
kita membikin list jadi public, hal ini belum tentu benar: HashSet<i32>
dan Vec<i32> punya method yang berbeda buat menambahkan dan menghapus item,
jadi kode eksternalnya kemungkinan besar juga harus diubah kalau mereka
awalnya memodifikasi list secara langsung.
Kalau encapsulation adalah aspek yang wajib dipunyai sama sebuah bahasa agar
bisa dianggap berorientasi objek, maka Rust memenuhi syarat tersebut. Pilihan
buat memakai pub atau tidak pada berbagai bagian kode memungkinkan adanya
encapsulation terhadap detail-detail implementasi.
Inheritance sebagai Sistem Tipe (Type System) dan Pembagian Kode (Code Sharing)
Inheritance (pewarisan) adalah sebuah mekanisme di mana sebuah objek bisa mewarisi (inherit) elemen-elemen dari definisi objek lain, dengan begitu ia mendapatkan data dan perilaku dari objek induk (parent object) tanpa kita harus mendefinisikannya lagi.
Kalau sebuah bahasa wajib punya inheritance buat bisa disebut berorientasi objek, maka Rust tidak termasuk dalam bahasa tersebut. Tidak ada cara buat mendefinisikan sebuah struct yang mewarisi field-field dan implementasi method dari struct induk tanpa memakai macro.
Namun, kalau kita udah biasa (used to) punya inheritance di alat (toolbox) pemrograman kita, kita bisa memakai solusi lain di Rust, tergantung dari alasan kenapa kita mencari inheritance tersebut sejak awal.
Kita biasanya bakal milih buat memakai inheritance karena dua alasan utama.
Alasan pertama adalah buat pemakaian ulang kode (reuse of code): kita bisa
mengimplementasikan perilaku tertentu untuk satu tipe, dan inheritance
memungkinkan kita buat memakai ulang implementasi tersebut buat tipe yang berbeda.
Kita bisa melakukan hal ini secara terbatas di kode Rust dengan memakai
default trait method implementations (implementasi bawaan pada trait method),
yang udah kita lihat di Listing 10-14 pas kita menambahkan implementasi default
dari method summarize pada trait Summary. Tipe apa pun yang
mengimplementasikan trait Summary bakal langsung punya method summarize yang
bisa dipakai padanya tanpa perlu nulis kode tambahan lagi. Ini mirip dengan
sebuah parent class (kelas induk) yang punya implementasi dari suatu method
dan sebuah inheriting child class (kelas anak yang mewarisi) yang juga ikutan
punya implementasi dari method tersebut. Kita juga bisa menimpa (override)
implementasi default dari method summarize pas kita mengimplementasikan
trait Summary, yang mana mirip kayak saat sebuah child class menimpa
implementasi method yang diwarisinya dari sebuah parent class.
Alasan lain memakai inheritance berkaitan dengan sistem tipenya: yakni memungkinkan sebuah tipe child (anak) buat dipakai di tempat-tempat yang sama kayak tipe parent (induk)-nya. Ini juga disebut dengan polymorphism (polimorfisme), yang berarti kita bisa mensubstitusikan (menggantikan) berbagai objek untuk satu sama lain pas runtime asalkan mereka berbagi karakteristik tertentu.
Polimorfisme
Buat banyak orang, polimorfisme itu sinonim dengan inheritance. Tapi dia sebenarnya adalah konsep yang lebih umum yang mengacu ke kode yang bisa bekerja bareng data dari berbagai macam tipe. Buat inheritance, tipe-tipe itu umumnya adalah subclasses (subkelas).
Sebaliknya, Rust memakai generics (generik) buat mengabstraksi bermacam- macam kemungkinan tipe dan trait bounds buat memaksakan batasan-batasan (constraints) pada apa yang wajib disediakan sama tipe-tipe tersebut. Ini kadang-kadang disebut sebagai bounded parametric polymorphism (polimorfisme parametrik terbatas).
Rust memilih serangkaian tradeoffs yang berbeda dengan tidak menawarkan inheritance. Inheritance sering kali punya risiko buat nge-share lebih banyak kode daripada yang diperlukan. Subclasses tidak seharusnya selalu berbagi semua karakteristik dari parent class mereka, tapi mereka bakal melakukan itu kalau pakai inheritance. Hal ini bisa bikin desain dari sebuah program jadi kurang fleksibel. Ini juga memunculkan kemungkinan memanggil method-method pada subclasses yang sebenarnya tidak masuk akal atau menyebabkan error gara-gara method-method itu nyatanya tidak berlaku (apply) buat subclass tersebut. Selain itu, beberapa bahasa cuma mengizinkan single inheritance (berarti satu subclass cuma boleh mewarisi dari satu kelas saja), yang lebih lanjut membatasi fleksibilitas dari desain sebuah program.
Atas alasan-alasan ini, Rust mengambil pendekatan yang berbeda yaitu dengan memakai trait objects (objek trait) sebagai ganti inheritance buat memungkinkan adanya polimorfisme. Mari kita lihat gimana cara trait objects bekerja.