Bisakah saya kabur? Dipukul oleh pemrograman tipe generik

Hai, Habr.


Terakhir kali, kami menjelaskan Has pola, menguraikan masalah yang dipecahkan, dan menulis beberapa contoh spesifik:


 instance HasDbConfig AppConfig where getDbConfig = dbConfig instance HasWebServerConfig AppConfig where getWebServerConfig = webServerConfig instance HasCronConfig AppConfig where getCronConfig = cronConfig 

Itu terlihat bagus. Kesulitan apa yang bisa timbul di sini?


gambar


Nah, mari kita pikirkan contoh apa lagi yang mungkin kita butuhkan. Pertama-tama, tipe konkret dengan konfigurasi sendiri adalah kandidat yang baik untuk implementasi (sepele) dari typeclasses ini, yang memberi kita tiga contoh lagi di mana setiap metode diimplementasikan melalui id , misalnya


 instance HasDbConfig DbConfig where getDbConfig = id 

Mereka memungkinkan kita untuk dengan mudah menulis tes individual atau utilitas pembantu yang independen dari seluruh AppConfig .


Ini sudah membosankan, tetapi masih berlanjut. Sangat mudah untuk membayangkan bahwa beberapa tes integrasi memeriksa interaksi sepasang modul, dan kami masih tidak ingin bergantung pada konfigurasi seluruh aplikasi, jadi sekarang kita perlu menulis enam contoh (dua per jenis), masing-masing akan dikurangi menjadi fst atau snd . Misalnya, untuk DbConfig :


 instance HasDbConfig (DbConfig, b) where getDbConfig = fst instance HasDbConfig (a, DbConfig) where getDbConfig = snd 

Horor Diharapkan bahwa kita tidak akan pernah perlu menguji operasi tiga modul secara bersamaan - jika tidak, Anda harus menulis sembilan contoh yang membosankan. Bagaimanapun, saya pribadi sudah sangat tidak nyaman, dan saya lebih suka menghabiskan beberapa jam mengotomatisasi masalah ini daripada beberapa menit menulis selusin baris kode tambahan.


Jika Anda tertarik untuk menyelesaikan masalah ini secara umum, terlebih lagi, ini tergantung pada jenisnya, dan bagaimana semuanya akan berakhir seperti kucing Haskell - Welkom.


Meringkas kelas Has


Pertama, perhatikan bahwa kami memiliki kelas yang berbeda untuk lingkungan yang berbeda. Ini dapat mengganggu pembuatan solusi universal, jadi kami mengambil lingkungan dalam parameter terpisah:


 class Has part record where extract :: record -> part 

Kita dapat mengatakan bahwa Has part record berarti bahwa beberapa nilai part jenis dapat diekstraksi dari nilai record tipe. Dalam istilah-istilah ini, HasDbConfig lama kami yang baik menjadi Has DbConfig , dan juga untuk typeclasses lain yang kami tulis sebelumnya. Ternyata perubahan hampir murni sintaksis, dan, misalnya, jenis salah satu fungsi dari posting kami sebelumnya berubah dari


 doSmthWithDbAndCron :: (MonadReader rm, HasDbConfig r, HasCronConfig r) => ... 

masuk


 doSmthWithDbAndCron :: (MonadReader rm, Has DbConfig r, Has CronConfig r) => ... 

Satu-satunya perubahan adalah beberapa ruang di tempat yang tepat.


Selain itu, kami tidak kehilangan banyak dalam inferensi tipe: timer masih dapat menampilkan nilai pengembalian yang diperlukan dari extract dalam konteks sekitarnya dalam sebagian besar kasus yang ditemukan dalam praktek.


Sekarang kami tidak peduli dengan jenis lingkungan tertentu, mari kita lihat catatan mana yang dapat menerapkan kelas Has part record untuk bagian tetap. Tugas ini memiliki struktur induktif yang baik:


  1. Setiap jenis memiliki sendiri: Has record record diimplementasikan dalam cara yang sepele ( extract = id ).
  2. Jika record adalah produk dari tipe rec1 dan rec2 , maka Has part record diimplementasikan jika dan hanya jika salah satu Has part rec1 atau Has part rec2 .
  3. Jika record adalah jumlah dari tipe rec1 dan rec2 , maka Has part record diimplementasikan jika dan hanya jika Has part rec1 dan Has part rec2 . Meskipun prevalensi praktis dari kasus ini dalam konteks ini tidak jelas, masih perlu disebutkan untuk kelengkapannya.

