Instans Functor

Mapping atas Maybe, list, dan IO dengan fmap.

Daftar isi

Kelas Functor mengatur cara satu fungsi masuk ke dalam konteks melalui fungsi fmap, dan setiap tipe yang mendukungnya memiliki instans (instance) Functor. Topik ini membahas instans Functor pada tipe Maybe, list, dan IO, hukum yang wajib dipatuhi setiap instansnya, serta satu instans yang ditulis sendiri sebagai latihan untuk memahami bentuknya. Setelah membacanya, pemetaan atas ketiga tipe dapat dilakukan dengan satu pola yang sama dan deklarasi instance Functor dapat ditulis tanpa menebak-nebak isi definisinya.

Kelas Functor dan hukumnya#

Fungsi fmap bertipe (a -> b) -> f a -> f b, sehingga fungsi biasa dari a ke b diterapkan pada isi konteks tanpa membuka pembungkusnya. Notasi f pada tipe tersebut adalah variabel tipe yang mewakili type constructor apa pun yang menjadi instans Functor. Pada Maybe, fungsi diterapkan hanya apabila nilainya ada. Pada list, fungsi diterapkan pada setiap elemen. Pada IO, fungsi disiapkan untuk nilai yang akan dihasilkan aksi. Satu tipe operasi, tiga perilaku yang mengikuti makna konteksnya masing-masing.

Kemampuan menerapkan fungsi semacam itu dibatasi oleh dua hukum yang menjaga perilakunya tetap terduga. Hukum pertama menyatakan fmap id sama dengan id, artinya memetakan dengan fungsi identitas tidak mengubah apa pun. Hukum kedua menyatakan fmap (f . g) sama dengan fmap f . fmap g, artinya memetakan dua fungsi sekaligus memberikan hasil yang sama dengan memetakan keduanya satu per satu. Keduanya menjamin bahwa struktur konteks tidak berubah, karena yang boleh berubah hanya isinya.

bash
# hukum identitas pada Maybe dan list
ghc -e 'fmap id (Just 3) == id (Just 3)'
ghc -e 'fmap id [1, 2, 3] == id [1, 2, 3]'
# hukum komposisi pada list
ghc -e 'fmap (negate . (* 2)) [1, 2, 3] == (fmap negate . fmap (* 2)) [1, 2, 3]'
text Keluaran
True
True
True

Tiga baris keluaran membandingkan kedua sisi hukum dengan operator ==, dan semuanya bernilai True. Pemeriksaan semacam ini bukan bagian dari compiler karena hukum kelas tidak dapat ditegakkan secara mekanis. Instans yang melanggar hukumnya tetap lolos kompilasi, tetapi kode lain yang mengandalkan janji kelas tersebut akan berperilaku tidak terduga. Karena itu, hukum ini dipatuhi sebagai kontrak, dan pemeriksaan manual seperti di atas menjadi kebiasaan yang bermanfaat ketika menulis instans sendiri.

fmap pada Maybe dan list#

Program berikut memetakan fungsi ke dalam Maybe dan list dari kamus nilai siswa. Pada Maybe, fungsi hanya diterapkan apabila nilainya ada, sedangkan pada list, fungsi diterapkan pada setiap elemennya.

haskell mungkin-list.hs
-- fmap menerapkan fungsi di dalam Maybe dan list tanpa membuka pembungkusnya
kamusNilai :: [(String, Int)]
kamusNilai = [("Ali", 85), ("Budi", 78)]

nilai :: String -> Maybe Int
nilai nama = lookup nama kamusNilai

main :: IO ()
main = do
  -- Maybe: hasil yang berhasil dan yang kosong
  print (fmap (+ 5) (nilai "Ali"))
  print (fmap (+ 5) (nilai "Citra"))
  -- list: fungsi diterapkan pada setiap elemen
  print (fmap (* 10) [1, 2, 3])
  print (fmap show [1, 2, 3])
text Keluaran
Just 90
Nothing
[10,20,30]
["1","2","3"]

Baris pertama menambahkan 5 pada nilai Ali yang ditemukan di kamus sehingga hasilnya Just 90. Baris kedua memetakan fungsi yang sama ke Nothing, dan hasilnya tetap Nothing karena fungsi tidak pernah dijalankan. Baris ketiga mengalikan setiap elemen list dengan 10, sedangkan baris keempat memetakan fungsi show yang mengubah tipe isi dari Int menjadi String. Baris terakhir itu menunjukkan bahwa fmap bebas mengganti tipe isinya selama fungsinya benar, persis seperti map pada list, tetapi berlaku untuk semua instans Functor.

