Kelas dan antarmuka

Pemrograman berorientasi objek di tempat yang memang cocok, tanpa mengorbankan tipe domain.

Daftar isi

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.

fsharp tiket-loket.fsx
// 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.Nomor
text Keluaran
Loket 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 1

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

fsharp properti-ambang.fsx
// 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.Ambang
text Keluaran
Ambang awal: 75
Ambang baru: 60
Ambang setelah nilai tak sah: 60

Setter 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 :>.

fsharp antarmuka-sendiri.fsx
// 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.Nilai
text Keluaran
Nilai: 100
Berikutnya: 101
Berikutnya: 102
Nilai sekarang: 102

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

fsharp antarmuka-bawaan.fsx
// 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)
text Keluaran
Jumlah dokumen: 3
Dokumen: laporan
Dokumen: slip
Dokumen: surat
Pertama: laporan

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

KebutuhanPilihan yang cocok
Data imutabel yang dibandingkan secara strukturalrecord
Alternatif domain yang tertutup dan dicocokkan poladiscriminated union
Identitas yang keadaannya berubah secara terkendalikelas dengan member
Interop dengan API .NET yang menuntut antarmukakelas 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 let yang selalu privat dan membukanya lewat member, dengan do binding sebagai kode konstruksi serta konstruktor tambahan berbentuk new(...).
  • Properti dengan getter dan setter menjadi tempat mutasi terkendali, termasuk pemeriksaan nilai sebelum field dituliskan.
  • Antarmuka dinyatakan dengan abstract member dan diimplementasikan pada blok interface ... with, lalu dipanggil setelah upcast :>.
  • Implementasi IEnumerable<'T> membuat objek kelas dapat digunakan oleh fungsi modul Seq dan seluruh pustaka iterasi .NET, sedangkan kelas itu sendiri dipilih untuk mutasi terkendali dan interoperabilitas.

Referensi#