Tipe IO

Nilai yang mendeskripsikan interaksi dengan dunia luar dan kemurnian yang tetap terjaga.

Daftar isi

Tipe IO menandai bahwa sebuah nilai mendeskripsikan interaksi dengan dunia luar, seperti mencetak teks ke layar, membaca masukan, atau menulis berkas. Topik ini membahas makna tipe tersebut, cara membaca tipe aksi yang paling sering ditemui, perbedaan antara mendefinisikan aksi dan menjalankannya, serta alasan kemurnian fungsi tetap terjaga di sekelilingnya. Setelah membacanya, tipe seperti IO () dan IO String dapat dibaca dengan tepat, dan batas antara perhitungan murni dan interaksi dunia luar menjadi mudah dikenali.

Efek samping yang diberi tipe#

Haskell memisahkan perhitungan murni dari efek samping (side effect) melalui sistem tipenya. Fungsi murni hanya menghitung keluaran dari masukannya, sedangkan setiap interaksi dunia luar dinyatakan sebagai nilai bertipe IO. Pemisahan ini membuat compiler mengetahui dengan tepat bagian mana dari program yang menyentuh layar, masukan, atau berkas, dan bagian mana yang sekadar menghitung. Pada program kecil perbedaannya tampak sekadar formal, tetapi pada program besar pemisahan tersebut menjaga bagian logika tetap dapat diuji tanpa perangkat luar.

Program berikut memisahkan keduanya secara eksplisit. Fungsi konversiKeFahrenheit murni menghitung konversi suhu tanpa menyentuh apa pun di luar dirinya, sedangkan main yang bertipe IO () bertugas menampilkan hasilnya ke terminal. Baris main = do menandai rangkaian aksi yang dijalankan berurutan dari atas ke bawah.

haskell pengukuran-suhu.hs
-- fungsi murni: tidak menyentuh layar maupun berkas
konversiKeFahrenheit :: Double -> Double
konversiKeFahrenheit c = c * 9 / 5 + 32

main :: IO ()
main = do
  putStrLn ("100 C = " ++ show (konversiKeFahrenheit 100) ++ " F")
  putStrLn ("0 C = " ++ show (konversiKeFahrenheit 0) ++ " F")
text Keluaran
100 C = 212.0 F
0 C = 32.0 F

Tipenya dapat dibaca sebagai janji. Anotasi konversiKeFahrenheit :: Double -> Double tidak memuat IO, sehingga fungsi tersebut terbukti tidak dapat mencetak, membaca masukan, atau mengubah berkas, bagaimanapun tubuh fungsinya disunting. Sebaliknya, main :: IO () menyatakan bahwa program berinteraksi dengan dunia luar, dan seluruh interaksi itu terkumpul di satu tempat. Apabila logika konversi suatu saat perlu diuji ulang, pengujian cukup memanggil fungsinya tanpa terminal maupun berkas.

Membaca tipe aksi#

Tipe IO a menyatakan sebuah aksi yang, apabila dijalankan, dapat berinteraksi dengan dunia luar dan menghasilkan nilai bertipe a. Huruf setelah IO menunjukkan jenis hasil yang dibawa aksi itu, sehingga IO String adalah aksi yang menghasilkan String dan IO () adalah aksi yang tidak membawa hasil berarti. Tipe aksi yang paling sering ditemui dapat ditelusuri melalui GHCi sebagaimana tipe fungsi biasa.

bash
# telusuri tipe aksi pencetakan teks
ghci -e ':t putStrLn'
# telusuri tipe aksi pembacaan satu baris masukan
ghci -e ':t getLine'
# telusuri tipe aksi pencetakan nilai dengan show
ghci -e ':t print'
text Keluaran
putStrLn :: String -> IO ()
getLine :: IO String
print :: Show a => a -> IO ()

Baris putStrLn :: String -> IO () dibaca sebagai berikut. putStrLn menerima satu String dan menghasilkan aksi bertipe IO (), yaitu aksi mencetak teks tersebut diikuti baris baru tanpa mengembalikan nilai apa pun. Baris getLine :: IO String tidak menerima argumen sama sekali dan menghasilkan aksi yang membaca satu baris masukan, kemudian menyampaikan baris itu sebagai String kepada program. Baris print :: Show a => a -> IO () memuat batasan Show, sehingga aksi cetaknya bekerja untuk sebarang tipe yang dapat diubah menjadi teks.

Aksi didefinisikan belum berarti dijalankan#

