Привет, Хабр.
В прошлый раз мы описали Has-паттерн, обрисовали проблемы, которые он решает, и написали несколько конкретных инстансов:
instance HasDbConfig AppConfig where
getDbConfig = dbConfig
instance HasWebServerConfig AppConfig where
getWebServerConfig = webServerConfig
instance HasCronConfig AppConfig where
getCronConfig = cronConfigВыглядит неплохо. Какие тут могут возникнуть сложности?

Ну, давайте подумаем, какие ещё инстансы нам могут понадобиться. В первую очередь конкретные типы с конфигурацией сами по себе хорошие кандидаты на (тривиальную) реализацию этих тайпклассов, что даёт нам ещё три инстанса, где каждый метод реализуется через id, например
instance HasDbConfig DbConfig where
getDbConfig = idОни позволяют нам легко писать отдельные тесты или вспомогательные утилиты, не зависящие от всего AppConfig.
Это уже скучновато, но таки продолжим. Легко представить, что некоторые интеграционные тесты проверяют взаимодействие какой-то пары модулей, и мы всё ещё не хотим зависеть от конфигурации всего приложения целиком, так что теперь нам надо написать шесть инстансов (по два на тип), каждый из которых будет сводиться к fst или snd. Например, для DbConfig:
instance HasDbConfig (DbConfig, b) where
getDbConfig = fst
instance HasDbConfig (a, DbConfig) where
getDbConfig = sndУжас. Остаётся надеяться, что нам никогда не понадобится тестировать работу трёх модулей одновременно — а то ведь придётся писать девять скучнейших инстансов. В любом случае, лично мне уже очень некомфортно, и я скорее потрачу несколько часов на автоматизацию этого дела, чем пару минут на написание десятка лишних строк кода.
Если интересно, как решить эту задачу в общем виде, причём тут зависимые типы, и как всё это в итоге будет выглядеть на хаскеле — велкам под кат.
Обобщаем Has-класс
Во-первых, заметим, что для разных окружений у нас разные классы. Это может помешать сделать универсальное решение, так что вынесем окружение в отдельный параметр:
class Has part record where
extract :: record -> partМожно сказать, что Has part record означает, что из значения типа record можно извлечь какое-то значение типа part. В этих терминах наш старый добрый HasDbConfig становится Has DbConfig, и аналогично для прочих тайпклассов, которые мы написали ранее. Получается практически чисто синтаксическое изменение, и, например, тип одной из функций из нашего предыдущего поста превращается из
doSmthWithDbAndCron :: (MonadReader r m, HasDbConfig r, HasCronConfig r) => ...в
doSmthWithDbAndCron :: (MonadReader r m, Has DbConfig r, Has CronConfig r) => ...Единственное изменение — пара пробелов в нужных местах.
Кроме того, мы не сильно потеряли в выводе типов: тайпчекер всё равно может вывести необходимое возвращаемое значение extract по окружающему контексту в подавляющем большинстве встречающихся на практике случаев.
Теперь, когда нам не важен конкретный тип окружения, посмотрим, какие record могут реализовывать тайпкласс Has part record для фиксированного part. У этой задачи есть хорошая индуктивная структура:
- Каждый тип имеет сама себя:
Has record recordреализуется тривиальным образом (extract = id). - Если
record— произведение типовrec1иrec2, тоHas part recordреализуетcя тогда и только тогда, когда реализуется либоHas part rec1, либоHas part rec2. - Если
record— сумма типовrec1иrec2, тоHas part recordреализуется тогда и только тогда, когда реализуется иHas part rec1, иHas part rec2. Хотя практическая распространённость этого случая в этом контексте неочевидна, но его всё равно стоит упомяунть для полноты.
Итак, похоже, мы сформулировали набросок алгоритма для автоматического определения, реализуется ли Has part record для данных part и record!
К счастью, подобные индуктивные рассуждения на типах очень хорошо кладутся на механизм хаскелевских Generics. Вкратце и упрощая, Generics — это один из способов обобщённого метапрограммирования в хаскеле, вытекающий из наблюдения, что каждый тип является либо типом-суммой, либо типом-произведением, либо одноконструкторным базовым типом с одним полем.
Я не буду писать ещё один туторил по дженерикам, поэтому просто перейдём к коду.
Первая попытка
Воспользуемся классическим способом Generic-реализации нашего Has через вспомогательный класс GHas:
class GHas part grecord where
gextract :: grecord p -> partЗдесь grecord — Generic-представление нашего типа record.
Реализации GHas следуют индуктивной структуре, которую мы заметили выше:
instance GHas record (K1 i record) where
gextract (K1 x) = x
instance GHas part record => GHas part (M1 i t 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 rK1соответствует базовому случаю.M1— Generics-специфичные метаданные, которые нам в нашей задаче не нужны, поэтому мы их просто игнорируем и проходим их насквозь.- Первый инстанс для типа-произведения
l :*: rсоответствует случаю, когда «левая» часть произведения имеет нужное нам значение типаpart(возможно, рекурсивно). - Аналогично второй инстанс для типа-произведения
l :*: rсоответствует случаю, когда »правая» часть произведения имееет нужное нам значение типаpart(естественно тоже, возможно, рекурсивно).
Мы здесь поддерживаем только типы-произведения. Моё субъективное впечатление — суммы не так часто используются в контекстах для MonadReader и тому подобных классов, так что ими можно пренебречь для упрощения рассмотрения.
Кроме того, тут полезно заметить, что каждый -арный тип-произведение (a1, ..., an) может быть представлен как композиция пар (a1, (a2, (a3, (..., an)))), так что я позволю себе ассоциировать типы-произведения c парами.
Имея наш GHas, можно написать реализацию по умолчанию для Has, использующую дженерики:
class Has part record where
extract :: record -> part
default extract :: Generic record => record -> part
extract = gextract . fromГотово.
Или нет?
Проблема
Если мы попробуем скомпилировать этот код, то увидим, что он не тайпчекается даже безо всяких попыток использовать эту реализацию по умолчанию, сообщая о каких-то там overlapping instances. Хуже того, эти инстансы в некотором cмысле одинаковы. Похоже, пришла пора разобраться с тем, как работает механизм разрешения инстансов в хаcкеле.
Пусть у нас есть
instance context => Foo barPattern bazPattern where
...(кстати, вот эта штука после => называется instance head.)
Выглядит естественным прочитать это как
Пусть нам надо выбрать инстанс дляFoo bar baz. Еслиcontextудовлетворён, то можно выбрать этот инстанс при условии, чтоbarиbazсоответствуютbarPatternиbazPattern.
Однако это неправильная интерпретация, и всё как раз наоборот:
Пусть нам надо выбрать инстанс дляFoo bar baz. ЕслиbarиbazсоответствуютbarPatternиbazPattern, то мы выбираем этот инстанс и добавляемcontextв список констрейнтов, которые надо разрешить.
Теперь очевидно, в чём проблема. Давайте повнимательнее рассмотрим следующую пару инстансов:
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Они имеют одинаковые instance heads, так что неудивительно, они пересекаются! Кроме того, ни одиз из них не является более специфичным, чем другой.
Кроме того, не существует способа как-то уточнить эти инстансы, чтобы они перестали пересекаться. Ну, кроме добавления ещё параметров GHas.
Выразительные типы спешат на помощь!
Решение проблемы — предварительно рассчитать «путь» к интересующему нас значению, и использовать этот путь для того, чтобы направлять выбор инстансов.
Так как мы договорились не поддерживать типы-суммы, путь — это в прямом смысле последовательность поворотов налево или направо в типах-произведениях (то есть, выборов первого или второго компонента пары), завершающаяся большим указателем «ЗДЕСЬ», как только мы нашли нужный тип. Запишем это:
data Path = L Path | R Path | Here deriving (Show)Рассмотрим следующие типы:
data DbConfig = DbConfig
{ dbAddress :: DbAddress
, dbUsername :: Username
, dbPassword :: Password
}
data AppConfig = AppConfig
{ dbConfig :: DbConfig
, webServerConfig :: WebServerConfig
, cronConfig :: CronConfig
}Какие могут быть примеры путей из AppConfig?
- К
DbConfig?L Here. - К
WebServerConfig?R (L Here). - К
CronConfig?R (R Here). - К
DbAddress?L (L Here).
Каким может быть результат поиска значения нужного типа? Два варианта очевидны: мы его можем либо найти, либо не найти. Но на самом деле всё чуточку сложнее: мы можем найти более одного значения данного типа. По-видимому, наиболее разумным поведением в этом неоднозначном случае тоже будет сообщение об ошибке. Любой выбор конкретного значения будет иметь известную долю произвольности.
Действительно, рассмотрим наш стандартный пример веб-сервиса. Если кто-то хочет получить значение типа (Host, Port), то должен ли это быть адрес сервера БД или адрес веб-сервера? Уж лучше не рисковать.
В любом случае, давайте выразим это в коде:
data MaybePath = NotFound | Conflict | Found Path deriving (Show)Мы разделяем NotFound и Conflict, так как обработка этих случаев принципиально различается: если мы получим NotFound в одной из веток нашего типа-произведения, то это не помешает найти нужное значение в какой-то другой ветке, тогда как Conflict в любой ветке сразу означает полную неудачу.
Теперь рассмотрим частный случай типов-произведений (которые, как мы договорились, мы считаем парами). Как в них найти значение нужного типа? Можно запустить поиcк рекурсивно в каждой компоненте пары, получить результаты p1 и p2 соответственно, и затем каким-то образом их скомбинировать.
Так как мы говорим о выборе инстансов тайпклассов, что происходит во время компиляции, то нам на самом деле нужны компилтайм-вычисления, которые в хаскеле выражаются через вычисления на типах (пусть даже типы представлены через поднятые во вселенную типов термы при помощи DataKinds). Соответственно, такая функция на типах представляется как type family:
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Эта функция представляет несколько случаев:
- Если один из рекурсивных поисков успешен, а другой привёл к
NotFound, то мы берём путь из успешного поиска и дописываем поворот в нужную сторону. - Если оба рекурсивных поиска завершились с
NotFound, то, очевидно, и весь поиск завершается сNotFound. - В любом другом случае мы получаем
Conflict.
Теперь напишем тайп-левел-функцию, которая принимает part, который нужно найти, и Generic-представление типа, в котором нужно найти part, и производит поиск:
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Заметим, что мы получили что-то очень похожее по смыслу на нашу предыдущую попытку с GHas. Это вполне ожидаемо, так как мы на самом деле воспроизводим алгоритм, который мы пытались выразить через тайпклассы.
К слову о GHas, всё, что нам осталось — добавить к этому классу дополнительный параметр, отвечающий за найденный ранее путь, и который будет служить для выбора конкретных инстансов:
class GHas (path :: Path) part grecord where
gextract :: Proxy path -> grecord p -> partМы также добавили дополнительный аргумент для gextract, чтобы компилятор мог выбрать правильный инстанс для данного пути (который для этого должен упоминаться в сигнатуре функции).
Теперь инстансы написать довольно легко:
instance GHas 'Here record (K1 i record) where
gextract _ (K1 x) = x
instance GHas path part record => GHas path part (M1 i t 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Действительно, мы просто выбираем нужный инстанс, основываясь на пути в path, который мы рассчитали ранее.
Как теперь написать нашу default-реализацию функции extract :: record -> part в классе Has? У нас есть несколько условий:
recordдолжен реализовыватьGeneric, чтобы можно было применять механизм дженериков, так что мы получаемGeneric record.- Функция
Searchдолжна найтиpartвrecord(вернее, вGeneric-представленииrecord, которое выражается какRep record). В коде это выглядит чуть более непривычно:Search part (Rep record) ~ 'Found path. Эта запись означает ограничение, что результатSearch part (Rep record)должен быть равен'Found pathдля некоторогоpath(которое нам, собственно, и интересно). - Мы должны иметь возможность использовать
GHasвместе сpart, дженерик-представлениемrecordиpathc прошлого шага, что превращается вGHas path part (Rep record).
Мы встретим последние два констрейнта ещё несколько раз, так что полезно их вынести в отдельный констрейнт-синоним:
type SuccessfulSearch part record path = (Search part (Rep record) ~ 'Found path, GHas path part (Rep record))С учётом этого синонима, мы получаем
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Теперь всё!
Использование обобщённого Has
Чтобы посмотреть на всё это в деле, напишем несколько общих инстансов для туплов:
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)Здесь SuccessfulSearch a (a0, ..., an) path отвечает за то, что a встречается среди a0, ..., an ровно один раз.
Пусть теперь у нас есть наш старый-добрый
data AppConfig = AppConfig
{ dbConfig :: DbConfig
, webServerConfig :: WebServerConfig
, cronConfig :: CronConfig
}и мы хотим вывести Has DbConfig, Has WebServerConfig и Has CronConfig. Достаточно включить расширения DeriveGeneric и DeriveAnyClass и дописать правильное deriving-объявление:
data AppConfig = AppConfig
{ dbConfig :: DbConfig
, webServerConfig :: WebServerConfig
, cronConfig :: CronConfig
} deriving (Generic, Has DbConfig, Has WebServerConfig, Has CronConfig)Нам повезло (или мы были достаточно проницательны) так упорядочить аргументы для Has, что имя вложенного типа идёт первым, так что мы можем опираться на механизм DeriveAnyClass для минимизации писанины.
Безопасность превыше всего
Что, если у нас нет какого-то типа?
data AppConfig = AppConfig
{ dbConfig :: DbConfig
, webServerConfig :: WebServerConfig
} deriving (Generic, Has CronConfig)Неа, мы получаем ошибку прямо в точке определения типа:
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)
| ^^^^^^^^^^^^^^Не самое дружелюбное сообщение об ошибке, но даже по нему всё ещё можно понять, в чём проблема: чётотам чётотам NotFound чётотам чётотам CronConfig.
Что, если у нас есть несколько полей с одним и тем же типом?
data AppConfig = AppConfig
{ prodDbConfig :: DbConfig
, qaDbConfig :: DbConfig
, webServerConfig :: WebServerConfig
, cronConfig :: CronConfig
} deriving (Generic, Has DbConfig)Неа, как и ожидалось:
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)
| ^^^^^^^^^^^^Похоже, что всё действительно хорошо.
Подытоживая
Итак, попробуем кратко сформулировать предложенный метод.
Пусть у нас есть какой-то тайпкласс, и мы хотим автоматически выводить его инстансы согласно каким-то рекурсивным правилам. Тогда мы можем избежать неоднозначностей (и вообще выразить эти правила, если они нетривиальны и не укладываются в стандартный механизм разрешения инстансов) следующим образом:
- Закодируем рекурсивные правила в виде индуктивного типа данных
T. - Напишем функцию на типах (в виде type family) для предварительного расчёта значения
vэтого типаT(или, в терминах хаскеля, типаvсортаT— где ж мои зависимые типы), описывающего конкретную последовательность шагов, которые надо предпринять. - Использовать этот
vкак дополнительный аргументGeneric-хелперу для определения конкретной последовательности инстансов, которые теперь матчатся на значенияv.
Ну и всё!
В следующих постах мы рассмотрим некоторые изящные расширения (а также изящные ограничения) этого подхода.
А, ну и да. Интересно отследить последовательность наших обобщений.
- Начали с
Env -> Foo. - Недостаточно общо, завернёмся в монаду
Reader Env. - Недостаточно общо, перепишем с тайпклассом
MonadReader Env m. - Недостаточно общо, перепишем как
MonadReader r m, HasEnv r. - Недостаточно общо, напишем как
MonadReader r m, Has Env rи добавим дженериков, чтобы компилятор там всё сам делал. - Теперь норм.
mayorovp
Наконец-то я понял, что такое type families и дженерики в Хаскеле. Кстати, опять они умудрились выбрать слово, означающее совершенно не то что в других языках...
Generic в Хаскеле больше на Reflection похож.
0xd34df00d Автор
Да, дженерики — это именно такой компилтайм-рефлекшон. Там и имена конструкторов и полей ещё есть, и прочая метаинформация.
Как они имена выбирают — отличный вопрос.