[Перевод] Монады за 15 минут
Вступление
На конференции YOW! 2013 один из разработчиков языка Haskell, проф. Филип Вадлер, показал, как монады позволяют чистым функциональным языкам осуществлять императивные по сути операции, такие, как ввод-вывод и обработку исключений. Неудивительно, что интерес аудитории к этой теме породил взрывной рост публикаций о монадах в Интернет. К сожалению, бо́льшая часть этих публикаций использует примеры, написанные на функциональных языках, подразумевая, что о монадах хотят узнать новички в функциональном программировании. Но монады не специфичны для Haskell или функциональных языков, и вполне могут быть проиллюстрированы примерами на императивных языках программирования. Это и является целью данного руководства.
Чем это руководство отличается от остальных? Мы попытаемся не более чем за 15 минут «открыть» монады, используя лишь интуицию и несколько элементарных примеров кода на Python. Мы поэтому не станем теоретизировать и углубляться в философию, рассуждая о буррито, космических скафандрах, письменных столах и эндофункторах.
Мотивационные примеры
Мы рассмотрим три проблемы, относящиеся к композиции функций. Мы решим их двумя способами: обычным императивным и при помощи монад. Затем мы сравним разные подходы.
1. Логгирование
Допустим, у нас есть три унарные функции: f1
, f2
и f3
, принимающие число и возвращающие его увеличенным на 1, 2 и 3 соответственно. Мы хотели бы объединить их в цепочку для обработки параметра x
, иначе говоря, мы хотели бы вычислить x+1+2+3
. Кроме того, нам нужно получить человекочитаемое объяснение того, что было сделано каждой функцией.
Можно добиться нужного нам результата следующим способом:
log = "Ops:"
res, log1 = f1(x)
log += log1 + ";"
res, log2 = f2(res)
log += log2 + ";"
res, log3 = f3(res)
log += log3 + ";"
print(res, log)
Это решение неидеально, так как состоит из большого количества однообразного связующего кода. Если мы захотим добавить к нашей цепочке новую функцию, мы вынуждены будем повторить этот связующий код. Кроме того, манипуляции с переменными res
и log
ухудшают читаемость кода, мешая следить за основной логикой программы.
В идеале, программа должна выглядеть как простая цепочка функций, вроде f3(f2(f1(x)))
. К сожалению, типы данных, возвращаемых f1
и f2
, не соответствуют типам параметров f2
и f3
. Но мы можем добавить в цепочку новые функции:
def unit(x):
return (x, "Ops:")
def bind(t, f):
res = f(t[0])
return (res[0], t[1] + res[1] + ";")
Теперь мы можем решить проблему следующим образом:
print(bind(bind(bind(unit(x), f1), f2), f3))
Следующая диаграмма показывает вычислительный процесс, происходящий при x=0
. Здесь v1
, v2
и v3
− значения, полученные в результате вызовов unit
и bind
.
Функция unit
преобразует входной параметр x
в кортеж из числа и строки. Функция bind
вызывает функцию, переданную ей в качестве параметра, и накапливает результат в промежуточной переменной t
.
Мы смогли избежать повторений связующего кода, поместив его в функцию bind
. Теперь, если у нас появится функция f4
, мы просто включим её в цепочку:
bind(f4, bind(f3, ... ))
И никаких других изменений нам делать не понадобится.
2. Список промежуточных значений
Этот пример мы также начнём с простых унарных функций.
def f1(x): return x + 1
def f2(x): return x + 2
def f3(x): return x + 3
Как и в предыдущем примере, нам нужно скомпоновать эти функции, чтобы вычислить x+1+2+3
. Также нам нужно получить список всех значений, полученных в результате работы наших функций, то есть x
, x+1
, x+1+2
и x+1+2+3
.
В отличие от предыдущего примера, наши функции компонуемы, то есть типы их входных параметров совпадают с типом результата. Поэтому простая цепочка f3(f2(f1(x)))
вернёт нам конечный результат. Но в таком случае мы потеряем промежуточные значения.
Решим задачу «в лоб»:
lst = [x]
res = f1(x)
lst.append(res)
res = f2(res)
lst.append(res)
res = f3(res)
lst.append(res)
print(res, lst)
К сожалению, это решение также содержит много связующего кода. И если мы решим добавить f4
, нам опять придётся повторять этот код, чтобы получить верный список промежуточных значений.
Поэтому добавим две дополнительные функции, как и в предыдущем примере:
def unit(x):
return (x, [x])
def bind(t, f):
res = f(t[0])
return (res, t[1] + [res])
Теперь мы перепишем программу в виде цепочки вызовов:
print(bind(bind(bind(unit(x), f1), f2), f3))
Следующая диаграмма показывает вычислительный процесс, происходящий при x=0
. Снова v1
, v2
и v3
обозначают значения, полученные в результате вызовов unit
и bind
.
3. Пустые значения
Попробуем привести более интересный пример, на этот раз с классами и объектами. Допустим, у нас есть класс Employee
с двумя методами:
class Employee:
def get_boss(self):
# Return the employee's boss
def get_wage(self):
# Compute the wage
Каждый объект класса Employee
имеет руководителя (другой объект класса Employee
) и зарплату, доступ к которым осуществляется через соответствующие методы. Оба метода могут также возвращать None
(работник не имеет руководителя, зарплата неизвестна).
В этом примере мы создадим программу, которая показывает зарплату руководителя данного работника. Если руководитель не найден, или его зарплата не может быть определена, то программа вернёт None
.
В идеале нам нужно написать что-то вроде
print(john.get_boss().get_wage())
Но в таком случае, если какой-то из методов вернёт None
, наша программа завершится с ошибкой.
Очевидный способ учесть эту ситуацию может выглядеть так:
result = None
if john is not None and john.get_boss() is not None and john.get_boss().get_wage() is not None:
result = john.get_boss().get_wage()
print(result)
В этом случае мы допускаем лишние вызовы методов get_boss
и get_wage
. Если эти методы достаточно тяжелы (например, обращаются к базе данных), наше решение не годится. Поэтому изменим его:
result = None
if john is not None:
boss = john.get_boss()
if boss is not None:
wage = boss.get_wage()
if wage is not None:
result = wage
print(result)
Этот код оптимален в плане вычислений, но плохо читается за счёт трёх вложенных if
. Поэтому попробуем использовать тот же трюк, что и в предыдущих примерах. Определим две функции:
def unit(e):
return e
def bind(e, f):
return None if e is None else f(e)
И теперь мы можем скомпоновать всё решение в одну строку.
print(bind(bind(unit(john), Employee.get_boss), Employee.get_wage))
Как вы, наверное, уже заметили, в этом случае нам не обязательно было писать функцию unit
: она просто возвращает входной параметр. Но мы оставим её, чтобы нам легче было потом обобщить наш опыт.
Обратите внимание также на то, что в Python мы можем использовать методы как функции, что позволило нам написать Employee.get_boss(john)
вместо john.get_boss()
.
Следующая диаграмма показывает процесс вычислений в том случае, когда у Джона нет руководителя, то есть john.get_boss()
возвращает None
.
Выводы
Допустим, мы хотим объединить однотипные функции f1
, f2
, …
, fn
. Если их входные параметры совпадают по типу с результатами, мы можем использовать простую цепочку вида fn(… f2(f1(x)) …)
. Следующая диаграмма показывает обобщённый процесс вычислений с промежуточными результатами, обозначенными как v1
, v2
, …
, vn
.
Зачастую этот подход неприменим. Типы входных значений и результатов функций могут различаться, как в первом примере. Либо же функции могут быть компонуемы, но мы хотим вставить дополнительную логику между вызовами, как в примерах 2 и 3 мы вставляли аггрегацию промежуточных значений и проверку на пустое значение соответственно.
1. Императивное решение
Во всех примерах мы вначале использовали самый прямолинейный подход, который можно отобразить следующей диаграммой:
Перед вызовом f1
мы выполняли некоторую инициализацию. В первом примере мы инициализировали переменную для хранения общего лога, во втором − для списка промежуточных значений. После этого мы перемежали вызовы функций неким связующим кодом: вычисляли агрегатные значения, проверяли результат на None
.
2. Монады
Как мы видели на примерах, императивные решения всегда страдали многословностью, повторениями и запутанной логикой. Чтобы получить более элегантный код, мы использовали некий шаблон проектирования, согласно которому мы создавали две функции: unit
и bind
. Этот шаблон и называется монадой. Функция bind
содержит связующий код, в то время, как unit
осуществляет инициализацию. Это позволяет упростить итоговое решение до одной строки:
bind(bind( ... bind(bind(unit(x), f1), f2) ... fn-1), fn)
Процесс вычисления можно представить диаграммой:
Вызов unit(x)
генерирует начальное значение v1
. Затем bind(v1, f1)
генерирует новое промежуточное значение v2
, которое используется в следующем вызове bind(v2, f2)
. Этот процесс продолжается, пока не будет получен итоговый результат. Определяя различные unit
и bind
в рамках этого шаблона, мы можем объединять различные функции в одну цепочку вычислений. Библиотеки монад (например, PyMonad или OSlash, − прим. перев.) обычно содержат готовые к употреблению монады (пары функций unit
и bind
) для реализации определённых композиций функций.
Чтобы собрать функции в цепочку, значения, возвращаемые unit
и bind
, должны быть того же типа, что и входные параметры bind
. Этот тип называется монадическим. В терминах вышеприведённой диаграммы, тип переменных v1
, v2
, …
, vn
должен быть монадическим типом.
Наконец, рассмотрим, как можно улучшить код, написанный с использованием монад. Очевидно, повторяющиеся вызовы bind
выглядят неэлегантно. Чтобы избежать этого, определим ещё одну внешнюю функцию:
def pipeline(e, *functions):
for f in functions:
e = bind(e, f)
return e
Теперь вместо
bind(bind(bind(bind(unit(x), f1), f2), f3), f4)
мы можем использовать следующее сокращение:
pipeline(unit(x), f1, f2, f3, f4)
Заключение
Монады − это простой и мощный шаблон проектирования, используемый для композиции функций. В декларативных языках программирования он помогает реализовать такие императивные механизмы, как логгирование или ввод/вывод. В императивных языках
он помогает обобщить и сократить код, связывающий серию вызовов однотипных функций.
Эта статья даёт только поверхностное, интуитивное понимание монад. Вы можете узнать больше, обратившись к следующим источникам:
- Википедия
- Monads in Python (with nice syntax!)
- Monad tutorials timeline