Skip to content

Latest commit

 

History

History
457 lines (324 loc) · 35.4 KB

ch09-ru.md

File metadata and controls

457 lines (324 loc) · 35.4 KB

Глава 09: Монадические луковицы

Функторы и минимальный контекст

Прежде чем мы пойдем дальше, я должен признаться: я кое-что умолчал о методе of, который мы поместили в каждый из наших типов. На самом деле он нужен не для того, чтобы избежать использования ключевого слова new, а для того, чтобы поместить значения в так называемый минимальный контекст определённого функтора (чистый контекст, default minimal context). Да-да, of не имеет своей целью заменить конструктор — вместо этого он является частью важного интерфейса Pointed.

pointed функтор — это функтор, для которого реализована функция of — то есть операция помещения в минимальный контекст (метод это будет или функция — не существенно — прим. пер.).

Здесь важна возможность вбросить любое значение в наш тип и начать mapить.

IO.of('tetris').map(concat(' master'));
// IO('tetris master')

Maybe.of(1336).map(add(1));
// Maybe(1337)

Task.of([{ id: 2 }, { id: 3 }]).map(map(prop('id')));
// Task([2,3])

Either.of('The past, present and future walk into a bar...').map(concat('it was tense.'));
// Right('The past, present and future walk into a bar...it was tense.')

Если вы помните, конструкторы значений IO и Task ожидают в качестве аргумента функцию, но Maybe и Either этого не требуют. Назначение интерфейса Pointed — предоставить обобщённый и унифицированный способ поместить значение в какой-либо функтор, не требуя знания особенностей его конструкторов. Пусть термин «минимальный контекст» звучит не слишком точно, зато он хорошо отражает идею: мы хотели бы иметь возможность поднимать на уровень нужного типа любое произвольное значение, производить вычисления, применяя map, и получать предсказуемый и ожидаемый результат c любым функтором.

Сейчас я должен сделать важное замечание о реализации of. Например, реализовывать Left.of не имеет никакого смысла. У каждого pointed функтора должен быть единственный универсальный способ поместить значение внутрь него, и в случае с Either, of будет реализован как new Right(x). Мы определяем of через Right, чтобы к произведённому значению map действительно применял функции, ничего не пропуская. Из приведённых примеров нам должно быть интуитивно понятно, как будет работать of, а Left такое поведение обеспечить не сможет.

Вы могли слышать о таких функциях, как pure,point, unit и return. Так вот, все они являются синонимами of. Важность of вы оцените, когда мы начнём использовать монады, потому что нам придётся часто возвращать значение в контекст вручную.

Для того, чтобы обходиться без ключевого слова new, в JavaScript существует ряд стандартных трюков/инструментов, но ставить of в один ряд с ними мы не будем, потому что его предназначение отличается. Что же касается библиотечных реализаций функторов, то я рекомендую folktale, ramda и спецификацию fantasy-land, поскольку они предоставляют корректную реализацию of и конструкторы, которые не рассчитывают на new.

Стоит также обратить внимание на серьёзнейшую библиотеку sanctuary, разработанную David Chambers (активным контрибьютором ramda и fantasy-land), и библиотеку fp-ts. С ними можно извлечь максимальную пользу из идей, изложенных в данной книге — прим. пер.

Соединяем метафоры

onion

Видите ли, в дополнение к буррито (если до вас доходили слухи), монады похожи на лук. Позвольте продемонстрировать типовую ситуацию:

const fs = require('fs');

// readFile :: String -> IO String
const readFile = filename => new IO(() => fs.readFileSync(filename, 'utf-8'));

// print :: String -> IO String
const print = x => new IO(() => {
  console.log(x);
  return x;
});

// cat :: String -> IO (IO String)
const cat = compose(map(print), readFile);

cat('.git/config');
// IO(IO('[core]\nrepositoryformatversion = 0\n'))

