С большим опозданием, продолжаю цикл статей про Workplace бота. Ссылки на предыдущие части: 1, 2, 3 и 4. Сегодня, впрочем как обычно, речь пойдет про очедную функциональную дичь ;).
Столпом ООП является инкапсуляция (каждый раз тянет по английски это слово с i начать), которая про “сокрытие реализации”. Даже самому начинающему программисту известно, что достигается инкапсуляция в mainstream языках программирования при помощи interface-ов. Я постараюсь показать совершенно иной способ инкапсуляции – экзистенциальные эффекты.
Что за абстракция такая – “эффект” и для чего нужна? Для понимания, давайте рассмотрим примитивную функцию для сложения двух чисел:
function add(a: Int, b: Int): Int {
.debug("${Time.current} - Adding numbers $a and $b.")
Loggerreturn a + b;
}
Обычная функция – делает что и должна, да еще и записи в лог добавляет. Красота? С обывательской точки зрения с функцией все в порядке (возможные переполнения Int
оставим за кадром). А вот с точки зрения компилятора – сплошное расстройство: вызов add(2, 2)
невозможно вычислить и заменить на 4
на этапе сборки. Примерно так же на эту функцию смотрит и разработчик на Haskell – она просто “не может быть функцией”. Точнее для него – это и не функция вовсе (в математическом смысле) – она не просто возвращает результат, совершая операции над аргументами. Она еще записывает сообщение в лог, должна откуда-то взять текущее время. Вызывая ее в разное время она хоть и вернет один и тот же результат, но в логи будет записаны разные строки. Обычно в таких случаях говорят, что у функции есть “побочный эффект” (side effect).
Наличие side effect-ов в императивных ЯП – обычное дело. Ими удобно пользоваться не только чтобы логи писать, но и для других, не менее Эффектных вещей – Thread.current
, Time.now
, println
в конце концов. Проблема в том, что за удобство приходится расплачиваться. Высока ли цена? В этом примере – не очень. Подумаешь, в логи будут сыпаться сообщения при запуске unit тестов, а сама функция будет выполняться в 1000 раз медленнее чем могла бы… Если это и станет проблемой – мы либо вставим проверку if (debug)
либо подменим Logger
на “ничего не делающую заглушку” при запуске тестов. Один вопрос – а как мы узнаем о том, что это нужно сделать, глядя на сигнатуру функции? В том-то и дело что “никак”… Наличие side effect-а никак не отражено в сигнатуре типа, но тем не менее добавляет в код несколько неявных зависимостей – работу с логами и чтение времени.
В ООП с проблемой принято бороться только одним способом (хоть он и может выглядеть по-разному на первый взгляд) – при помощи техники Dependency Injection.
class Calculator {
@Autowiredlogger: Logger;
val
function add(a: Int, b: Int, calendar: Calendar = Time): Int {
.debug("${calendar.current} - Adding numbers $a and $b.")
loggerreturn a + b;
} }
В такую функцию действительно можно передать и “пустой” логгер и “константный, замороженный во времени календарь” и даже протестировать поведение side effect-а, ожидая что у логгера вызовется функция debug
с аргументом-строкой, начинающейся на то, что вернет current
из переданного календаря. Это ведь был всего-лишь примитивный пример, в реальных системах количество таких неявных зависимостей явно больше пальцев на прямых руках. Да и если подходить скурпулезно, то и до FizzBuzzEnterpriseEdition недалеко…
Впрочем, в функциональном Haskell тоже можно писать примерно так:
add :: Int -> Int -> IO Int
= do
add a b >>= \time -> putStrLn(time <> " - Adding numbers" <> intercalate " and " [a, b])
getCurrentTime return $ a + b
Обратите внимание, возвращаемый тип теперь не Int
, а IO Int
. Это “позволяет” внутри функции делать что угодно – хоть с лог писать, хоть по HTTP запросы слать. Работать с такой функцией все еще можно, но только из кода, который тоже “помечен” IO
. Позволяя делать что угодно, IO
как вирус заражает части программы, мешая пользоваться всеми преимуществами функционального подхода. Хотелось бы явно указывать – add
, в качестве side effect-ов делает не что угодно, а только логгирование и работу со временем. Суть “эффектов” в этом и заключается – определить явно какие-то операции (влияющие или зависящие от “внешнего мира”) – обозвать их эффектами и использовать в сигнатуре функций. В этом случае можно будет совершенно четко видеть с какими именно эффектами функция работает (и ничего другого ей позволено не будет).
Тело функции никак не меняется, другой становится сигнатура типа:
add :: (Member TimeEffect eff, Member ConsoleEffect eff) => Int -> Int -> Effect eff Int
Читать ее можно так: допустим в композитном эффекте eff
содержатся два эффекта – TimeEffect
и ConsoleEffect
, тогда функция add
– принимая два Int
-а возвращает Int
, но при этом может выполнять “работу со временем” и “печатать на консоль”. Посылать HTTP запросы ей не позволено, так как ни TimeEffect
ни ConsoleEffect
этого не позволяют. Их тоже определяет сам программист, например так:
data TimeEffect m a where
urrentTime :: TimeEffect m String
С
'TimeEffect
makeEffect '
data ConsoleEffect m a where
PrintLn :: String -> ConsoleEffect m ()
ReadLn :: ConsoleEffect m String
'ConsoleEffect makeEffect '
Ну хорошо, вместо всепозволяющего Int -> Int -> IO Int
мы теперь имеем более конкретизированное (Member TimeEffect eff, Member ConsoleEffect eff) => Int -> Int -> Effect eff Int
(обратите внимание на сходство с возможной математической нотацией: add :: Int -> Int -> Effect eff Int
, где eff
такое, что истинны оба утверждения: Member TimeEffect eff
и Member ConsoleEffect eff
).
Комбинируя функции с явно определенными эффектами в одной программе (функции) – эффекты тоже объединяются:
greeting :: Member ConsoleEffect eff => Effect eff ()
= do
greeting "What is your name?")
printLn(<- readLn
name "Hello" <> name)
printLn(
measure :: (Memeber ConsoleEffect actionEff, Memeber TimeEffect measureEff) => Effect actionEff a -> Effect (measureEff : actionEff) a
= do
measure action <- currentTime
startTime <- action
result <- currentTime
endTime
$ "Action took: " <> endTime - startTime <> " seconds"
printLn return result
greeting :: (Memeber ConsoleEffect eff, Memeber TimeEffect eff) => Effect eff () measure
По сути, мы создаем не последовательность инструкций, а сложную структуру данных – дерево последовательности вызовов и callback-ов. Дело за малым – интерпретировать (схлопнуть, вычислить) это дерево до получения единственного значения. Прелесть подхода с эффектами в том, что самостоятельно интерпретатор для дерева писать не приходится. Нужно всего-лишь “объяснить” что делать с тем или иным эффектом.
runConsole :: Member (Embed IO) r => InterpreterFor ConsoleEffect r
= interpret $ \case
runConsole PrintLn string -> System.IO.putStrLn string
ReadLn -> System.IO.getLine
runTime :: Member (Embed IO) r => InterpreterFor TimeEffect r
= interpret $ \case
runTime -> Data.Time.Clock.getCurrentTime СurrentTime
Итоговый интерпретатор, которым можно выполнить всю программу (именно он будет использоваться в main), комбинируют из индивидуальных:
= runIO . runTime . runConsole interpreter
Применив получившийся интерпретатор interpreter
к программе measure greeting
, получим:
=> What is your name?
<= Alex
=> Hello Alex
=> Action took: 5 seconds
Для целей тестирования функции greeting
можно написать специальный интерпретатор, который по readLn
всегда возвращает нужную нам строку, а printLn
постоянно добавляет к аккумулятору переданную ему строку. Но создавать такие интерпретаторы – на наш путь. Мы воспользуемся готовеньким и реализуем эффект ConsoleEffect
в терминах двух других библиотечных эффектов – Reader
и Writer
. В этом случае можно будет воспользоваться уже готовыми интерпретаторами для них:
fakeInterpreter :: Effect ConsoleEffect a -> Effect (Writer String : Reader String) a
= reinterpret \case
fakeInterpreter PrintLn string -> write string
ReadLn -> ask
Была программа с side effect-ом ConsoleEffect
, а стала программой для работы с эффектами Reader String
и Writer String
(про то почему reader и writer эффекты – можно и отдельную статью сделать). Выполнить ее можно получившимся “чистым” (без всяких IO
и Effect
) интерпретатором:
pureGreeter :: String -> [String]
= intepret greeter
pureGreeter name where
= run . runWriter . runReader name . fakeInterpreter intepret
Тестировать такую функцию элементарно – передаешь имя на вход, получаешь массив строк на выходе – сравниваешь с ожидаемым.
Программирование “на эффектах” – во многом схоже с программированием “на интерфейсах” из объектно-ориентированного программирования. Выделяются абстракции, которые можно в последствии подменить (в целях тестирования или другого полиморфизма). В обоих случаях ядро программы представляет собой ответ на “что делать”, а на “как делать” отвечает самая внешняя часть – чем ближе к main, тем лучше. В случае ООП этим занимается DI фреймворк – регистрирует при старте программы все реализации интерфейсов, внедряет зависимости туда, где они требуются и запускает исполнение основной программы.
Вроде все “так же”, зачем тогда весь этот функциональные приседания? Да, на первый взгляд отличий не так много. Но задумайтесь – ваша программа перестала быть просто программой, она стала данными, выстроенным в памяти деревом вызовов, сформулированным в терминах выбранных вами эффектов. Это ведь можно как-то использовать…
Например для интроспекции – можно пройтись по по этой структуре и чего-нибудь туда добавить – трассировку, скажем:
logConsole :: Member ConsoleEffect eff => Effect eff a -> Effect (eff : Trace) a
= intercept $ \case
logConsole PrintLn string -> do
$ unwords ["Going to print", length string, "characters"]
trace
printLn stringReadLn -> do
$ unwords ["Going to read from a console"]
trace <- readLn
result $ unwords ["Successfully read", length result, "from a console"]
trace return result
Или время заморозить, как в мультике:
fronzen :: Member TimeEffect eff => Time -> Effect eff a -> Effect (eff : Trace) a
= intercept $ const $ pure instant fronzen instant
Кстати, а вы заметили в примерах множество полей с навешанными на них Autowired
аннотациями или может быть десятки аргументов в конструкторах классов? Прелесть в том, что они оказываются не нужны – роль DI framework-а играет простая композиция функций. Чуть не забыл про тесты упомянуть – благодаря тому, что все тестовые интерпретаторы не работают с IO
– выполняются они довольно быстро: