Приветы! Меня зовут Максим Зималиев, я работаю в 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Я не очень профессионал в таких делах, поэтому ограничусь тем, что скажу, что даже с бойлерплейтом
парам-парам-пам!
s/new Robto/new Robot/