Jadi, sepertinya kami telah merumuskan sketsa algoritma untuk secara otomatis menentukan apakah Has part record diimplementasikan untuk part dan record data!


Untungnya, alasan induktif semacam itu sangat cocok dengan mekanisme Haskell Generics . Secara singkat dan menyederhanakan, Generics adalah salah satu metode metaprogramming yang digeneralisasi di Haskell, yang dihasilkan dari pengamatan bahwa setiap tipe dapat berupa tipe penjumlahan, tipe produk, atau tipe dasar konstruksi tunggal dengan satu bidang.


Saya tidak akan menulis tutorial lain tentang obat generik, jadi lanjutkan saja ke kode.


Upaya pertama


Kami akan menggunakan metode klasik implementasi Generic dari Has kami melalui kelas tambahan GHas :


 class GHas part grecord where gextract :: grecord p -> part 

Di sini grecord adalah representasi Generic dari tipe record kami.


Implementasi GHas mengikuti struktur induktif yang kami catat di atas:


 instance GHas record (K1 i record) where gextract (K1 x) = x instance GHas part record => GHas part (M1 it record) where gextract (M1 x) = gextract x instance GHas part l => GHas part (l :*: r) where gextract (l :*: _) = gextract l instance GHas part r => GHas part (l :*: r) where gextract (_ :*: r) = gextract r 

  1. K1 sesuai dengan kasus dasar.
  2. M1 - Metadata khusus-generik yang tidak kita perlukan dalam tugas, jadi kita abaikan saja dan lewati.
  3. Contoh pertama untuk tipe produk l :*: r sesuai dengan kasus ketika bagian "kiri" dari produk memiliki nilai tipe part kita butuhkan (mungkin, secara rekursif).
  4. Demikian pula, contoh kedua untuk jenis produk l :*: r sesuai dengan kasus ketika bagian "kanan" dari produk memiliki nilai jenis part kita butuhkan (secara alami, juga, mungkin, secara rekursif).

Kami hanya mendukung jenis produk di sini. Kesan subjektif saya adalah bahwa jumlah tidak terlalu sering digunakan dalam konteks untuk MonadReader dan kelas yang serupa, sehingga MonadReader dapat diabaikan untuk menyederhanakan pertimbangan.


Selain itu, penting untuk dicatat bahwa masing-masing n-ary type-product (a1, ..., an) dapat direpresentasikan sebagai komposisi n1pasangan (a1, (a2, (a3, (..., an)))) , jadi saya membiarkan diri saya mengaitkan jenis produk dengan pasangan.


Dengan GHas kami, Anda dapat menulis implementasi default untuk Has yang menggunakan generik:


 class Has part record where extract :: record -> part default extract :: Generic record => record -> part extract = gextract . from 

Selesai


Atau tidak?


Masalah


Jika kami mencoba mengkompilasi kode ini, kami akan melihat bahwa kode itu tidak taypechaetsya bahkan tanpa ada upaya untuk menggunakan implementasi ini secara default, melaporkan beberapa contoh yang tumpang tindih di sana. Lebih buruk lagi, contoh ini sama dalam beberapa hal. Sepertinya sudah waktunya untuk mencari tahu bagaimana mekanisme untuk menyelesaikan instance dalam Haskell bekerja.


Semoga kita punya


 instance context => Foo barPattern bazPattern where ... 

(Ngomong-ngomong, benda setelah => ini disebut kepala instance.)


Tampaknya wajar untuk membaca ini sebagai


Mari kita pilih contoh untuk Foo bar baz . Jika context puas, maka Anda dapat memilih contoh ini asalkan bar dan baz sesuai dengan barPattern dan bazPattern .

Namun, ini adalah salah tafsir, dan justru sebaliknya:


Mari kita pilih contoh untuk Foo bar baz . Jika bar dan baz berhubungan dengan barPattern dan bazPattern , maka kami memilih instance ini dan menambahkan context ke daftar konstanta yang harus diselesaikan.