Так у нас получилась IO, пойманная внутрь другой IO, поскольку функция print привнесла ещё один слой IO (что разумно, ведь это «эффектное» действие), когда применялась с map. И теперь, чтобы добраться до полученной строки, нам нужно делать map(map(f)), а чтобы выполнить эти эффекты, нам придётся сделать unsafePerformIO().unsafePerformIO().

// cat :: String -> IO (IO String)
const cat = compose(map(print), readFile);

// catFirstChar :: String -> IO (IO String)
const catFirstChar = compose(map(map(head)), cat);

catFirstChar('.git/config');
// IO(IO('['))

Хоть это и удобно — иметь два эффекта, упакованных и готовых к использованию, но сейчас это напоминает работу в двух костюмах, и API получается ужасно неудобным. Давайте рассмотрим ещё одну ситуацию:

// safeProp :: Key -> {Key: a} -> Maybe a
const safeProp = curry((x, obj) => Maybe.of(obj[x]));

// safeHead :: [a] -> Maybe a
const safeHead = safeProp(0);

// firstAddressStreet :: User -> Maybe (Maybe (Maybe Street))
const firstAddressStreet = compose(
  map(map(safeProp('street'))),
  map(safeHead),
  safeProp('addresses'),
);

firstAddressStreet({
  addresses: [{ street: { name: 'Mulburry', number: 8402 }, postcode: 'WC2N' }],
});
// Maybe(Maybe(Maybe({name: 'Mulburry', number: 8402})))

Мы снова наблюдаем эту ситуацию с вложенными функторами, в которой обрабатываются 3 различных причины неудачи, но было бы самонадеянно ожидать, что клиентский код будет применять map трижды, пользуясь результатом работы нашей функции. Такие ситуации будут складываться раз за разом, и именно они порождают для нас потребность в монадах.

Я заявил, что монады похожи на лук, потому что слёзы текут ручьём, когда мы снимаем очередной слой с вложенных друг в друга функторов, пытаясь выполнять свою работу с помощью map. Пора вытереть глаза, сделать глубокий вдох и воспользоваться join.

const mmo = Maybe.of(Maybe.of('nunchucks'));
// Maybe(Maybe('nunchucks'))

mmo.join();
// Maybe('nunchucks')

const ioio = IO.of(IO.of('pizza'));
// IO(IO('pizza'))

ioio.join();
// IO('pizza')

const ttt = Task.of(Task.of(Task.of('sewers')));
// Task(Task(Task('sewers')));

ttt.join();
// Task(Task('sewers'))

Если у нас есть два слоя одного типа, мы можем объединить их в один при помощи join. Эта способность соединяться вместе (такое «функторное бракосочетание») делает монаду монадой. Давайте сформулируем это чуть более аккуратно:

Монады — это pointed функторы, которые могут быть выровнены (flatten).

Любой функтор, для которого определены операции join и of, и который подчиняется нескольким законам, является монадой. Определение join совсем несложное. Давайте напишем его для Maybe:

Maybe.prototype.join = function join() {
  return this.isNothing() ? Maybe.of(null) : this.$value;
};

Если наш Maybe(Maybe(x)) представляет собой Just(Nothing) или Just(Just(x)), то мы возвращаем в качестве результата то, что содержится в .$value, чем бы оно ни было (оно будет либо Nothing, либо Just(x) соответственно). В противном случае, если поверхностный слой — это Nothing, то и объединять там нечего, и мы возвращаем его как есть.

Теперь, когда у нас есть join, давайте бросим щепотку волшебного монадного пороха в пример firstAddressStreet и посмотрим, что из этого выйдет:

// join :: Monad m => m (m a) -> m a
const join = mma => mma.join();

// firstAddressStreet :: User -> Maybe Street
const firstAddressStreet = compose(
  join,
  map(safeProp('street')),
  join,
  map(safeHead), safeProp('addresses'),
);

firstAddressStreet({
  addresses: [{ street: { name: 'Mulburry', number: 8402 }, postcode: 'WC2N' }],
});
// Maybe({name: 'Mulburry', number: 8402})

Мы добавили join везде, где нам встретились вложенные Maybe, чтобы вложенность не выходила из-под контроля. Давайте проделаем то же самое с IO, чтобы чувствовать себя уверенно.

IO.prototype.join = () => this.unsafePerformIO();

Как и в прошлый раз, мы просто снимаем один слой. Уверяю, мы не обращаем нашу функцию в нечистую, а просто удаляем один избыточный слой.

// log :: a -> IO a
const log = x => IO.of(() => {
  console.log(x);
  return x;
});

// setStyle :: Selector -> CSSProps -> IO DOM
const setStyle =
  curry((sel, props) => new IO(() => jQuery(sel).css(props)));

// getItem :: String -> IO String
const getItem = key => new IO(() => localStorage.getItem(key));

// applyPreferences :: String -> IO DOM
const applyPreferences = compose(
  join,
  map(setStyle('#main')),
  join,
  map(log),
  map(JSON.parse),
  getItem,
);

applyPreferences('preferences').unsafePerformIO();
// Object {backgroundColor: "green"}
// <div style="background-color: 'green'"/>

getItem возвращает IO String, и мы применяем map, чтобы распарсить результат. Функции log и setStyle тоже возвращают IO, поэтому мы должны делать join, чтобы держать вложенность под контролем.

Цепочки монадических вычислений

chain

Вы наверняка заметили повторяющийся приём: мы применяем join сразу после map. Давайте абстрагируем это в функцию chain.

// chain :: Monad m => (a -> m b) -> m a -> m b
const chain = curry((f, m) => m.map(f).join());

// или

// chain :: Monad m => (a -> m b) -> m a -> m b
const chain = f => compose(join, map(f));

Мы просто композируем map и join в одну функцию. Если ранее вам доводилось читать о монадах, то вы встречали chain под именем flatMap или >>= (читается как bind), которые являются псевдонимами для одного и того же понятия — «монадического связывания». Я считаю, что flatMap — это самое точное название, но мы будем придерживаться chain, поскольку такое название более распространено в JS. Давайте отрефакторим предыдущие примеры, используя chain:

// map/join
const firstAddressStreet = compose(
  join,
  map(safeProp('street')),
  join,
  map(safeHead),
  safeProp('addresses'),
);

// chain
const firstAddressStreet = compose(
  chain(safeProp('street')),
  chain(safeHead),
  safeProp('addresses'),
);

// map/join
const applyPreferences = compose(
  join,
  map(setStyle('#main')),
  join,
  map(log),
  map(JSON.parse),
  getItem,
);

// chain
const applyPreferences = compose(
  chain(setStyle('#main')),
  chain(log),
  map(JSON.parse),
  getItem,
);

Я заменил все сочетания map/join нашей новой функцией chain и тем самым навёл в коде порядок. Красивые строчки кода — это, конечно, хорошо, но chain — это нечто большее, чем кажется на первый взгляд. Это скорее торнадо, нежели вакуум. Поскольку chain легко вкладывает «эффектные» вычисления друг в друга, мы можем охватить такие понятия, как последовательное выполнение и присваивание значений в чистом функциональном стиле.

// getJSON :: Url -> Params -> Task JSON
getJSON('/authenticate', { username: 'stale', password: 'crackers' })
  .chain(user => getJSON('/friends', { user_id: user.id }));
// Task([{name: 'Seimith', id: 14}, {name: 'Ric', id: 39}]);

// querySelector :: Selector -> IO DOM
querySelector('input.username')
  .chain(({ value: uname }) => querySelector('input.email')
  .chain(({ value: email }) => IO.of(`Welcome ${uname} prepare for spam at ${email}`)));
// IO('Welcome Olivia prepare for spam at olivia@tremorcontrol.net');

Maybe.of(3)
  .chain(three => Maybe.of(2).map(add(three)));
// Maybe(5);

