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

от автора

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/578070/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *