Higher-Kinded Data, или ещё один способ работать с сущностями базы данных (и не только) +9


AliExpress RU&CIS

image


Важный дисклеймер


Перед началом хочу позволить себе небольшой, но важный дисклеймер.


Я не стараюсь вам продать этот cпособ как панацею.
Я лишь хочу рассказать вам ещё один способ представлять данные и показать, как его можно использовать, на конкретном примере.
Как и все остальные подходы, этот имеет свои недостатки. И кое-где придётся приседать. С этими приседаниями мы встретимся довольно скоро.


«Не думайте, что я сейчас буду развивать эту концепцию, а затем разочаруюсь в ней. Такой драматургии не будет. Я изначально уже в ней разочарован.»
Роман Михайлов

Ещё хочется заметить, что далее все примеры кода будут приводиться на Haskell. Но в конце я покажу, как можно некоторые из них повторить на Scala.


Что такое HKD


Конечно, прежде, чем писать этот раздел, я полез в интернет, чтобы посмотреть, как этот термин определяют другие люди. Чёткого определения я не нашёл.
Грубо говоря, HKD — это то, что предоставляет возможность держать в одном типе данных сразу несколько представлений. Давайте посмотрим на примеры.


Простейшие HKD


Обычно данные в Haskell определяются так:


data User = User
  { login :: String
  , email :: String }

-- >>> :t User
-- User :: String -> String -> User

Но что, если мы хотим добавить какой-то эффект нашим полям? Тогда можно попробовать обернуть поля в конструктор типа. Мы можем параметризовать User конструктором типа Maybe. Представить, для чего бы это могло быть полезно, несложно. Например, структура-патч. Мы можем воспользоваться ей, чтобы обновить только некоторые поля структуры.


data User m = User
  { login :: m String
  , email :: m String }

-- >>> :t User
-- User :: forall {m :: * -> *}. m String -> m String -> User m

-- >>> :t User @Maybe
-- User @Maybe :: Maybe String -> Maybe String -> User Maybe

-- >>> :t User @Identity 
-- User @Identity :: Identity String -> Identity String -> User Identity

И вот мы получили полноценный HKD. Но мы можем начать его улучшать! Например, если понадобится избавиться от каких-либо эффектов, мы вынуждены будем использовать Identity, который не является прозрачным, что заставляет нас распаковывать и запаковывать значения. Естественно, во время исполнения этой обёртки не будет, но это делает код более многословным.


Кстати, тот знак собачки/улитки в листинге — специальный синтаксис для Type Applications.


Ускоренный курс по TypeApplications


> :set -XTypeApplications 
> :set -fprint-explicit-foralls 

> :t fmap
fmap
  :: forall {f :: * -> *} {a} {b}.
     Functor f =>
     (a -> b) -> f a -> f b

> :t fmap @Maybe
fmap @Maybe :: forall {a} {b}. (a -> b) -> Maybe a -> Maybe b

> :t fmap @Maybe @Int
fmap @Maybe @Int :: forall {b}. (Int -> b) -> Maybe Int -> Maybe b

> :t fmap @_ @Int
fmap @_ @Int
  :: forall {_ :: * -> *} {b}.
     Functor _ =>
     (Int -> b) -> _ Int -> _ b

Усложняем: TypeFamily. Прячем Identity


Избавиться от недоразумения с Identity нам помогут Type Families.


type family WithEffect m a where
  WithEffect Identity a = a
  WithEffect m        a = m a

data User m = User
  { login :: WithEffect m String
  , email :: WithEffect m String }

-- >>> :t User @Identity 
-- User @Identity :: String -> String -> User Identity

-- >>> :t User @Maybe 
---User @Maybe :: Maybe String -> Maybe String -> User Maybe

type UserCreate = User Identity
type UserUpdate = User Maybe

Теперь перегрузка User стала для нас бесплатной.


Усложняем: кастомные эффекты. Конкретизируем смысл


Maybe, Either, [] — это всё очень хорошо. Но нужна более конкретная, привязанная к доменной области, семантика. Давайте создадим свои эффекты.


data Create
data Update 

type family OnAction action a where
  OnAction Create a = a
  OnAction Update a = Maybe a

