Apakah return-type- (hanya) -polymorphism di Haskell adalah hal yang baik?


29

Satu hal yang belum pernah saya setujui di Haskell adalah bagaimana Anda dapat memiliki konstanta dan fungsi polimorfik yang jenis kembalinya tidak dapat ditentukan oleh jenis inputnya, seperti

class Foo a where
    foo::Int -> a

Beberapa alasan yang saya tidak suka ini:

Transparansi referensial:

"Di Haskell, diberi input yang sama, suatu fungsi akan selalu mengembalikan output yang sama", tetapi apakah itu benar? read "3"mengembalikan 3 saat digunakan dalam Intkonteks, tetapi melempar kesalahan saat digunakan dalam, katakanlah, (Int,Int)konteks. Ya, Anda dapat berargumen bahwa readjuga mengambil parameter tipe, tetapi implikasi parameter tipe membuatnya kehilangan sebagian keindahannya menurut saya.

Pembatasan monomorfisme:

Salah satu hal yang paling menyebalkan tentang Haskell. Koreksi saya jika saya salah, tetapi seluruh alasan MR adalah bahwa perhitungan yang terlihat dibagi mungkin bukan karena parameter type implisit.

Ketik standar:

Lagi-lagi salah satu hal yang paling menyebalkan tentang Haskell. Terjadi misalnya jika Anda melewatkan hasil fungsi polimorfik dalam outputnya ke fungsi polimorfik dalam inputnya. Sekali lagi, koreksi saya jika saya salah, tetapi ini tidak akan diperlukan tanpa fungsi yang tipe kembalinya tidak dapat ditentukan oleh tipe inputnya (dan konstanta polimorfik).

Jadi pertanyaan saya adalah (menghadapi risiko dicap sebagai "pertanyaan diskusi"): Apakah mungkin untuk membuat bahasa seperti Haskell di mana pemeriksa tipe menolak jenis definisi seperti ini? Jika demikian, apa manfaat / kerugian dari pembatasan itu?

Saya dapat melihat beberapa masalah langsung:

Jika, katakanlah, 2hanya memiliki tipe Integer, 2/3tidak akan mengetik centang lagi dengan definisi saat ini /. Tetapi dalam kasus ini, saya pikir kelas tipe dengan dependensi fungsional dapat menyelamatkan (ya, saya tahu bahwa ini adalah ekstensi). Selain itu, saya pikir jauh lebih intuitif untuk memiliki fungsi yang dapat mengambil tipe input yang berbeda, daripada memiliki fungsi yang dibatasi pada tipe input mereka, tetapi kami hanya memberikan nilai polimorfik kepada mereka.

Mengetik nilai-nilai seperti []dan Nothingmenurut saya seperti kacang yang lebih keras untuk dipecahkan. Saya belum memikirkan cara yang baik untuk menangani mereka.

Jawaban:


37

Saya benar-benar berpikir bahwa polimorfisme tipe kembali adalah salah satu fitur terbaik dari kelas tipe. Setelah menggunakannya sebentar, kadang-kadang sulit bagi saya untuk kembali ke pemodelan gaya OOP di mana saya tidak memilikinya.

Pertimbangkan pengkodean aljabar. Di Haskell kita memiliki kelas tipe Monoid(mengabaikan mconcat)

class Monoid a where
   mempty :: a
   mappend :: a -> a -> a

Bagaimana kita bisa menyandikan ini sebagai antarmuka dalam bahasa OO? Jawaban singkatnya adalah kita tidak bisa. Itu karena tipe memptyis (Monoid a) => aalias, return type polymorphism. Memiliki kemampuan memodel aljabar adalah IMO yang sangat berguna. *

Anda memulai posting Anda dengan keluhan tentang "Transparansi Referensial." Ini memunculkan poin penting: Haskell adalah bahasa yang berorientasi nilai. Jadi ekspresi seperti read 3tidak harus dipahami sebagai hal yang menghitung nilai, mereka juga dapat dipahami sebagai nilai. Apa artinya ini adalah bahwa masalah sebenarnya bukan tipe kembali polimorfisme: itu adalah nilai dengan tipe polimorfik ( []dan Nothing). Jika bahasa harus memiliki ini, maka itu benar-benar harus memiliki tipe pengembalian polimorfik untuk konsistensi.

Haruskah kita bisa mengatakan []tipe forall a. [a]? Aku pikir begitu. Fitur-fitur ini sangat berguna, dan membuat bahasanya lebih sederhana.