Sekarang sudah jelas apa masalahnya. Mari kita lihat lebih dekat pasangan contoh berikut:


 instance GHas part l => GHas part (l :*: r) where gextract (l :*: _) = gextract l instance GHas part r => GHas part (l :*: r) where gextract (_ :*: r) = gextract r 

Mereka memiliki kepala contoh yang sama, jadi tidak heran mereka berpotongan! Selain itu, tidak ada yang lebih spesifik dari yang lain.


Selain itu, tidak ada cara untuk memperbaiki kasus-kasus ini sehingga tidak lagi tumpang tindih. Nah, selain menambahkan lebih banyak parameter GHas .


Tipe ekspresif bergegas menyelamatkan!


Solusi untuk masalah ini adalah dengan pra-menghitung "jalan" ke nilai yang menarik bagi kami, dan menggunakan jalur ini untuk memandu pilihan contoh.


Karena kami sepakat untuk tidak mendukung tipe penjumlahan, jalur dalam arti literal merupakan urutan belokan kiri atau kanan dalam tipe produk (yaitu, pilihan komponen pertama atau kedua dari suatu pasangan), berakhir dengan penunjuk “HERE” yang besar, segera setelah kami menemukan jenis yang diinginkan . Kami menulis ini:


 data Path = L Path | R Path | Here deriving (Show) 

Sebagai contoh

Pertimbangkan jenis-jenis berikut:


 data DbConfig = DbConfig { dbAddress :: DbAddress , dbUsername :: Username , dbPassword :: Password } data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } 

Apa saja contoh jalur dari AppConfig ?


  1. Untuk DbConfigL Here .
  2. Ke WebServerConfigR (L Here) .
  3. Ke CronConfigR (R Here) .
  4. Ke DbAddressL (L Here) .

Apa yang bisa menjadi hasil pencarian untuk nilai dari tipe yang diinginkan? Dua opsi sudah jelas: kita dapat menemukannya atau tidak menemukannya. Tetapi pada kenyataannya, semuanya sedikit lebih rumit: kita dapat menemukan lebih dari satu nilai dari jenis ini. Tampaknya perilaku yang paling masuk akal dalam kasus kontroversial ini juga akan menjadi pesan kesalahan. Setiap pilihan nilai tertentu akan memiliki jumlah keacakan tertentu.


Memang, perhatikan contoh layanan web standar kami. Jika seseorang ingin mendapatkan nilai jenis (Host, Port) , haruskah itu alamat server database atau alamat server web? Lebih baik tidak mengambil risiko.


Bagaimanapun, mari kita ungkapkan ini dalam kode:


 data MaybePath = NotFound | Conflict | Found Path deriving (Show) 

Kami memisahkan NotFound dan Conflict , karena penanganan kasus-kasus ini pada dasarnya berbeda: jika kami mendapatkan NotFound di salah satu cabang dari jenis produk kami, maka tidak ada salahnya untuk menemukan nilai yang diinginkan di beberapa cabang lain, sementara Conflict di cabang mana pun segera berarti penuh sebuah kegagalan.


Sekarang kami mempertimbangkan kasus khusus jenis produk (yang, seperti yang kami sepakati, kami anggap sebagai pasangan). Bagaimana menemukan nilai tipe yang diinginkan di dalamnya? Anda dapat menjalankan pencarian secara rekursif di setiap komponen pasangan, mendapatkan hasil p1 dan p2 masing-masing, dan kemudian menggabungkannya.


Karena kita berbicara tentang pilihan instance timeclasses yang terjadi selama kompilasi, kita sebenarnya membutuhkan perhitungan compiltime, yang diekspresikan dalam Haskell melalui perhitungan pada jenis (bahkan jika tipe diwakili melalui istilah yang diangkat di alam semesta menggunakan DataKinds ). Dengan demikian, fungsi seperti pada tipe direpresentasikan sebagai tipe keluarga:


 type family Combine p1 p2 where Combine ('Found path) 'NotFound = 'Found ('L path) Combine 'NotFound ('Found path) = 'Found ('R path) Combine 'NotFound 'NotFound = 'NotFound Combine _ _ = 'Conflict 

