Lewati ke konten

Design Pattern di Rust

Design pattern (pola desain) adalah cara yang sudah terbukti untuk menyelesaikan masalah yang terus muncul berulang kali. Pattern bukan library yang kamu instal. Pattern lebih mirip resep: “kalau kamu punya masalah ini, bentuk kodemu seperti ini”.

Banyak pattern dari Java atau C# terlihat berbeda di Rust, atau bahkan tidak diperlukan sama sekali. Rust punya pattern favoritnya sendiri, yang dibangun di atas sistem tipenya yang kuat, enum, trait, dan ownership (kepemilikan). Ide besar di balik sebagian besar pattern itu sama: biarkan compiler menangkap kesalahan untukmu, supaya kesalahan itu tidak pernah sampai ke pengguna. Setiap bagian di bawah dimulai dengan masalahnya, lalu menunjukkan pattern yang menyelesaikannya.

Masalahnya. f64 biasa bisa berarti apa saja: meter, kaki (feet), detik, euro. Kalau sebuah fungsi menerima f64 dalam meter dan kamu mengirim nilai dalam kaki, compiler tidak bisa membantu, karena keduanya sama-sama f64. (Salah satuan seperti inilah yang terkenal membuat NASA kehilangan Mars Climate Orbiter pada tahun 1999.)

Pattern-nya. Bungkus nilainya dalam struct kecil dengan satu field. Sekarang Meters dan Feet adalah tipe yang berbeda, dan Rust tidak akan membiarkanmu mencampurnya:

#[derive(Debug, Clone, Copy)]
struct Meters(f64);
#[derive(Debug, Clone, Copy)]
struct Feet(f64);
impl Feet {
fn to_meters(self) -> Meters {
Meters(self.0 * 0.3048)
}
}
fn safe_altitude(height: Meters) -> bool {
height.0 >= 300.0
}
fn main() {
let tower = Meters(330.0);
let plane = Feet(900.0);
println!("Menara aman? {}", safe_altitude(tower));
println!("900 kaki = {:.1} meter", plane.to_meters().0);
println!("Pesawat aman? {}", safe_altitude(plane.to_meters()));
}

Output:

Menara aman? true
900 kaki = 274.3 meter
Pesawat aman? false
▶ Coba sendiri

Kalau kamu lupa mengonversi dan menulis safe_altitude(plane), programnya bahkan tidak bisa di-compile:

error[E0308]: mismatched types
--> src/main.rs:13:34
|
13 | println!("{}", safe_altitude(plane));
| ------------- ^^^^^ expected `Meters`, found `Feet`
| |
| arguments to this function are incorrect

Compiler bilang tipenya tidak cocok: fungsi mengharapkan Meters, tapi menerima Feet.

Newtype sama sekali tidak menambah biaya saat runtime: di memori, Meters persis sama dengan sebuah f64. Pakai newtype untuk ID (UserId(u32) vs OrderId(u32)), satuan, dan string yang sudah divalidasi. Newtype juga membantumu mengakali orphan rule (lihat Trait lanjutan).

Builder: banyak opsi, satu hasil yang jelas

Section titled “Builder: banyak opsi, satu hasil yang jelas”

Masalahnya. Sebagian struct punya banyak field, dan sebagian besar opsional. Konstruktor seperti Email::new("[email protected]", "Hi", "", vec![], false) sulit dibaca: false yang mana artinya apa? Lalu bagaimana kalau alamatnya kosong atau salah?

Pattern-nya. Struct builder terpisah mengumpulkan pengaturan lewat method-method kecil yang punya nama. Setiap method mengembalikan builder-nya, jadi kamu bisa merangkai (chain) pemanggilan. Di akhir, build() memeriksa semuanya dan mengembalikan Result:

struct Email {
to: String,
subject: String,
body: String,
urgent: bool,
}
#[derive(Default)]
struct EmailBuilder {
to: Option<String>,
subject: Option<String>,
body: String,
urgent: bool,
}
impl EmailBuilder {
fn new() -> Self {
EmailBuilder::default()
}
fn to(mut self, address: &str) -> Self {
self.to = Some(address.to_string());
self
}
fn subject(mut self, subject: &str) -> Self {
self.subject = Some(subject.to_string());
self
}
fn body(mut self, text: &str) -> Self {
self.body = text.to_string();
self
}
fn urgent(mut self) -> Self {
self.urgent = true;
self
}
fn build(self) -> Result<Email, String> {
let to = self.to.ok_or("email butuh penerima")?;
if !to.contains('@') {
return Err(format!("'{}' bukan alamat email", to));
}
let subject = self.subject.unwrap_or_else(|| String::from("(tanpa subjek)"));
Ok(Email { to, subject, body: self.body, urgent: self.urgent })
}
}
fn show(result: Result<Email, String>) {
match result {
Ok(e) => println!("Siap: to={} subject={} body={:?} urgent={}", e.to, e.subject, e.body, e.urgent),
Err(msg) => println!("Error: {}", msg),
}
}
fn main() {
let email = EmailBuilder::new()
.subject("Makan siang?")
.body("Pizza jam 12?")
.urgent()
.build();
show(email);
show(EmailBuilder::new().to("[email protected]").build());
show(EmailBuilder::new().subject("Halo").build());
show(EmailBuilder::new().to("ana.example.com").build());
}

Output:

Siap: [email protected] subject=Makan siang? body="Pizza jam 12?" urgent=true
Siap: [email protected] subject=(tanpa subjek) body="" urgent=false
Error: email butuh penerima
Error: 'ana.example.com' bukan alamat email
▶ Coba sendiri

Setiap setter menerima mut self (mengambil ownership builder-nya), mengubah satu field, lalu mengembalikan builder itu. Itulah yang membuat rangkaian pemanggilan bisa berjalan. Pemanggil hanya mengatur yang penting baginya, nama method menjelaskan setiap nilai, dan build() menjadi satu-satunya tempat pemeriksaan aturan. Di dalamnya, ok_or mengubah None (penerima kosong) menjadi Err, dan ? langsung mengembalikan error itu lebih awal. Kamu akan menemukan builder di banyak crate, misalnya std::thread::Builder dan std::process::Command (lihat Environment dan proses).

Default dan struct update: pilihan yang ringan

Section titled “Default dan struct update: pilihan yang ringan”

Masalahnya. Builder itu butuh banyak kode. Kalau struct-mu tidak punya aturan yang perlu diperiksa, hanya nilai awal yang masuk akal, builder jadi berlebihan.

Pattern-nya. Implementasikan Default dengan nilai awal yang baik, lalu pakai struct update syntax .. untuk mengubah field yang kamu pedulikan saja:

#[derive(Debug, Clone)]
struct ServerConfig {
host: String,
port: u16,
workers: u32,
verbose: bool,
}
impl Default for ServerConfig {
fn default() -> Self {
ServerConfig {
host: String::from("127.0.0.1"),
port: 8080,
workers: 4,
verbose: false,
}
}
}
fn main() {
let dev = ServerConfig { verbose: true, ..Default::default() };
let prod = ServerConfig {
host: String::from("0.0.0.0"),
workers: 16,
..ServerConfig::default()
};
// Mulai dari config lain, bukan dari default:
let staging = ServerConfig { port: 9090, ..prod.clone() };
println!("{:?}", dev);
println!("{:?}", prod);
println!("{:?}", staging);
}

Output:

ServerConfig { host: "127.0.0.1", port: 8080, workers: 4, verbose: true }
ServerConfig { host: "0.0.0.0", port: 8080, workers: 16, verbose: false }
ServerConfig { host: "0.0.0.0", port: 9090, workers: 16, verbose: false }
▶ Coba sendiri

..sesuatu artinya “ambil setiap field yang tidak kutulis dari sesuatu”. Kenapa prod.clone()? Struct update memindahkan (move) field-field sisanya, dan host adalah String. Tanpa clone, sebagian isi prod sudah dipindahkan dan kamu tidak bisa mencetaknya lagi sesudahnya. Aturan yang bagus: pakai Default + .. kalau semua kombinasi nilai boleh, dan builder kalau ada kombinasi yang tidak valid.

Typestate: state machine yang diperiksa saat compile

Section titled “Typestate: state machine yang diperiksa saat compile”

Masalahnya. Ada hal yang harus terjadi berurutan. Sebuah web request butuh URL sebelum bisa dikirim. Dengan struct biasa, kamu akan memeriksanya saat runtime (“URL-nya sudah diisi? kalau belum, kembalikan error”) dan berharap setiap pemanggil menangani error itu.