Jika Haskell memiliki subtipe polimorfisme []bisa menjadi subtipe untuk semua [a]. Masalahnya adalah, saya tidak tahu cara pengkodean yang tanpa jenis daftar kosong menjadi polimorfik. Pertimbangkan bagaimana hal itu akan dilakukan dalam Scala (lebih pendek daripada melakukannya dalam bahasa OOP yang diketik secara kanonik kanonik, Jawa)

abstract class List[A]
case class Nil[A] extends List[A]
case class Cons[A](h: A. t: List[A]) extends List[A]

Bahkan di sini, Nil()adalah objek tipe Nil[A]**

Keuntungan lain dari polimorfisme tipe kembali adalah membuat Curry-Howard lebih sederhana.

Pertimbangkan teorema logis berikut:

 t1 = forall P. forall Q. P -> P or Q
 t2 = forall P. forall Q. P -> Q or P

Kita dapat dengan mudah menangkap ini sebagai teorema di Haskell:

data Either a b = Left a | Right b
t1 :: a -> Either a b
t1 = Left
t2 :: a -> Either b a
t2 = Right

Singkatnya: Saya suka polimorfisme tipe kembali, dan hanya berpikir itu merusak transparansi referensial jika Anda memiliki gagasan terbatas tentang nilai-nilai (walaupun ini kurang menarik dalam kasus kelas tipe ad hoc). Di sisi lain, saya menemukan poin Anda tentang MR dan ketik defaulting menarik.


*. Dalam komentar ysdx tunjukkan ini tidak sepenuhnya benar: kita bisa mengimplementasikan kembali tipe kelas dengan memodelkan aljabar sebagai tipe lain. Seperti java:

abstract class Monoid<M>{
   abstract M empty();
   abstract M append(M m1, M m2);
}

Anda kemudian harus melewati objek jenis ini dengan Anda. Scala memiliki gagasan tentang parameter implisit yang menghindari beberapa, tetapi dalam pengalaman saya tidak semua, overhead secara eksplisit mengelola hal-hal ini. Menempatkan metode utilitas Anda (metode pabrik, metode biner, dll) pada tipe F-bounded yang terpisah ternyata merupakan cara yang sangat bagus untuk mengelola berbagai hal dalam bahasa OO yang memiliki dukungan untuk obat generik. Yang mengatakan, saya tidak yakin saya akan mengelus pola ini jika saya tidak memiliki pengalaman memodelkan hal dengan kacamata ketik, dan saya tidak yakin orang lain akan melakukannya.

Ini juga memiliki keterbatasan, di luar kotak tidak ada cara untuk mendapatkan objek yang mengimplementasikan typeclass untuk jenis sewenang-wenang. Anda harus melewati nilai-nilai secara eksplisit, menggunakan sesuatu seperti implisit Scala, atau menggunakan beberapa bentuk teknologi injeksi ketergantungan. Hidup jadi jelek. Di sisi lain, itu menyenangkan bahwa Anda dapat memiliki beberapa implementasi untuk tipe yang sama. Sesuatu dapat menjadi monoid dalam berbagai cara. Juga, membawa sekitar struktur ini secara terpisah memiliki IMO yang lebih modern, konstruktif, merasa secara matematis. Jadi, meskipun saya umumnya masih lebih suka cara Haskell melakukan ini, saya mungkin melebih-lebihkan kasus saya.

Kacamata jenis dengan polimorfisme tipe kembali membuat hal semacam ini mudah ditangani. Itu tidak berarti itu adalah cara terbaik untuk melakukannya.

**. Jörg W Mittag menunjukkan ini bukan cara kanonik untuk melakukan ini di Scala. Sebagai gantinya, kami akan mengikuti perpustakaan standar dengan sesuatu yang lebih seperti:

abstract class List[+A] ...  
case class Cons[A](head: A, tail: List[A]) extends List[A] ...
case object Nil extends List[Nothing] ...

Ini mengambil keuntungan dari dukungan Scala untuk tipe bawah, serta paramaters tipe kovarian. Jadi, bukan Niltipe . Pada titik ini kita cukup jauh dari Haskell, tetapi menarik untuk dicatat bagaimana Haskell mewakili tipe bawahNilNil[A]

undefined :: forall a. a

Artinya, ini bukan subtipe dari semua jenis, itu adalah polimorfis (sp) anggota dari semua jenis.
Namun lebih banyak polimorfisme tipe kembali.


4
"Bagaimana kita bisa menyandikan ini sebagai antarmuka dalam bahasa OO?" Anda bisa menggunakan aljabar kelas satu: antarmuka Monoid <X> {X empty (); X menambahkan (X, X); } Namun Anda harus meneruskannya secara eksplisit (ini dilakukan di belakang layar di Haskell / GHC).
ysdx

