Mengetik Haskell dengan Ketergantungan, Sekarang?
Haskell, sebagian kecil, adalah bahasa yang diketik secara dependen. Ada gagasan tentang tipe data tingkat, sekarang lebih masuk akal mengetik berkat DataKinds
, dan ada beberapa cara ( GADTs
) untuk memberikan representasi run-time untuk data tipe tingkat. Oleh karena itu, nilai run-time stuff secara efektif muncul dalam tipe , yang artinya apa yang harus diketik dalam bahasa.
Tipe data sederhana dipromosikan ke tingkat jenis, sehingga nilai yang dikandungnya dapat digunakan dalam jenis. Karena itulah contoh pola dasar
data Nat = Z | S Nat
data Vec :: Nat -> * -> * where
VNil :: Vec Z x
VCons :: x -> Vec n x -> Vec (S n) x
menjadi mungkin, dan dengan itu, definisi seperti
vApply :: Vec n (s -> t) -> Vec n s -> Vec n t
vApply VNil VNil = VNil
vApply (VCons f fs) (VCons s ss) = VCons (f s) (vApply fs ss)
itu bagus. Perhatikan bahwa panjangnya n
adalah hal yang murni statis dalam fungsi itu, memastikan bahwa vektor input dan output memiliki panjang yang sama, meskipun panjang itu tidak berperan dalam eksekusi
vApply
. Sebaliknya, itu jauh lebih sulit (yaitu, tidak mungkin) untuk melaksanakan fungsi yang membuat n
salinan yang diberikan x
(yang akan pure
ke vApply
's <*>
)
vReplicate :: x -> Vec n x
karena sangat penting untuk mengetahui berapa banyak salinan yang harus dibuat pada saat run-time. Masukkan lajang.
data Natty :: Nat -> * where
Zy :: Natty Z
Sy :: Natty n -> Natty (S n)
Untuk semua jenis yang dapat dipromosikan, kita dapat membangun keluarga tunggal, diindeks dari jenis yang dipromosikan, dihuni oleh duplikat run-time dari nilainya. Natty n
adalah jenis salinan run-time dari tipe-level n
:: Nat
. Kita sekarang bisa menulis
vReplicate :: Natty n -> x -> Vec n x
vReplicate Zy x = VNil
vReplicate (Sy n) x = VCons x (vReplicate n x)
Jadi, di sana Anda memiliki nilai level-tipe yang dipasangkan ke nilai run-time: memeriksa salinan run-time memurnikan pengetahuan statis tentang nilai level-tipe. Meskipun istilah dan jenisnya terpisah, kita dapat bekerja dengan cara yang diketik secara dependen dengan menggunakan konstruksi tunggal sebagai semacam resin epoksi, menciptakan ikatan di antara fase-fase tersebut. Itu jauh dari memungkinkan ekspresi run-time sewenang-wenang dalam jenis, tapi itu bukan apa-apa.
Apa itu Nasty? Apa yang hilang?
Mari kita beri sedikit tekanan pada teknologi ini dan lihat apa yang mulai goyah. Kita mungkin mendapat gagasan bahwa lajang harus dikelola sedikit lebih implisit
class Nattily (n :: Nat) where
natty :: Natty n
instance Nattily Z where
natty = Zy
instance Nattily n => Nattily (S n) where
natty = Sy natty
memungkinkan kita untuk menulis, katakan,
instance Nattily n => Applicative (Vec n) where
pure = vReplicate natty
(<*>) = vApply
Itu bekerja, tetapi sekarang berarti bahwa Nat
tipe asli kami telah menghasilkan tiga salinan: sejenis, keluarga tunggal dan kelas tunggal. Kami memiliki proses yang agak kikuk untuk bertukar Natty n
nilai dan Nattily n
kamus eksplisit . Selain itu, Natty
tidak Nat
: kita memiliki semacam ketergantungan pada nilai run-time, tetapi tidak pada jenis yang pertama kali kita pikirkan. Tidak ada bahasa yang diketik sepenuhnya tergantung membuat jenis dependen ini rumit!
Sementara itu, meski Nat
bisa dipromosikan, Vec
tidak bisa. Anda tidak dapat mengindeks berdasarkan tipe yang diindeks. Penuh pada bahasa yang diketik secara dependen tidak memberlakukan batasan seperti itu, dan dalam karier saya sebagai pamer yang diketik secara dependen, saya telah belajar untuk menyertakan contoh pengindeksan dua lapis dalam pembicaraan saya, hanya untuk mengajar orang-orang yang telah membuat pengindeksan satu lapis sulit-tetapi-mungkin untuk tidak mengharapkan saya melipat seperti rumah kartu. Apa masalahnya? Persamaan. GADT bekerja dengan menerjemahkan kendala yang Anda capai secara implisit ketika Anda memberikan konstruktor tipe pengembalian spesifik ke dalam tuntutan persamaan eksplisit. Seperti ini.
data Vec (n :: Nat) (x :: *)
= n ~ Z => VNil
| forall m. n ~ S m => VCons x (Vec m x)
Di masing-masing dari dua persamaan kami, kedua belah pihak memiliki jenis Nat
.
Sekarang coba terjemahan yang sama untuk sesuatu yang diindeks oleh vektor.
data InVec :: x -> Vec n x -> * where
Here :: InVec z (VCons z zs)
After :: InVec z ys -> InVec z (VCons y ys)
menjadi
data InVec (a :: x) (as :: Vec n x)
= forall m z (zs :: Vec x m). (n ~ S m, as ~ VCons z zs) => Here
| forall m y z (ys :: Vec x m). (n ~ S m, as ~ VCons y ys) => After (InVec z ys)
dan sekarang kita membentuk kendala persamaan antara as :: Vec n x
dan di
VCons z zs :: Vec (S m) x
mana kedua belah pihak memiliki jenis yang berbeda secara sintaksis (tetapi terbukti sama). Inti GHC saat ini tidak dilengkapi untuk konsep seperti itu!
Apa lagi yang hilang? Yah, sebagian besar Haskell hilang dari level tipenya. Bahasa istilah yang dapat Anda promosikan benar-benar memiliki variabel dan konstruktor non-GADT. Setelah Anda memilikinya, type family
mesinnya memungkinkan Anda untuk menulis program level-type: beberapa di antaranya mungkin sangat mirip dengan fungsi yang Anda pertimbangkan untuk menulis pada level term (misalnya, melengkapi Nat
dengan penambahan, sehingga Anda dapat memberikan tipe yang bagus untuk ditambahkan Vec
) , tapi itu hanya kebetulan!
Hal lain yang hilang, dalam praktiknya, adalah perpustakaan yang memanfaatkan kemampuan baru kami untuk mengindeks tipe berdasarkan nilai. Apa yang terjadi Functor
dan Monad
menjadi di dunia baru yang berani ini? Saya sedang memikirkannya, tetapi masih banyak yang harus dilakukan.
Menjalankan Program Level-Type
Haskell, seperti kebanyakan bahasa pemrograman yang diketik secara dependen, memiliki dua
semantik operasional. Ada cara sistem run-time menjalankan program (hanya ekspresi tertutup, setelah penghapusan tipe, sangat dioptimalkan) dan kemudian ada cara typechecker menjalankan program (keluarga tipe Anda, "jenis kelas Prolog", dengan ekspresi terbuka). Untuk Haskell, Anda biasanya tidak mencampur keduanya, karena program yang dijalankan dalam bahasa yang berbeda. Bahasa ketergantungan diketik memiliki terpisah run-time dan model eksekusi statis untuk sama bahasa program, tapi jangan khawatir, model run-time masih memungkinkan Anda melakukan penghapusan jenis dan, memang, bukti penghapusan: yang ini apa Coq ini ekstraksimekanisme memberi Anda; setidaknya itulah yang dikompilasi oleh Edwin Brady (walaupun Edwin menghapus nilai-nilai yang tidak perlu, juga tipe dan bukti). Perbedaan fase mungkin bukan perbedaan kategori sintaksis
lagi, tapi itu hidup dan sehat.
Bahasa yang diketik dengan tergantung, sebagai total, memungkinkan juru ketik untuk menjalankan program bebas dari rasa takut akan sesuatu yang lebih buruk daripada menunggu lama. Ketika Haskell menjadi lebih sering diketik, kita menghadapi pertanyaan seperti apa model eksekusi statisnya? Salah satu pendekatan mungkin untuk membatasi eksekusi statis untuk fungsi total, yang akan memungkinkan kita untuk menjalankan kebebasan yang sama, tetapi mungkin memaksa kita untuk membuat perbedaan (setidaknya untuk kode tingkat-jenis) antara data dan codata, sehingga kita dapat menentukan apakah harus memberlakukan pemutusan hubungan kerja atau produktivitas. Tapi itu bukan satu-satunya pendekatan. Kita bebas memilih model pelaksanaan yang jauh lebih lemah yang enggan menjalankan program, dengan biaya membuat lebih sedikit persamaan yang keluar hanya dengan perhitungan. Dan sebenarnya, itulah yang sebenarnya dilakukan GHC. Aturan pengetikan untuk inti GHC tidak menyebutkan menjalankan
program, tetapi hanya untuk memeriksa bukti untuk persamaan. Saat menerjemahkan ke inti, pemecah kendala GHC mencoba menjalankan program level-type Anda, menghasilkan sedikit jejak bukti keperakan bahwa ekspresi yang diberikan sama dengan bentuk normalnya. Metode pembuktian-bukti ini sedikit tidak dapat diprediksi dan tidak bisa dihindari: metode ini berjuang melawan rekursi yang tampak menakutkan, misalnya, dan itu mungkin bijaksana. Satu hal yang tidak perlu kita khawatirkan adalah pelaksanaan IO
perhitungan di typechecker: ingat bahwa typechecker tidak harus memberikan
launchMissiles
arti yang sama dengan sistem run-time!
Budaya Hindley-Milner
Sistem tipe Hindley-Milner mencapai kebetulan yang benar-benar luar biasa dari empat perbedaan yang berbeda, dengan efek samping budaya yang disayangkan bahwa banyak orang tidak dapat melihat perbedaan antara perbedaan dan menganggap kebetulan itu tidak bisa dihindari! Apa yang saya bicarakan?
- istilah vs tipe
- hal-hal yang ditulis secara eksplisit vs hal-hal yang ditulis secara implisit
- Kehadiran pada run-time vs penghapusan sebelum run-time
- abstraksi non-dependen vs kuantifikasi dependen
Kami terbiasa menulis istilah dan meninggalkan jenis yang akan disimpulkan ... lalu dihapus. Kami terbiasa menghitung variabel tipe dengan abstraksi tipe yang sesuai dan aplikasi terjadi secara diam-diam dan statis.
Anda tidak perlu membelok terlalu jauh dari vanilla Hindley-Milner sebelum perbedaan ini tidak selaras, dan itu bukan hal yang buruk . Sebagai permulaan, kita dapat memiliki jenis yang lebih menarik jika kita bersedia menulisnya di beberapa tempat. Sementara itu, kita tidak harus menulis kamus kelas ketik ketika kita menggunakan fungsi-fungsi yang kelebihan beban, tetapi kamus-kamus itu pasti ada (atau sebaris) pada saat run-time. Dalam bahasa yang diketik secara dependen, kami berharap untuk menghapus lebih dari sekadar tipe saat run-time, tetapi (seperti dengan kelas tipe) bahwa beberapa nilai yang disimpulkan secara implisit tidak akan dihapus. Misalnya, vReplicate
argumen numerik seringkali dapat disimpulkan dari jenis vektor yang diinginkan, tetapi kita masih perlu mengetahuinya pada saat run-time.
Pilihan desain bahasa mana yang harus kita tinjau karena kebetulan ini tidak lagi berlaku? Misalnya, apakah benar Haskell tidak memberikan cara untuk membuat instance forall x. t
quantifier secara eksplisit? Jika typechecker tidak dapat menebak x
dengan menyatukan t
, kami tidak memiliki cara lain untuk mengatakan apa yang x
harus terjadi.
Secara lebih luas, kita tidak bisa memperlakukan "inferensi tipe" sebagai konsep monolitik yang kita miliki semuanya atau tidak sama sekali. Sebagai permulaan, kita perlu memisahkan aspek "generalisasi" (aturan "biarkan" Milner), yang sangat bergantung pada pembatasan jenis mana yang ada untuk memastikan bahwa mesin bodoh dapat menebak satu, dari aspek "spesialisasi" (Milner's "var "rule) yang sama efektifnya dengan pemecah kendala Anda. Kita dapat berharap bahwa tipe tingkat atas akan menjadi lebih sulit untuk disimpulkan, tetapi informasi tipe internal akan tetap cukup mudah untuk disebarkan.
Langkah Selanjutnya Untuk Haskell
Kami melihat jenis dan tingkat jenis tumbuh sangat mirip (dan mereka sudah berbagi representasi internal di GHC). Kita mungkin juga menggabungkan mereka. Akan menyenangkan untuk mengambil * :: *
jika kita bisa: kita kehilangan
kesehatan logis sejak lama, ketika kita membiarkan bagian bawah, tetapi jenis
kesehatan biasanya merupakan persyaratan yang lebih lemah. Kita harus periksa. Jika kita harus memiliki tipe, jenis, dan level yang berbeda, kita setidaknya dapat memastikan semuanya pada level tipe dan di atas selalu dapat dipromosikan. Alangkah baiknya hanya menggunakan kembali polimorfisme yang sudah kita miliki untuk tipe, daripada menciptakan kembali polimorfisme pada tingkat jenis.
Kita harus menyederhanakan dan menggeneralisasi sistem kendala saat ini dengan memungkinkan persamaan heterogen dia ~ b
mana jenis a
dan
b
tidak identik secara sintaksis (tetapi dapat dibuktikan sama). Ini adalah teknik lama (dalam tesis saya, abad lalu) yang membuat ketergantungan jauh lebih mudah untuk diatasi. Kami dapat mengekspresikan kendala pada ekspresi di GADT, dan dengan demikian melonggarkan pembatasan pada apa yang dapat dipromosikan.
Kita harus menghilangkan kebutuhan untuk konstruksi tunggal dengan memperkenalkan tipe fungsi dependen pi x :: s -> t
,. Fungsi dengan tipe seperti itu dapat diterapkan secara eksplisit pada ekspresi tipe apa pun s
yang hidup di persimpangan jenis dan istilah bahasa (jadi, variabel, konstruktor, dengan lebih banyak yang akan datang nanti). Lambda dan aplikasi yang sesuai tidak akan dihapus pada saat run-time, jadi kami akan dapat menulis
vReplicate :: pi n :: Nat -> x -> Vec n x
vReplicate Z x = VNil
vReplicate (S n) x = VCons x (vReplicate n x)
tanpa mengganti Nat
dengan Natty
. Domain pi
dapat berupa jenis apa pun yang dapat dipromosikan, jadi jika GADT dapat dipromosikan, kita dapat menulis sekuens kuantifier dependen (atau "teleskop" seperti yang disebut de Briuijn)
pi n :: Nat -> pi xs :: Vec n x -> ...
sejauh apa pun yang kita butuhkan.
Inti dari langkah-langkah ini adalah untuk menghilangkan kerumitan dengan bekerja secara langsung dengan alat yang lebih umum, alih-alih puas dengan alat yang lemah dan penyandian yang kikuk. Dukungan parsial saat ini membuat manfaat tipe dependen Haskell lebih mahal daripada yang seharusnya.
Terlalu keras?
Jenis ketergantungan membuat banyak orang gelisah. Mereka membuat saya gugup, tetapi saya suka gugup, atau setidaknya saya merasa sulit untuk tidak gugup. Tapi itu tidak membantu bahwa ada kabut ketidaktahuan di sekitar topik tersebut. Beberapa di antaranya karena kenyataan bahwa kita semua masih harus banyak belajar. Namun para pendukung pendekatan yang kurang radikal diketahui memicu rasa takut terhadap tipe-tipe dependen tanpa selalu memastikan fakta sepenuhnya ada pada mereka. Saya tidak akan menyebutkan nama. Ini "pemeriksaan ketik yang tidak dapat ditentukan", "Turing tidak lengkap", "tidak ada perbedaan fasa", "tidak ada penghapusan tipe", "bukti di mana-mana", dll, mitos tetap ada, meskipun itu adalah sampah.
Ini tentu bukan kasus bahwa program yang diketik secara dependen harus selalu terbukti benar. Seseorang dapat meningkatkan kebersihan dasar dari program seseorang, memberlakukan invarian tambahan dalam jenis tanpa pergi ke spesifikasi lengkap. Langkah-langkah kecil ke arah ini cukup sering menghasilkan jaminan yang lebih kuat dengan sedikit atau tanpa kewajiban bukti tambahan. Tidak benar bahwa program-program yang diketik secara tergantung pasti penuh dengan bukti, memang saya biasanya mengambil keberadaan bukti dalam kode saya sebagai isyarat untuk mempertanyakan definisi saya .
Karena, seperti halnya dengan peningkatan dalam artikulasi, kita menjadi bebas untuk mengatakan hal-hal baru yang buruk serta adil. Misalnya, ada banyak cara payah untuk mendefinisikan pohon pencarian biner, tetapi itu tidak berarti tidak ada cara yang baik . Sangat penting untuk tidak menganggap bahwa pengalaman buruk tidak bisa lebih baik, bahkan jika ego menolaknya. Desain definisi dependen adalah keterampilan baru yang membutuhkan pembelajaran, dan menjadi programmer Haskell tidak secara otomatis membuat Anda menjadi seorang ahli! Dan bahkan jika beberapa program curang, mengapa Anda menolak orang lain kebebasan untuk adil?
Kenapa Masih Mengganggu Haskell?
Saya sangat menikmati tipe dependen, tetapi sebagian besar proyek peretasan saya masih di Haskell. Mengapa? Haskell memiliki kelas tipe. Haskell memiliki perpustakaan yang bermanfaat. Haskell memiliki perawatan pemrograman yang bisa diterapkan (walaupun jauh dari ideal) dengan efek. Haskell memiliki kompiler kekuatan industri. Bahasa yang diketik secara dependen berada pada tahap yang jauh lebih awal dalam menumbuhkan komunitas dan infrastruktur, tetapi kita akan sampai di sana, dengan perubahan generasi nyata dalam apa yang mungkin, misalnya, dengan cara pemrograman dan generik tipe data. Tetapi Anda hanya perlu melihat-lihat apa yang dilakukan orang sebagai hasil dari langkah-langkah Haskell menuju tipe-tipe dependen untuk melihat bahwa ada banyak manfaat yang bisa diperoleh dengan mendorong generasi bahasa sekarang ke depan juga.