Fungsi ini mewakili beberapa kasus:


  1. Jika salah satu pencarian rekursif berhasil, dan yang lainnya NotFound ke NotFound , maka kami mengambil jalur dari pencarian yang sukses dan menambahkan belokan ke arah yang benar.
  2. Jika kedua pencarian rekursif berakhir dengan NotFound , maka jelas seluruh pencarian berakhir dengan NotFound .
  3. Dalam kasus lain, kita mendapatkan Conflict .

Sekarang kita akan menulis fungsi tipe-level yang mengambil part akan ditemukan, dan representasi Generic dari tipe untuk menemukan part , dan mencari:


 type family Search part (grecord :: k -> *) :: MaybePath where Search part (K1 _ part) = 'Found 'Here Search part (K1 _ other) = 'NotFound Search part (M1 _ _ x) = Search part x Search part (l :*: r) = Combine (Search part l) (Search part r) Search _ _ = 'NotFound 

Perhatikan bahwa kami mendapatkan sesuatu yang sangat mirip dalam arti dengan upaya kami sebelumnya dengan GHas . Ini diharapkan, karena kami sebenarnya mereproduksi algoritma yang kami coba ekspresikan melalui timeclasses.


GHas , yang tersisa bagi kita adalah menambahkan parameter tambahan ke kelas ini, yang bertanggung jawab untuk jalur yang ditemukan sebelumnya, dan yang akan berfungsi untuk memilih contoh spesifik:


 class GHas (path :: Path) part grecord where gextract :: Proxy path -> grecord p -> part 

Kami juga menambahkan argumen tambahan untuk gextract sehingga kompiler dapat memilih contoh yang benar untuk jalur yang diberikan (yang harus disebutkan dalam tanda tangan fungsi untuk ini).


Sekarang menulis instance cukup mudah:


 instance GHas 'Here record (K1 i record) where gextract _ (K1 x) = x instance GHas path part record => GHas path part (M1 it record) where gextract proxy (M1 x) = gextract proxy x instance GHas path part l => GHas ('L path) part (l :*: r) where gextract _ (l :*: _) = gextract (Proxy :: Proxy path) l instance GHas path part r => GHas ('R path) part (l :*: r) where gextract _ (_ :*: r) = gextract (Proxy :: Proxy path) r 

Memang, kami cukup memilih turunan yang diinginkan berdasarkan jalur di path yang kami hitung sebelumnya.


Bagaimana sekarang untuk menulis implementasi default kami dari extract :: record -> part fungsi extract :: record -> part di kelas Has ? Kami memiliki beberapa kondisi:


  1. record harus mengimplementasikan Generic sehingga mekanisme generic dapat diterapkan, sehingga kita mendapatkan Generic record .
  2. Fungsi Search harus menemukan part dalam record (atau lebih tepatnya, dalam representasi Generic record , yang dinyatakan sebagai Rep record ). Dalam kode, ini terlihat sedikit lebih tidak biasa: Search part (Rep record) ~ 'Found path . Catatan ini berarti batasan bahwa hasil bagian Search part (Rep record) harus sama dengan 'Found path untuk beberapa path (yang, pada kenyataannya, menarik bagi kami).
  3. Kita harus dapat menggunakan GHas bersama dengan part , representasi generik dari record dan path dari langkah terakhir, yang berubah menjadi GHas path part (Rep record) .

Kami akan bertemu dengan dua konstanta terakhir beberapa kali lagi, jadi berguna untuk menempatkannya dalam sinonim const yang terpisah:


 type SuccessfulSearch part record path = (Search part (Rep record) ~ 'Found path, GHas path part (Rep record)) 

Dengan sinonim ini, kami dapat


 class Has part record where extract :: record -> part default extract :: forall path. (Generic record, SuccessfulSearch part record path) => record -> part extract = gextract (Proxy :: Proxy path) . from 

Sekarang semuanya!


Menggunakan Generic Has


Untuk melihat semua ini dalam tindakan, kami akan menulis beberapa contoh umum untuk boneka:


 instance SuccessfulSearch a (a0, a1) path => Has a (a0, a1) instance SuccessfulSearch a (a0, a1, a2) path => Has a (a0, a1, a2) instance SuccessfulSearch a (a0, a1, a2, a3) path => Has a (a0, a1, a2, a3) 

Di sini SuccessfulSearch a (a0, ..., an) path bertanggung jawab atas fakta bahwa a terjadi di antara a0, ..., an tepat sekali.


Semoga sekarang kita memiliki yang baik


 data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } 

dan kami ingin menampilkan Has DbConfig , Has WebServerConfig dan Has CronConfig . Cukup memasukkan DeriveAnyClass dan DeriveAnyClass dan menambahkan deklarasi deriving benar:


 data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } deriving (Generic, Has DbConfig, Has WebServerConfig, Has CronConfig) 

