Pola builder

Definisi Bind dan return di balik workflow khusus beserta kelas builder-nya.

Daftar isi

Topik ini membahas pola builder, yaitu mekanisme yang menggerakkan setiap computation expression pada F#. Pembahasan mencakup kelas builder beserta Bind dan Return, dekompilasi konseptual atas apa yang dilakukan compiler untuk let! dan return, serta builder minimal untuk tipe Option yang langsung digunakan pada workflow pencarian nilai ujian. Setelah mekanismenya terlihat, workflow seperti async { } dapat ditelusuri dari akarnya, karena perilaku serupa cukup dibangun dari dua member sederhana yang ditulis sendiri.

Ekspresi builder dan kelas builder#

Computation expression adalah kode berbentuk blok yang ditulis di antara nama { }, dan compiler tidak menjalankan blok itu apa adanya. Compiler menerjemahkan seluruh isi blok menjadi pemanggilan member, yaitu anggota kelas, pada sebuah objek yang disebut builder. Nama sebelum kurung kurawal hanyalah pengenal biasa yang menunjuk objek tersebut, sehingga option { ... } berarti blok yang diterjemahkan melalui objek bernama option. Kelas dari objek itulah yang menentukan seluruh perilaku workflow, mulai dari arti let!, arti return, sampai cara langkah-langkahnya disambungkan.

Pemetaan dasarnya hanya dua baris untuk workflow paling sederhana. Pernyataan let! x = e diikuti sisa badan diterjemahkan menjadi Bind(e, fun x -> sisaBadan), sedangkan return e diterjemahkan menjadi Return(e). Bentuk tipe Bind yang lazim adalah 'm -> ('a -> 'm) -> 'm, yaitu menerima nilai terbungkus beserta fungsi lanjutan, lalu mengembalikan nilai terbungkus yang baru. Bentuk inilah yang membuat pemanggilan Bind dapat dirantai tanpa batas, karena keluarannya bertipe sama dengan masukan yang diharapkan langkah berikutnya.

Objek builder berperilaku seperti objek biasa, sehingga dibentuk lewat let option = OptionBuilder() dan dapat diberi nama bebas. Satu kelas builder dapat digunakan untuk banyak objek sekaligus, dan dua objek dari kelas yang sama menghasilkan workflow dengan perilaku yang identik. Hal yang membedakan satu workflow dari workflow lainnya bukan letak kurung kurawalnya, melainkan kelas yang menyusun objek builder di depannya.

fsharp Pemetaan let! dan return ke pemanggilan member
// let! x = e          -->  builder.Bind(e, fun x -> ...)
// return e           -->  builder.Return(e)
// kedua bentuknya mengembalikan 'm, yaitu tipe workflow itu sendiri

Builder minimal untuk Option#

Tipe Option memodelkan nilai yang mungkin tidak ada, dan workflow di atasnya lazim berisi pencarian beruntun. Tanpa computation expression, setiap sambungan pencarian menuntut match yang membongkar Some dan meneruskan None. Builder minimal menyerahkan seluruh pencabangan itu kepada Bind, sehingga badan workflow kembali berbentuk urutan yang lurus. Berkas berikut mendefinisikan OptionBuilder dengan dua member, kemudian menggunakannya untuk mencari nilai ujian lalu menetapkan predikatnya.

Anotasi tipe pada Bind memperlihatkan kontraknya secara eksplisit, yaitu menerima 'a option beserta fungsi dari 'a ke 'b option, lalu mengembalikan 'b option. Tipe 'a dan 'b boleh berbeda, sehingga langkah pertama yang menghasilkan int option dapat disambungkan ke langkah kedua yang menghasilkan string option, sebagaimana terjadi pada workflow predikat berikut.

