Pola Pemrograman Defensif di Rust
(corrode.dev)- Memperkenalkan kebiasaan menulis kode untuk mencegah bug sejak awal dengan memanfaatkan secara aktif sistem tipe dan compiler Rust
- Menunjukkan contoh bau kode (Code Smell) yang rapuh seperti pengindeksan vektor, penyalahgunaan
Default,matchyang tidak lengkap, dan parameter boolean yang tidak perlu, serta menjelaskan alternatifnya - Prinsip utamanya adalah merancang struktur agar compiler memaksa invarian, dengan memanfaatkan pattern matching, field privat, dan atribut
#[must_use] - Menyajikan secara konkret teknik defensif di tingkat kode nyata seperti penggunaan
TryFrom, pembongkaran struct secara lengkap, mutabilitas sementara, dan validasi di konstruktor - Pola-pola ini penting untuk menjaga stabilitas saat refactoring dan meningkatkan maintainability jangka panjang
Gambaran umum pemrograman defensif
- Titik yang diberi komentar
// this should never happenadalah lokasi ketika invarian implisit rusak- Dalam banyak kasus, pengembang tidak mempertimbangkan semua kondisi batas atau perubahan kode di masa depan
- Compiler Rust menjamin keamanan memori, tetapi kesalahan logika bisnis tetap bisa terjadi
- Pola kebiasaan kecil (idiom) yang diperoleh dari pengalaman kerja bertahun-tahun dapat sangat meningkatkan kualitas kode
Code Smell: pengindeksan vektor
- Bentuk
if !vec.is_empty() { let x = &vec[0]; }memiliki risiko panic saat runtime karena pemeriksaan panjang dan pengindeksan terpisah - Dengan menggunakan pattern matching pada slice (
match vec.as_slice()), compiler memaksa pemeriksaan semua keadaan- Semua kasus seperti vektor kosong, satu elemen, atau elemen duplikat dapat ditangani secara eksplisit
- Ini adalah contoh representatif dari desain yang membuat compiler menjamin invarian
Code Smell: penggunaan Default yang sembarangan
..Default::default()menimbulkan masalah risiko field terlewat saat field baru ditambahkan dan penetapan nilai implisit- Jika semua field diinisialisasi secara eksplisit, compiler akan memaksa field baru untuk ikut diatur
- Dengan bentuk
let Foo { field1, field2, .. } = Foo::default();, dimungkinkan membongkar struct default lalu menimpa sebagian field secara selektif- Ini menjaga keseimbangan antara mempertahankan nilai default dan override yang eksplisit
Code Smell: implementasi trait yang rapuh
- Saat membandingkan dengan membongkar seluruh field struct, penambahan field baru akan memunculkan error kompilasi sebagai peringatan
- Contoh: saat mengimplementasikan
PartialEq, gunakanlet Self { size, toppings, .. } = self;
- Contoh: saat mengimplementasikan
- Jika field baru seperti
extra_cheeseditambahkan, logika perbandingan dipaksa untuk ditinjau ulang - Prinsip yang sama juga dapat diterapkan pada trait lain seperti
Hash,Debug, danClone
Code Smell: perlu TryFrom alih-alih From
- Jika konversi tidak selalu berhasil, gunakan
TryFromuntuk menyatakan kemungkinan kegagalan alih-alihFrom - Penggunaan
unwrap_or_elseadalah sinyal bahwa kegagalan potensial sedang disembunyikan, dan pendekatan fail fast lebih aman
Code Smell: match yang tidak lengkap
- Pola catch-all seperti
_ => {}berisiko membuat kasus baru terlewat saat variant baru ditambahkan - Jika semua variant dicantumkan secara eksplisit, compiler akan memperingatkan ketika ada kasus baru yang belum ditangani
- Logika yang sama dapat dikelompokkan dalam bentuk
Variant3 | Variant4
Code Smell: penyalahgunaan placeholder _
- Jika hanya menggunakan
_, tidak jelas variabel mana yang dihilangkan - Nama eksplisit seperti
has_fuel: _, has_crew: _meningkatkan keterbacaan
Pattern: mutabilitas sementara (Temporary Mutability)
- Saat data hanya perlu mutable selama inisialisasi, gunakan bentuk
let mut data = ...; data.sort(); let data = data; - Dengan memanfaatkan block scope, variabel sementara tidak terekspos ke luar
- Contoh:
let data = { let mut d = get_vec(); d.sort(); d };
- Contoh:
- Ini memungkinkan pemisahan cakupan yang jelas dalam proses inisialisasi yang memakai beberapa variabel sementara
Pattern: memaksa validasi konstruktor
- Saat membuat struct, pastikan logika validasi wajib dilalui
- Jika field
_private: ()ditambahkan, pembuatan langsung dari luar menjadi tidak mungkin - Atribut
#[non_exhaustive]mencegah konstruksi dari luar crate dan memberi sinyal adanya perluasan di masa depan
- Jika field
- Jika ingin memaksanya juga di modul internal, gunakan struktur modul bertingkat dengan tipe privat (
Seal)- Karena
Sealhanya ada di internal, pembuatan langsung selain melaluinew()menjadi tidak mungkin
- Karena
- Dengan menjadikan field privat dan menyediakan getter, keadaan immutable dapat dipertahankan
- Kriteria penerapan
- Memblokir kode eksternal:
_privateatau#[non_exhaustive] - Memblokir kode internal: modul privat +
Seal - Mengubah logika validasi menjadi jaminan di tingkat compiler
- Memblokir kode eksternal:
Pattern: memanfaatkan atribut #[must_use]
#[must_use]mencegah nilai return penting diabaikan- Contoh:
#[must_use = "Configuration must be applied to take effect"]
- Contoh:
- Jika pengguna mengabaikan nilai return, compiler akan mengeluarkan peringatan
- Ini adalah mekanisme defensif yang sederhana tetapi kuat, dan juga banyak dipakai di standard library seperti
Result
Code Smell: parameter boolean
- Bentuk
fn process_data(..., compress: bool, encrypt: bool, validate: bool)memiliki makna yang tidak jelas dan risiko salah urutan - Gunakan
enum Compression,enum Encryption, dan sebagainya untuk mengekspresikan maksud secara eksplisit - Jika ada banyak opsi, gunakan parameter struct (Params struct)
- Metode preset seperti
ProcessDataParams::production()meningkatkan reusabilitas
- Metode preset seperti
- Saat opsi baru ditambahkan, dampak pada titik pemanggilan yang ada dapat diminimalkan
Otomatisasi dengan Clippy lints
- Pola defensif utama dapat diperiksa secara otomatis dengan lint Clippy
indexing_slicing: melarang pengindeksan langsungfallible_impl_from: menyarankanTryFromalih-alihFromwildcard_enum_match_arm: melarang pola_fn_params_excessive_bools: peringatan untuk parameter boolean yang terlalu banyakmust_use_candidate: menyarankan kandidat#[must_use]
- Dapat diterapkan ke seluruh proyek melalui
#![deny(clippy::...)]atau pengaturan diCargo.toml
Kesimpulan
- Inti pemrograman defensif adalah memanfaatkan sistem tipe dan compiler Rust secara aktif untuk membuat invarian menjadi eksplisit dan dapat diverifikasi
- Pola-pola ini membantu menjaga stabilitas saat refactoring, meminimalkan kemungkinan bug, dan memperkuat maintainability jangka panjang
- Ini adalah pendekatan yang menjalankan prinsip: “bug yang tidak bisa dikompilasi adalah bug terbaik”
1 komentar
Komentar Hacker News
Tulisannya bagus. Namun, contoh PizzaOrder terasa seperti memasukkan terlalu banyak concern ke dalam satu struct
Jika tujuannya adalah mengecualikan
ordered_atdari perbandingan, menurut saya lebih baik memisahkannya menjadi dua struct:PizzaDetailsdanPizzaOrderDengan begitu, saat mengimplementasikan
PartialEq, bisa dibuat jelas bahwa yang dibandingkan hanyadetailsJika waktu pemesanan berbeda, maka itu bukan pesanan yang sama, jadi mendefinisikannya setara di level tipe itu berbahaya
Menaruh
PartialEqpadaPizzaDetailstidak masalah, tetapi logika perbandingan pesanan seharusnya diletakkan di fungsi bisnis yang terpisahPizzaDetailsdiubah, perubahan itu bisa memengaruhi logika deduplikasi pizzaIdealnya, struct hanya digunakan untuk mengelompokkan data
Agar perubahan tidak berdampak ke tempat lain, bisa juga dipertimbangkan tipe terpisah seperti
PizzaComparatoratauPizzaFlavorAkan bagus jika, seperti di Protobuf, kita bisa memberi anotasi field seperti
{important_to_flavour=true}Misalnya, bagaimana pemisahannya jika kita ingin membandingkan string tanpa membedakan huruf besar-kecil?
Salah satu hal yang sangat keren di Rust adalah sering kali pemrograman defensif tidak diperlukan
Berkat aturan ownership dan reference, kita bisa mendapat jaminan bahwa akses ke objek tertentu itu unik di seluruh program
Reference tidak bisa bernilai null, dan smart pointer juga tidak bisa bernilai null
Sistem tipe juga menjamin bahwa setelah ownership
selfdipindahkan, pemanggilan metode berikutnya tidak mungkin dilakukanKarena itu, keamanan thread, lifetime, kemampuan cloning, dan sebagainya dapat diverifikasi secara global saat waktu kompilasi
Di bahasa lain, manfaat yang didapat dengan menjaga immutability lewat gaya fungsional dipaksakan oleh Rust melalui sistem tipe
Topik artikelnya adalah bug logis yang tidak tertangkap bahkan oleh borrow checker
Rasanya bijak untuk menghindari indexing langsung ke array atau vector
Pada hari terjadinya insiden unwrap Cloudflare, saya juga menemukan bug ketika slice melewati akhir vector
Setelah itu saya beralih ke pendekatan berbasis iterator dan rasanya jauh lebih aman
unwrapdi Rust sama sepertiassertdi C. Jika gagal, itu hanya berfungsi memberi tahu adanya masalahDi Rust pun bug tetap bisa ditulis
Salah satu kebiasaan yang perlu diwaspadai pengembang Rust adalah menambahkan dependensi crate yang tidak perlu
Rust cenderung mendorong kebiasaan seperti ini. Misalnya, penggunaan crate
randsebagai contoh dasar di Rust Book ikut menciptakan suasana semacam ituTentu ini adalah pilihan strategis agar paket terkait kriptografi bisa diganti dengan mudah, tetapi tetap saja menjadi kebiasaan adalah masalah
Tapi belakangan saya memahami maksudnya dan pandangan saya berubah
Implementasi partial equality itu menarik
Hal lain yang membuat saya penasaran adalah cara memakai enum saat menghindari parameter boolean
Saya biasanya memakai struct pembungkus bool, tetapi sayang tidak bisa diperlakukan seperti bool biasa
Saya jadi penasaran apakah ada cara memakai enum seperti bool
Logika yang diperlukan saya bungkus dalam Trait, atau saya tambahkan metode bersama di blok
impl <Enum>Dengan begitu, keterbacaan lebih baik dan perilaku tiap anggota bisa didefinisikan dengan jelas
impl Deref, tetapi saya tidak yakin itu ide yang bagusPernyataan
matchpada contoh pertama terasa berlebihanVec.first()atauVec.iter().nth(0)lebih jelas dan lebih sesuai dengan maksudnyamatchjustru menjadi solusi yang lebih rumit daripada masalahnyaJika
ifbisa dihapus, makamatchjuga bisa dihapus, jadi tidak ada perbedaan dari sisi keamananfirst()jauh lebih ringkas dan jelasmatchtetap bermakna karena mendorong kita untuk juga menangani kasus “saat ada satu atau lebih elemen”Dengan kata lain, ini menegaskan prinsip hindari pemisahan antara pemeriksaan dan kode yang bergantung padanya
Setiap kali membaca tulisan seperti ini, saya jadi bertanya-tanya kenapa tidak ada tim khusus yang memantau pola kode
Akan bagus jika ada tim yang, seperti SOC atau QA, mengamati pola dalam codebase dalam jangka panjang
Alat pendeteksi code smell otomatis punya keterbatasan
Mereka menangani aturan lint, dokumentasi, pelatihan pengembang, dan pemeliharaan pustaka bersama
Jika banyak tim mengulangi masalah yang sama, mereka merancang API inti yang bisa menyatukannya
Hanya saja, jika kodenya sudah mencapai jutaan baris, kenyataannya pengelolaannya sangat sulit
Saya sedang memikirkan bagaimana mendorong pola penulisan kode yang baik seperti ini di dalam tim
Saat code review, ini sering berkembang menjadi “perdebatan gaya” dan menjadi tidak produktif
Tapi anehnya, ketika linter mengeluarkan peringatan, perdebatan seperti itu hampir hilang
Penambahan trait
TryFromdi versi 1.34 benar-benar sangat bergunaBisa jadi kode yang memakai
unwrap_or_else()adalah sisa dari masa sebelum ituDokumentasi trait From sekarang menjelaskan dengan sangat jelas kapan trait itu sebaiknya diimplementasikan
unwrap_or_else()terdengar lucu bagi saya, seolah seperti “memerintah komputer sambil mengancamnya”Saya rasa pola pemrograman defensif seperti ini juga akan membantu meningkatkan kualitas pembuatan kode AI skala besar
Umpan balik konkret yang diberikan Clippy dan compiler Rust bisa berperan besar membantu agen AI mengurangi kesalahan dan menemukan arah yang tepat