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?

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:
- Setiap jenis memiliki sendiri:
Has record record
diimplementasikan dalam cara yang sepele ( extract = id
). - 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
. - 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
K1
sesuai dengan kasus dasar.M1
- Metadata khusus-generik yang tidak kita perlukan dalam tugas, jadi kita abaikan saja dan lewati.- Contoh pertama untuk tipe produk
l :*: r
sesuai dengan kasus ketika bagian "kiri" dari produk memiliki nilai tipe part
kita butuhkan (mungkin, secara rekursif). - 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 -ary type-product (a1, ..., an)
dapat direpresentasikan sebagai komposisi pasangan (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 contohPertimbangkan 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
?
- Untuk
DbConfig
⟶ L Here
. - Ke
WebServerConfig
⟶ R (L Here)
. - Ke
CronConfig
⟶ R (R Here)
. - Ke
DbAddress
⟶ L (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:
- 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. - Jika kedua pencarian rekursif berakhir dengan
NotFound
, maka jelas seluruh pencarian berakhir dengan NotFound
. - 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:
record
harus mengimplementasikan Generic
sehingga mekanisme generic dapat diterapkan, sehingga kita mendapatkan Generic record
.- 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). - 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:
- Kami menyandikan aturan rekursif dalam bentuk tipe data induktif
T
- 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. - 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.
- Dimulai dengan
Env -> Foo
. - Tidak cukup umum, bungkus dalam
Reader Env
monad. - Tidak cukup umum, tulis ulang dengan
MonadReader Env m
. - Tidak cukup umum, tulis ulang
MonadReader rm, HasEnv r
. - Tidak cukup umum,
MonadReader rm, Has Env r
menulis MonadReader rm, Has Env r
dan menambahkan generik sehingga kompiler melakukan semuanya di sana. - Sekarang norma.