Haskell: что такое система видов (Kind) и higher-Kinded data на примере конфигурации Redis

Автор блога thunk.blog (имя в видимом тексте не указано) разбирает систему видов (Kind) в Haskell. Поводом стала его работа над типизированной конфигурацией для собственного клона Redis-сервера на Haskell (проект redis-haskell): по его словам, на этом пути он узнал несколько новых приёмов, и все они связаны с системой видов.

История строится по ступеням. Сначала значения (термы): 1, 2, True, "hello", то, с чем программа работает напрямую. Значения со схожими свойствами объединяются в типы (Int, Bool, Text и т. п.). Главная польза типов, по мнению автора, абстракция: они сообщают и программисту, и компилятору, какие операции над значением допустимы. Аналогия, классы оружия в ролевых играх: класс определяет, какие атаки и навыки доступны. Следующий вопрос: а как классифицировать сами типы? Ответ, виды. Автор формулирует так: виды относятся к типам так же, как типы к значениям.

Вид, способ классификации всего, что живёт на уровне типов. Это не только обычные типы: есть числа на уровне типов (вид Nat), строки на уровне типов (вид Symbol) и ограничения классов типов (вид Constraint). Обычные типы имеют вид Type. Конструкторы типов (Maybe, Either, Last, Proxy, Dual и др.) получают вид в зависимости от числа параметров и видов этих параметров; автор ссылается на таблицу, которой в доступном тексте нет. Класс типов Show имеет вид Type -> Constraint, а Show Int, вид Constraint. В старой документации и сообщениях GHC вместо Type встречается *; автор пишет, что, насколько он понимает (AFAICT), это синонимы, что *, старое имя и что GHC планирует его объявить устаревшим (-Wall уже предупреждает об использовании). Обычные типы он называет конструкторами типов без параметров (в литературе встречается термин «nullary type constructors»). Без видов, считает автор, нельзя было бы отличить Maybe, Maybe Int и Show.

Далее, higher-Kinded типы (HKT). Вид Type -> Type описывает все конструкторы типов с одним обычным типом в качестве аргумента: Maybe, а также частичные применения вроде Either String. Аналогия с функциями: сигнатура Int -> Bool описывает множество функций, а (Int -> Int) -> Bool, функции высшего порядка. Если сделать то же на уровне видов, получится вид (Type -> Type) -> Type: конструкторы типов, принимающие другой конструктор типов. Такие конструкторы и называются higher-Kinded типами. Пример: data Wrap (f :: Type -> Type) = Wrap (f Int) имеет вид (Type -> Type) -> Type; Wrap Maybe и Wrap [], обычные типы. Простыми словами, HKT, это обобщённые типы (generics), которые принимают другие обобщённые типы как аргументы.

Вместе с классами типов HKT позволяют описывать операции над конструкторами типов. Пример, Functor с видом (Type -> Type) -> Constraint: сам он не HKT, так как его вид заканчивается на Constraint, а не на Type, но его параметр, конструктор типов, поэтому он higher-Kinded. Экземпляр Functor определяется для Maybe, а не для Maybe a, поэтому fmap работает с любой конкретизацией: Maybe Int, Maybe String, Maybe Bool. Автор поясняет это образом миски, пригодной для микроволновки: ярлык «можно в микроволновку» относится к миске, а не к тому, что в ней лежит.

Higher-Kinded data (HKD), шаблон, при котором тип данных принимает один или несколько конструкторов типов как параметры и оборачивает ими часть полей или все поля. В примере с записью Profile (age, name, profession, address) параметр f задаёт обёртку: при f = Identity все поля обязательны, при f = Maybe любое поле может отсутствовать (как в недозаполненной форме), при f = Const String каждое поле хранит строку независимо от типа (как подписи формы).

Дальше автор переходит к своему проекту: в redis-haskell он добавил типизированную конфигурацию, берущую значения из параметров командной строки и из файла redis.conf. Проблема, дублирование. Нужны два контекста: конфигурация по умолчанию, где все поля заданы официальными значениями по умолчанию, и конфигурации из командной строки и redis.conf, где поля могут отсутствовать. Первая идея, две записи: RedisConfig с обычными полями (путь к каталогу RDB, имя файла, сжатие RDB, контрольная сумма, порт) и PartialRedisConfig, где каждое поле обёрнуто в Last. Last (поверх Maybe) при объединении через <> оставляет последнее присутствующее значение; обычное объединение не подошло бы, потому что у Bool и Int нет экземпляра Semigroup, а для типов, где он есть, <> сливало бы значения, а не выбирало одно.

Автору такой подход не понравился: нужен единый источник истины для названий и типов полей, а с двумя типами легко изменить один и забыть другой. Типовая система могла бы поймать расхождения, но этого, по его словам, мало. Другой вариант, один тип, где все поля обёрнуты в Last: тогда defaults <> confFileOpts <> cliOpts даёт нужный приоритет (командная строка, затем redis.conf, затем значения по умолчанию). Но итог остался бы записью, где все поля обёрнуты в Last. Видимый текст обрывается на этом месте, до финального решения автора.

Ключевые факты

  • Виды (Kind) классифицируют типы так же, как типы классифицируют значения; обычные типы имеют вид Type, Show, Type -> Constraint, Functor, (Type -> Type) -> Constraint.
  • Higher-Kinded типы (HKT), конструкторы типов, принимающие другие конструкторы типов; пример: Wrap с видом (Type -> Type) -> Type.
  • Higher-Kinded data (HKD), шаблон, где тип данных оборачивает поля передаваемым конструктором типов; Profile с Identity, Maybe и Const String показывает три разных поведения одной записи.
  • Практический повод, типизированная конфигурация в клоне Redis на Haskell: нужен единый источник истины для полей и приоритет «командная строка, затем redis.conf, затем значения по умолчанию» через Last.
  • Автор называет * старым именем Type и сообщает, что GHC планирует его объявить устаревшим; -Wall уже предупреждает об использовании.

Почему это важно

Это не новость, а обучающий разбор из личного блога: он объясняет, зачем в Haskell нужна система видов и как из неё вырастают higher-Kinded типы и данные. Ценность материала в том, что автор идёт от знакомого (значения, типы, обобщённые типы других языков) к непривычному и привязывает абстракцию к реальной задаче: слиянию настроек из нескольких источников без дублирования описания полей.

Кому это важно

Программистам на Haskell, которые уже знакомы с классами типов вроде Functor и хотят понять, что означают сообщения GHC о видах и почему выражение вроде Type -> Type имеет смысл. Также тем, кто пишет конфигурацию для сервисов и сталкивается с дублированием двух почти одинаковых записей. К теме ИИ материал отношения не имеет.

Как это применить

Идея шаблона HKD из примера: вместо двух записей с одинаковыми названиями полей описать одну запись с параметром f :: Type -> Type и подставлять разные обёртки. Identity означает «все поля обязательны», Maybe, «любое поле может отсутствовать», Const String, «в каждом поле одна и та же строка, например подпись». Для слияния источников настроек автор использует Last и оператор <>: последний присутствующий источник побеждает. Как именно он собрал итоговое решение, в доступном тексте не показано.

Можно ли доверять

Это личный блог, автор в видимом тексте не назван. Объяснения подаются как его собственная интуиция («IMO», «AFAICT»): утверждение, что * и Type, синонимы, он сам оговаривает. Заявление о том, что GHC планирует объявить * устаревшим и что -Wall уже предупреждает об этом, приведено без версии и ссылки. Таблица видов и ссылка на изменения в проекте в доступном тексте не воспроизведены, а сам текст обрывается до финального решения автора.

Риски и подводные камни

Тема объективно сложная: автор сам признаёт, что вид Type для обычных типов запутывает, а HKD люди склонны считать «продвинутым». Вариант с одним типом, где все поля обёрнуты в Last, по словам автора, оставляет итоговую конфигурацию с полями в Last, хотя нужны обычные значения, решение этого вопроса в видимой части не приведено. Кроме того, Last работает только как «последний побеждает» и не заменяет слияние значений через Semigroup.

«Виды относятся к типам так же, как типы, к значениям.»

— автор поста на thunk.blog