Важный дисклеймер
Перед началом хочу позволить себе небольшой, но важный дисклеймер.
Я не стараюсь вам продать этот 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), я пробовал. Но типы становятся слишком вербозными и чаще бесполезными, чем полезными.