Topik ini membahas penulisan kelas dengan member (anggota tipe), properti, dan antarmuka (interface) di dalam F#, beserta kondisi ketika gaya berorientasi objek lebih tepat daripada record atau discriminated union. Pembahasannya bergerak dari kelas dengan field privat dan konstruktor tambahan, properti dengan getter dan setter, deklarasi antarmuka sendiri, implementasi antarmuka bawaan .NET, hingga kriteria pemilihan antara objek dan tipe data imutabel. Setelah topik ini dibaca, penulisan kelas F# untuk domain yang menuntut mutasi terkendali maupun untuk interoperabilitas sudah dapat dilakukan dengan pola yang benar.
Kelas dengan field privat dan member#
Kelas di F# dinyatakan dengan kata kunci type yang diikuti parameter konstruktor dan tanda sama dengan, sama seperti tipe lain. Perbedaannya, badan kelas memuat binding let untuk field privat, do binding untuk kode konstruksi, dan member untuk anggota yang dapat diakses dari luar. Binding let di dalam kelas selalu privat, sehingga keadaan objek tidak terlihat dari luar kecuali dibuka lewat member. Pola ini menjaga mutasi tetap mungkin, tetapi gerbang masuknya ditentukan oleh penulis kelas.
// Kelas tiket loket: field privat, member, do binding, dan konstruktor tambahan
type TiketLoket(nomorAwal: int) =
// binding let di dalam kelas menyimpan state yang tidak terlihat dari luar
let mutable nomor = nomorAwal
let awal = nomorAwal
// do binding dijalankan sekali pada saat objek dibangun
do printfn "Loket dibuka, tiket pertama bernomor %d" awal
member this.Nomor = nomor
member this.Awal = awal
member this.Cetak() =
nomor <- nomor + 1
printfn "Tiket nomor %d diterbitkan" nomor
// konstruktor tambahan memberi nilai default untuk nomorAwal
new() = TiketLoket(1)
let loket = TiketLoket(100)
printfn "Awal: %d, Nomor: %d" loket.Awal loket.Nomor
loket.Cetak()
loket.Cetak()
let loketCepat = TiketLoket()
printfn "Loket cepat mulai dari %d" loketCepat.NomorLoket dibuka, tiket pertama bernomor 100
Awal: 100, Nomor: 100
Tiket nomor 101 diterbitkan
Tiket nomor 102 diterbitkan
Loket dibuka, tiket pertama bernomor 1
Loket cepat mulai dari 1Parameter nomorAwal menjadi bagian dari konstruktor utama, dan nilainya disalin ke dua binding let, yaitu nomor yang mutable sebagai keadaan berjalan dan awal yang imutabel sebagai catatan asal. Do binding dijalankan sekali pada saat objek dibangun, sehingga pesan pembukaan loket tampil sebelum member mana pun dipanggil. Konstruktor tambahan new() mendelegasikan ke konstruktor utama dengan nilai default 1, dan pesan pembukaan ikut tampil kembali karena seluruh badan kelas dijalankan ulang untuk setiap objek baru.
Properti dengan getter dan setter#
Member boleh berupa properti yang dapat dibaca dan ditulis melalui pasangan getter dan setter. Bentuknya member this.Nama with get() = ... and set(nilai) = .... Tempat paling wajar bagi mutasi terkendali adalah badan setter, yaitu nilai masuk diperiksa terlebih dahulu sebelum dituliskan ke field. Contoh berikut hanya menerima nilai ambang kelulusan pada rentang yang sah.
// Properti dengan getter dan setter untuk mutasi yang terkendali
type PengaturanNilai() =
let mutable ambang = 75
member this.Ambang
with get() = ambang
and set(nilai) =
if nilai >= 0 && nilai <= 100 then
ambang <- nilai
let pengaturan = PengaturanNilai()
printfn "Ambang awal: %d" pengaturan.Ambang
pengaturan.Ambang <- 60
printfn "Ambang baru: %d" pengaturan.Ambang
pengaturan.Ambang <- 150
printfn "Ambang setelah nilai tak sah: %d" pengaturan.AmbangAmbang awal: 75
Ambang baru: 60
Ambang setelah nilai tak sah: 60Setter memeriksa rentang 0 sampai 100 sebelum menuliskan sel mutable ambang. Pencobaan mengisi 150 diabaikan, sehingga nilai yang tampil tetap 60, yaitu nilai sah terakhir. Inilah perbedaan utama kelas terhadap record, karena record F# dibangun sekali dan tidak menyimpan logika perubahan, sedangkan kelas dapat menuliskan aturan pada pintu masuknya.
Antarmuka dan implementasinya#
Antarmuka dinyatakan seperti tipe lain, dengan anggota berupa abstract member yang hanya menyatakan tanda tangan tanpa badan. Kelas mengimplementasikan antarmuka melalui blok interface ... with yang memuat badan setiap anggotanya. Implementasi di F# bersifat eksplisit, artinya anggota antarmuka tidak menjadi bagian antarmuka publik kelas. Pemanggilan dilakukan setelah objek diangkat ke antarmukanya dengan operator upcast :>.
// Antarmuka sendiri dinyatakan dengan abstract member
type PencacahTiket =
abstract Berikutnya : unit -> int
abstract Terbitkan : int
// Kelas mengimplementasikan antarmuka melalui blok interface ... with
type LoketNomor(mulai: int) =
let mutable nilai = mulai
member this.Nilai = nilai
interface PencacahTiket with
member this.Berikutnya() =
nilai <- nilai + 1
nilai
member this.Terbitkan = this.Nilai
let loket = LoketNomor(100)
let pencacah = loket :> PencacahTiket
printfn "Nilai: %d" loket.Nilai
printfn "Berikutnya: %d" (pencacah.Berikutnya())
printfn "Berikutnya: %d" (pencacah.Berikutnya())
printfn "Nilai sekarang: %d" loket.NilaiNilai: 100
Berikutnya: 101
Berikutnya: 102
Nilai sekarang: 102Baris let pencacah = loket :> PencacahTiket mengangkat objek LoketNomor ke antarmukanya, dan baris berikutnya memanggil Berikutnya() melalui antarmuka tersebut. Nilai loket.Nilai ikut berubah karena implementasi antarmuka menuliskan sel mutable milik objek yang sama, bukan salinannya. Antarmuka seperti ini dapat digunakan oleh pustaka mana pun yang berjalan di atas .NET, termasuk kode C#.
Mengimplementasikan antarmuka bawaan .NET#
Antarmuka bawaan .NET diimplementasikan dengan pola yang sama, dan implementasinya membuat kelas dapat diterima oleh pustaka .NET yang menuntut antarmuka tersebut. Contoh yang sering muncul adalah System.Collections.Generic.IEnumerable<'T>, yaitu antarmuka untuk tipe yang dapat diiterasi. F# menuntut antarmuka non-generik System.Collections.IEnumerable ikut diimplementasikan karena keduanya merupakan pasangan wajib pada tipe koleksi .NET.
// Kelas yang mengimplementasikan antarmuka bawaan .NET
open System.Collections
open System.Collections.Generic
type AntreanCetak(items: string list) =
let semua = items
member this.Jumlah = List.length semua
interface IEnumerable<string> with
member this.GetEnumerator() =
(semua :> seq<string>).GetEnumerator()
interface IEnumerable with
member this.GetEnumerator() =
(this :> IEnumerable<string>).GetEnumerator() :> IEnumerator
let antrean = AntreanCetak(["laporan"; "slip"; "surat"])
printfn "Jumlah dokumen: %d" antrean.Jumlah
// Setelah diangkat ke antarmuka, fungsi modul Seq dapat digunakan
let antreanNet = antrean :> IEnumerable<string>
for dokumen in antreanNet do
printfn "Dokumen: %s" dokumen
printfn "Pertama: %s" (antreanNet |> Seq.head)Jumlah dokumen: 3
Dokumen: laporan
Dokumen: slip
Dokumen: surat
Pertama: laporanSetelah objek diangkat ke IEnumerable<string>, seluruh fungsi modul Seq dapat digunakan pada objek kelas tersebut, seperti Seq.head dan perulangan for pada contoh. Pola yang sama berlaku untuk antarmuka bawaan lain, misalnya System.IComparable untuk perbandingan dan System.IDisposable untuk pembersihan sumber daya, masing-masing diimplementasikan pada blok interface ... with milik kelasnya.
Kapan objek masuk akal#
Record dan discriminated union tetap menjadi pilihan baku untuk data. Record cocok untuk nilai yang dibangun sekali dan dibandingkan secara struktural, sedangkan discriminated union cocok untuk alternatif domain yang tertutup. Kelas masuk akal pada dua situasi, yaitu ketika sebuah identitas memang berubah keadaan dari waktu ke waktu dan ketika kode harus berinteraksi dengan API .NET yang berorientasi objek. Ringkasannya seperti tabel berikut.
| Kebutuhan | Pilihan yang cocok |
|---|---|
| Data imutabel yang dibandingkan secara struktural | record |
| Alternatif domain yang tertutup dan dicocokkan pola | discriminated union |
| Identitas yang keadaannya berubah secara terkendali | kelas dengan member |
| Interop dengan API .NET yang menuntut antarmuka | kelas atau implementasi antarmuka |
Kelas TiketLoket pada contoh di atas memenuhi situasi pertama, karena nomor tiket terus berubah sedangkan objek loketnya tetap satu identitas. Menulis ulang pemodelan semacam ini dengan record akan menghasilkan rantai salinan yang tidak menambah kejelasan. Contoh kedua adalah kelas yang mengimplementasikan IEnumerable<'T>, karena objeknya dapat diteruskan ke pustaka .NET mana pun tanpa konversi.
Ringkasan#
- Kelas F# menyimpan keadaan pada binding
letyang selalu privat dan membukanya lewat member, dengan do binding sebagai kode konstruksi serta konstruktor tambahan berbentuknew(...). - Properti dengan getter dan setter menjadi tempat mutasi terkendali, termasuk pemeriksaan nilai sebelum field dituliskan.
- Antarmuka dinyatakan dengan
abstract memberdan diimplementasikan pada blokinterface ... with, lalu dipanggil setelah upcast:>. - Implementasi
IEnumerable<'T>membuat objek kelas dapat digunakan oleh fungsi modulSeqdan seluruh pustaka iterasi .NET, sedangkan kelas itu sendiri dipilih untuk mutasi terkendali dan interoperabilitas.
Referensi#
- Members — Microsoft Learn — Rujukan member F#, termasuk properti, method, dan konstruktor tambahan.
- Interfaces — Microsoft Learn — Deklarasi antarmuka dan implementasinya pada kelas serta object expression.
- F# Style Guide — Microsoft Learn — Konvensi pemilihan antara gaya objek dan gaya fungsional pada pustaka F#.