Maybe.of(null)
  .chain(safeProp('address'))
  .chain(safeProp('street'));
// Maybe(null);

Мы могли бы написать эти примеры с помощью compose, но нам потребовалось бы несколько вспомогательных функций, и это всё равно подтолкнуло бы нас к переприсваиванию переменных, добираясь до них через замыкание. Вместо этого мы используем chain, которая, кстати, может быть выведена из map и join для любого типа: t.prototype.chain = function(f) { return this.map(f).join(); }. Мы могли бы также написать chain вручную (например, если бы нам захотелось потешить себя иллюзией производительности). Но в таком случае нам придётся позаботиться о корректности своей реализации — она должна давать в точности такой же результат, как map и join. Интересный факт: мы можем «бесплатно» получить map, если у нас уже есть реализация chain, для этого нужно композировать применяемую функцию с of, чтобы поместить значение обратно в контекст. Также, имея chain, мы можем определить join как chain(id). Это может напоминать игру в Холдем с магом-иллюзионистом, будто я просто достаю все эти комбинации из-за спины, но в функциональном программировании, как и в математике, все основные конструкции взаимосвязаны. Множество таких операций, опирающихся друг на друга, описаны в fantasyland — общепризнанной спецификации алгебраических типов данных для JavaScript.

Давайте всё-таки обсудим вышеприведённые примеры. В первом примере два Task соединены в последовательность асинхронных действий — сначала получаем user, а затем запрашиваем его друзей (по идентификатору этого пользователя). Мы используем chain, чтобы не городить Task(Task([Friend])).

Далее мы используем querySelector, чтобы собрать необходимые исходные данные и составить из них приветствие. Обратите внимание: у нас есть доступ и к uname, и к email в самой вложенной функции — это функциональное назначение переменных во всей красе (и никаких присваиваний). Поскольку IO любезно предоставляет нам значение, то и новое значение мы должны вернуть в подобном обёрнутом виде — мы ведь не хотим нарушать доверие к IO (а заодно и к нашей программе). IO.of — идеальный инструмент для этого шага, вот почему Pointed является важной составляющей интерфейса Monad. Хотя на этом шаге мы могли бы выбрать map и получить на выходе значение нужного типа:

querySelector('input.username').chain(({ value: uname }) =>
  querySelector('input.email').map(({ value: email }) =>
    `Welcome ${uname} prepare for spam at ${email}`));
// IO('Welcome Olivia prepare for spam at olivia@tremorcontrol.net');

Ещё два примера используют Maybe. Поскольку chain реализован подобно map, вычисление «засохнет» и не будет продолжено, если на каком-то шаге встретится Nothing.

Не беспокойтесь, если эти примеры поначалу сложно понять. Поиграйте с ними. Ткните их палкой. Разберите их на части и соберите снова. Помните, что вам нужен map для применения некоторой функции, если она возвращает обычное значение, и chain, если она возвращает значение, обёрнутое в монаду того же типа. В следующей главе мы задействуем аппликативные функторы и рассмотрим полезные трюки, с которыми составлять и читать подобные выражения станет легко и приятно.

Напоминаю, что chain не работает с двумя разными функторами, вложенными друг в друга. Для этого существует композиция функторов и трансформеры монад (композиция рассматривается в этой книге, а трансформеры монад — нет. Изучить эту тему можно в языке Haskell, а потом, при желании, сделать что-то похожее в JS — прим. пер.).

В полную силу

Программирование с контейнерами иногда может сбивать с толку. Иногда бывает сложно понять, на какой глубине находится вложенное значение, или сделать выбор между map и chain (а скоро мы изучим ещё больше функций для работы с контейнерами). Мы можем значительно улучшить свою производительность за счёт подходящих инструментов отладки. Для этого мы реализуем inspect и узнаем, как организовать для себя подобие «стека», в который мы сможем бросать всё, что может пригодиться при отладке позднее. Хотя нередко разработчики обходятся без этого (потому что нет желания возиться).

