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, поэтому мучать себя и компилятор дальнейшими экспериментами не стал). Тогда можно писать сколько угодно эффектов, пусть и ценой лаконичности.


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

Комментарии (3)


  1. 0xd34df00d
    15.09.2021 19:05
    +3

    Чего только люди не придумают, лишь бы на идрисе не писать. Хорошая статья, спасибо!


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

    Либо я чего-то не понял, либо он же у вас вот тут как a, и можно сделать:


    type instance Field name Schema constraints a = Named name a

    К слову, выглядит интересным выводить имена дженериками (как тот же aeson делает), и оставить возможность указывать их вручную только тогда, когда этого не хватает.


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

    Было бы прикольно траверсить не только по Maybe, но и по произвольному (аппликативному) эффекту. Например, по тому же Validation, чтобы сразу собрать все ошибки. Там, правда, надо будет думать, чтобы сделать правые части одинаковыми, но оно того стоит, ИМХО.


    1. goosedb Автор
      15.09.2021 19:09

      Да, так сделать можно (я про Named), я пробовал. Но типы становятся слишком вербозными и чаще бесполезными, чем полезными.


  1. leon_nikitin
    23.09.2021 10:31

    Отличный материал!