Idiom pemodelan domain yang paling dikenal pada F# adalah membuat status ilegal tidak dapat direpresentasikan (make illegal states unrepresentable), yaitu merancang tipe sedemikian rupa sehingga data yang tidak logis tidak dapat dikonstruksi sama sekali. Pendekatan desain berbasis tipe ini dipopulerkan oleh buku Domain Modeling Made Functional karya Scott Wlaschin. Topik ini membahas transformasi bertahap dari record yang longgar menjadi model yang ketat melalui studi kasus arsip catatan.
Model longgar dan status ilegal#
Titik awalnya berupa record dengan tipe primitif, yaitu catatan tanya jawab antar siswa yang menyimpan pengarang, pertanyaan, jawaban, dan penanda terarsip. Model ini terasa sederhana, tetapi kombinasi fieldnya tidak dikawal oleh tipe apa pun.
// model longgar: kombinasi field yang tidak logis tetap lolos kompilasi
type CatatanLonggar =
{ Pengarang: string
Pertanyaan: string
Jawaban: string
Terarsip: bool }
let ilegal1 = { Pengarang = "Dewi"; Pertanyaan = "Kapan ujian susun?"; Jawaban = ""; Terarsip = true }
let ilegal2 = { Pengarang = "Budi"; Pertanyaan = "Kapan ujian susun?"; Jawaban = "Besok pukul 8"; Terarsip = false }
printfn "%A" ilegal1
printfn "%A" ilegal2{ Pengarang = "Dewi"
Pertanyaan = "Kapan ujian susun?"
Jawaban = ""
Terarsip = true }
{ Pengarang = "Budi"
Pertanyaan = "Kapan ujian susun?"
Jawaban = "Besok pukul 8"
Terarsip = false }Kedua nilai pada keluaran adalah status ilegal yang tetap lolos kompilasi. Nilai pertama menyatakan catatan terarsip dengan jawaban kosong, sedangkan nilai kedua membawa jawaban lengkap tetapi belum terarsip. Penanda bool dan teks jawaban menyimpan dua fakta yang semestinya bergerak bersama, dan keduanya dapat berbenturan tanpa peringatan apa pun dari compiler.
Setiap kombinasi ilegal menuntut pemeriksaan manual di seluruh kode yang membaca record. Fungsi yang mencetak catatan, misalnya, harus menangani kasus terarsip tanpa jawaban, meskipun kasus itu seharusnya tidak pernah ada. Jumlah kombinasi menunjukkan besarnya masalah. Dua fakta yang semestinya bergerak bersama menghasilkan empat kombinasi, dan hanya dua di antaranya yang sah. Setiap fakta baru yang seharusnya menyertai jawaban menggandakan kombinasi lagi, sedangkan pemeriksaan manual harus menutup setiap kombinasi ilegal di setiap tempat record dibaca.
Mengetatkan model secara bertahap#
Model yang ketat dibangun dengan tiga langkah yang berurutan. Langkah-langkah berikut mengubah fakta yang bergerak bersama menjadi satu nilai yang tidak terpisahkan.
- Ganti pasangan
Terarsip: booldanJawaban: stringdengan discriminated unionStatusCatatanyang kasusTerarsip-nya membawa jawaban itu sendiri. - Bungkus
stringmenjadi tipe single-case DU, yaitu DU dengan satu kasus yang membungkus satu nilai, sehingga pengarang, pertanyaan, dan jawaban tidak dapat saling dipertukarkan. - Ganti nilai balik pencarian menjadi
option, karena catatan pada posisi tertentu dapat tidak ada.
Setelah langkah pertama, catatan terarsip harus membawa jawaban sejak konstruksinya, sehingga status ilegal pertama hilang. Setelah langkah kedua, nilai Pengarang "Citra" bertipe Pengarang dan tidak dapat diberikan pada field bertipe Pertanyaan, sehingga kesalahan urutan argumen tertangkap compiler. Langkah ketiga memindahkan ketidaktahuan tentang hasil pencarian ke dalam tipe, bukan ke dalam konvensi.
Tipe pembungkus pada langkah kedua hanya membutuhkan satu baris per tipe, dan namanya diambil dari kata yang digunakan di domain. Kode yang memuat Pengarang "Citra" langsung terbaca maksudnya, sedangkan string polos memaksa pembaca menelusuri konteksnya. Tipe pembungkus juga menjadi tempat menaruh aturan tambahan di kemudian hari, misalnya menolak teks kosong pada pembuatannya, tanpa mengubah bentuk data di tempat lain.
Studi kasus: arsip catatan#
Berkas berikut memuat model yang telah diketatkan beserta fungsi-fungsi yang membangun statusnya. Konstruksi hanya dapat melewati buat dan jawab, dan keduanya selalu menghasilkan status yang sah.
// Arsip tanya jawab antar siswa: setiap catatan membawa satu pertanyaan.
type Pengarang = Pengarang of string
type Pertanyaan = Pertanyaan of string
type Jawaban = Jawaban of string
type StatusCatatan =
| MenungguJawaban
| Terarsip of Jawaban
type Catatan =
{ Pengarang: Pengarang
Pertanyaan: Pertanyaan
Status: StatusCatatan }
let buat pengarang pertanyaan =
{ Pengarang = pengarang
Pertanyaan = pertanyaan
Status = MenungguJawaban }
// parameter data diletakkan paling akhir agar mudah dirangkai dengan pipeline
let jawab jawaban catatan =
{ catatan with Status = Terarsip jawaban }
let deskripsi catatan =
match catatan.Status with
| MenungguJawaban -> "menunggu jawaban"
| Terarsip (Jawaban teks) -> "terarsip: " + teks
// pencarian berdasarkan posisi; catatan bisa tidak ada, sehingga hasilnya option
let cari posisi arsip =
List.tryItem posisi arsip
let catatan1 = buat (Pengarang "Citra") (Pertanyaan "Mengapa daun berwarna hijau?")
let catatan2 = catatan1 |> jawab (Jawaban "Karena mengandung klorofil.")
printfn "%s" (deskripsi catatan1)
printfn "%s" (deskripsi catatan2)
let arsip = [ catatan1; catatan2 ]
match cari 1 arsip with
| Some c -> printfn "posisi 1: %s" (deskripsi c)
| None -> printfn "posisi 1 tidak ada"
match cari 5 arsip with
| Some c -> printfn "posisi 5: %s" (deskripsi c)
| None -> printfn "posisi 5 tidak ada"
// status ilegal berikut tidak dapat ditulis karena Terarsip menuntut Jawaban
// let ilegal = { catatan1 with Status = Terarsip }menunggu jawaban
terarsip: Karena mengandung klorofil.
posisi 1: terarsip: Karena mengandung klorofil.
posisi 5 tidak adaKasus Terarsip menuntut satu nilai Jawaban, sehingga baris terakhir yang dikomentari memang tidak dapat ditulis dalam bentuk apa pun. Tipe Catatan tidak lagi menampung field Jawaban yang terpisah, karena jawaban hanya berada di dalam Status. Fungsi jawab meletakkan parameter data pada posisi terakhir agar dapat dirangkai dengan operator pipeline, dan pola semacam ini lazim pada fungsi transformasi domain. Membaca jawaban tetap menuntut pembongkaran kasus melalui deskripsi, sehingga tidak ada kode yang menganggap jawaban selalu ada.
Fungsi buat menjadi satu-satunya pintu masuk ke status MenungguJawaban, sedangkan jawab menjadi satu-satunya jalan menuju Terarsip. Kedua fungsi itu menutup celah kombinasi ilegal karena bentuk datanya sudah menuntut kelengkapan. Fungsi-fungsi di atas membentuk antarmuka kecil untuk arsip, dan setiap fungsi baru tetap diwajibkan memilih salah satu status yang ada. Baris yang dikomentari pada berkas memperlihatkan wujud pembatasan itu: Terarsip tanpa nilai tidak dapat ditulis, sehingga compiler menolaknya sebelum program berjalan.
Option pada batas pencarian#
Pencarian berdasarkan posisi memiliki dua hasil yang mungkin, yaitu catatan ditemukan atau tidak ada, dan tipe option menyatakan kedua kemungkinan itu secara eksplisit. Fungsi List.tryItem mengembalikan Catatan option, sehingga pemanggilnya dipaksa menangani kasus None melalui match sebelum dapat menyentuh isinya. Pola ini menggantikan dua kebiasaan yang rawan, yaitu mengembalikan nilai kosong sebagai tanda tidak ditemukan dan melempar exception untuk keadaan yang sebenarnya biasa. Ketidaktahuan tentang hasil pencarian kini tertulis pada tipe nilai balik, sehingga pembaca fungsi cari langsung mengetahui bahwa hasilnya mungkin tidak ada. Bahasan mendalam tentang option tersedia pada topik khusus di modul penanganan error.
Keluaran pada berkas memperlihatkan kedua kemungkinan tipe option tersebut. Posisi satu menghasilkan catatan yang statusnya sudah terarsip, sedangkan posisi lima menghasilkan None yang diperlakukan sebagai keadaan yang sah, bukan kegagalan program. Cabang None yang tidak ditangani mendapat peringatan yang sama seperti match pada discriminated union yang tidak meliputi seluruh kasusnya. Kewajiban penanganan ini berbeda dengan nilai balik yang hanya dijelaskan dalam komentar, karena compiler tidak punya cara memaksa pembacaan komentar. Dengan option, ketidakhadiran nilai menjadi bagian dari kontrak tipe.
Peta siklus status#
Diagram berikut merangkum siklus status model yang ketat. Transisi satu-satunya adalah menjawab catatan, dan setiap status terarsip selalu membawa jawabannya.
Kotak kiri menyatakan status awal setiap catatan baru, sedangkan kotak kanan menyatakan status yang selalu membawa satu Jawaban. Pembacaan diagram dimulai dari kiri, dan satu-satunya panah diberi label nama fungsi pemicunya, yaitu jawab. Tidak ada panah dari MenungguJawaban menuju status terarsip yang kosong, karena bentuk data kasus Terarsip memang tidak menyediakan tempat untuk keadaan itu. Inilah makna idiomnya: pemeriksaan yang tadinya tersebar di banyak fungsi berpindah ke definisi tipe, dan compiler yang menjaganya di seluruh program.
Diagram juga memperlihatkan apa yang hilang dari model. Panah mundur tidak digambar karena catatan terarsip tidak kembali menunggu jawaban, dan keputusan domain semacam ini tertulis lebih dahulu pada tipe sebelum kode dibaca ulang. Apabila domain berubah, misalnya arsip boleh dibuka kembali, perubahannya berupa satu kasus atau satu fungsi baru, lalu compiler menunjuk seluruh titik yang terdampak.
Ringkasan#
- Model yang longgar memungkinkan kombinasi field yang tidak logis, dan kombinasi tersebut lolos kompilasi tanpa peringatan.
- Pasangan
booldanstringdiganti dengan DU yang kasus datanya membawa nilai, sehingga fakta yang bergerak bersama menjadi satu nilai. - Single-case DU membedakan teks pengarang, pertanyaan, dan jawaban, sehingga argumen yang terpindah tertangkap compiler.
- Hasil pencarian dinyatakan sebagai
option, dan pembongkaran kasusnya memaksa penanganan kondisi tidak ditemukan.
Referensi#
- Language reference F# — Microsoft Learn — Rujukan menyeluruh untuk sintaks bahasa F#, termasuk record, discriminated union, dan option.
- Discriminated Unions — Microsoft Learn — Dokumentasi resmi DU F# yang menjadi dasar model status pada topik ini.