Сейчас я взмахну огненным монадическим мечом и продемонстрирую силу такого стиля программирования.

Давайте прочитаем файл и сразу после этого загрузим его:

// readFile :: Filename -> Either String (Task Error String)
// httpPost :: String -> String -> Task Error JSON
// upload :: String -> Either String (Task Error JSON)
const upload = compose(map(chain(httpPost('/uploads'))), readFile);

Здесь мы разветвляем наш код несколько раз. Глядя на сигнатуры типов, я вижу, что мы защищаемся от 3 ошибок

  • readFile использует Either для проверки ввода (видимо, удостоверяясь, что имя файла было предоставлено).
  • readFile может завершиться с ошибкой при получении доступа к файлу, тогда это будет выражено типом Error
  • загрузка файла может быть прервана по любой причине, и это выражает тип Error в httpPost И при этом мы, как обычно, последовательно выполняем два вложенных асинхронных действия с помощью chain.

Всего этого мы добиваемся линейно, слева направо. Всё это чисто и декларативно, поддаётся эквациональным рассуждениям и обладает надёжными свойствами. Нам не приходится добавлять ненужные и запутанные имена переменных. Наша функция upload написана для универсальных интерфейсов, а не под чей-то конкретный одноразовый API. Одной строкой кода.

Для контраста давайте рассмотрим типичную императивную реализацию того же самого:

// upload :: String -> (String -> a) -> Void
const upload = (filename, callback) => {
  if (!filename) {
    throw new Error('You need a filename!');
  } else {
    readFile(filename, (errF, contents) => {
      if (errF) throw errF;
      httpPost('/uploads', contents, (errH, json) => {
        if (errH) throw errH;
        callback(json);
      });
    });
  }
};

Ну разве это не дьявольская арифметика? Какой-то пинбол в переменчивом лабиринте безумия. А теперь представьте, что это типичное реальное приложение, которое, помимо этой функции, мутирует переменные всюду. Мы бы действительно оказались в битумной яме.

Теория

Первый закон, который мы рассмотрим, это закон ассоциативности, хоть он, возможно, и выглядит непривычно.

// закон ассоциативности
compose(join, map(join)) === compose(join, join);

Эти законы нацелены на вложенную природу монад, поэтому ассоциативность проявляется в том, что порядок соединения слоёв — изнутри или снаружи — не имеет значения, и приводит к одному и тому же результату.

Обычно закон ассоциативности для монад записывается в другом виде, через chain и несколько функций. Но пусть это вас не слишком смущает, так как join — это chain(id) — прим. пер.

Схема будет нагляднее:

monad associativity law

Начиная с верхнего левого угла и двигаясь вниз, мы можем «соединить» сначала внешние два M из M(M(M a)), а затем перейти к желаемому M a, ещё раз применив join. Другой путь — соединить внутренние два M «под капотом» с помощью map(join). В итоге мы получаем один и тот же M a, независимо от того, соединяем ли мы сначала внутренние или внешние M, и в этом вся суть ассоциативности. Стоит отметить, что map(join) != join. Промежуточные шаги могут различаться по значению, но конечный результат последнего join будет одним и тем же.

Второй закон похож:

// закон идентичности для всех (M a)
compose(join, of) === compose(join, map(of)) === id;

Этот закон гласит, что для любой монады M последовательное применение of и join равны id. Мы также можем вооружиться map(of) и атаковать её изнутри. Некоторые называют этот закон «треугольной идентичностью», потому что он производит такую ​​форму при визуализации:

monad identity law

Если мы отправимся из левого верхнего угла направо, то увидим, что of помещает наш M a в другой контейнер M. Затем, если мы направимся вниз и соединим слои, то получим то же самое, как если бы сразу применили id. Двигаясь справа налево, мы видим, что если мы прокрадёмся изнутри с помощью map и применим of к a, мы всё равно получим M(M a), и join вернёт всё на круги своя.