Nilai bertipe IO adalah deskripsi aksi, dan deskripsi baru menjadi efek nyata ketika aksinya dijalankan oleh pelari program melalui main. Evaluasi ekspresi yang menghasilkan aksi tidak menjalankan apa pun, sebagaimana mendefinisikan fungsi tidak menjalankan tubuh fungsinya. Perilaku ini terlihat pada program berikut yang mendefinisikan sebuah aksi tetapi tidak pernah merangkainya ke dalam main.

haskell aksi-terlewat.hs
-- aksi yang didefinisikan tetapi tidak pernah dijalankan
terlewat :: IO ()
terlewat = putStrLn "baris ini tidak pernah tampil"

main :: IO ()
main = do
  putStrLn "program dimulai"
  putStrLn "program berakhir"
text Keluaran
program dimulai
program berakhir

Aksi terlewat tidak pernah tampil karena nilainya hanya dibuat, bukan dijalankan. Keluaran program hanya memuat dua baris dari main, dan compiler tidak memberi peringatan tentang aksi yang tak terpakai. Cara pandang ini konsisten dengan sifat data Haskell yang pasif, yaitu nilai tidak melakukan apa pun sampai diminta. Perbedaannya, aksi IO yang benar-benar dijalankan menimbulkan efek dunia luar, sedangkan perhitungan murni hanya menghasilkan nilai.

Batas tipe yang menjaga kemurnian#

Batas IO membuat kemurnian dapat dipaksakan oleh compiler, bukan oleh disiplin menulis. Fungsi tanpa IO pada tipenya tidak mungkin mencetak, membaca berkas, atau membaca waktu sistem, karena semua kemampuan itu hanya tersedia melalui tipe IO. Compiler menolak program yang mencoba menyelipkan interaksi ke dalam fungsi murni, dan pesan errornya menyebut ketidakcocokan antara tipe IO dan tipe biasa. Dengan demikian, anotasi tipe berfungsi sebagai bukti yang diperiksa ulang setiap kali program dibangun.

Konsekuensi praktisnya terasa pada pengujian dan pemfaktoran ulang. Logika yang disimpan dalam fungsi murni dapat diuji dengan membandingkan keluarannya pada masukan yang sama, tanpa perlu terminal maupun berkas. Efek dunia luar dikumpulkan di sekitar main, sedangkan keputusan-keputusan penting tetap berada dalam fungsi yang dapat dianalisis secara terpisah. Pembagian peran ini menjadi pola kerja baku dalam Haskell, yaitu inti murni di tengah dan lapisan IO yang tipis di tepinya.

Kesalahan umum#

Kesalahan yang paling lazim pada tahap ini adalah menggunakan hasil aksi sebelum aksinya dijalankan. Karena getLine bertipe IO String, hasilnya tidak boleh diperlakukan sebagai String biasa, misalnya diteruskan langsung kepada putStrLn. Compiler menolak program semacam itu dengan pesan yang menunjukkan ketidakcocokan tipe.

bash
# meneruskan hasil aksi tanpa menjalankan aksinya dahulu
ghci -e 'putStrLn getLine'
text Keluaran
<interactive>:0:10: error: [GHC-83865]
    • Couldn't match type: IO String
                     with: [Char]
      Expected: String
        Actual: IO String
    • In the first argument of ‘putStrLn’, namely ‘getLine’
      In the expression: putStrLn getLine
      In an equation for ‘it’: it = putStrLn getLine

Pesan Couldn't match type: IO String with [Char] berarti program mengharapkan String, yaitu list karakter yang pada pesan error ditulis [Char], tetapi menerima aksi IO String. Perbaikannya bukan mengubah tipe, melainkan menjalankan aksinya terlebih dahulu dan mengikat hasilnya, mekanisme yang dibahas pada topik Notasi do.

Kesalahan kedua berupa mengira seluruh program harus diawali aksi. Fungsi murni tetap ditulis dan dipanggil dari dalam main, sehingga tidak perlu mengubah tipenya menjadi IO hanya agar dapat dipanggil. Apabila sebuah fungsi terasa perlu mencetak, tempatkan pencetakan itu di main atau aksi lain yang memanggilnya, kemudian biarkan fungsinya tetap murni. Kebiasaan ini menjaga pengujian tetap sederhana.

Ringkasan#

  • Tipe IO a menyatakan aksi yang berinteraksi dengan dunia luar dan menghasilkan nilai bertipe a ketika dijalankan.
  • Mendefinisikan aksi hanyalah membuat deskripsi, sedangkan efek nyata terjadi hanya melalui rangkaian aksi yang dijalankan main.
  • Tanda () pada IO () adalah unit, sehingga aksi tersebut selesai tanpa menyampaikan hasil.
  • Fungsi tanpa IO pada tipenya terbukti bebas efek samping, sehingga logika murni dapat diuji terpisah dari interaksi dunia luar.

Referensi#