Kesalahpahaman tentang bahasa murni fungsional?

39

Saya sering menemui pernyataan / argumen berikut:

  1. Bahasa pemrograman fungsional murni tidak memungkinkan efek samping (dan karena itu tidak banyak digunakan dalam praktik karena setiap program yang bermanfaat memang memiliki efek samping, misalnya ketika berinteraksi dengan dunia luar).
  2. Bahasa pemrograman fungsional murni tidak memungkinkan untuk menulis sebuah program yang mempertahankan keadaan (yang membuat pemrograman sangat canggung karena dalam banyak aplikasi Anda membutuhkan keadaan).

Saya bukan ahli dalam bahasa fungsional tetapi inilah yang saya mengerti tentang topik-topik ini sampai sekarang.

Mengenai poin 1, Anda dapat berinteraksi dengan lingkungan dalam bahasa murni fungsional tetapi Anda harus secara eksplisit menandai kode (fungsi) yang memperkenalkan efek samping (misalnya di Haskell dengan cara tipe monadik). Juga, sejauh yang saya tahu komputasi dengan efek samping (memperbarui data secara destruktif) juga harus dimungkinkan (menggunakan tipe monadik?) Walaupun itu bukan cara kerja yang disukai.

Mengenai poin 2, sejauh yang saya tahu Anda dapat mewakili negara dengan memasukkan nilai melalui beberapa langkah perhitungan (di Haskell, sekali lagi, menggunakan tipe monadik) tetapi saya tidak memiliki pengalaman praktis melakukan ini dan pemahaman saya agak kabur.

Jadi, apakah kedua pernyataan di atas benar dalam arti apa pun atau mereka hanya kesalahpahaman tentang bahasa murni fungsional? Jika mereka salah paham, bagaimana hal itu terjadi? Bisakah Anda menulis potongan kode (mungkin kecil) yang menggambarkan cara Haskell idiomatis untuk (1) menerapkan efek samping dan (2) menerapkan perhitungan dengan status?

Giorgio
sumber
7
Saya pikir sebagian besar bergantung pada apa yang Anda definisikan sebagai bahasa fungsional 'murni'.
jk.
@ jk: Untuk menghindari masalah mendefinisikan bahasa fungsional 'murni', asumsikan kemurnian dalam arti Haskell (yang didefinisikan dengan baik). Dalam kondisi apa bahasa fungsional dapat dianggap 'murni' dapat menjadi topik pertanyaan di masa depan.
Giorgio
Kedua jawaban tersebut mengandung banyak ide klarifikasi dan sulit bagi saya untuk memilih mana yang akan diterima. Saya memutuskan untuk menerima jawaban sepp2k karena contoh pseudo-code tambahan.
Giorgio

Jawaban:

26

Untuk keperluan jawaban ini saya mendefinisikan "bahasa murni fungsional" yang berarti bahasa fungsional di mana fungsi-fungsi transparan secara referensial, yaitu memanggil fungsi yang sama beberapa kali dengan argumen yang sama akan selalu menghasilkan hasil yang sama. Saya percaya ini adalah definisi umum dari bahasa yang murni fungsional.

Bahasa pemrograman fungsional murni tidak memungkinkan efek samping (dan karena itu tidak banyak digunakan dalam praktik karena setiap program yang bermanfaat memang memiliki efek samping, misalnya ketika berinteraksi dengan dunia luar).

Cara termudah untuk mencapai transparansi referensial memang dengan melarang efek samping dan memang ada bahasa di mana itu terjadi (kebanyakan yang spesifik domain). Namun itu tentu saja bukan satu-satunya cara dan tujuan paling umum bahasa murni fungsional (Haskell, Clean, ...) memungkinkan efek samping.

Juga mengatakan bahwa bahasa pemrograman tanpa efek samping sedikit digunakan dalam praktiknya tidak benar-benar adil saya pikir - tentu saja tidak untuk bahasa domain spesifik, tetapi bahkan untuk bahasa tujuan umum, saya membayangkan sebuah bahasa bisa sangat berguna tanpa memberikan efek samping . Mungkin bukan untuk aplikasi konsol, tapi saya pikir aplikasi GUI dapat diimplementasikan dengan baik tanpa efek samping, katakanlah, paradigma reaktif fungsional.

Mengenai poin 1, Anda dapat berinteraksi dengan lingkungan dalam bahasa murni fungsional tetapi Anda harus secara eksplisit menandai kode (fungsi) yang memperkenalkan mereka (misalnya dalam Haskell dengan cara tipe monadik).