Важно понимать, что of, о которой мы рассуждаем — это функция M.of, которая для каждой монады M реализована по-разному.

Постойте, где-то я уже видел эти законы идентичности и ассоциативности... Я думаю... Да, конечно! Это законы для категории. Значит, нам нужна функция композиции, чтобы завершить определение. Вот:

const mcompose = (f, g) => compose(chain(f), g);

// идентичность слева
mcompose(M.of, f) === f;

// идентичность справа
mcompose(f, M.of) === f;

// ассоциативность
mcompose(mcompose(f, g), h) === mcompose(f, mcompose(g, h));

В конце концов, они являются законами категории. Монады образуют категорию, называемую «категорией Клейсли», в которой объекты — это монады, а морфизмы — монадические функции. Я не ставлю перед собой цель посмеяться над вами с помощью теории категорий без объяснения того, как кусочки головоломки сочетаются между собой. Моё намерение состоит в том, чтобы в достаточной степени поцарапать поверхность, показать релевантность и пробудить интерес, при этом не отрываться практических свойств, которыми мы с вами пользуемся ежедневно.

Идентичностью этот закон называется потому, что подчиняющиеся ему функции of не должны делать ничего, кроме помещения значения в минимальный контекст монады, то есть, вести себя как тождественный морфизм в категории Клейсли, ведь chain(of) — это id — прим. пер.

Итог

Монады позволяют нам углубляться во вложенные вычисления. Мы можем назначать переменные, запускать последовательные эффекты, выполнять асинхронные задачи, и все это — не устраивая callback hell. Они приходят на помощь, когда значение оказывается заточённым в нескольких однотипных слоях. Благодаря верному помощнику «Pointed» монады могут предоставлять нам значение без упаковки и знают, что мы сможем сами завернуть его обратно, когда закончим свои дела.

Да, монады очень мощные, но нам потребуются ещё некоторые дополнительные функции для полноценной работы с контейнерами. Например, что если мы захотим запустить множество вызовов API за один раз, а затем собрать результаты? Мы можем выполнить эту задачу с помощью монад, но нам придётся ждать завершения каждого вызова, прежде чем перейти к следующему. А как насчёт объединения результатов нескольких валидаций? Если бы мы захотели продолжить проверку, чтобы собрать все возникшие ошибки, а монады остановили бы шоу после первого Left?

В следующей главе мы увидим, какое место аппликативные функторы занимают в мире контейнеров, и почему во многих случаях мы предпочитаем их монадам.

Глава 10: Аппликативные функторы

Упражнения

Используя следующий объект пользователя:

const user = {  
  id: 1,  
  name: 'Albert',  
  address: {  
    street: {  
      number: 22,  
      name: 'Walnut St',  
    },  
  },  
};  

Упражнение A

Используйте safeProp и map/join или chain, чтобы безопасно получить название улицы из данных о пользователе.

// getStreetName :: User -> Maybe String  
const getStreetName = undefined;  

Упражнение B

Используя следующие вспомогательные функции:

// getFile :: () -> IO String
const getFile = () => IO.of('/home/mostly-adequate/ch09.md');

// pureLog :: String -> IO ()
const pureLog = str => new IO(() => console.log(str));

Используйте getFile, чтобы получить путь к файлу, удалить из него директорию и оставить только имя файла, а затем просто залогировать его. Подсказка: вы можете использовать split и last для получения имени из пути к файлу.

// logFilename :: IO ()  
const logFilename = undefined;

Упражнение C

В этом упражнении у нас будут вспомогательные функции с такими сигнатурами:

// validateEmail :: Email -> Either String Email
// addToMailingList :: Email -> IO([Email])
// emailBlast :: [Email] -> IO ()

Используйте validateEmail, addToMailingList и emailBlast, чтобы создать функцию, которая добавляет новый адрес в список рассылки, если он действителен, и затем уведомляет весь список.

// joinMailingList :: Email -> Either String (IO ())  
const joinMailingList = undefined;