Monad yang lazim

Maybe, Either, list, dan IO tanpa mistisisme.

Daftar isi

Empat tipe yang paling sering ditemui pada program Haskell, yaitu Maybe, Either, list, dan IO, semuanya menjadi Monad melalui satu operator bind dengan perilaku yang berbeda-beda. Topik ini merangkum perilaku (>>=) pada keempatnya secara langsung, kemudian menutup dengan kesalahan umum yang muncul saat pola tersebut dipraktikkan. Setelah membacanya, pemilihan tipe untuk rantai langkah dapat didasarkan pada makna kegagalan atau urutan yang dibutuhkan, bukan pada kesan bahwa Monad bersifat sulit dipahami.

Maybe: berhenti saat kosong#

Pada Maybe, bind hanya memiliki satu aturan, yaitu isi Just diteruskan kepada fungsi berikutnya, sedangkan Nothing dipertahankan apa adanya sampai akhir rantai. Rantai panjang menjadi aman secara otomatis karena satu Nothing di mana pun memotong seluruh ekspresi tanpa langkah berikutnya dipanggil.

bash
# rantai dua langkah pada nilai yang ada
ghc -e 'Just 5 >>= \x -> Just (x + 1) >>= \y -> Just (y * 2)'
# satu Nothing memotong rantai tanpa pesan apa pun
ghc -e 'Nothing >>= \x -> Just (x + 1) >>= \y -> Just (y * 2)'
text Keluaran
Just 12
Nothing

Rantai pertama menghasilkan Just 12 karena kedua langkahnya menemukan isi yang diteruskan. Rantai kedua berhenti pada langkah pertama dan tetap Nothing tanpa menyebut alasan apa pun. Kesenyapan ini sesuai dengan makna Maybe, yaitu kekosongan yang penyebabnya sudah jelas dari konteksnya sehingga pesan tambahan tidak diperlukan. Apabila penyebab kegagalan perlu diberitahukan kepada pemanggil, tipe Either mengambil peran tersebut dengan struktur yang sama.

Sifat pemotongan ini menggantikan pencocokan pola yang berulang pada setiap langkah. Dalam bentuk manual, setiap langkah memerlukan satu cabang Just untuk melanjutkan dan satu cabang Nothing untuk berhenti, dan kedua cabang itu isinya sama untuk semua fungsi. Bind mengumpulkan pola berulang tersebut satu kali dalam definisi instansnya, sehingga rantai baru hanya menambah fungsi tanpa menambah cabang. Tipe rantainya pun tetap Maybe Int berapa pun panjangnya, selama fungsi terakhir menghasilkan Int.

Either: berhenti sambil membawa pesan#

Bind pada Either meniru perilaku Maybe dengan satu perbedaan, yaitu jalur gagalnya membawa nilai. Nilai Left diteruskan apa adanya sampai akhir rantai, sedangkan isi Right menjadi masukan langkah berikutnya. Program berikut membaca catatan suhu dari teks, kemudian memeriksa rentangnya, dan kedua langkah menggunakan String yang sama sebagai tipe kegagalannya.

haskell rantai-either.hs
-- Bind pada Either menyambung langkah yang membawa pesan error
bacaSuhu :: String -> Either String Int
bacaSuhu teks = case reads teks of
  [(n, "")] -> Right n
  _         -> Left ("bukan bilangan bulat: " ++ teks)

periksa :: Int -> Either String Int
periksa n
  | n < -20 || n > 60 = Left ("suhu di luar rentang sensor: " ++ show n)
  | otherwise         = Right n

main :: IO ()
main = do
  print (bacaSuhu "35" >>= periksa)
  print (bacaSuhu "75" >>= periksa)
  print (bacaSuhu "dingin" >>= periksa)
text Keluaran
Right 35
Left "suhu di luar rentang sensor: 75"
Left "bukan bilangan bulat: dingin"

Baris pertama melalui kedua langkah dan menghasilkan Right 35. Baris kedua gagal pada periksa karena suhu 75 berada di luar rentang yang diizinkan, sedangkan baris ketiga gagal lebih awal pada bacaSuhu karena teksnya bukan bilangan. Seluruh baris bertipe sama, yaitu Either String Int, sehingga pesan dari langkah mana pun diperlakukan seragam oleh pemanggilnya. Pola ini memungkinkan rantai pemeriksaan yang panjang tanpa satu pun pencocokan pola di antara langkah-langkahnya.

Perilaku Left berbeda dari Nothing pada satu hal penting, yaitu nilainya ikut diteruskan sampai akhir rantai tanpa tersentuh. Fungsi pada langkah berikutnya tidak pernah melihat isi Left, sehingga pesan kegagalan tidak tertimpa oleh langkah lain. Konvensi ini menuntut satu tipe kegagalan yang disepakati seluruh rantai, misalnya String pada program di atas, karena hasil akhir harus bertipe sama apa pun langkah yang gagal. Pada program yang lebih besar, tipe data khusus sering digunakan sebagai pengganti String agar penyebab kegagalannya lebih terstruktur.

List: bercabang ke semua jalur#

Pada list, bind berperilaku seperti concatMap, yaitu fungsi diterapkan pada setiap elemen dan seluruh hasilnya disambungkan menjadi satu list. Satu nilai masukan tidak lagi menghasilkan satu jalur, melainkan seluruh cabang yang dihasilkan fungsinya. Program berikut membangun daftar pasangan dari dua list, kemudian membandingkan pemahaman list (list comprehension) dengan bentuk bind yang setara.

haskell kombinasi-bind.hs
-- Bind pada list mengunjungi setiap jalur yang mungkin
pasangan :: [(Int, Int)]
pasangan = [1, 2] >>= \x -> [10, 20] >>= \y -> return (x, y)