Dua perilaku tersebut berasal dari tipe yang sama karena keduanya menaati fmap yang sama. Perbedaannya murni pada makna konteksnya, yaitu kekosongan yang mungkin terjadi pada Maybe dan banyaknya elemen pada list. Pembaca kode yang melihat fmap tidak perlu menelusuri definisi instansnya untuk memahami niat penulisnya, karena satu-satunya hal yang dijanjikan adalah penerapan fungsi pada isi. Kebiasaan ini membuat pemetaan atas tipe baru dapat dibaca dengan cepat sekaligus menjaga perilakunya tetap sesuai hukum.

fmap pada IO#

Instans Functor pada IO sering tampak paling asing karena aksi IO belum menghasilkan nilainya saat didefinisikan. Karena itu fmap pada IO tidak menjalankan aksi lalu menerapkan fungsi, melainkan menyusun aksi baru yang nantinya menerapkan fungsi pada hasil aksi semula. Program berikut menunjukkan pola tersebut dengan aksi yang menghasilkan nilai tetap, sehingga keluarannya dapat direproduksi tanpa masukan dari pengguna.

haskell io-functor.hs
-- Functor pada IO: fungsi murni diterapkan pada nilai yang akan dihasilkan aksi
main :: IO ()
main = do
  print =<< (fmap (* 2) (return 21) :: IO Int)
  print =<< (fmap (++ "!") (return "siap") :: IO String)
text Keluaran
42
"siap!"

Ekspresi print =<< (fmap (* 2) (return 21) :: IO Int) menjalankan aksi hasil pemetaan, lalu meneruskan nilainya kepada print. Anotasi :: IO Int diperlukan karena konteks yang dimaksud belum tersirat dari ekspresi tersebut. Hasilnya 42 sesuai perhitungan 21 * 2, sedangkan baris kedua memetakan fungsi penggabung teks ke dalam IO String. Perilaku ini membuat pemetaan dapat disusun sebelum aksi dijalankan, dan aksi semula tetap utuh untuk keperluan lain.

Instans Functor buatan sendiri#

Bentuk deklarasi instans paling mudah dipelajari dengan menulis satu instans untuk tipe sederhana. Tipe Kotak pada program berikut dideklarasikan dengan newtype, yaitu deklarasi pembungkus satu nilai tanpa biaya tambahan ketika program berjalan. Instans untuk Kotak ditulis manual agar langkah-langkahnya terlihat, sementara program nyata biasanya memanfaatkan deriving untuk kelas standar yang sesuai.

haskell kotak-functor.hs
-- Instans Functor buatan sendiri, untuk latihan pemahaman bentuknya
newtype Kotak a = Kotak a deriving (Eq, Show)

instance Functor Kotak where
  fmap f (Kotak x) = Kotak (f x)

main :: IO ()
main = do
  print (fmap (+ 1) (Kotak 41))
  print (fmap id (Kotak 7) == Kotak 7)
  print (fmap (negate . (* 3)) (Kotak 5) == fmap negate (fmap (* 3) (Kotak 5)))
text Keluaran
Kotak 42
True
True

Baris instance Functor Kotak where memulai deklarasi instans, dan baris setelahnya mendefinisikan fmap dengan pencocokan pola terhadap Kotak x. Fungsi diterapkan pada x, lalu hasilnya dibungkus kembali dengan Kotak. Tipe Kotak menerima parameter tipe a, sehingga instans ditulis dengan nama tipe Kotak tanpa parameter, bukan Kotak a, karena instans selalu dideklarasikan untuk type constructor utuhnya sebagaimana fmap yang bekerja pada f a. Dua baris terakhir menguji kedua hukum Functor pada instans ini dan keduanya bernilai True.

Klausul deriving (Eq, Show) menambahkan kemampuan membandingkan dan mencetak nilai Kotak, sehingga pengujian hukum dan keluaran program dapat ditampilkan apa adanya. Pola definisi semacam ini berulang pada instans lain, yaitu membuka pembungkus dengan pencocokan pola, menerapkan fungsi, lalu membungkus ulang hasilnya sesuai makna konteksnya.

Ringkasan#

  • fmap bertipe (a -> b) -> f a -> f b dan menerapkan fungsi murni pada isi konteks tanpa membuka pembungkusnya.
  • Instans Maybe melewati Nothing, instans list menerapkan fungsi pada setiap elemen, dan instans IO menyusun aksi baru tanpa menjalankan aksi lama.
  • Setiap instans Functor wajib mematuhi fmap id = id dan fmap (f . g) = fmap f . fmap g, dan keduanya dapat diuji dengan perbandingan biasa.
  • Deklarasi instance Functor untuk tipe buatan sendiri cukup mendefinisikan fmap, biasanya dengan pencocokan pola lalu membungkus ulang hasilnya.

Referensi#