@ysdx Itu benar. Dan dalam bahasa yang mendukung parameter implisit Anda mendapatkan sesuatu yang sangat dekat dengan kelas tipe haskell (seperti di Scala). Saya sadar akan hal itu. Maksud saya adalah bahwa ini membuat hidup sangat sulit: Saya mendapati diri saya harus menggunakan wadah yang menyimpan "typeclass" di semua tempat (yuck!). Namun, saya mungkin seharusnya kurang hiperbolik dalam jawaban saya.
Philip JF

+1, jawaban yang luar biasa. Namun, satu nitpick yang tidak relevan: Nilmungkin seharusnya a case object, bukan a case class.
Jörg W Mittag

@ Jörg W Mittag Ini tidak sepenuhnya tidak relevan. Saya telah mengedit untuk memberi komentar pada komentar Anda.
Philip JF

1
Terima kasih atas jawaban yang sangat bagus. Mungkin saya perlu sedikit waktu untuk mencerna / memahaminya.
dainichi

12

Hanya untuk mencatat kesalahpahaman:

"Di Haskell, diberi input yang sama, suatu fungsi akan selalu mengembalikan output yang sama", tetapi apakah itu benar? baca "3" mengembalikan 3 saat digunakan dalam konteks Int, tetapi melempar kesalahan ketika digunakan dalam konteks, katakanlah, (Int, Int).

Hanya karena istri saya mengendarai Subaru dan saya mengendarai Subaru tidak berarti kami mengendarai mobil yang sama. Hanya karena ada 2 fungsi yang dinamai readbukan berarti fungsinya sama. Memang read :: String -> Intdidefinisikan dalam instance Baca dari Int, di mana read :: String (Int, Int)didefinisikan dalam instance Baca dari (Int, Int). Karenanya, mereka adalah fungsi yang sama sekali berbeda.

Fenomena ini biasa terjadi dalam bahasa pemrograman dan biasanya disebut overloading .


6
Saya pikir Anda agak merindukan inti pertanyaan. Dalam sebagian besar bahasa yang memiliki polimorfisme ad-hoc, alias overloading, pemilihan fungsi yang dipanggil hanya bergantung pada tipe parameter, bukan pada tipe kembali. Ini membuatnya lebih mudah untuk beralasan tentang arti pemanggilan fungsi: cukup mulai dari daun pohon ekspresi, dan kerjakan cara Anda "ke atas". Di Haskell (dan sejumlah kecil bahasa lain yang mendukung over-type return) Anda berpotensi harus mempertimbangkan seluruh pohon ekspresi sekaligus, bahkan untuk mengetahui arti dari subekspresi kecil.
Laurence Gonsalves

1
Saya pikir Anda mencapai inti dari pertanyaan dengan sempurna. Bahkan Shakespeare berkata, "Suatu fungsi dengan nama lain juga akan berfungsi." +1
Thomas Eding

@Laurence Gonsalves - Ketikkan inferensi di Haskell tidak secara transparan transparan. Arti kode dapat bergantung pada konteks di mana ia digunakan karena jenis inferensi menarik informasi ke dalam. Itu tidak terbatas pada masalah tipe-kembali. Haskell secara efektif memiliki Prolog yang dibangun ke dalam sistem tipenya. Ini dapat membuat kode menjadi kurang jelas, tetapi memiliki keuntungan besar juga. Secara pribadi, saya pikir jenis transparansi referensial yang paling penting adalah apa yang terjadi pada saat run-time, karena kemalasan tidak mungkin dihadapi tanpa itu.
Steve314

@ Steve314 Saya kira saya belum melihat situasi di mana kurangnya jenis inferensi transparan tidak membuat kode kurang jelas. Untuk alasan tentang kompleksitas apa pun, seseorang harus mampu secara mental "memotong" berbagai hal. Jika Anda memberi tahu saya bahwa Anda memiliki kucing, saya tidak berpikir tentang awan miliaran atom atau sel individual. Demikian juga, ketika membaca kode saya partisi ekspresi ke dalam sub-ekspresi mereka. Haskell mengalahkan ini dengan dua cara: inferensi tipe "salah arah", dan overloading operator yang terlalu rumit. Komunitas Haskell juga memiliki keengganan untuk mengasuh anak, membuat yang terakhir bahkan lebih buruk.
Laurence Gonsalves

1
@LaurenceGonsalves Anda benar bahwa infixfitur dapat disalahgunakan. Tapi ini adalah kegagalan pengguna. OTOH, pembatasan, seperti di Jawa, adalah IMHO bukan cara yang benar. Untuk melihat ini, tidak terlihat lagi dari beberapa kode yang berhubungan dengan BigIntegers.
Ingo