triple :: [(Int, Int, Int)]
triple =
  [ (x, y, z)
  | x <- [1 .. 10], y <- [x + 1 .. 10], z <- [1 .. 10]
  , x * x + y * y == z * z
  ]

main :: IO ()
main = do
  print pasangan
  print triple
text Keluaran
[(1,10),(1,20),(2,10),(2,20)]
[(3,4,5),(6,8,10)]

Definisi pasangan menyambungkan dua bind, sehingga setiap elemen [1, 2] dipasangkan dengan setiap elemen [10, 20] dan empat pasangan muncul berurutan. Definisi triple menggunakan pemahaman list untuk mencari triple Pythagoras pada rentang kecil, dan hasilnya dua triple yang memenuhi syaratnya. Pemahaman list dibangun di atas mekanisme bind yang sama, sehingga sintaks khusus itu tidak memperkenalkan kekuatan baru, melainkan menyusun ulang bind untuk keterbacaan.

Perilaku bercabang ini juga menjadi cara menyaring data secara alami. Fungsi yang diberikan kepada bind boleh mengembalikan list kosong untuk elemen yang tidak lolos syarat, dan cabang kosong itu tidak menyisakan apa pun pada hasil akhir. Syarat x * x + y * y == z * z pada triple bekerja dengan cara tersebut melalui pemahaman listnya, yaitu kombinasi yang tidak memenuhi syarat tidak pernah muncul. Jumlah hasil akhir dengan demikian tidak perlu ditetapkan di awal, karena seluruhnya ditentukan oleh banyaknya cabang yang dihasilkan setiap langkah.

IO: menyusun aksi dan menjaga kemurnian#

Pada IO, bind menyambungkan dua aksi menjadi satu aksi yang dijalankan berurutan, dan isi aksi pertama diteruskan kepada aksi kedua. Tidak ada perhitungan yang dipotong atau bercabang di sini, karena makna konteksnya adalah urutan efek. Program berikut menyusun tiga aksi kecil tanpa masukan pengguna sehingga keluarannya tetap dapat direproduksi.

haskell io-berantai.hs
-- Bind pada IO menyusun aksi menjadi urutan dan meneruskan nilainya
main :: IO ()
main =
  return (7 * 3) >>= \angka ->
  return (angka + 4) >>= \jumlah ->
  print ("total: " ++ show jumlah)
text Keluaran
"total: 25"

Dua aksi return menyusun nilai di dalam konteks IO, kemudian print mencetak hasil penjumlahannya. Fungsi return di sini tidak menghentikan fungsi dan tidak mengakhiri perhitungan, melainkan sekadar mengangkat nilai murni ke konteks IO. Pemisahan inilah yang menjaga fungsi murni tetap dapat diuji tanpa efek, sedangkan seluruh efek terkumpul pada tipe IO yang diperiksa compiler. Tipe masukan dan keluaran setiap aksi tetap ditentukan compiler, sehingga nilai yang salah arah tidak dapat lolos dari satu aksi ke aksi berikutnya.

Empat instans ini cukup untuk sebagian besar program yang baru mulai dibangun, dan polanya saling terkait. Maybe dan Either menyusun rantai perhitungan yang bisa gagal dengan tingkat keterangan yang berbeda, list menyusun pencarian atas banyak kemungkinan, sedangkan IO menyusun urutan efek. Perbedaan keempat instans itu hanya berada pada definisi bind dan makna konteksnya, bukan pada operator yang digunakan, sehingga kebiasaan membaca rantai pada satu instans langsung terbawa ke instans lainnya.

Kesalahan umum#

Kesalahan yang paling sering muncul adalah memperlakukan return sebagaimana pernyataan return pada bahasa imperatif. Pada Haskell, return tidak menghentikan fungsi dan boleh muncul di tengah rantai tanpa memotong apa pun. Nama tersebut dipertahankan karena alasan sejarah, sedangkan maknanya sama persis dengan pure, yaitu mengangkat nilai murni ke dalam konteks.

Kesalahan kedua adalah menggunakan map untuk tipe berkonteks lain. Fungsi map bertipe (a -> b) -> [a] -> [b] dan hanya menerima list, sedangkan fmap menerima semua tipe yang menjadi instans Functor.

bash
# map dan fmap memberi hasil sama pada list
ghc -e 'map (+ 1) [1, 2, 3]'
# fmap juga menerima tipe berkonteks lain
ghc -e 'fmap (+ 1) (Just 2)'
text Keluaran
[2,3,4]
Just 3

Kesalahan ketiga adalah memberikan fungsi yang menghasilkan nilai murni kepada bind. Ekspresi Just 4 >>= \n -> n * 2 ditolak compiler karena n * 2 bertipe Int, bukan Maybe Int. Fungsi yang disambungkan (>>=) wajib menghasilkan konteks, sehingga fungsi murni terlebih dahulu dibungkus dengan return atau pure apabila akan disambungkan. Ketiga kesalahan ini tertangkap pada waktu kompilasi karena tipe operasinya tidak cocok, dan pesan errornya selalu menunjuk ke tipe yang tidak sesuai tersebut.

Ringkasan#

  • Maybe menghentikan rantai dengan Nothing tanpa pesan, sedangkan Either menghentikan rantai sambil meneruskan isi Left.
  • Bind pada list bekerja seperti concatMap, sehingga satu masukan bercabang menjadi seluruh hasil yang dihasilkan fungsinya.
  • Bind pada IO menyusun aksi berurutan dan meneruskan nilai antaraksi, sementara fungsi murni tetap bebas dari efek.
  • return setara dengan pure dan bukan pernyataan keluar, serta fungsi yang disambungkan (>>=) wajib menghasilkan konteks.

Referensi#