Приветы! Меня зовут Максим Зималиев, я работаю в SlamData и уже почти два с половиной года пилю в продакшн код на PureScript'е. Как и большинство пришедших в прекрасный мир ФП, я начинал со всяких там джаваскриптов, пхп и шарпов. Соответственно, у меня есть бывшие коллеги и друзья, которые по-прежнему работают на императивных языках, используют GoF, DDD и тому подобное.
Ну так вот, пойдешь такой пиво пить, и внезапно "Слушай, а как у вас в PureScript'е, например ORM запилить, объектов же нет?" Или: "Это здорово всё, но вот я домены описываю вот так вот, у них там поведение есть, а как это PureScript'е сделать?" Или: "А что у вас вместо GoF?" И так далее... И тому подобное...
Так-то я отбиваюсь, дескать, не нужен нам ORM, не нужен DDD и GoF не нужен. Правда, почему-то мои "не нужно" не вызывают доверия :(
Короче говоря, я тут думал-думал и решил систематизировать ответ на все эти вопросы, хехей!
Вообщем, представим себе, что нам нужно написать программку, которая управляет роботом
var Beer = function() {
};
var Rod = function() {
};
var Robot = function() {
};
Robot.prototype.bend = function(rod) {
console.log("Bend!");
return this;
};
Robot.prototype.drink = function(beer) {
console.log("Drink!");
return this;
};
И нам надо робота заправить, а потом согнуть арматурину.
var bender = new Robot();
var beer = new Beer();
var rod = new Rod()
bender.drink(rod);
bender.bend(rod);
Понятное дело, что мы как правильные и хорошие программисты не просто так все это выложим в продакшн, а прогоним тесты, или хотя бы попросим кого-нибудь сделать ревью.
Эй, чел, твой робот пьет арматуру
Ой! Исправляем, отправляем снова, все отлично. Было бы неплохо, если бы нам не приходилось расстраивать нашего коллегу. И вот мы используем тесты, всякие instanceof
ну или ультимативно переходим на TypeScript
class Rod {}
class Beer {}
class Robot {
drink(beer: Beer): Robot {
console.log("Drink!");
return this;
}
bend(rod: Rod): Robot {
console.log("Bend!");
return this;
}
}
Океюшки, теперь роботы не могут пить арматурины!
var bender = new Robto();
bender
// .drink(new Rod()) won't compile
.drink(new Beer())
.bend(new Rod());
Наш коллега стал счастливее! Мы только что запретили себе и другим делать бессмысленные глупости.
У нас есть робот, у робота есть пиво: время УБИТЬ ВСЕХ ЧЕЛОВЕКОВ!!!. Для этого нужно больше роботов.
class RobotFactory {
robots: Robot[];
make(): Robot {
var robot = new Robot();
this.robots.push(robot);
return robot;
}
KILL_ALL_HUMANS(): void {
if (robots.length > 1000000) {
console.log("SUCCESS!");
} else {
console.log("There is not enough robots :(");
}
}
}
Работать будет так
var factory = new RobotFactory();
for (var i = 0; i < 10000000; i++) {
factory.make();
}
factory.KILL_ALL_HUMANS();
Отправляем на ревью. Грустный коллега
Слушай, у тебя тут класс с размытой областью ответственности, так делать нельзя. Нужно прикрутить какой нибудь другой класс для этого.
сlass RobotFactory {
make(): Robot {
return new Robot();
}
}
class RobotArmy {
var robots: Robot[];
joinArmy(robot: Robot): RobotArmy {
robots.push(robot);
return this;
}
KILL_ALL_HUMANS(): void {
if (robots.lenght > 1000000) {
console.log("SUCCESS: all humans are killed!");
} else {
console.log("FAILURE: please add more robots to this army");
}
}
}
// client code
var army = new Army();
var factory = new Factory();
var i: Number, robot: Robot;
for (i = 0; i < 10000000; i++) {
robot = factory.make();
army.joinArmy(robot);
}
army.KILL_ALL_HUMANS();
Ну ок, пойдет. Заметили общее? Суть в том, что мы сами отвергаем какие-то программы. Почему?
- Если куски программ работают только с одной областью ответственности, то их проще понять.
- Чем ниже когнитивная нагрузка, тем больше может быть приложение в целом!
Причем здесь паттерны? При том, что паттерны они, потому что они одинаковые. Все фабрики просто собирают: роботов-убийц, хтмл-элементы, пиво. Зная паттерн, программист может быстро понять, что код ему соответсвует или не соответствует. Короче говоря, они снижают сложность анализа и приема/отбрасывания кусков кода для человека!
Еще разок
class Robot {
bend(rod: Rod) { return this; }
drink(beer: Beer) { return this; }
}
class Factory {
make(): Robot { return new Robot(); }
}
// client
var factory = new Factory();
var beer = new Beer();
var rod = new Rod();
factory.make().drink(beer).bend(rod);
Внимательнее
factory
.make()
.drink(beer)
.bend(rod)
Этот код
- Подмножество TypeScript, нельзя сделать что-то вроде
factory.make().drink(beer)[0]
- У него есть значение
drink(beer)
очевидно значитпить пиво
- Он работает с конкретной областью реальности. Предполагается, что роботы пьют пиво, знаете ли :)
А значит
- У него есть синтаксис
- У него есть семантика
- У него есть домен Боже мой! Это же специальный доменный язык! Не может быть! Как так-то???
На самом деле, оказывается, что у всех этих ваших паттернов есть синтаксис/семантика/домен. Даже у метапаттернов, даже в PHP :)
Проблема у нас только одна, TS не может в проверку структруы DSL'ей :(
Лучшего способа для отбраковки "плохих" программ, чем типы на мой взгляд нет. Чем мощнее система типов, тем больше можно обраковать.
- Нет типов: добро пожаловать
undefined is not a function
- есть типы: уже не напишешь
foo = 1 + "2"
- есть типы высших порядков: можно проверить проверить, что структура паттернов выполняется
- есть зависимые типы: ХАХАХА! Ты больше не разделишь ничего на ноль!
Про зависимые типы посмотрите где-нибудь в другом месте, я сосредоточусь на PureScript'е и том, как в нем писать DSL'и.
Внимание! Внимание! DSL'и -- это способ организации программ и разделения ответственности, который понижает когнитивную сложность, и, как следствие позволяет управлять большим по размеру проектом. Паттерны -- унифицированный способ, постройки DSL'ей, унифицирован он, чтобы его было проще проверить человеку. Жителям ООП мира не интересны билдеры/фабрики/посетители/DDD, им интересны способы унификации интерфейсов доменных языков, чтобы их проще было проверить.
В PS есть такая фича -- тайпклассы. Она используется примерно так же, как интерфейсы или протоколы. Определяет контракт, короче говоря. Там, естественно, есть пара нюансов, вроде того, что для того, что у тайпклассов есть законы, которые должны выполняться, что это не про наследование, и никакого отношение к ООП классам они не имеют, хотя и наследуются
class Semigroupoid a where
append :: a -> a -> a
class Semigroupoid a <= Monoid a where
mempty :: a
-- client
dummyExample :: forall a. Monoid a => Boolean -> a -> a
dummyExample true a = a
dummyExample false _ = mempty
Так-то поглядите на dummyExample
- Он использует подмножество языка:
a + "foo"
-- бессмыслица - У него есть значение, правда абстрактное: ноль и сложить
- У него есть домен: штуки у которых есть ноль и можно их сложить.
Так что это DSL с двумя ключевыми словами: <>
и mempty
.
Тайпклассов очень много, все они определяют контракты и у них есть свои значения. Круто вот что: мы можем их использовать вместе. Тем самым расширяя наш "язык"
otherMeaninglessExample :: forall a. Monoid a => Show a => Eq a => а -> String
otherMeaninglessExample a
| a == mempty = "This is mempty"
| otherwise = show a
Впрочем ничего нового :| интерфейсы работают похоже.
Время поговорить о монадах. В общем если моноид -- это штука, у которой есть ноль и его можно сложить, то монада (я тут иерархию склеил)
class Monad m where
pure :: a -> m a
bind :: m a -> (a -> m b) -> m b -- он же >>=
map :: (a -> b) -> m a -> m b
apply :: m (a -> b) -> m a -> m b
Ничего я тут объяснять не буду. Просто приведу примерчик.
monadicComputation z =
takeFromContext >>= \x ->
workWithContext x >>= \y ->
pure (x + y * z)
monadicDoComputation z= do
x <- takeFromContext
y <- workWithContext x
pure (x + y * z)
Эта штука очень-очень похожа на императивные вычисления, именно поэтому она так часто используется.
В целом, монада с точки зрения доменных языков -- минимальная реализация императивного вычисления как цепочки.
Ее еще и расширять можно, например MonadState s
-- любое "императивное" вычисление в контексте состояния s
.
Это выглядит немножко странно, то есть, на кой черт нужен маленький язык для императивных вычислений?
А нужен он, потому что императивные вычисления в фп было бы неплохо выделить и ограничить, (Так же как и работу
с состоянием, или с моноидом, или с профунктором) потому что не все вокруг цепочки вычислений.
Внимание! Внимание! Монада -- это не цепочка вычислений! На самом деле монада -- это монада. Просто ее можно использовать в качестве DSL'я, который моделирует цепочку вычислений.
Абстракции -- это очень абстрактно. Они уже позволяют нам выделять разные штуки, и (уверяю вас) писать код, в котором области ответственности отлично разделены. Но хотелось бы снова вернуться к роботу. Очевидно, что робот -- это не монада и не моноид, и даже не функтор. Так же как фабрика роботов -- это не полугруппа. Можно ли как-то использовать эти ваши тайпклассы с роботами?
Можно! Это даже (внезапно!) паттерн, который называется Finally Tagless. В нем мы моделируем поведение нашего маленького языка через тайпкласс, предельно конкретный тайпкласс.
class RobotProgram robot beer rod army factory | robot -> beer, robot -> rod, robot -> factory, robot -> army where
makeRobot :: factory -> robot
drink :: robot -> beer -> robot
bend :: robot -> rod -> Tuple rod robot
joinArmy :: army -> robot -> army
killAllHumans :: army -> Boolean
Внимание! Скорее всего эти вычисления затрагивают состояние и это должно быть указано, я опустил это для простоты. Функциональные зависимости -- штука, которая говорит, что пиво, например, полностью определяется роботом.
Использовать так
data Factory = Factory
data Beer = Beer
data Robot = Robot
data Factory = Factory
data Rod = Rod
instance robot :: RobotProgram Robot Beer Rod (Array robot) Factory where
makeRobot _ = Robot
drink robot _ _ = robot
bend robot rod = Tuple robot rod
joinArmy army robot = cons robot army
killAllHumans a = length a > 1000000
test
:: forall robot beer rod army factory
. RobotProgram robot beer rod army factory
=> Monoid army
=> factory
=> army
test factory =
let
emptyArmy = monoid
robot = makeRobot factory
in joinArmy emptyArmy robot
Опять же, в этом примере, язык RobotProgram
расширен (хотя, я предпочитаю, сужен, потому что не все подходящие армии моноиды,
знаете ли) ограничением на тип армии, она может быть пустой. Мы могли бы засунуть это ограничение в определение RobotProgram
,
кстати говоря.
- Тайпклассы обеспечивают абстракцию
- Определяют синтаксис
- Обладают значением
- Поддерживают связность
- Код использующий их проще в понимании и поддержке.
- Они проверяются компилятором.
Это к чему, это к тому, что они уже решают все, что нужно решать паттернам.
Классы типов -- это здорово, они позволяют решить нашу проблему. Однако, иногда такой подход бывает немного, эм... многословным. И у него, в случае finally tagless, есть недостаток, который на первый взгляд не особо виден.
class Robot robot beer rod | robot -> beer, robot -> rod where
bend :: robot -> rod -> Tuple robot rod
drink :: robot -> beer -> robot
data Robot = Full | Empty
data Beer = Beer
data Rod = Straight | Bended
instance robot :: Robot Robot Beer Rod where
bend r _ = Tuple r Bended
drink _ _ = Full
Теперь определим пивоварню
class Brewer brewery beer | brewery -> beer where
brew :: brewery -> beer
data Brewery = Brewery
instance brewery :: Brewery Brewery where
brew _ = Beer
Теперь попробуем сделать робота-пивоварню и попробуем использовать существующие интерпретаторы
-- Erm...
data BrewerRobot = BrewerRobot Robot Brewery
instance robotBrewery :: Robot BrewerRobot Beer Rod where
bend (BrewerRobot r _) rod = bend r rod
drink (BrewerRobot r _) beer = drink r beer
instance brewRobot :: Brewery BrewerRobot Beer where
brew (BrewerRobot _ b) = brew b
То есть мы конечно использовали код повторно, но как-то это повторно не очень повторно. Прикол в том, что если у нас есть два интерпретатора/комплиятора для тайпклассового DSL'я, мы не можем их просто сложить. Нужно делать новый тип данных, нужно вручную диспатчить, что не может не напрягать.
Так-то в PureScript'е есть еще несколько прекрасных абстракций, например, алгебраические типы данных. Те самые с конструкторами, сопоставлением с образцом и прочими радостями. Попробуем описать команды робота и пивоварни в виде ADT.
data RobotCommand beer rod
= Drink beer
| Bend rod
data BreweryCommand beer
= Brew beer
Отлично! Что теперь? Программа наша -- это же список команд, ну так давайте и запилим список(здесь массив будет) команд:
robotCommands :: Array (RobotCommand Robot Beer Rod)
robotCommands =
let
beer = Beer
robot = Robot
rod = Rod
in [ Drink beer, Bend Rod ]
breweryCommands :: Array (BreweryCommand Beer)
breweryCommands = [ Brew Beer, Brew Beer ]
Опять же, список команд -- штука чрезвычайно строгая, в ней могут быть только команды, типы же, все дела. Чтобы интерпретировать такую штуку можно использовать что-нибудь вроде этого:
interpretRobot :: forall e. Array (RobotCommand Robot Beer Rod) -> Eff (console :: CONSOLE|e) Unit
interpretRobot cs = for_ cs interpretRobotCommand
interpretRobotCommand = case _ of
Drink _ -> log "Drink!"
Bend _ -> log "Bend!"
interpretBrewery :: forall e. Array (BreweryCommand Beer) -> Eff (console :: CONSOLE|e) Unit
interpretBrewery cs = for_ cs interpretBreweryCommand
interpretBreweryCommand = case _ of
Brew _ -> log "Brew..."
Ну это-то понятно, а в чем профит?
type Command = Either (BreweryCommand Beer) (RobotCommand Robot Beer Rod)
program :: Array Command
program =
let
robot = Robot
beer = Beer
rod = Rod
in [ Right $ Brew beer, Right $ Brew beer, Left $ Drink beer, Left $ Bend rod ]
interpretCommand :: forall e. Array Command -> Eff (console :: CONSOLE|e) Unit
interpretCommand cs = for_ cs $ either interpretBreweryCommand interpretRobotCommand
Тададам!
- Не нужен новый тип данных, его, конечно, можно добавить, но это необязательно.
- Интерпретаторы работают так же, как и раньше. Их можно склеивать и так далее, и тому подобное.
DSL'и построенные на списках очень просты, если к ним прикрутить что-нибудь еще поинтереснее, то
можно получить, например, HTML
из purescript-halogen. Они работают быстро и вообще говоря очень
даже понятны.
Но иногда хочется странного, например, использовать монады, просто так, для красоты. Особенно это украшательство прикольно работает, если у нас там вложеные команды. Сравните:
program =
[ SayHello
, SubProgram
[ OpenPort
, SendMessage
, ClosePort
]
, Exit 0
]
programDo = do
sayHello
subProgram do
openPort
sendMessage
closePort
exit 0
Разницы особой нет, но мне лично больше нравится монадка тут. Чтобы это сделать на самом деле даже напрягаться не надо! Есть такая штука называется MonadTell
и WriterT
, первый -- это класс, который определяет синтаксис для императивных вычислений, который умеют писать в лог, второе -- это реализация этой штуки (конкретная то есть).
brew :: Beer -> WriterT (Array Beer) Unit
brew beer = tell [ beer ]
brewery :: WriterT (Array Beer) Unit
brewery = do
brew Beer
brew Beer
brew Beer
interpret :: forall e. WriterT (Array Beer) Unit -> Eff (console :: CONSOLE|e) Unit
interpret = interpretBrewery $ runWriter brewery
Мне лично нравится.
Что если робот может сгибать только, если он заправлен? Это значит, что нам нужно как-то узнать состояние робота. То есть подъязык
теперь не только список команд, он еще и возвращать что-то должен уметь. Естественно, что со списком этого не сделать. Нам нужна
монада, и WriterT
не подойдет. Может подойти State
но о нем я не буду говорить, резко бросившись к свободным монадам.
Итак, чтобы сделать монаду нам надо уметь
- Оборачивать значение вне монады в монаду
pure
- Связывать монадические вычисления
>>=
И есть такая штука, которая умеет делать вот эти вот две операции для любого типа данных, который имеет дополнительный параметр.
Внимание! Внимание! Ковариантный параметр!
data Foo a = Foo (a -> Int)
не подойдет! Кроме того, в общем случае свободная монада работает с функторами, просто PureScript'овая библиотека на самом деле делает Freer monad, которые работают для алгебр в общем виде.
Штука эта -- свободная монада (Free Monad). И она позволяет описать что-то вроде
-- Мне надоело делать этот тип параметризованным :)
data RobotF a
= Bend Rod a
| Drink Beer a
| IsEmpty (Boolean -> a)
type Robot = Free RobotF
bend :: Rod -> Robot Unit
bend rod = liftF $ Bend rod unit
drink :: Beer -> Robot Unit
drink beer = liftF $ Bend beer unit
isEmpty :: Robot Boolean
isEmpty = liftF $ IsEmpty id
program :: Robot Unit
program = do
needBeer <- isEmpty
when needBeer $ drink Beer
bend Rod
Чтобы эту штуку интерпретировать надо использовать foldFree
, там параметр натуральная трансформация. Что-то вроде
interpretRobot :: forall e a. Robot a -> Eff (console :: CONSOLE|e) a
interpretRobot = foldFree nat
robotNat :: forall e. RobotF ~> Eff (console :: CONSOLE|e) Unit
robotNat = case _ of
Drink beer next -> do
log "Drink!"
pure next
Bend rod next -> do
log "Bend!"
pure next
IsEmpty cont -> do
log "I have no idea, because I'm dummy example implementation"
pure $ cont false
Чтобы склеивать команды в свободной монаде надо использовать не Either
, а Coproduct
(тот же ейзер, но для штук с параметром типа)
data BreweryF a = Brew (Beer -> a)
interpretBrewery :: forall a e. Free BreweryF a -> Eff (console :: CONSOLE|e) a
interpretBrewery = foldFree breweryNat
breweryNat :: forall e. BreweryF ~> Eff (console :: CONSOLE|e) a
breweryNat = case _ of
Brew cont -> pure $ cont Beer
brew :: Free BreweryF Beer
brew = liftF $ BrewF id
type BrewerRobotF = Coproduct BrewerF RobotF
program = do
beer <- left brew
needBeer <- right isEmpty
when needBeer $ drink beer -- Ура! Вечный двигатель!
bend Rod
interpret = foldFree $ coproduct breweryNat robotNat
Точно такая же штука, как со списковым доменным языком, но у нас тут есть <-
и это немножко увеличивает мощность языка. (Уже не говоря о том, что Free RobotF a
-- аппликатив, функтор и так далее).
- Код по-прежнему расширяем, ограничен (очсильно) и выглядит прямо скажем неплохо
- У языка есть четкая спецификация так же как в ТК
- Интерпретация и сама программа разделены, как и в ТК.
- Легко расширяемый синтаксис -- новые команды можно добавлять через копродукты.
Все это делает поддержку таких программ простой, а модульность просто необычайно высокой. По опыту могу сказать, что при работе со свободными монадами обычно даже не думаешь о том, что они где-то там интерпретируются.
Внимание! Внимание! Свободными бывают не только монады. Еще и аппликативы, моноиды, полукольца, да что угодно. И большая часть этих структур данных полезна! Потому что позволяет работать с заданной структурой (вот эта вот
RobotF
, например) абстрактно и независимо.
А еще свободные монады (аппликативы и прочие) умеют быть инстансами классов типов. И это тоже полезно! Потому что у нас есть жесткий абстрактный контракт (например, последовательность команд, которая может неожиданно завершиться ошибкой), которому удовлетворяет абстрактная структура данных (например, свободная монада).
class Monad robot <= RobotDSL robot where
needBeer :: robot Boolean
bend :: Rod -> robot Unit
drink :: Beer -> robot Unit
program :: forall robot m. RobotDSL robot Beer Rod => MonadThrow String robot => robot Beer
program = do
isEmpty <- needBeer
when isEmpty $ throw "Robot is empty, it can't bend"
bend Rod
Тут опять finally tagless, ага. Монады свободные тут причем? Притом
newtype RobotM a = RobotDSL (Free RobotF a)
derive instance newtypeRobotDSL :: Newtype (RobotM a) _
derive newtype instance functorRobotDSL :: Functor RobotM
derive newtype instance applyRobotDSL :: Apply RobotM
derive newtype instance applicativeRobotDSL :: Applicative RobotM
derive newtype instance bindRobotDSL :: Bind RobotM
derive newtype instance monadRobotDSL :: Monad RobotM
instance robotMRobotDSL :: RobotDSL RobotM where
needBeer = RobotM <<< (liftF $ IsEmpty id)
bend rod = RobotM $ Bend rod unit
drink beer = RobotM $ Drink beer unit
А вот теперь это уже серьезно.
- Код клиента понятия не имеет, что это свободная монада. Он использует только ограничения классов типов.
- Алгебры свободной монады по-прежнему можно складывать копродуктами.
- Интерпретаторы по-прежнему можно менять в рантайме.
На самом деле сочетания ограничений тайпклассами и свободными штуками уже достаточно, чтобы делать гигантские вещи. Но, если вдруг.
Если я хочу использовать язык программирования в языке программирования, то я могу не ограничиваться тем, свободными монадами, списками команд, тайпклассами. Я могу просто запилить язык программирования! Хехей! Тем более, что это офигительно просто и очень похоже на работу со свободными монадами.
На самом деле
Free
это частный случай представления того, о чем сейчас пойдет речь. В общем случае это рекурсивные и корекурсивные штуки, которые умеют сворачивать и разворачивать алгебры (те штуки с дополнительными параметрами вродеRobotF
). Здесь я использую purescript-matryoshka, потому что я к ней привык и она удобная.
Для того, чтобы сделать эту штуку надо
- Определить базовую алгебру синтаксического дерева
data RobotC
= Bend Rod a
| CaseEmpty a a
| Drink Beer a
-- Эти инстансы нужны, чтобы можно было собирать, разбирать дерево потом
instance functorRobotC :: Functor RobotC where
map f = case _ of
Bend rod a -> Bend rod $ f a
CaseEmpty a b -> CaseEmpty (f a) (f b)
Drink beer a -> Drink beer $ f a
instance foldableRobotC :: Foldable RobotC where
foldl f = ...
foldr f = ...
foldMap f = ...
instance traversableRobotC :: Traversable RobotC where
traverse = ...
sequence = ...
- Собрать дерево используя
embed
(если мы дико крутые, то можем запилить парсер и свой прямо синтаксис, как у невстроенных языков)
program :: forall t. Corecursive t RobotC => t RobotC
program =
embed
$ CaseEmpty
(embed $ Drink Beer $ embed $ Bend Rod)
(embed $ Bend Rod)
- Свернуть его используя алгебру!
alglog :: Algebra RobotC (Array String)
alglog = do
CaseEmpty a b -> cons "case" $ a <> b
Drink _ a -> cons "drink" a
Bend _ a -> cons "bend" b
logAllCommands :: forall t. Recursive t RobotC => t RobotC -> Array String
logAllCommands = cata alglog
Я не очень профессионал в таких делах, поэтому ограничусь тем, что скажу, что даже с бойлерплейтом
парам-парам-пам!