Pattern-nya. Jadikan state sebagai bagian dari tipe. Request<NoUrl> memang tidak punya method send. Hanya dengan mengisi URL, ia berubah menjadi Request<HasUrl>, yang punya method itu:

// Dua state-nya. Keduanya tidak menyimpan data (atau hanya yang dibutuhkan state itu).
struct NoUrl;
struct HasUrl(String);
struct Request<U> {
url: U,
headers: Vec<(String, String)>,
}
impl Request<NoUrl> {
fn new() -> Self {
Request { url: NoUrl, headers: Vec::new() }
}
// Mengubah tipenya: Request<NoUrl> -> Request<HasUrl>
fn url(self, url: &str) -> Request<HasUrl> {
Request { url: HasUrl(url.to_string()), headers: self.headers }
}
}
// Tersedia di semua state
impl<U> Request<U> {
fn header(mut self, key: &str, value: &str) -> Self {
self.headers.push((key.to_string(), value.to_string()));
self
}
}
// Hanya tersedia setelah URL diisi
impl Request<HasUrl> {
fn send(self) -> String {
format!("GET {} dengan {} header", self.url.0, self.headers.len())
}
}
fn main() {
let response = Request::new()
.header("Accept", "text/html")
.url("https://example.com")
.header("User-Agent", "rust-tutorial")
.send();
println!("{}", response);
}

Output:

GET https://example.com dengan 2 header
▶ Coba sendiri

Sekarang coba kirim tanpa URL, Request::new().send(), dan compiler akan menghentikanmu:

error[E0599]: no method named `send` found for struct `Request<NoUrl>` in the current scope
--> src/main.rs:22:35
|
22 | let response = Request::new().send();
| ^^^^ method not found in `Request<NoUrl>`
|
= note: the method was found for `Request<HasUrl>`

Artinya: method send tidak ada di Request<NoUrl>, tapi ada di Request<HasUrl>. Bug ini tidak mungkin ada di program yang berhasil di-compile. Tidak ada pemeriksaan saat runtime dan tidak ada biaya tambahan: NoUrl berukuran nol byte. Karena url() menerima self secara langsung (by value), Request<NoUrl> yang lama sudah habis terpakai, jadi tidak ada yang bisa tanpa sengaja terus memakai state “sebelum”-nya. Pattern ini memakai generics dengan cara yang cerdik; ada baiknya kamu mencoba sedikit perubahan di Playground untuk merasakan cara kerjanya.

RAII dan Drop guard: pembersihan yang tidak mungkin terlupa

Section titled “RAII dan Drop guard: pembersihan yang tidak mungkin terlupa”

Masalahnya. Resource perlu dibersihkan: tutup file, buka kunci (lock), hapus folder sementara, cetak “selesai”. Kalau pembersihan itu berupa fungsi yang harus diingat untuk dipanggil, pasti ada yang lupa, terutama saat ada return lebih awal atau error.

Pattern-nya. Taruh pembersihannya di Drop. Rust memanggil drop secara otomatis saat sebuah nilai keluar dari cakupan (scope), di setiap jalan keluar: akhir yang normal, return lebih awal, ?, bahkan saat panic. Ini disebut RAII (“Resource Acquisition Is Initialization”: mendapatkan resource dan memiliki sebuah nilai adalah langkah yang sama). Nilai yang tugasnya hanya membersihkan disebut guard:

struct Guard {
name: String,
}
impl Guard {
fn new(name: &str) -> Guard {
println!("mulai {}", name);
Guard { name: name.to_string() }
}
}
impl Drop for Guard {
fn drop(&mut self) {
println!("bersihkan {}", self.name);
}
}
fn risky(fail: bool) -> Result<(), String> {
let _guard = Guard::new("tugas berisiko");
if fail {
return Err(String::from("terjadi kesalahan")); // guard tetap membersihkan
}
println!("tugas selesai");
Ok(())
}
fn main() {
{
let _block = Guard::new("blok");
println!("di dalam blok");
} // _block di-drop di sini
println!("{:?}", risky(false));
println!("{:?}", risky(true));
}

Output:

mulai blok
di dalam blok
bersihkan blok
mulai tugas berisiko
tugas selesai
bersihkan tugas berisiko
Ok(())
mulai tugas berisiko
bersihkan tugas berisiko
Err("terjadi kesalahan")
▶ Coba sendiri