Itu sedikit terlalu menyederhanakannya. Hanya memiliki sistem di mana fungsi efek samping perlu ditandai seperti itu (mirip dengan koreksi-benar di C ++, tetapi dengan efek samping umum) tidak cukup untuk memastikan transparansi referensial. Anda perlu memastikan bahwa suatu program tidak pernah dapat memanggil fungsi beberapa kali dengan argumen yang sama dan mendapatkan hasil yang berbeda. Anda bisa melakukannya dengan membuat hal-hal sepertireadLinemenjadi sesuatu yang bukan fungsi (itulah yang Haskell lakukan dengan IO monad) atau Anda bisa membuatnya mustahil untuk memanggil fungsi efek samping beberapa kali dengan argumen yang sama (itulah yang dilakukan Clean). Dalam kasus terakhir kompiler akan memastikan bahwa setiap kali Anda memanggil fungsi efek samping, Anda melakukannya dengan argumen baru, dan itu akan menolak program apa pun di mana Anda meneruskan argumen yang sama ke fungsi efek samping dua kali.

Bahasa pemrograman fungsional murni tidak memungkinkan untuk menulis sebuah program yang mempertahankan keadaan (yang membuat pemrograman sangat canggung karena dalam banyak aplikasi Anda membutuhkan keadaan).

Sekali lagi, bahasa yang murni fungsional mungkin sangat tidak memungkinkan keadaan yang bisa berubah, tetapi tentu saja mungkin untuk menjadi murni dan masih memiliki keadaan yang bisa berubah, jika Anda menerapkannya dengan cara yang sama seperti yang saya jelaskan dengan efek samping di atas. Keadaan yang benar-benar bisa berubah hanyalah bentuk lain dari efek samping.

Yang mengatakan, bahasa pemrograman fungsional pasti mencegah negara bisa berubah - yang murni terutama begitu. Dan saya tidak berpikir itu membuat pemrograman canggung - justru sebaliknya. Kadang-kadang (tetapi tidak semua yang sering) keadaan bisa berubah tidak dapat dihindari tanpa kehilangan kinerja atau kejelasan (itulah sebabnya bahasa seperti Haskell memang memiliki fasilitas untuk keadaan bisa berubah), tetapi paling sering itu bisa.

Jika mereka salah paham, bagaimana hal itu terjadi?

Saya pikir banyak orang hanya membaca "suatu fungsi harus menghasilkan hasil yang sama ketika dipanggil dengan argumen yang sama" dan menyimpulkan bahwa itu tidak mungkin untuk mengimplementasikan sesuatu seperti readLineatau kode yang mempertahankan keadaan bisa berubah. Jadi mereka sama sekali tidak menyadari "cheat" yang dapat digunakan bahasa murni untuk memperkenalkan hal-hal ini tanpa melanggar transparansi referensial.

Keadaan yang bisa berubah juga sangat tidak mendukung dalam bahasa fungsional, sehingga tidak terlalu banyak lompatan untuk menganggap itu tidak diperbolehkan sama sekali dalam bahasa yang murni fungsional.

Bisakah Anda menulis potongan kode (mungkin kecil) yang menggambarkan cara Haskell idiomatis untuk (1) menerapkan efek samping dan (2) menerapkan perhitungan dengan status?

Berikut aplikasi di Pseudo-Haskell yang meminta nama pengguna dan menyapanya. Pseudo-Haskell adalah bahasa yang baru saja saya temukan, yang memiliki sistem IO Haskell, tetapi menggunakan sintaksis yang lebih konvensional, nama fungsi yang lebih deskriptif dan tidak memiliki donotasi (karena itu hanya akan mengalihkan perhatian dari bagaimana tepatnya IO monad bekerja):

greet(name) = print("Hello, " ++ name ++ "!")
main = composeMonad(readLine, greet)

Petunjuk di sini adalah bahwa itu readLineadalah nilai tipe IO<String>dan composeMonadmerupakan fungsi yang mengambil argumen tipe IO<T>(untuk beberapa tipe T) dan argumen lain yang merupakan fungsi yang mengambil argumen tipe Tdan mengembalikan nilai tipe IO<U>(untuk beberapa tipe U). printadalah fungsi yang mengambil string dan mengembalikan nilai tipe IO<void>.

