Mengimplementasikan Desain Pola Object-Oriented
State pattern (pola status) adalah sebuah desain pola object-oriented (berorientasi objek). Inti dari pola ini adalah kita mendefinisikan serangkaian states (status/keadaan) yang bisa dimiliki oleh suatu nilai di dalamnya (internally). States ini direpresentasikan oleh sekumpulan state objects, dan perilaku dari nilai tersebut berubah berdasarkan state yang dia miliki saat itu. Kita bakal mengerjakan contoh berupa struct postingan blog yang punya field buat menampung state-nya, yang mana bakal berupa state object dari serangkaian pilihan: “draft” (draf), “review” (tinjauan), atau “published” (dipublikasikan).
Objek-objek state ini saling berbagi fungsionalitas: di Rust, tentu saja, kita memakai struct dan traits bukannya objek dan pewarisan (inheritance). Setiap state object bertanggung jawab buat perilakunya sendiri dan mengatur kapan dia harus berubah jadi state lain. Nilai yang menampung state object tersebut sama sekali tidak tahu tentang perilaku yang berbeda dari setiap state atau kapan waktu yang tepat buat bertransisi (transition) antar states.
Keuntungan dari memakai state pattern ini adalah, saat ada perubahan persyaratan bisnis (business requirements) di program kita, kita tidak perlu mengubah kode dari nilai yang menampung state-nya atau kode yang memakai nilai tersebut. Kita cuma perlu meng-update kode di dalam salah satu state objects buat ngubah aturan-aturannya atau mungkin nambahin state objects baru.
Pertama-tama kita bakal mengimplementasikan state pattern pakai cara yang lebih tradisional ala object-oriented, terus kita bakal memakai pendekatan yang lebih natural di Rust. Mari kita gali pelan-pelan buat mengimplementasikan workflow (alur kerja) postingan blog pakai state pattern.
Fungsionalitas akhirnya bakal kelihatan kayak gini:
- Postingan blog bermula dari draft kosong.
- Saat draft-nya beres, sebuah review dari postingan itu diminta (requested).
- Saat postingannya disetujui (approved), dia bakal di-publish (dipublikasikan).
- Cuma postingan blog yang udah di-publish yang mengembalikan konten buat dicetak, jadi postingan yang belum disetujui tidak bakal bisa tidak sengaja ke-publish.
Perubahan lain yang dicoba dilakukan pada postingan tersebut seharusnya tidak bisa mengubah apa pun. Misalnya, kalau kita mencoba men-approve sebuah postingan draft sebelum kita me-request review, postingan itu seharusnya tetap berupa draft yang belum di-publish.
Percobaan Object-Oriented yang Tradisional
Ada sangat banyak cara buat menata struktur kode buat menyelesaikan masalah yang sama, masing-masing dengan trade-offs (kekurangan/kelebihan) yang beda. Implementasi di bagian ini memakai gaya object-oriented yang lebih tradisional, yang mana bisa ditulis di Rust, tapi tidak memanfaatkan beberapa dari kekuatan unggulan yang dimiliki Rust. Nanti, kita bakal mendemonstrasikan solusi lain yang tetap memakai desain pola object-oriented tapi disusun sedemikian rupa hingga mungkin kelihatan kurang familier buat programmer yang punya pengalaman object-oriented. Kita bakal membandingkan kedua solusi ini buat ngalamin langsung trade-offs dari mendesain kode di Rust dengan cara yang beda daripada nulis kode di bahasa lain.
Listing 18-11 menunjukkan workflow ini dalam bentuk kode: ini adalah contoh
pemakaian dari API yang bakal kita implementasikan di sebuah library crate
bernama blog. Ini masih belum bisa di-compile karena kita belum
mengimplementasikan crate blog-nya.
use blog::Post;
fn main() {
let mut post = Post::new();
post.add_text("I ate a salad for lunch today");
assert_eq!("", post.content());
post.request_review();
assert_eq!("", post.content());
post.approve();
assert_eq!("I ate a salad for lunch today", post.content());
}
blog kitaKita mau ngebolehin user bikin postingan blog draft baru pakai Post::new. Kita
mau ngebolehin teks buat ditambahin ke postingan blog itu. Kalau kita nyoba ngambil
konten (content) dari postingan itu langsung, sebelum adanya approval, kita tidak
seharusnya dapat teks apa pun karena postingannya masih berupa draft. Kita udah
nambahin assert_eq! di kode ini buat tujuan demonstrasi aja. Pengujian unit test
yang cakep sekali buat ini adalah dengan menegaskan (assert) kalau postingan blog
draft mengembalikan string kosong dari method content, tapi kita tidak bakal
nulis tests buat contoh ini.
Berikutnya, kita mau memungkinkan adanya permintaan (request) buat me-review
postingan tersebut, dan kita pengen method content tetap mengembalikan string
kosong selama masih nungguin review. Pas postingannya udah dapat approval,
dia harusnya langsung ke-publish, yang berarti teks dari postingan tersebut bakal
dikembalikan saat method content dipanggil.
Perhatikan bahwa satu-satunya tipe yang kita pakai buat berinteraksi dari crate ini
adalah tipe Post. Tipe ini bakal memakai state pattern dan bakal menampung
sebuah nilai yang merupakan salah satu dari tiga state objects yang mewakili
berbagai macam state yang mungkin ada pada suatu postingan—draft, review,
atau published. Perubahan dari satu state ke state lainnya bakal dikelola
secara internal di dalam tipe Post. States-nya berubah sebagai respons
terhadap method-method yang dipanggil sama para pengguna library kita pada
instance Post tersebut, tapi mereka sendiri tidak perlu ngurusin (manage)
perubahan state-nya secara langsung. Selain itu, user juga tidak bisa ngelakuin
kesalahan pada states-nya, kayak nge-publish sebuah postingan sebelum dia
di-review.
Mendefinisikan Post dan Membikin Instance Baru di State Draft
Mari kita mulai ngimplementasiin library-nya! Kita tahu kita butuh sebuah
struct public Post yang menampung beberapa konten, jadi kita bakal mulai dengan
definisi dari struct tersebut dan fungsi associated public bernama new buat
bikin sebuah instance dari Post, seperti yang ditunjukkan di Listing 18-12. Kita
juga bakal bikin trait private bernama State yang bakal mendefinisikan
perilaku yang wajib dimiliki sama semua state objects buat Post.
Terus, Post bakal menampung sebuah trait object berupa Box<dyn State> di dalam
sebuah Option<T> di sebuah field private bernama state buat naruh si
state object. Kita bakal ngelihat kenapa Option<T> ini dibutuhkan sebentar lagi.
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
}
trait State {}
struct Draft {}
impl State for Draft {}
Post dan fungsi new yang ngebikin instance Post baru, trait State, dan struct DraftTrait State mendefinisikan perilaku yang di-share (dibagi) oleh states dari
postingan yang beda-beda. State objects-nya adalah Draft, PendingReview,
dan Published, dan mereka semua bakal mengimplementasikan trait State. Buat
sekarang, trait-nya belum punya method apa-apa, dan kita bakal mulai dengan cuma
mendefinisikan state Draft aja karena itu adalah state awal (start) yang kita
pengen ada di sebuah postingan.
Pas kita membikin Post baru, kita nge-set field state-nya jadi sebuah nilai
Some yang nampung sebuah Box. Box ini menunjuk ke sebuah instance baru dari
struct Draft. Hal ini memastikan bahwa kapan pun kita membikin instance baru
dari Post, dia bakal selalu mulai sebagai draft. Karena field state pada
Post itu private, tidak ada cara buat membikin sebuah Post di state
selain draft! Di fungsi Post::new, kita menge-set field content jadi String
baru yang masih kosong.
Menyimpan Teks dari Konten Postingan
Kita udah lihat di Listing 18-11 kalau kita pengen bisa memanggil method bernama
add_text lalu ngasih dia sebuah &str yang kemudian ditambahkan sebagai teks
konten dari postingan blog tersebut. Kita mengimplementasikan ini sebagai sebuah
method, bukannya ngekspos field content sebagai pub, supaya nanti kita bisa
mengimplementasikan sebuah method yang bakal ngontrol gimana data di field content
ini bisa dibaca. Method add_text ini lumayan straightforward (sederhana), jadi
mari kita tambahin implementasinya di Listing 18-13 ke dalam blok impl Post.
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
// --snip--
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
}
trait State {}
struct Draft {}
impl State for Draft {}
add_text buat nambahin teks ke content di sebuah postinganMethod add_text menerima referensi mutable ke self karena kita mau mengubah
instance Post tempat kita manggil add_text. Kemudian kita memanggil push_str
pada String di content dan masukin argumen text buat ditambahin ke content
yang udah tersimpan. Perilaku ini tidak bergantung pada state yang lagi
dimiliki postingan, jadi ini bukanlah bagian dari state pattern. Method
add_text tidak berinteraksi dengan field state sama sekali, tapi dia adalah
bagian dari perilaku yang mau kita dukung (support).
Memastikan Konten dari Postingan Draft Itu Kosong
Bahkan setelah kita manggil add_text dan nambahin beberapa konten ke postingan
kita, kita tetap pengen method content buat mengembalikan string slice kosong
karena postingannya masih ada di state draft, kayak yang ditunjukin di baris 7
di Listing 18-11. Buat sekarang, mari kita implementasikan method content dengan
cara paling simpel yang bisa menuhi persyaratan ini: selalu mengembalikan string
slice kosong. Kita bakal mengubah ini nanti pas kita udah ngimplementasiin
kemampuan buat mengubah state postingan jadi bisa di-publish. Sejauh ini,
postingan cuma bisa ada di state draft, jadi konten postingan harus selalu
kosong. Listing 18-14 menunjukkan implementasi placeholder (sementara) ini.
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
// --snip--
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn content(&self) -> &str {
""
}
}
trait State {}
struct Draft {}
impl State for Draft {}
content pada Post yang selalu ngembaliin string slice kosongDengan method content yang ditambahkan ini, semua yang ada di Listing 18-11
sampai baris 7 bakal jalan sesuai rencana.
Meminta Review Bakal Mengubah State dari Postingan
Berikutnya, kita perlu nambahin fungsionalitas buat meminta sebuah review dari
sebuah postingan, yang mana bakal mengubah state-nya dari Draft jadi
PendingReview. Listing 18-15 nunjukin kodenya.
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
// --snip--
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn content(&self) -> &str {
""
}
pub fn request_review(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.request_review())
}
}
}
trait State {
fn request_review(self: Box<Self>) -> Box<dyn State>;
}
struct Draft {}
impl State for Draft {
fn request_review(self: Box<Self>) -> Box<dyn State> {
Box::new(PendingReview {})
}
}
struct PendingReview {}
impl State for PendingReview {
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
}
request_review pada Post dan trait StateKita ngasih Post sebuah method public bernama request_review yang bakal
menerima referensi mutable ke self. Terus kita memanggil method internal
request_review pada state saat ini dari Post, dan method request_review
yang kedua ini bakal mengonsumsi state saat ini dan mengembalikan state yang
baru.
Kita nambahin method request_review ke trait State; semua tipe yang
mengimplementasikan trait tersebut sekarang juga harus mengimplementasikan method
request_review. Perhatikan bahwa ketimbang pakai self, &self, atau &mut self
sebagai parameter pertama di method ini, kita malah pakai self: Box<Self>.
Sintaks ini berarti method tersebut cuma valid pas dipanggil pada sebuah Box
yang nampung tipe tersebut. Sintaks ini ngambil kepemilikan (ownership) atas
Box<Self>, yang bakal membikin state yang lama jadi tidak valid sehingga
nilai state dari Post bisa berubah jadi state yang baru.
Buat mengonsumsi state yang lama, method request_review butuh mengambil
kepemilikan dari nilai state-nya. Di sinilah Option yang ada di field state
milik Post kepake: kita memanggil method take buat mengambil (take out)
nilai Some dari field state dan ninggalin nilai None di tempatnya karena
Rust tidak ngebolehin kita punya field yang tidak berisi apa-apa (unpopulated
fields) di dalam struct. Ini memungkinkan kita mindahin nilai state keluar dari
Post ketimbang meminjamnya (borrowing). Terus kita bakal menge-set nilai
state dari postingannya ke hasil dari operasi ini.
Kita perlu nge-set state ke None untuk sementara waktu ketimbang menge-setnya
secara langsung pakai kode seperti self.state = self.state.request_review();
supaya kita bisa dapat kepemilikan atas nilai state-nya. Ini memastikan Post
tidak bisa memakai nilai state yang lama setelah kita udah ngubah dia jadi
state yang baru.
Method request_review pada Draft mengembalikan sebuah instance baru yang
dibungkus Box dari sebuah struct baru bernama PendingReview, yang mana
merepresentasikan state saat sebuah postingan lagi nunggu review. Struct
PendingReview juga mengimplementasikan method request_review tapi dia tidak
melakukan perubahan (transformations) apa pun. Sebaliknya, dia ngembaliin dirinya
sendiri (returns itself) karena kalau kita meminta review pada postingan yang
emang udah ada di state PendingReview, dia seharusnya tetap berada di state
PendingReview.
Sekarang kita bisa mulai ngelihat keuntungan dari state pattern: method
request_review pada Post itu sama persis terlepas dari apa nilai state-nya.
Setiap state bertanggung jawab atas aturannya sendiri.
Kita bakal biarin method content pada Post apa adanya, yang mana bakal
ngembaliin string slice kosong. Kita sekarang bisa punya Post di state
PendingReview maupun di state Draft, tapi kita pengen perilaku yang sama di
state PendingReview. Listing 18-11 sekarang udah bisa jalan sampai baris ke-10!
Menambahkan approve buat Mengubah Perilaku dari content
Method approve bakal mirip sama method request_review: dia bakal menge-set
state ke nilai yang dibilang sama state saat ini sebagai nilai yang harusnya
dia miliki saat state itu di-approve, seperti yang ditunjukin di Listing 18-16.
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
// --snip--
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn content(&self) -> &str {
""
}
pub fn request_review(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.request_review())
}
}
pub fn approve(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.approve())
}
}
}
trait State {
fn request_review(self: Box<Self>) -> Box<dyn State>;
fn approve(self: Box<Self>) -> Box<dyn State>;
}
struct Draft {}
impl State for Draft {
// --snip--
fn request_review(self: Box<Self>) -> Box<dyn State> {
Box::new(PendingReview {})
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
}
struct PendingReview {}
impl State for PendingReview {
// --snip--
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
Box::new(Published {})
}
}
struct Published {}
impl State for Published {
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
}
approve pada Post dan trait StateKita nambahin method approve ke trait State dan nambahin struct baru yang
mengimplementasikan State, yaitu state Published.
Mirip sama cara kerja request_review di PendingReview, kalau kita memanggil
method approve pada sebuah Draft, hal itu tidak bakal punya efek apa-apa karena
approve bakal mengembalikan self. Saat kita memanggil approve pada
PendingReview, dia mengembalikan instance baru yang dibungkus Box dari struct
Published. Struct Published mengimplementasikan trait State, dan baik untuk
method request_review maupun method approve, dia mengembalikan dirinya sendiri
karena postingan seharusnya tetap berada di state Published dalam kasus-kasus
tersebut.
Sekarang kita perlu meng-update method content pada Post. Kita mau supaya nilai
yang dikembalikan dari content itu bergantung sama state saat ini dari si Post,
jadi kita bakal membikin si Post mendelegasikan (delegate) panggilan ini ke method
content yang didefinisikan pada state-nya, seperti yang ditunjukin di Listing
18-17.
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
// --snip--
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn content(&self) -> &str {
self.state.as_ref().unwrap().content(self)
}
// --snip--
pub fn request_review(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.request_review())
}
}
pub fn approve(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.approve())
}
}
}
trait State {
fn request_review(self: Box<Self>) -> Box<dyn State>;
fn approve(self: Box<Self>) -> Box<dyn State>;
}
struct Draft {}
impl State for Draft {
fn request_review(self: Box<Self>) -> Box<dyn State> {
Box::new(PendingReview {})
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
}
struct PendingReview {}
impl State for PendingReview {
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
Box::new(Published {})
}
}
struct Published {}
impl State for Published {
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
}
content pada Post buat mendelegasikan panggilan ke method content pada StateKarena tujuan utamanya adalah buat menyimpan semua aturan ini di dalam struct-struct
yang mengimplementasikan State, kita memanggil sebuah method content pada nilai
yang ada di dalam state dan memasukkan instance dari postingan tersebut (yaitu,
self) sebagai argumen. Terus kita mengembalikan nilai yang dikembalikan dari
pemakaian method content pada nilai state tadi.
Kita memanggil method as_ref pada Option tersebut karena kita cuma pengen referensi
ke nilai yang ada di dalam Option, bukannya ngambil kepemilikan dari nilai itu
sendiri. Karena state adalah Option<Box<dyn State>>, pas kita manggil as_ref,
yang dikembalikan adalah Option<&Box<dyn State>>. Kalau kita tidak memanggil
as_ref, kita bakal dapat error karena kita tidak bisa mindahin state ke luar
dari referensi pinjaman (borrowed reference) &self dari parameter fungsinya.
Terus kita memanggil method unwrap, yang mana kita tahu pasti tidak bakal pernah
menyebabkan panic karena kita tahu method-method pada Post memastikan kalau state
bakal selalu berisi nilai Some saat method-method itu selesai dijalankan. Ini
adalah salah satu kasus yang kita obrolin di “Kasus Di Mana kita Punya Lebih
Banyak Informasi Daripada Compiler” di Bab 9, di mana kita
tahu pasti kalau nilai None itu mustahil, meskipun compiler tidak mampu buat
memahami hal itu.
Pada titik ini, pas kita memanggil content pada &Box<dyn State>, fitur deref
coercion (paksaan dereferensi) bakal bekerja pada tanda & dan Box tersebut,
sehingga pada akhirnya method content bakal dipanggil pada tipe yang
mengimplementasikan trait State. Itu artinya kita perlu nambahin content ke
definisi trait State, dan di sanalah kita bakal menaruh logika soal konten mana
yang harus dikembalikan tergantung di state mana kita berada sekarang, kayak
yang ditunjukin di Listing 18-18.
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn content(&self) -> &str {
self.state.as_ref().unwrap().content(self)
}
pub fn request_review(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.request_review())
}
}
pub fn approve(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.approve())
}
}
}
trait State {
// --snip--
fn request_review(self: Box<Self>) -> Box<dyn State>;
fn approve(self: Box<Self>) -> Box<dyn State>;
fn content<'a>(&self, post: &'a Post) -> &'a str {
""
}
}
// --snip--
struct Draft {}
impl State for Draft {
fn request_review(self: Box<Self>) -> Box<dyn State> {
Box::new(PendingReview {})
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
}
struct PendingReview {}
impl State for PendingReview {
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
Box::new(Published {})
}
}
struct Published {}
impl State for Published {
// --snip--
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
fn content<'a>(&self, post: &'a Post) -> &'a str {
&post.content
}
}
content ke trait StateKita nambahin sebuah implementasi default (bawaan) buat method content yang
mengembalikan string slice kosong. Itu artinya kita tidak perlu repot-repot
mengimplementasikan content di struct Draft dan PendingReview. Nah, buat struct
Published, kita bakal menimpa (override) method content ini lalu mengembalikan
nilai yang ada di post.content. Walaupun praktis, membiarkan method content di
State yang menentukan apa isi dari content milik Post itu agak mengaburkan garis
batas antara apa yang jadi tanggung jawab State dan apa yang jadi tanggung
jawab Post.
Perhatikan bahwa kita juga butuh anotasi lifetime (waktu hidup) di method ini,
seperti yang kita bahas di Bab 10. Kita menerima referensi ke sebuah post sebagai
argumen dan mengembalikan sebuah referensi ke bagian dari post itu, jadi lifetime
dari referensi yang dikembalikan itu berkaitan erat sama lifetime dari argumen
post tersebut.
Dan kita udah selesai—semua kode di Listing 18-11 sekarang berjalan sesuai rencana!
Kita sudah berhasil mengimplementasikan state pattern dengan aturan-aturan dari
workflow postingan blog. Logika yang berkaitan sama aturan-aturannya kini hidup di
dalam state objects ketimbang bertebaran (scattered) ke mana-mana di dalam Post.
Kenapa Tidak Pake Enum Aja?
kita mungkin bingung dan nanya-nanya kenapa kita tidak pakai sebuah enum aja buat
berbagai macam kemungkinan state postingan tersebut sebagai varian-variannya.
Itu emang salah satu solusi yang mungkin; cobain aja terus bandingin hasil akhirnya
buat ngelihat mana yang lebih kita suka! Satu kekurangan dari memakai enum adalah
di setiap tempat yang ngecek nilai dari enum itu, kita bakal butuh ekspresi match
atau sejenisnya buat menangani setiap kemungkinan varian yang ada. Ini bisa jadi
jauh lebih berulang-ulang (repetitive) ketimbang solusi yang memakai trait
object ini.
Trade-offs dari State Pattern
Kita udah nunjukin kalau Rust itu mampu buat mengimplementasikan state pattern
ala object-oriented buat mengenkapsulasi (encapsulate) berbagai jenis perilaku yang
seharusnya dimiliki sama sebuah postingan di tiap state-nya. Method-method pada Post
sama sekali tidak tahu soal perilaku yang bermacam-macam itu. Dengan cara kita menata
kode ini, kita cuma perlu ngecek di satu tempat buat tahu bermacam-macam cara
gimana sebuah postingan yang di-publish bisa berperilaku: yaitu di implementasi
trait State pada struct Published.
Seandainya kita membikin implementasi alternatif yang tidak pakai state pattern,
kita mungkin bakal milih buat pakai ekspresi match di dalam method-method pada
Post atau bahkan di dalam kode main yang ngecek state dari postingannya dan
mengubah perilaku di tempat-tempat itu. Itu artinya kita harus ngecek di beberapa tempat
buat bisa paham semua implikasi dari sebuah postingan saat ia berada di state
published.
Dengan state pattern, method-method di Post dan tempat-tempat di mana kita memakai
Post tidak butuh ekspresi match, dan buat nambahin sebuah state baru, kita cuma
perlu nambahin satu struct baru lalu mengimplementasikan trait methods pada struct
baru itu di satu tempat aja.
Implementasi yang memakai state pattern ini gampang sekali buat diperluas buat nambahin lebih banyak fungsionalitas. Buat ngelihat sendiri seberapa simpelnya memelihara (maintaining) kode yang pakai state pattern, coba deh beberapa saran ini:
- Tambahin method
reject(tolak) yang ngubah state postingan dariPendingReviewbalik lagi keDraft. - Wajibkan (require) dua panggilan ke
approvesebelum state-nya bisa berubah jadiPublished. - Izinkan (allow) user buat nambahin teks konten cuma pas postingan lagi ada
di state
Draft. Petunjuk: biarin state object yang bertanggung jawab soal apa yang mungkin berubah terkait konten, tapi jangan biarin dia bertanggung jawab buat memodifikasiPostsecara langsung.
Satu kelemahan dari state pattern ini adalah karena states-nya sendirilah yang
mengimplementasikan proses transisi (perpindahan) ke state lain, beberapa states
jadi terikat (coupled) satu sama lain. Kalau kita nambahin state lain di antara
PendingReview dan Published, seperti Scheduled (dijadwalkan), kita harus mengubah
kode di PendingReview buat bertransisi ke Scheduled sebagai gantinya. Bakal lebih
sedikit kerjaannya kalau seandainya PendingReview tidak perlu diubah pas kita
nambahin state baru, tapi itu berarti kita harus beralih ke desain pola yang beda
(another design pattern).
Kelemahan lainnya adalah kita jadi menduplikasi beberapa logika. Buat menghilangkan
sebagian dari duplikasi ini, kita mungkin nyoba buat bikin implementasi default buat
method request_review dan approve di trait State yang selalu mengembalikan
self. Namun, ini tidak bakal jalan: pas kita pakai State sebagai trait object,
trait itu tidak tahu apa tipe konkret yang bakal jadi self-nya nanti, jadi
tipe kembalian (return type) itu tidak bisa diketahui saat compile time. (Ini
adalah salah satu dari aturan dyn compatibility yang udah disebutin sebelumnya.)
Duplikasi lainnya termasuk implementasi method request_review dan approve
yang mirip-mirip di Post. Kedua method itu memakai Option::take dengan field
state milik Post, dan kalau state itu isinya Some, mereka mendelegasikannya
ke implementasi nilai yang dibungkus tersebut buat method yang sama lalu menge-set
nilai baru dari field state dengan hasil panggilannya. Kalau kita punya
sangat banyak method di Post yang ngikutin pola ini, kita mungkin bakal
pertimbangin buat mendefinisikan sebuah macro buat ngebuang pengulangan ini
(lihat “Macros” di Bab 20).
Dengan mengimplementasikan state pattern persis kayak yang didefinisikan buat bahasa
pemrograman object-oriented, kita nyatanya tidak memanfaatkan kekuatan unggulan Rust
semaksimal mungkin. Mari kita lihat beberapa perubahan yang bisa kita lakukan pada
crate blog yang bisa ngebikin invalid states (state yang tidak valid) dan
transisi yang salah menjadi error saat compile-time (compile-time errors).
Menge-encode States dan Perilaku (Behavior) ke dalam Types (Tipe)
Kita bakal nunjukin gimana cara memikirkan ulang (rethink) state pattern buat dapat kumpulan trade-offs yang berbeda. Ketimbang mengenkapsulasi states dan transisi secara keseluruhan supaya kode luar tidak tahu apa-apa soal mereka, kita bakal menge-encode (menyandikan) states ke dalam tipe-tipe yang berbeda-beda. Konsekuensinya, sistem pengecekan tipe Rust (type checking system) bakal mencegah usaha (attempts) buat memakai postingan draft di tempat-tempat yang mana cuma postingan yang udah published (dipublikasikan) yang boleh dipakai, dengan ngeluarin error compiler.
Mari kita perhatikan bagian awal dari main di Listing 18-11:
use blog::Post;
fn main() {
let mut post = Post::new();
post.add_text("I ate a salad for lunch today");
assert_eq!("", post.content());
post.request_review();
assert_eq!("", post.content());
post.approve();
assert_eq!("I ate a salad for lunch today", post.content());
}
Kita tetep mau ngasih kemampuan buat bikin postingan baru di state draft memakai
Post::new dan kemampuan buat nambahin teks ke dalam konten postingannya. Tapi
ketimbang punya sebuah method content pada postingan draft yang cuma ngembaliin
string kosong, kita malah bakal membikin supaya postingan draft itu sama sekali
tidak punya method content. Dengan begitu, kalau kita nyoba ngambil konten
dari postingan draft, kita bakal dapat error compiler yang ngasih tahu kita
kalau method itu tidak eksis. Sebagai hasilnya, bakal jadi mustahil bagi kita
buat secara tidak sengaja menampilkan konten dari postingan draft pas udah masuk
production (produksi), karena kodenya aja tidak bakal bisa di-compile.
Listing 18-19 nunjukin definisi struct Post dan struct DraftPost, beserta method
yang ada pada keduanya.
pub struct Post {
content: String,
}
pub struct DraftPost {
content: String,
}
impl Post {
pub fn new() -> DraftPost {
DraftPost {
content: String::new(),
}
}
pub fn content(&self) -> &str {
&self.content
}
}
impl DraftPost {
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
}
Post yang punya method content dan DraftPost yang tidak punya method contentBaik struct Post maupun DraftPost punya field private bernama content yang
nyimpen teks dari postingan blog tersebut. Struct-struct ini tidak lagi punya
field state karena kita udah mindahin encoding dari state-nya ke dalam tipe
dari struct-struct itu. Struct Post bakal merepresentasikan sebuah postingan yang
udah di-publish, dan dia punya method content yang bakal ngembaliin nilai dari
content.
Kita tetap punya fungsi Post::new, tapi ketimbang mengembalikan sebuah instance dari
Post, dia sekarang mengembalikan sebuah instance dari DraftPost. Karena content
itu sifatnya private dan tidak ada fungsi lain yang ngembaliin Post, maka mustahil
buat membikin sebuah instance dari Post saat ini.
Struct DraftPost punya sebuah method add_text, jadi kita bisa nambahin teks ke
dalam content sama kayak sebelumnya, tapi perhatikan kalau DraftPost tidak
punya method content yang didefinisikan! Jadi sekarang programnya memastikan
kalau semua postingan selalu bermula sebagai postingan draft, dan postingan
draft ini belum punya konten yang bisa ditampilkan. Usaha apa pun buat ngakalin
atau ngelewatin batasan-batasan ini bakal ngasilin error compiler.
Lalu gimana dong caranya kita dapet postingan yang udah ke-publish? Kita mau
menegakkan aturan bahwa sebuah postingan draft itu wajib di-review dan
di-approve (disetujui) sebelum bisa di-publish. Postingan yang lagi ada di
state pending review (nunggu review) juga seharusnya masih belum bisa nampilin
konten apa pun. Mari kita implementasikan batasan-batasan ini dengan menambahkan struct
baru, PendingReviewPost, lalu mendefinisikan method request_review pada
DraftPost yang bakal mengembalikan PendingReviewPost dan mendefinisikan method
approve pada PendingReviewPost yang bakal mengembalikan Post, seperti yang
ditunjukin di Listing 18-20.
pub struct Post {
content: String,
}
pub struct DraftPost {
content: String,
}
impl Post {
pub fn new() -> DraftPost {
DraftPost {
content: String::new(),
}
}
pub fn content(&self) -> &str {
&self.content
}
}
impl DraftPost {
// --snip--
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn request_review(self) -> PendingReviewPost {
PendingReviewPost {
content: self.content,
}
}
}
pub struct PendingReviewPost {
content: String,
}
impl PendingReviewPost {
pub fn approve(self) -> Post {
Post {
content: self.content,
}
}
}
PendingReviewPost yang dibikin lewat manggil request_review pada DraftPost dan method approve yang mengubah PendingReviewPost jadi sebuah Post yang ke-publishMethod request_review dan approve mengambil kepemilikan (ownership) dari self,
dengan begitu mengonsumsi instance DraftPost dan PendingReviewPost lalu mengubah
(transforming) mereka secara berurutan menjadi PendingReviewPost dan Post yang
ke-publish. Dengan cara ini, kita tidak bakal punya sisa-sisa (lingering) instance
dari DraftPost setelah kita memanggil request_review pada mereka, dan seterusnya.
Struct PendingReviewPost tidak punya method content padanya, jadi usaha buat
ngebaca kontennya bakal menghasilkan error compiler, persis kayak DraftPost. Karena
satu-satunya cara buat dapetin instance Post yang udah ke-publish (yang mana emang
punya method content padanya) adalah dengan manggil method approve pada sebuah
PendingReviewPost, dan satu-satunya cara buat dapetin PendingReviewPost adalah
dengan manggil method request_review pada sebuah DraftPost, kita sekarang
telah berhasil menge-encode workflow dari postingan blog ini ke dalam sistem tipe
(type system) di Rust.
Tapi kita juga harus membikin beberapa perubahan kecil di main. Method
request_review dan approve mengembalikan instance baru alih-alih memodifikasi
struct tempat mereka dipanggil, jadi kita perlu nambahin assignment shadowing
let post = lagi buat nyimpen instance-instance baru yang dikembalikan itu. Kita
juga tidak bisa lagi punya penegasan (assertions) soal postingan draft maupun
postingan yang nunggu review punya konten string kosong, tapi kita juga tidak butuh
penegasan itu lagi kok: kita emang udah tidak bisa lagi nge-compile kode yang mencoba
buat memakai konten dari postingan yang ada di states tersebut. Kode yang udah
di-update di main ini ditunjukin di Listing 18-21.
use blog::Post;
fn main() {
let mut post = Post::new();
post.add_text("I ate a salad for lunch today");
let post = post.request_review();
let post = post.approve();
assert_eq!("I ate a salad for lunch today", post.content());
}
main buat memakai implementasi yang baru dari workflow postingan blog kitaPerubahan-perubahan yang perlu kita lakuin di main buat me-reassign nilai ke
post berarti kalau implementasi ini udah tidak bener-bener ngikutin state pattern
ala object-oriented lagi: transisi-transisi antara states tersebut udah tidak lagi
dienkapsulasi sepenuhnya di dalam implementasi Post. Namun, keuntungan yang kita
dapat adalah bahwa invalid states sekarang jadi mustahil terjadi berkat sistem
tipe dan type checking (pengecekan tipe) yang terjadi saat compile time! Ini
memastikan kalau beberapa jenis bugs tertentu, seperti nge-display (nampilin) konten
dari postingan yang belum di-publish, bakal ketahuan jauh-jauh sebelum kodenya
berhasil masuk ke production.
Cobalah beberapa tugas yang disarankan di awal bagian ini pada crate blog
setelah memakai desain yang ada di Listing 18-21 buat melihat apa pendapat kita
soal desain dari versi kode yang ini. Perhatikan kalau beberapa dari tugas
tersebut mungkin emang udah terselesaikan secara natural di desain yang ini.
Kita udah melihat kalau walaupun Rust itu mampu mengimplementasikan desain pola object-oriented, pola-pola lain, kayak nge-encode state ke dalam sistem tipe, juga tersedia dan bisa diimplementasikan di Rust. Pola-pola ini punya kumpulan trade-offs yang beda-beda. Walaupun kita mungkin udah familier sekali sama pola-pola object-oriented, memikirkan kembali (rethinking) masalahnya buat memanfaatkan fitur-fitur dari Rust bisa ngasih banyak keuntungan, seperti mencegah munculnya bugs tertentu saat compile time. Pola-pola object-oriented tidak bakal selalu jadi solusi yang terbaik di Rust karena adanya fitur-fitur tertentu, seperti ownership, yang mana memang tidak dipunyai oleh bahasa-bahasa pemrograman object-oriented lainnya.
Ringkasan
Terlepas dari apakah kita mikir kalau Rust itu adalah sebuah bahasa yang object-oriented atau bukan setelah baca bab ini, kita sekarang udah tahu kalau kita bisa memakai trait objects buat dapetin beberapa fitur ala object-oriented di Rust. Dynamic dispatch bisa ngasih kode kita sedikit keluwesan (flexibility) yang harus dibayar dengan sedikit pinalti di performa runtime. Kita bisa memakai keluwesan ini buat mengimplementasikan pola-pola object-oriented yang bisa ngebantu kode kita supaya lebih gampang dipelihara (maintainability). Rust juga punya fitur lain, kayak ownership, yang tidak dipunyai sama bahasa-bahasa object-oriented pada umumnya. Sebuah pola object-oriented tidak bakal selalu jadi cara yang paling oke buat memanfaatkan kekuatan dari Rust, tapi dia jelas adalah salah satu pilihan yang tersedia.
Berikutnya, kita bakal melihat patterns (pola), yang mana merupakan fitur Rust lainnya yang juga ngasih keluwesan tingkat tinggi. Kita udah ngelihat sedikit soal pola-pola ini di sepanjang buku, tapi kita belum ngelihat potensi penuh dari kemampuan mereka. Ayo gas!