Idiom pemodelan domain

Menggunakan tipe untuk membuat status ilegal tidak dapat direpresentasikan sejak konstruksi.

Daftar isi

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.

fsharp Contoh: model longgar dengan tipe primitif
// 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
text Keluaran
{ 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.

  1. Ganti pasangan Terarsip: bool dan Jawaban: string dengan discriminated union StatusCatatan yang kasus Terarsip-nya membawa jawaban itu sendiri.
  2. Bungkus string menjadi tipe single-case DU, yaitu DU dengan satu kasus yang membungkus satu nilai, sehingga pengarang, pertanyaan, dan jawaban tidak dapat saling dipertukarkan.
  3. 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.

fsharp arsip-catatan.fsx
// 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 }
text Keluaran
menunggu jawaban
terarsip: Karena mengandung klorofil.
posisi 1: terarsip: Karena mengandung klorofil.
posisi 5 tidak ada

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

Siklus status catatanMenungguJawabancatatan baru dibuatjawab (dengan Jawaban)Terarsipmembawa satu Jawabantransisi satu arah, tanpa Jawaban status terarsip tidak terbentuk
Siklus status catatan pada model yang ketat

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 bool dan string diganti 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#