Nilai tipe IO<A>adalah nilai yang "menyandikan" tindakan yang diberikan yang menghasilkan nilai tipe A. composeMonad(m, f)menghasilkan IOnilai baru yang mengkodekan tindakan mdiikuti oleh tindakan f(x), di mana xnilai menghasilkan dengan melakukan tindakan m.

Status yang bisa berubah akan terlihat seperti ini:

counter = mutableVariable(0)
increaseCounter(cnt) =
    setIncreasedValue(oldValue) = setValue(cnt, oldValue + 1)
    composeMonad(getValue(cnt), setIncreasedValue)

printCounter(cnt) = composeMonad( getValue(cnt), print )

main = composeVoidMonad( increaseCounter(counter), printCounter(counter) )

Berikut mutableVariableadalah fungsi yang mengambil nilai dari jenis apa pun Tdan menghasilkan a MutableVariable<T>. Fungsi getValuemengambil MutableVariabledan mengembalikan suatu IO<T>yang menghasilkan nilai saat ini. setValuemengambil a MutableVariable<T>dan a Tdan mengembalikan sebuah IO<void>yang menetapkan nilai. composeVoidMonadsama seperti composeMonadkecuali bahwa argumen pertama adalah argumen IOyang tidak menghasilkan nilai yang masuk akal dan argumen kedua adalah monad lain, bukan fungsi yang mengembalikan monad.

Dalam Haskell ada beberapa gula sintaksis, yang membuat seluruh cobaan ini tidak terlalu menyakitkan, tetapi masih jelas bahwa keadaan yang bisa berubah adalah sesuatu yang tidak benar-benar ingin Anda lakukan bahasa.

sepp2k
sumber
Jawaban bagus, mengklarifikasi banyak ide. Haruskah baris terakhir dari potongan kode menggunakan nama counter, yaitu increaseCounter(counter)?
Giorgio
@Iorgio Ya, seharusnya. Tetap.
sepp2k
1
@Giorgio Satu hal yang saya lupa sebutkan secara eksplisit dalam posting saya adalah bahwa tindakan IO yang dikembalikan oleh mainakan menjadi salah satu yang benar-benar dieksekusi. Selain mengembalikan IO dari maintidak ada cara untuk melakukan IOtindakan (tanpa menggunakan fungsi jahat mengerikan yang ada unsafedalam nama mereka).
sepp2k
BAIK. scarfridge juga menyebutkan IOnilai-nilai yang merusak . Saya tidak mengerti apakah ia merujuk pada pencocokan pola, yaitu fakta bahwa Anda dapat mendekonstruksi nilai tipe data aljabar, tetapi seseorang tidak dapat menggunakan pencocokan pola untuk melakukan ini dengan IOnilai.
Giorgio
16

IMHO Anda bingung karena ada perbedaan antara bahasa murni dan fungsi murni . Mari kita mulai dengan fungsinya. Suatu fungsi murni jika akan (diberi input yang sama) selalu mengembalikan nilai yang sama dan tidak menyebabkan efek samping yang dapat diamati. Contoh umum adalah fungsi matematika seperti f (x) = x * x. Sekarang pertimbangkan implementasi dari fungsi ini. Ini akan menjadi murni di sebagian besar bahasa bahkan mereka yang umumnya tidak dianggap bahasa fungsional murni misalnya ML. Bahkan metode Java atau C ++ dengan perilaku ini dapat dianggap sebagai murni.

Jadi apa itu bahasa murni? Sebenarnya orang mungkin berharap bahwa bahasa murni tidak membiarkan Anda mengekspresikan fungsi yang tidak murni. Mari kita sebut ini definisi idealis dari bahasa murni. Perilaku seperti itu sangat diinginkan. Mengapa? Nah hal yang menyenangkan tentang program yang hanya terdiri dari fungsi murni adalah Anda dapat mengganti aplikasi fungsi dengan nilainya tanpa mengubah arti program. Hal ini membuat alasan yang sangat mudah tentang program karena setelah Anda tahu hasilnya Anda bisa melupakan cara itu dihitung. Purity juga memungkinkan kompiler melakukan optimasi agresif tertentu.

Jadi bagaimana jika Anda memerlukan kondisi internal? Anda dapat meniru status dalam bahasa murni hanya dengan menambahkan status sebelum perhitungan sebagai parameter input dan status setelah perhitungan sebagai bagian dari hasil. Alih-alih Int -> BoolAnda mendapatkan sesuatu seperti Int -> State -> (Bool, State). Anda cukup membuat ketergantungan secara eksplisit (yang dianggap praktik yang baik dalam paradigma pemrograman apa pun). BTW ada monad yang merupakan cara yang sangat elegan untuk menggabungkan fungsi meniru negara menjadi fungsi meniru negara yang lebih besar. Dengan cara ini Anda pasti dapat "mempertahankan status" dalam bahasa murni. Tetapi Anda harus membuatnya eksplisit.

