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

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:

  1. Postingan blog bermula dari draft kosong.
  2. Saat draft-nya beres, sebuah review dari postingan itu diminta (requested).
  3. Saat postingannya disetujui (approved), dia bakal di-publish (dipublikasikan).
  4. 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.

Filename: src/main.rs
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());
}
Listing 18-11: Kode yang mendemonstrasikan perilaku yang kita pengen ada di crate blog kita

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

Filename: src/lib.rs
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 {}
Listing 18-12: Definisi dari struct Post dan fungsi new yang ngebikin instance Post baru, trait State, dan struct Draft

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

Filename: src/lib.rs
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 {}
Listing 18-13: Mengimplementasikan method add_text buat nambahin teks ke content di sebuah postingan

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

Filename: src/lib.rs
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 {}
Listing 18-14: Nambahin implementasi placeholder buat method content pada Post yang selalu ngembaliin string slice kosong

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

Filename: src/lib.rs
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
    }
}
Listing 18-15: Mengimplementasikan method request_review pada Post dan trait State

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

Filename: src/lib.rs
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
    }
}
Listing 18-16: Mengimplementasikan method approve pada Post dan trait State

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

Filename: src/lib.rs
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
    }
}
Listing 18-17: Meng-update method content pada Post buat mendelegasikan panggilan ke method content pada State

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

Filename: src/lib.rs
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
    }
}
Listing 18-18: Menambahkan method content ke trait State

Kita 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 dari PendingReview balik lagi ke Draft.
  • Wajibkan (require) dua panggilan ke approve sebelum state-nya bisa berubah jadi Published.
  • 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 memodifikasi Post secara 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:

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

Filename: src/lib.rs
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);
    }
}
Listing 18-19: Sebuah Post yang punya method content dan DraftPost yang tidak punya method content

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

Filename: src/lib.rs
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,
        }
    }
}
Listing 18-20: Sebuah PendingReviewPost yang dibikin lewat manggil request_review pada DraftPost dan method approve yang mengubah PendingReviewPost jadi sebuah Post yang ke-publish

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

Filename: src/main.rs
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());
}
Listing 18-21: Modifikasi ke main buat memakai implementasi yang baru dari workflow postingan blog kita

Perubahan-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!