data User a = User
  { login :: OnAction a String
  , email :: OnAction a String }

-- >>> :t User @Create
-- User @Create :: String -> String -> User Create

-- >>> :t User @Update
-- User @Update :: Maybe String -> Maybe String -> User Update

Мы дали нашим эффектам конкретные имена, и, кажется, сделали User чем-то более содержательным, чем просто набор полей. Научили его подстараиваться под наши нужды. А ещё мы можем наделить его характером.


Усложняем: больше TypeFamilies. Список модификаторов


С помощью следующей итерации мы сможем добавлять конкретные свойства конкретным рекордам. Вот так:


data User a = User
  { login :: Field a '[Immutable]    String -- Обратите внимание на список,
  , email :: Field a '[]             String -- можно иметь больше одного модификатора на одно поле
  , about :: Field a '[NotForSearch] String }

Здесь сказано, что поле login нельзя изменять, а по полю about запрещено искать. Я так хочу, такая у меня бизнес-логика. Можно сказать иначе, более конкретно: это означает, что User @Update не ждёт ничего содержательного в поле login (только ()), так же ведёт себя и поле about в User @Filter (до сих пор мы не вводили действия Filter, но он появится уже в следующем сниппете). Это поможет нам не совершить ошибку в процессе написания кода, ведь не получится запихнуть () на место строки. Давайте посмотрим, как этого добиться.


data Create 
data Update 
data Filter

-- | Аналог функции `elem` на уровне типов
type family Contains a as where
  Contains a (a ': as) = 'True 
  -- | Для поднятия конструкторов типа Bool на уровень 
  -- типов понадобится DataKinds
  Contains b (a ': as) = Contains b as
  Contains a '[]       = 'False

-- | Аналог ifThenElse на уровне типов
type family If c t f where 
  If 'True  t f = t
  If 'False t f = f

data Immutable
data NotForSearch

type family Field action (modifiers :: [*]) a :: *

type instance Field Create constraints a = a 

type instance Field Update constraints a = 
  --_если_ (список модификаторов содержит Immutable) _тогда_ () _иначе_ (Maybe a)
    If     (Contains Immutable constraints)                  ()         (Maybe a)

type instance Field Filter constraints a =
    If (Contains NotForSearch constraints) () [a]

data User a = User
  { login :: Field a '[Immutable]    String
  , email :: Field a '[]             String
  , about :: Field a '[NotForSearch] String }

-- >>> :t User @Create
-- User @Create :: String -> String -> String -> User Create

-- >>> :t User @Update
-- User @Update :: () -> (Maybe String) -> (Maybe String) -> User Update

-- >>> :t User @Filter
-- User @Filter :: [String] -> [String] -> () -> User Filter

Вот, что я подразумевал, говоря о характере:
До внесённых выше изменений все наши сущности (пусть, например, Comment или Article) вели бы себя одинаково:


Create? — Потребовать все поля!
Update? — Потребовать хоть что-нибудь :(
Filter? — Потребовать набор значений для поиска.

Но теперь поля получили модификаторы, которые "активируются" в зависимости от выбранного действия. Свойства, делающие из пачки рекордов данные, которые специфичны для заданной доменной области.

Что ещё можно изобразить, двигаясь в эту сторону?


Усложняем: DataKinds


Для того, чтобы сохранить данные, вероятно нам понадобятся имена их полей. А чего точно не хочется делать, так это использовать голые строки. Давайте зашьём имена там же, в описании модели.


-- / Symbol -- поднятые на уровень типов литералы строк.
data Named (a :: Symbol)

data Schema

type family Field (named :: Symbol) action (modifiers :: [*]) a :: *

type instance Field name Schema constraints a = Named name

data User a = User
  { login :: Field "login" a '[Immutable]    String
  , email :: Field "email" a '[]             String
  , about :: Field "about" a '[NotForSearch] String }

nameOf :: forall e n a. KnownSymbol n => (e Schema -> Named n) -> Text 
nameOf _ = pack $ symbolVal (Proxy @n)

-- >>> nameOf login
-- "login"
-- >>> nameOf about
-- "about"
-- >>> nameOf email
-- "email"

Для того чтобы достать название поля, просто передадим в функцию nameOf рекорд структуры. Таким образом:


  1. невозможно ошибиться в названии, очередной раз вбивая голый литерал
  2. невозможно обратиться к полю, которого нет

Вероятно возможно даже как-то протащить тип самого поля в тип Named, чтобы случайно не перепутать рекорды. Но я не нашёл удобного способа это сделать и использовать. Для любителей поковырять Haskell это может стать интересной задачкой.


Усложняем: ещё больше TypeFamilies. Опциональные поля


В формате описания данных уже довольно много всего, но их всё ещё недостаточно. Может быть у кого-то из вас даже возникли вопросы к способу фильтрации. Неужели списка может хватить для полноценной фильтрации? Или что делать с опциональными полями? Просто оборачивать в Maybe? Может быть. Но мы сделаем иначе.
Я отдаю себе отчёт, что некоторые из вас могут счесть это костылём (хотя опциональность поля выглядит довольно базовым понятием, достойным его упоминании в описании сущности). В конце концов, я предупреждал. что они точно будут.


Давайте по порядку.


Опциональность.


data Required = Required | Optional

type family ApplyRequired (req :: Required) m a where 
  ApplyRequired 'Required m a = a
  -- | Позволим использовать разные эффекты для изображения опциональности.
  -- Вскорости станет ясно, зачем это было сделано.
  ApplyRequired 'Optional m a = m a

-- / Так теперь выглядит Field
type family Field (name :: Symbol) (req :: Required) action (modifiers :: [*]) a :: *

type instance Field name req Create modifiers a = 
    ApplyRequired req Maybe a

type instance Field name req Update modifiers a = 
-- / Неважно, опционально поле или нет -- изменять мы его не можем
  If (Contains Immutable modifiers) ()
    (Maybe (ApplyRequired req Maybe a)) 

data User a = User
  { login :: Field 'Required "login" a '[Immutable]    String
  , email :: Field 'Optional "email" a '[]             String
  , about :: Field 'Optional "about" a '[NotForSearch] String }

-- >>> :t User @Create
-- User @Create :: [Char] -> Maybe String -> Maybe String -> User Create
-- / Можем не заполнять оциональные поля

-- >>> :t User @Update
-- User @Update :: () -> Maybe (Maybe String) -> Maybe (Maybe String) -> User Update
-- / Можем обновить оциональное поле, можем его стереть
-- | У вас могут возникнуть вопросы с Maybe (Maybe a).
-- | Внешний и внутренний Maybe имеют разный смысл:
-- / * внеший -- обновляется ли значение в принципе
-- / * внутренний -- затираем или устанавливаем значение 

-- >>> :t User @Filter
-- User @Filter :: [String] -> Maybe [String] -> () -> User Filter
-- / Можем фильтровать по набору значений или по отсутсвию значения

-- >>> :t User @Schema
-- User @Schema :: Named "login" -> Named "email" -> Named "about" -> User Schema
-- / Схема не изменилась

Фильтрация:


data Filter -- / Новое действие

data CustomFilter a -- / `a` -- непосредственно фильтр

data ItSelf -- / Значение может выступать фильтром самого себя.

-- / Существование/отсутсвие поля
data Exists a = Exists a | DoesNotExist

-- / Нефильтруемое поле
newtype NotFiltered a = NotFiltered ()

type family ApplyFilter req qs a where
  ApplyFilter req (CustomFilter q ': qs) a = Maybe (ApplyRequired req Exists (q a))
  ApplyFilter req (nq ': qs)             a = ApplyFilter req qs a
  ApplyFilter req '[]                    a = Maybe (ApplyRequired req Exists [a])  

-- / Примеры использования этого действия будет предоставлено в следующем разделе
type instance Field name req Filter modifiers a = 
  If (Contains (CustomFilter ItSelf) modifiers) 
      (Maybe (ApplyRequired req Exists a)) 
      (ApplyFilter req modifiers a)

Теперь должна быть ясна мотивация перегрузки эффекта в ApplyRequired, а так же почему я не стал использовать Maybe на самом типе поля:
Если поле является опциональным, то мы должны уметь фильтровать не только по набору значений как таковых, но и на факт отсутвия/существования их вообще. Однако если поле помечено как NotFiltered, но является Optional, мы всё ещё способны его фильтровать, но только на наличие его в записи. Можно ли это изменить? — Да. Но я не считаю, что это проблема. Оставляю решение о том, является ли это в действительности приседанием, на вас.


А в реальности?


Теперь давайте соберём в кучу все наши наработки и спроектируем нашу доменку. Так как теперь кода становится реально много, я буду давать ссылки на конкретные файлы в репозитории.


Первое, что нужно сделать, это разнести отдельные типы по файлам, так будет удобнее. Результат можно посмотреть здесь.


А теперь объявим сущности нашей доменной области. В качестве примера и доказательства, что это действительно можно использовать, я реализовал простейший CRUD с тремя сущностями:
User:


data User a = User
  { registered :: Field "registered" 'Required a '[Immutable, CustomFilter Range, NotAllowedFromFront] UTCTime
  , modified   :: Field "modified"   'Optional a '[CustomFilter Range, NotAllowedFromFront]            UTCTime 
  , login      :: Field "login"      'Required a '[]                                                      Text
  , email      :: Field "email"      'Optional a '[]                                                      Text
  , about      :: Field "about"      'Optional a '[CustomFilter NotFiltered]                              Text }

Здесь появляются несколько новых штук: Range и NotAllowedFromFront.
Новые фильтры определены здесь и в них нет ничего особенного. Но они хорошо работают с тем, что описано в базовом модуле.


newtype Regex a = Regex Text

data Range a = Range { from :: Maybe a, to :: Maybe a }

А ещё я добавил новый action, который называется Front. Он определён здесь и нужен для того, чтобы задать некоторым полям особенное поведение для работы с фронтом. Совершенно естестественно, что мы не должны позволять обновлять системные поля снаружи. Поэтому просто запретим это делать.


instance J.FromJSON NotAllowedFromFront where
  parseJSON _ = fail "Can't specify this field"

type instance Field name req (Front b) modifiers a =
  If (Contains NotAllowedFromFront modifiers) 
    (Maybe NotAllowedFromFront)
    (Field name req b modifiers a)

-- >>> :t User @(Front Create)
-- User @(Front Create)
--  :: Maybe NotAllowedFromFront
--     -> Maybe NotAllowedFromFront
--     -> Text
--     -> Maybe Text
--     -> Maybe Text
--     -> User (Front Create)

Maybe здесь нужен исключительно для того, чтобы генеренные кодеки для JSON не падали, не найдя этого поля в документе. Если бы я, например, был готов писать эти кодеки руками, но этого можно было бы избежать (у вас ещё не сбился счётчик приседаний? :) ).


Далее: Article:


data Article a = Article
  { userID   :: Field "userID"   'Required a '[Immutable]                                     (ID User)
  , tags     :: Field "tags"     'Required a '[CustomFilter ItSelf]                     (NonEmpty Text)
  , title    :: Field "title"    'Required a '[CustomFilter Regex]                                Text
  , content  :: Field "content"  'Required a '[CustomFilter NotFiltered]                          Text 
  , created  :: Field "created"  'Required a '[CustomFilter Range, NotAllowedFromFront]        UTCTime
  , modified :: Field "modified" 'Optional a '[CustomFilter Range, NotAllowedFromFront]        UTCTime }

Здесь нет ничего нового, помимо ID, в котором нет ничего хитрого (относительно того, что есть уже). Вы можете в этом убедиться.


Тип Comment тоже довольно скучный, но предоставлен здесь для полноты:


data Comment a = Comment
  { userID    :: Field "userID"    'Required a '[Immutable]                                             (ID User)
  , articleID :: Field "articleID" 'Required a '[Immutable]                                          (ID Article)
  , content   :: Field "content"   'Required a '[CustomFilter NotFiltered]                                  Text 
  , created   :: Field "created"   'Required a '[Immutable, CustomFilter Range, NotAllowedFromFront]     UTCTime
  , modified  :: Field "modified"  'Optional a '[CustomFilter Range, NotAllowedFromFront]                UTCTime }

В том случае, если читатель действительно заглянул в файлы, он мог заметить, что для каждой сущности определён набор инстансов. Давайте обговорим каждый из них.


  • EmptyData для Update и Filter.


    deriving instance (EmptyData (Comment Update))
    deriving instance (EmptyData (Comment Filter))

    Этот класс определён здесь и реализован по умолчанию для всех типов определённой формы с помощью Genericов.


    Имея этот класс мы можем определить функции с забавной сигнатурой:


    update :: EmptyData (e Update) => (e Update -> e Update) -> e Update
    update = ($ emptyData) 
    
    query :: EmptyData (e Filter) => (e Filter -> e Filter) -> e Filter
    query = ($ emptyData)

    В том случае, если вам неясно их назначение, я думаю, что следующие примеры смогут внести ясность.


    -- Добавляем описание
    update @User (#about ?~ Just "this is information about me")
    -- > User {registered = Nothing, modified = Nothing, login = Nothing, email = Nothing, about = Just (Just "this is information about me")}l
    
    -- Стираем описание
    update @User (#about ?~ Nothing)
    -- > User {registered = Nothing, modified = Nothing, login = Nothing, email = Nothing, about = Just Nothing}
    
    -- Ищем по нику
    query @User (#login ?~ ["coolGuy"])
    -- > User {registered = Nothing, modified = Nothing, login = Just ["coolGuy"], email = Nothing, about = Nothing}
    
    -- Ищем из списка людей с данными никами тех, у кого нет почты
    query @User (
          (#email ?~ DoesNotExist) 
        . (#login ?~ ["yourMaster", "kekeHehe"])
        )
    -- > User {registered = Nothing, modified = Nothing, login = Just ["yourMaster","kekeHehe"], email = Just DoesNotExist, about = Nothing}

    Просто приятный DSL для написания фильтров и патчей с использованием generic-lens (хоть в силу простоты нашего CRUD они не будут использованы, я посчитал нужным рассказать об этих функциях).


  • Кстати о generic-lens. Так как HKD имеют слишком хитрую форму, нельзя просто взять и обновить его поле generic-lens'ой. Это известная проблема, и решение для обхода для этой неприятности уже существует. Можете почитать об этом в этом issue. Именно в связи с этим для каждой сущности объявлен странный инстанс класса HasField.


  • From/ToJSON. Просто генерация кодеков для энкодинга и декодинга JSON. Ничего интересного.


  • ToDBFilter, ToDBFormat, FromDBFormat, DBEntity. Специфичные для конкретной базы данных инстансы, которые нужны для перегонки Filter, Create, Update сущностей в документы MongoDB и, наоборот, парсинга Create-сущности (сущности с наиболее полным набором полей) из документов MongoDB. И ещё один интанс, чтобы привязать к каждому типу сущностей название коллекции в MongoDB. Тут, как раз нам пригодилась Schema. Возьмём для примера реализацию ToDBFormat для (Article Create):


    instance ToDBFormat (Article Create) where
        toDBFormat article =
            [ userID   =:: idVal (userID article)
            , tags     =:: toList (tags article)
            , title    =:: title article
            , content  =:: content article
            , created  =:: created article
            , modified =:: modified article 
            ]
    
    (=::) :: KnownSymbol s => Mongo.Val b => (a Schema -> Named s) -> b -> Mongo.Field
    (=::) field val = nameOf field Mongo.=: val     

    А теперь самое главное: сам CRUD :). Он полностью реализован здесь: Давайте посмотрим в деталях.
    Входная точка, ничего особенного, но для полноты я покажу это здесь:


    app :: IO ()
    app = do  
      pipe <- Mongo.connect (Mongo.host "127.0.0.1")
      let ?pipe = pipe
      let ?database = "database"
      scotty 8000 do
        userHandlers
        articleHandlers
        commentHandlers

    Далее. 3 набора хэндлеров для каждой сущности:


    userHandlers :: WithMongo => ScottyM ()
    userHandlers = do
      post "/user"        createUser
      get  "/user"        (getById @User)
      put  "/user"        updateUserByID
      get  "/user/search" (getByFilter @User)

    articleHandlers :: WithMongo => ScottyM ()
    articleHandlers = do
      post "/article"        createArticle
      get  "/article"        (getById @Article)
      put  "/article"        updateUserByID
      get  "/article/search" (getByFilter @Article)

    commentHandlers :: WithMongo => ScottyM ()
    commentHandlers = do
      post "/comment"        createComment
      get  "/comment"        (getById @Comment)
      put  "/comment"        updateCommentByID
      get  "/comment/search" (getByFilter @Comment)


Я хочу обратить ваше внимание, что функция getByID полиморфна и в качестве главного аргумента принимает тип сущности, которая нам нужна.


Вот её определение.


getById :: forall e. (WithMongo, DBEntity e, J.ToJSON (e Create)) => Handler
getById = do
  eID <- jsonData
  e <- liftIO do dbLoadByID @e eID
  json e

Функция loadByFilter определена похожим образом, можете посмотреть сами.


К сожалению, функции создания и обновления так красиво и просто определить не удастся, потому что они имеют специфичную логику. Но мы всё ещё можем пользоваться в написании тем, что наши сущности определены как HKD.


createUser :: Handler
createUser = do
  User {..} <- jsonData @(User (Front Create)) -- (1)
  alreadyExists <- liftIO $ dbSearch $ queryEntity @User (#entity . #login ?~ [login]) -- (2)
  case alreadyExists of
    [] -> do
      userID <- liftIO do 
        now <- getCurrentTime 
        dbCreate $ User { registered = now, modified = Nothing,  .. } -- (3)
      json userID
    _ -> text "User with such ID already exists"

На всякий случай, я поясню, что здесь происходит.


  1. Пытаемся распарсить из JSON-структуры, формы Front Create. Это значит, что некоторые поля мы запрещаем определять снаружи.
  2. Если всё прошло удачно, то пытаемся найти пользователя с ником, который имеет новый пользователь. Если такой пользователь уже есть. сообщаем об этом,
  3. Если пользователя с таким ником ещё не существует. регистрируем его. Тут используется расширение RecordWildCards. {..} При матчинге вводит все рекорды структуры в скоуп, как значения. А при создании структуры {..} выискивает все имена с названиями рекордов и вставялет их в себя (подробнее о RecordWildCards). Однако компилятор нам явно скажет, что заполнить таким способом поля registered и modified явно не выйдет. Поэтому их придётся указать руками (что довольно естественно в данном случае).

Подобным способом написаны все остальные функции (я настаиваю на том, чтобы вы обратились к исходникам и почитали их сами).


Что в итоге?


Внимательные хаскелисты узнают в этом подходе подход Beam к работе со структурами. И, хоть и пример, который использует данная заметка имеет явный уклон в написание CRUDов, я хочу обратить внимание на отличие, о котором я уже упоминал вскользь: Beam (хотя и делает много других интересных штук, таких как DSL для SQL запросов) лишь предоставляет HKD для работы с БД. В нашем же случае структуры получили характер. Они получились связанными с доменной областью. Некоторые поля нельзя изменять, некоторые — нельзя задавать с фронта, потому что они считаются самой системой. Beam же не наделяет ваши поля особыми смыслами, он лишь делает из данных, которые вы создаёте, таблички в БД.
И CRUDы — это, конечно, не единственное, на что способны HKD. В интернете я нашёл ещё несколько примеров использования HKD:


  • Валидация (перевод на habr, оригинал)):
    С помощью Generic и HKD (роль Generic в этом случае ни чуть не меньше, чем HKD) можно из невалидированных данных получить валидированные. Я приведу небольшой пример того, как это работает снаружи, а для выяснения деталей реализации прошу обратиться к источнику.


    type family HKD f a where
    HKD Identity a = a
    HKD f        a = f a
    
    data User f = User { login :: HKD f String, age :: HKD f Int }
    
    user login age = User @Maybe 
                    { login = if length login > 6 && length login < 20 then Just login else Nothing
                    , age = if age > 0 then Just age else Nothing  }
    
    >>> gvalidate $ user "foo" 7 -- одно из полей невалидно
    > Nothing -- вся структура обращается в Nothing
    >>> gvalidate $ user "foobar1" 15 -- оба поля валидны
    > Just (User { login = "foobar1" , age = 15 }) -- вся структура валидна

    TL;DR: Структура перегоняется в универсальное Generic-представление, "траверсится" по Maybe и собирается обратно.


  • Простой пример с данными о погоде. Аггрегация:
    В оригинале примеры приведены на Scala. Ниже — эквивалент на Haskell.


    
    data WheaterData f = WheaterData
    { temperature :: HKD f Double -- HKD из примера выше
    , windSpeed   :: HKD f Double
    , dewPoint    :: HKD f Double  }
    
    instance (Semigroup (HKD f Double)) => Semigroup (WheaterData f) where
    (<>) wd1 wd2 = WheaterData (comb temperature) (comb windSpeed) (comb dewPoint)
      where comb f = ((<>) `on` f) wd1 wd2
    
    stats :: Num (HKD f Double) => NonEmpty (WheaterData f)
    stats =  WheaterData 0 6 15 :| [WheaterData (-8) 2 19, WheaterData (-40) 30 10]
    
    >>> sconcat $ stats @Max
    > WheaterData {temperature = Max {getMax = 0.0}, windSpeed = Max {getMax = 30.0}, dewPoint = Max {getMax = 19.0}}
    >>> sconcat $ stats @Min
    > WheaterData {temperature = Min {getMin = -40.0}, windSpeed = Min {getMin = 2.0}, dewPoint = Min {getMin = 10.0}}  

  • HKD также используются в довольно обыденных для функциональных программистов вещах.
    Можно вспомнить, что эмуляция type classов в Scala это тоже пример HKD. Вот пример из библиотеки cats:
    trait Functor[F[_]] {
    def map[A, B](fa: F[A])(f: A => B): F[B]
    }

    На Haskell это можно изобразить так:

    data Functor f = Functor { map :: forall a b. f a -> (a -> b) -> f b }

    Не отходя от кассы, можно заметить, что по тому же принципу работает Service/Handle Pattern. Если вы с ним ещё не знакомы, ознакомиться можете здесь.



    А что на Scala?


    Некоторые вещи, которые я показал выше, без труда реализуются на Scala 3. Давайте разберём небольшой кусочек (этот пример вы можете вставить в Scastie и поиграться).


    class Create
    class Update
    class Filter
    class Schema
    
    class Required
    class Optional
    
    // Literal types, кажется, доступны уже в Scala 2.13. Аналог DataKinds.
    case class Named[T]()(implicit v: ValueOf[T]) {
      val name = v.value
    }
    
    // А вот match type доступен только в Scala 3. Аналог закрытых TypeFamilies.
    type ApplyRequired[R, T] = R match 
      case Required => T
      case Optional => Option[T]
    
    type Field[R, N, A, T] = A match
      case Create => ApplyRequired[R, T]
      case Update => Option[ApplyRequired[R, T]]
      case Schema => Named[N]
    
    case class User[A]
      ( login : Field[Required, "login", A, String]
      , email : Field[Optional, "email", A, String]
      )
    
    val userCreate = User[Create]("lologin", None)
    val userUpdate = User[Update](None, Some(Some("mamail")))
    val userSchema = User[Schema](Named(), Named())
    
    userCreate.login // "lologin"
    userUpdate.login // None
    userUpdate.email // Some(Some("mamail"))
    userSchema.login.name // "login"
    userSchema.email.name // "email"

    Стоит заметить, что в Scala нет открытых match type. Поэтому в таком виде добавлять "пользовательские" эффекты не получится. Выход, конечно есть, но вы потеряете часть "красоты". Можно избавиться от Field и дать всем эффектам принимать все 3 аргумента: опциональность, название, тип поля (да, список модификаторов в сниппете со Scala не предоставлен, я плохо знаю Scala, поэтому мучать себя и компилятор дальнейшими экспериментами не стал). Тогда можно писать сколько угодно эффектов, пусть и ценой лаконичности.


    Ну и в конце ещё раз: ссылка на репозиторий.




Комментарии (2):