Jadi apakah ini berarti saya bisa berinteraksi dengan luar? Toh program yang bermanfaat harus berinteraksi dengan dunia nyata agar bermanfaat. Tetapi input dan output jelas tidak murni. Menulis byte tertentu ke file tertentu mungkin baik-baik saja untuk pertama kalinya. Tetapi menjalankan operasi yang sama persis untuk kedua kalinya mungkin mengembalikan kesalahan karena disk penuh. Jelas tidak ada bahasa murni (dalam arti idealistik) yang dapat menulis ke file.

Jadi kita dihadapkan pada dilema. Kami ingin sebagian besar fungsi murni tetapi beberapa efek samping benar-benar diperlukan dan yang tidak murni. Sekarang definisi realistis dari bahasa murni adalah bahwa harus ada beberapa cara untuk memisahkan bagian murni dari bagian lain. Mekanisme harus memastikan bahwa tidak ada operasi yang tidak murni menyelinap masuk ke bagian murni.

Di Haskell ini dilakukan dengan tipe IO. Anda tidak dapat merusak hasil IO (tanpa mekanisme yang tidak aman). Dengan demikian Anda hanya dapat memproses hasil IO dengan fungsi yang didefinisikan dalam modul IO sendiri. Untungnya ada kombinator yang sangat fleksibel yang memungkinkan Anda untuk mengambil hasil IO dan memprosesnya dalam suatu fungsi selama fungsi itu mengembalikan hasil IO yang lain. Combinator ini disebut bind (atau >>=) dan memiliki tipe IO a -> (a -> IO b) -> IO b. Jika Anda menggeneralisasi konsep ini, Anda tiba di kelas monad dan IO merupakan turunannya.

scarfridge
sumber
4
Saya tidak benar-benar melihat bagaimana Haskell (mengabaikan fungsi apa pun dengan unsafenamanya) tidak memenuhi definisi idealistik Anda. Tidak ada fungsi tidak murni di Haskell (lagi-lagi mengabaikan unsafePerformIOdan bekerja sama).
sepp2k
4
readFiledan writeFileakan selalu mengembalikan nilai yang sama IO, mengingat argumen yang sama. Jadi misal dua cuplikan kode let x = writeFile "foo.txt" "bar" in x >> xdan writeFile "foo.txt" "bar" >> writeFile "foo.txt" "bar"akan melakukan hal yang sama.
sepp2k
3
@AidanCully Apa yang Anda maksud dengan "fungsi IO"? Fungsi yang mengembalikan nilai tipe IO Something? Jika demikian, sangat mungkin untuk memanggil fungsi IO dua kali dengan argumen yang sama: putStrLn "hello" >> putStrLn "hello"- di sini kedua panggilan putStrLnmemiliki argumen yang sama. Tentu saja itu bukan masalah karena, seperti yang saya katakan sebelumnya, kedua panggilan akan menghasilkan nilai IO yang sama.
sepp2k
3
@ scarfridge Mengevaluasi writeFile "foo.txt" "bar"tidak dapat menyebabkan kesalahan karena mengevaluasi panggilan fungsi tidak menjalankan tindakan. Jika Anda mengatakan bahwa dalam contoh saya sebelumnya versi dengan lethanya memiliki satu kesempatan untuk menyebabkan kegagalan IO sementara versi tanpa letmemiliki dua, Anda salah. Kedua versi memiliki dua peluang untuk kegagalan IO. Karena letversi mengevaluasi panggilan writeFilehanya sekali sementara versi tanpa letmengevaluasinya dua kali, Anda dapat melihat bahwa itu tidak masalah seberapa sering fungsi dipanggil. Yang penting hanyalah seberapa sering hasilnya ...
sepp2k
6
@AidanCully "mekanisme monad" tidak menyebarkan parameter implisit. The putStrLnFungsi mengambil tepat satu argumen, yang merupakan tipe String. Jika Anda tidak percaya padaku, melihat jenisnya: String -> IO (). Itu tentu saja tidak mengambil argumen tipe apa pun IO- itu menghasilkan nilai tipe itu.
sepp2k