7

Saya benar-benar berharap istilah "polimorfisme tipe kembali" tidak pernah dibuat. Ini mendorong kesalahpahaman tentang apa yang terjadi. Cukuplah untuk mengatakan, sementara menghilangkan "polimorfisme tipe kembali" akan menjadi perubahan yang sangat ad-hoc dan ekspresif, itu bahkan tidak akan jauh menyelesaikan masalah yang diangkat dalam pertanyaan.

Jenis pengembalian sama sekali tidak istimewa. Mempertimbangkan:

class Foo a where
    foo :: Maybe a -> Bool

x = foo Nothing

Kode ini menyebabkan semua masalah yang sama dengan "polimorfisme tipe kembali" dan menunjukkan perbedaan yang sama dari OOP. Tidak ada yang berbicara tentang "argumen pertama Mungkin ketik polimorfisme".

Gagasan utamanya adalah bahwa implementasinya menggunakan tipe untuk menyelesaikan instance mana yang akan digunakan. Nilai (run-time) apa pun tidak ada hubungannya dengan itu. Memang itu akan bekerja bahkan untuk tipe yang tidak memiliki nilai. Secara khusus, program Haskell tidak memiliki arti tanpa tipenya. (Ironisnya, ini menjadikan Haskell bahasa gaya Gereja yang bertentangan dengan bahasa gaya Kari. Saya punya artikel blog yang sedang menguraikan hal ini.)


'Kode ini menyebabkan semua masalah yang sama dengan "tipe kembali polimorfisme"'. Tidak, tidak. Saya dapat melihat "foo Nothing" dan menentukan apa jenisnya. Ini sebuah Bool. Tidak perlu melihat konteksnya.
dainichi

4
Sebenarnya, kode tidak mengetik cek karena kompiler tidak tahu seperti apa adalam kasus "tipe kembali". Sekali lagi, tidak ada yang istimewa tentang tipe pengembalian. Kita perlu mengetahui jenis semua sub-ekspresi. Pertimbangkan let x = Nothing in if foo x then fromJust x else error "No foo".
Derek Elkins

2
Belum lagi "polimorfisme argumen kedua"; fungsi seperti Int -> a -> Booladalah, dengan mencari, sebenarnya Int -> (a -> Bool)dan di sana Anda pergi, polimorfisme dalam nilai kembali lagi. Jika Anda mengizinkannya di mana saja itu harus ada di mana-mana.
Ryan Reich

4

Mengenai pertanyaan Anda tentang transparansi referensial pada nilai-nilai polimorfik, inilah sesuatu yang dapat membantu.

Pertimbangkan namanya 1 . Ini sering menunjukkan objek yang berbeda (tetapi tetap):

  • 1 seperti dalam Integer
  • 1 seperti dalam Real
  • 1 seperti dalam matriks identitas persegi
  • 1 seperti dalam fungsi identitas

Di sini penting untuk dicatat bahwa di bawah setiap konteks , 1adalah nilai tetap. Dengan kata lain, setiap pasangan nama-konteks menunjukkan objek yang unik . Tanpa konteksnya, kita tidak bisa tahu objek yang kita maksudkan. Jadi konteksnya harus disimpulkan (jika mungkin) atau disediakan secara eksplisit. Manfaat yang bagus (selain dari notasi yang mudah digunakan) adalah kemampuan untuk mengekspresikan kode dalam konteks umum.

Tapi karena ini hanya notasi, kita bisa menggunakan notasi berikut sebagai gantinya:

  • integer1 seperti dalam Integer
  • real1 seperti dalam Real
  • matrixIdentity1 seperti dalam matriks identitas persegi
  • functionIdentity1 seperti dalam fungsi identitas

Di sini, kami mendapatkan nama yang eksplisit. Ini memungkinkan kita untuk mendapatkan konteks hanya dari namanya. Dengan demikian hanya nama objek yang diperlukan untuk sepenuhnya menunjukkan suatu objek.

Kelas tipe Haskell memilih skema notasi sebelumnya. Sekarang inilah cahaya di ujung terowongan:

readadalah nama, bukan nilai (tidak memiliki konteks), tetapi read :: String -> amerupakan nilai (memiliki nama dan konteks). Dengan demikian fungsi read :: String -> Intdan fungsi read :: String -> (Int, Int)secara harfiah berbeda. Jadi jika mereka tidak setuju pada input mereka, transparansi referensial tidak rusak.

Dengan menggunakan situs kami, Anda mengakui telah membaca dan memahami Kebijakan Cookie dan Kebijakan Privasi kami.
Licensed under cc by-sa 3.0 with attribution required.