fsharp option-builder.fsx
// Kelas builder minimal untuk tipe Option beserta pemakaiannya
type OptionBuilder() =
    member _.Bind(m: 'a option, langkah: 'a -> 'b option) : 'b option =
        match m with
        | Some nilai -> langkah nilai
        | None -> None
    member _.Return(nilai: 'a) : 'a option = Some nilai

let option = OptionBuilder()

// workflow: mencari nilai ujian lalu menetapkan predikat
let nilaiUjian = Map.ofList [("Ali", 82); ("Budi", 74); ("Citra", 91)]

let predikatDari (nilai: int) : string option =
    if nilai >= 85 then Some "sangat baik"
    elif nilai >= 75 then Some "baik"
    else None

let predikat (nama: string) : string option =
    option {
        let! nilai = Map.tryFind nama nilaiUjian
        let! p = predikatDari nilai
        return p
    }

printfn "%A" (predikat "Citra")
printfn "%A" (predikat "Budi")
printfn "%A" (predikat "Dewi")
text Keluaran
Some "sangat baik"
None
None

Keluaran memperlihatkan tiga jalur yang berbeda dengan badan workflow yang sama. Nilai Citra adalah 91 sehingga predikatnya tercapai sebagai Some "sangat baik". Nilai Budi ditemukan pada peta, tetapi angka 74 tidak mencapai ambang predikat, sehingga langkah kedua menghasilkan None. Nama Dewi tidak terdaftar sama sekali, sehingga langkah pertama yang berhenti. Dua sumber kegagalan yang berbeda mengalir menjadi hasil None yang sama, dan pemanggil fungsi predikat memperlakukan keduanya dengan cara yang sama.

Bentuk let! p = predikatDari nilai diikuti return p dapat dipersingkat menjadi return! p, yaitu mengembalikan nilai yang sudah terbungkus. Bentuk ini diterjemahkan ke member ReturnFrom. Member itu tidak dimuat pada builder minimal karena contohnya tetap menggunakan return, dan menambahkannya hanya menyalin nilai tanpa mengubah perilaku apa pun.

Apa yang dilakukan compiler pada let! dan return#

Proses penerjemahan blok menjadi pemanggilan member oleh dokumentasi F# disebut desugaring, dan mekanismenya dapat direkonstruksi dengan kata-kata sederhana. let! membuka bungkus nilai melalui Bind, lalu menyerahkan sisa badan workflow kepada fungsi lanjutan. Karena sisa badan dikirim sebagai fungsi, Bind yang menemukan None cukup mengembalikan None tanpa pernah memanggil fungsi tersebut, dan seluruh langkah tersisa otomatis dilewati. Pencabangan tidak menghilang, melainkan berpindah dari badan workflow ke satu tempat, yaitu badan Bind. return bekerja sebaliknya, yaitu menutup workflow dengan membungkus nilai biasa menjadi nilai terbungkus melalui Return.

Berkas berikut membandingkan bentuk yang ditulis dengan bentuk yang disusun compiler secara konseptual untuk workflow yang sama. Kedua fungsi menghasilkan pasangan keluaran yang identik untuk setiap masukan.

fsharp desugaring.fsx
// Perbandingan workflow Option dengan bentuk hasil dekompilasinya
type OptionBuilder() =
    member _.Bind(m: 'a option, langkah: 'a -> 'b option) : 'b option =
        match m with
        | Some nilai -> langkah nilai
        | None -> None
    member _.Return(nilai: 'a) : 'a option = Some nilai

let option = OptionBuilder()

let nilaiUjian = Map.ofList [("Ali", 82); ("Budi", 74); ("Citra", 91)]

let predikatDari (nilai: int) : string option =
    if nilai >= 85 then Some "sangat baik"
    elif nilai >= 75 then Some "baik"
    else None

// bentuk yang ditulis
let predikatWorkflow (nama: string) : string option =
    option {
        let! nilai = Map.tryFind nama nilaiUjian
        let! p = predikatDari nilai
        return p
    }

// bentuk yang disusun compiler secara konseptual
let predikatManual (nama: string) : string option =
    option.Bind(
        Map.tryFind nama nilaiUjian,
        fun nilai ->
            option.Bind(
                predikatDari nilai,
                fun p -> option.Return p
            )
    )

for nama in ["Citra"; "Budi"; "Dewi"] do
    printfn "%A" (predikatWorkflow nama, predikatManual nama)
text Keluaran
(Some "sangat baik", Some "sangat baik")
(None, None)
(None, None)

Setiap baris keluaran berupa pasangan yang kedua elemennya sama, sehingga workflow dan bentuk manualnya terbukti setara pada ketiga jalur. Rekonstruksi ini juga menjelaskan mengapa urutan langkah pada workflow sama dengan urutan pembacaan. Fungsi lanjutan hanya dibuat pada saat let! diterjemahkan, sedangkan pemanggilannya terjadi dari dalam Bind, sehingga langkah kedua tidak mungkin berjalan sebelum Bind pertama selesai.

Pola yang sama berlaku untuk builder apa pun. Kelas builder yang berbeda hanya mengubah isi Bind dan Return, misalnya Bind pada workflow async yang menunggu pekerjaan selesai terlebih dahulu, atau Bind pada workflow Result yang meneruskan kegagalan dengan membawa alasannya. Kerangka penerjemahannya tidak berubah, dan itulah alasan satu pola builder cukup untuk memahami seluruh workflow F#. Rekonstruksi manual seperti ini menjadi alat baca ketika workflow lain ditemui, karena langkah pertama yang perlu dilihat dari builder baru hanyalah badan Bind dan Return, sedangkan sisa badan workflow cukup dibaca sebagai urutan biasa.

Ringkasan#

  • Compiler menerjemahkan computation expression menjadi pemanggilan member pada objek builder, dengan let! menuju Bind dan return menuju Return.
  • Bind bertipe 'm -> ('a -> 'm) -> 'm dan menerima sisa badan sebagai fungsi lanjutan, sehingga pencabangan terkonsentrasi di satu tempat.
  • OptionBuilder dengan dua member sudah cukup untuk workflow pencarian beruntun, dan bentuk hasil desugaringnya menghasilkan keluaran yang identik.
  • FSharp.Core menyediakan builder untuk async, seq, dan task, sedangkan builder untuk Option ditulis sendiri.

Referensi#