Пусть у нас есть задача: функция принимает целое число и делит его на 2. Если результат нечётный, функция не возвращает значения. Если результат чётный, она делит его на 2 ещё раз, после чего умножает на 10, если новый результат чётный, и на 5, если нечётный. Сначала напишем такую функцию на TypeScript.
function processNumber(value: number): number | undefined {
const halved = Math.floor(value / 2);
if (halved % 2 !== 0) {
return undefined;
}
const halvedAgain = halved / 2;
return halvedAgain % 2 === 0
? halvedAgain * 10
: halvedAgain * 5;
}Теперь перепишем этот код на Haskell в лоб. Сразу скажу, что на Haskell так не делают.
processNumber :: Int -> Maybe Int
processNumber x =
let halved = x `div` 2
in case even halved of
False -> Nothing
True ->
let halvedAgain = halved `div` 2
in case even halvedAgain of
True -> Just (halvedAgain * 10)
False -> Just (halvedAgain * 5)В приведённом выше коде явно плохо смотрится вложенность. В TypeScript её нет, но, чтобы понять, что возвращает функция, приходится внимательно следить за return. При этом вложенность однозначно показывает направление вычислений. В императивном языке оно и так понятно: код выполняется сверху вниз. Давайте перепишем код, разделив вычисление на две функции:
halveEven :: Int -> Maybe Int
halveEven x =
let halved = x `div` 2
in case even halved of
False -> Nothing
True -> Just halved
halveAndScale :: Int -> Maybe Int
halveAndScale x =
let halved = x `div` 2
in case even halved of
True -> Just (halved * 10)
False -> Just (halved * 5)
processNumber :: Int -> Maybe Int
processNumber x =
case halveEven x of
Nothing -> Nothing
Just halved -> halveAndScale halvedКажется, стало только хуже, но на самом деле это не так. В Haskell для этого уже есть готовый инструмент — класс Monad с его оператором связывания (bind):
class Applicative m => Monad m where
(>>=) :: m a -> (a -> m b) -> m b
processNumber :: Int -> Maybe Int
processNumber x = pure x >>= halveEven >>= halveAndScaleКрасиво? То-то же. Существует ещё так называемая do-нотация. Вот тот же пример с её использованием:
processNumber :: Int -> Maybe Int
processNumber x = do
halved <- halveEven x
result <- halveAndScale halved
pure resultНичего не напоминает? Запись действительно похожа на программу на императивном языке, хотя её семантика остаётся монадической. Монада позволяет явно управлять последовательностью зависимых вычислений — в этом её главная особенность. И делает она это красиво.
Аналогично функторам, у монад есть свои законы, соблюдение которых ложится на плечи программиста:
pure a >>= f == f a -- левая единица
m >>= pure == m -- правая единица
(m >>= f) >>= g == m >>= (\x -> f x >>= g) -- ассоциативностьС оператором связывания разобрались. Теперь посмотрим, какие монады встречаются в реальном коде. Почти у каждого привычного «императивного» приёма — исключений, общего конфига, лога, изменяемого состояния или ввода-вывода — есть чистый монадный двойник.
| Монада | Контекст / эффект | Императивный аналог |
|---|---|---|
| значения может не быть | null + ранний выход | |
| ошибка с причиной | исключения | |
| недетерминизм: ноль или несколько результатов | вложенные циклы | |
| общее окружение только на чтение | конфиг | |
| накопление лога | логирование | |
| изменяемое состояние | переменные | |
| внешний мир | системные вызовы: файлы, сеть, консоль |
На первый взгляд эти монады решают совсем разные задачи. Но Reader, Writer и State устроены очень похоже — это обёртки над функциями или значениями с дополнительным контекстом:
newtype Reader r a = Reader (r -> a) -- окружение -> значение
newtype Writer w a = Writer (a, w) -- значение + лог
newtype State s a = State (s -> (a, s)) -- старое состояние -> (значение, новое)Оператор связывания монады State сам протаскивает состояние сквозь цепочку — вручную писать больше не нужно. Примитивы: , .
Любая исполняемая программа на Haskell работает внутри монады IO: её точка входа имеет тип , а всё взаимодействие с внешним миром выполняется как IO-действия. Значение описывает действие, которое при выполнении вернёт . Из IO нельзя просто извлечь чистое значение: функции не существует.
Итак, у всех этих монад один интерфейс — поэтому один и тот же do-код работает с разными эффектами. При этом Haskell остаётся чистым: эффекты не выполняются незаметно, а явно отражаются в типах и связываются в контролируемую последовательность вычислений.