
Важный дисклеймер
Перед началом хочу позволить себе небольшой, но важный дисклеймер.
Я не стараюсь вам продать этот 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, поэтому мучать себя и компилятор дальнейшими экспериментами не стал). Тогда можно писать сколько угодно эффектов, пусть и ценой лаконичности.Ну и в конце ещё раз: ссылка на репозиторий.
ссылка на оригинал статьи https://habr.com/ru/articles/578070/
Добавить комментарий