Sebenarnya kamu sudah terus-menerus memakai pattern ini. File menutup dirinya sendiri saat di-drop. MutexGuard (dari mutex.lock(), lihat Konkurensi) membuka kunci mutex saat di-drop. Box membebaskan memorinya saat di-drop (Smart pointer). Itulah sebabnya kode Rust hampir tidak pernah punya pemanggilan “close” atau “free”.

Masalahnya. Fungsimu menerima String, jadi pemanggil yang punya &str harus menulis String::from("Ana") atau "Ana".to_string() setiap kali. Atau fungsimu menerima UserId, padahal pemanggil punya u32 biasa. Sepele, tapi menambah kode yang mengganggu di mana-mana.

Pattern-nya. Implementasikan From untuk tipe-tipemu, dan terima impl Into<T> di fungsimu. Dengan begitu pemanggil bisa mengirim apa saja yang bisa dikonversi:

#[derive(Debug)]
struct UserId(u32);
impl From<u32> for UserId {
fn from(n: u32) -> Self {
UserId(n)
}
}
#[derive(Debug)]
struct User {
id: UserId,
name: String,
}
impl User {
fn new(id: impl Into<UserId>, name: impl Into<String>) -> Self {
User { id: id.into(), name: name.into() }
}
}
fn main() {
let ana = User::new(1, "Ana"); // u32 dan &str
let ben = User::new(UserId(2), String::from("Ben")); // sudah tipe yang benar
println!("{:?}", ana);
println!("{:?}", ben);
}

Output:

User { id: UserId(1), name: "Ana" }
User { id: UserId(2), name: "Ben" }
▶ Coba sendiri

Implementasikan From, jangan pernah Into secara langsung; kamu mendapat Into secara gratis. Trik yang sama juga dipakai di penanganan error: ? memanggil From::from pada error-nya, jadi mengimplementasikan From<io::Error> for MyError membuat ? mengonversi error untukmu (lihat Custom error). Pakai ini untuk konversi yang tidak mungkin gagal. Kalau konversinya bisa gagal, pakai TryFrom, yang mengembalikan Result.

State machine dengan enum dan state yang mustahil

Section titled “State machine dengan enum dan state yang mustahil”

Masalahnya. Sebuah pesanan berpindah dari “di keranjang” ke “sudah dibayar” lalu ke “sudah dikirim”. Percobaan pertama sering terlihat seperti ini:

struct Order {
paid: bool,
shipped: bool,
tracking_number: Option<String>,
}

Tapi struct ini mengizinkan hal yang tidak masuk akal: shipped: true padahal paid: false, atau nomor resi pada pesanan yang belum dikirim. Setiap fungsi harus memeriksa kombinasi yang mustahil itu.

Pattern-nya. Buat state yang mustahil tidak bisa direpresentasikan. Pakai enum dengan satu variant untuk setiap state, dan beri setiap variant hanya data yang benar-benar dimiliki state itu. Perpindahan state berupa method yang menerima state lama dan mengembalikan state baru:

#[derive(Debug)]
enum Order {
Cart { items: Vec<String> },
Paid { items: Vec<String>, amount: u32 },
Shipped { tracking: String },
Cancelled,
}
impl Order {
fn pay(self, amount: u32) -> Order {
match self {
Order::Cart { items } => Order::Paid { items, amount },
other => other, // hanya keranjang yang bisa dibayar; selain itu tetap sama
}
}
fn ship(self, tracking: &str) -> Order {
match self {
Order::Paid { .. } => Order::Shipped { tracking: tracking.to_string() },
other => other,
}
}
fn cancel(self) -> Order {
match self {
Order::Shipped { .. } => self, // sudah terlambat untuk dibatalkan
_ => Order::Cancelled,
}
}
fn status(&self) -> String {
match self {
Order::Cart { items } => format!("keranjang berisi {} item", items.len()),
Order::Paid { items, amount } => format!("sudah dibayar {} untuk {} item", amount, items.len()),
Order::Shipped { tracking } => format!("sudah dikirim, resi {}", tracking),
Order::Cancelled => String::from("dibatalkan"),
}
}
}
fn main() {
let order = Order::Cart { items: vec![String::from("buku"), String::from("pena")] };
println!("{}", order.status());
let order = order.ship("XYZ"); // belum dibayar: tidak terjadi apa-apa
println!("{}", order.status());
let order = order.pay(25).ship("TRACK-42");
println!("{}", order.status());
let order = order.cancel(); // sudah dikirim
println!("{}", order.status());
let other = Order::Cart { items: vec![] }.cancel();
println!("{}", other.status());
}

Output:

keranjang berisi 2 item
keranjang berisi 2 item
sudah dikirim, resi TRACK-42
sudah dikirim, resi TRACK-42
dibatalkan
▶ Coba sendiri

Pesanan yang sudah dikirim tidak mungkin tidak punya nomor resi, karena variant Shipped selalu punya. Keranjang tidak mungkin punya nomor resi, karena tidak ada field untuk itu. Dan karena match harus mencakup setiap variant, menambahkan state baru (misalnya Refunded) membuat compiler menunjuk setiap tempat yang perlu kamu perbarui.

Enum atau trait object? Bahasa berorientasi objek sering memakai interface “State” dengan satu class untuk setiap state. Di Rust kamu bisa melakukannya dengan trait object (Box<dyn State>), dan Rust Book menunjukkan caranya. Begini cara memilihnya:

  • Enum: kamu sudah tahu semua state-nya dari awal. Kamu mendapat pemeriksaan match yang menyeluruh, dan semua logikanya ada di satu tempat. Ini pilihan yang biasa di Rust.
  • Trait object: orang lain (atau crate lain) perlu menambahkan state baru tanpa mengubah kodemu.
  • Typestate (dari bagian sebelumnya): kamu ingin urutan pemanggilan yang salah menjadi error saat compile, bukan sekadar diabaikan saat runtime. Lebih ketat, tapi state-nya harus sudah diketahui saat compile.

Menulis let _ = guard, bukan let _guard = guard. Keduanya terlihat hampir sama, tapi perilakunya berbeda. Nama yang diawali garis bawah (seperti _guard) menjaga nilainya tetap hidup sampai akhir scope. Sedangkan _ saja sama sekali bukan variabel, jadi nilainya langsung di-drop saat itu juga:

struct Guard;
impl Drop for Guard {
fn drop(&mut self) {
println!("guard di-drop");
}
}
fn main() {
let _ = Guard;
println!("bekerja dengan `let _`");
let _g = Guard;
println!("bekerja dengan `let _g`");
}

Output:

guard di-drop
bekerja dengan `let _`
bekerja dengan `let _g`
guard di-drop
▶ Coba sendiri

Dengan let _, pembersihannya berjalan sebelum pekerjaannya, dan itu jelas salah untuk lock atau timer. Untuk guard, selalu beri nama pada variabelnya.

Memakai pattern hanya karena pattern itu ada. Builder untuk struct dengan dua field, atau typestate untuk sesuatu yang tidak pernah disalahgunakan, hanya menambah kode tanpa membuat apa pun lebih aman. Mulailah dari yang sederhana. Pakai pattern saat kamu benar-benar merasakan masalah yang diselesaikannya.

  • Newtype (struct Meters(f64)) memberi data yang sama tipe yang berbeda, jadi compiler menangkap kesalahan campur aduk, tanpa biaya saat runtime.
  • Builder memberi pengaturan yang mudah dibaca dan bisa dirangkai, serta satu build() yang memvalidasi dan mengembalikan Result. Default + .. adalah pilihan yang lebih ringan kalau tidak ada yang perlu diperiksa.
  • Typestate menaruh state di dalam tipe (Request<NoUrl> vs Request<HasUrl>), jadi pemanggilan dengan urutan yang salah tidak bisa di-compile.
  • RAII / Drop guard menjalankan pembersihan secara otomatis di setiap jalan keluar dari sebuah scope.
  • From/Into membuat fungsi bisa menerima banyak tipe input dengan impl Into<T>.
  • Enum memodelkan state machine dan membuat state yang mustahil tidak bisa direpresentasikan; pakai trait object hanya kalau orang lain harus bisa menambahkan state.

Kuis singkat

1. Apa kelebihan newtype struct Meters(f64) dibanding f64 biasa?

2. Di pattern builder pada halaman ini, apa yang dikembalikan build()?

3. Di contoh typestate, kenapa kamu tidak bisa memanggil send() pada Request::new()?

4. Kapan pembersihan dari sebuah Drop guard dijalankan?

5. Apa yang dilakukan let _ = Guard;?

Skor: 0 / 5