
Важный дисклеймер
Перед началом хочу позволить себе небольшой, но важный дисклеймер.
Я не стараюсь вам продать этот 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 рекорд структуры. Таким образом:
- невозможно ошибиться в названии, очередной раз вбивая голый литерал
- невозможно обратиться к полю, которого нет
Вероятно возможно даже как-то протащить тип самого поля в тип 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"
На всякий случай, я поясню, что здесь происходит.
- Пытаемся распарсить из
JSON-структуры, формыFront Create. Это значит, что некоторые поля мы запрещаем определять снаружи. - Если всё прошло удачно, то пытаемся найти пользователя с ником, который имеет новый пользователь. Если такой пользователь уже есть. сообщаем об этом,
- Если пользователя с таким ником ещё не существует. регистрируем его. Тут используется расширение
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, поэтому мучать себя и компилятор дальнейшими экспериментами не стал). Тогда можно писать сколько угодно эффектов, пусть и ценой лаконичности.Ну и в конце ещё раз: ссылка на репозиторий.
0xd34df00d
Чего только люди не придумают, лишь бы на идрисе не писать. Хорошая статья, спасибо!
Либо я чего-то не понял, либо он же у вас вот тут как
a, и можно сделать:К слову, выглядит интересным выводить имена дженериками (как тот же aeson делает), и оставить возможность указывать их вручную только тогда, когда этого не хватает.
Было бы прикольно траверсить не только по
Maybe, но и по произвольному (аппликативному) эффекту. Например, по тому жеValidation, чтобы сразу собрать все ошибки. Там, правда, надо будет думать, чтобы сделать правые части одинаковыми, но оно того стоит, ИМХО.goosedb Автор
Да, так сделать можно (я про Named), я пробовал. Но типы становятся слишком вербозными и чаще бесполезными, чем полезными.