Kami beruntung (atau kami cukup berwawasan luas) untuk mengatur argumen untuk Has sehingga nama tipe bersarang DeriveAnyClass , sehingga kami dapat mengandalkan mekanisme DeriveAnyClass untuk meminimalkan coretan.


Keselamatan adalah yang utama


Bagaimana jika kita tidak memiliki tipe apa pun?


 data AppConfig = AppConfig { dbConfig :: DbConfig , webServerConfig :: WebServerConfig } deriving (Generic, Has CronConfig) 

Tidak, kami mendapatkan kesalahan tepat pada titik definisi tipe:


 Spec.hs:35:24: error: • Couldn't match type ''NotFound' with ''Found path0' arising from the 'deriving' clause of a data type declaration • When deriving the instance for (Has CronConfig AppConfig) | 35 | } deriving (Generic, Has CronConfig) | ^^^^^^^^^^^^^^ 

Bukan pesan kesalahan yang ramah, tetapi bahkan dari itu Anda masih bisa mengerti apa masalahnya: frekuensi aneh NotFound frekuensi aneh CronConfig .


Bagaimana jika kita memiliki beberapa bidang dengan tipe yang sama?


 data AppConfig = AppConfig { prodDbConfig :: DbConfig , qaDbConfig :: DbConfig , webServerConfig :: WebServerConfig , cronConfig :: CronConfig } deriving (Generic, Has DbConfig) 

Tidak, seperti yang diharapkan:


 Spec.hs:37:24: error: • Couldn't match type ''Conflict' with ''Found path0' arising from the 'deriving' clause of a data type declaration • When deriving the instance for (Has DbConfig AppConfig) | 37 | } deriving (Generic, Has DbConfig) | ^^^^^^^^^^^^ 

Segalanya tampak sangat baik.


Untuk meringkas


Jadi, kami akan mencoba merumuskan secara singkat metode yang diusulkan.


Misalkan kita memiliki semacam typklass, dan kami ingin secara otomatis menampilkan instansinya sesuai dengan beberapa aturan rekursif. Maka kita dapat menghindari ambiguitas (dan umumnya mengungkapkan aturan-aturan ini jika tidak bersifat sementara dan tidak cocok dengan mekanisme standar untuk menyelesaikan kasus) sebagai berikut:


  1. Kami menyandikan aturan rekursif dalam bentuk tipe data induktif T
  2. Kami akan menulis fungsi pada tipe (dalam bentuk tipe keluarga) untuk perhitungan awal dari nilai v tipe T (atau, dalam hal Haskell, tipe v tipe T - di mana tipe dependen saya), yang menggambarkan urutan spesifik dari langkah-langkah yang perlu diambil.
  3. Gunakan v ini sebagai argumen tambahan untuk Generic helper untuk menentukan urutan spesifik instance yang sekarang cocok dengan nilai v .

Yah, itu dia!


Dalam posting berikut, kita akan melihat beberapa ekstensi elegan (serta batasan elegan) dari pendekatan ini.


Oh, dan ya. Sangat menarik untuk melacak urutan generalisasi kami.


  1. Dimulai dengan Env -> Foo .
  2. Tidak cukup umum, bungkus dalam Reader Env monad.
  3. Tidak cukup umum, tulis ulang dengan MonadReader Env m .
  4. Tidak cukup umum, tulis ulang MonadReader rm, HasEnv r .
  5. Tidak cukup umum, MonadReader rm, Has Env r menulis MonadReader rm, Has Env r dan menambahkan generik sehingga kompiler melakukan semuanya di sana.
  6. Sekarang norma.

Source: https://habr.com/ru/post/id471052/


All Articles