Skip to content

Latest commit

 

History

History
782 lines (522 loc) · 70.6 KB

ch08-ru.md

File metadata and controls

782 lines (522 loc) · 70.6 KB

Глава 08: Контейнеры

Могучий контейнер

http://blog.dwinegar.com/2011/06/another-jar.html

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

Для начала мы создадим контейнер. Этот контейнер должен быть универсальным и позволять содержать любой тип значения (потому что, к примеру, контейнер для пудинга из тапиоки пригодится лишь изредка). Это будет объект, но мы не станем наделять его свойствами и методами в том смысле, который стоит за свойствами и методами в ООП. Вместо этого, мы будем относиться к нему, как к шкатулке с сокровищами — специальной коробке, которая хранит наши ценные данные.

class Container {
  constructor(x) {
    this.$value = x;
  }
  
  static of(x) {
    return new Container(x);
  }
}

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

Давайте испытаем получившуюся банку...

Container.of(3);
// Container(3)

Container.of('hotdogs');
// Container("hotdogs")

Container.of(Container.of({ name: 'yoda' }));
// Container(Container({ name: 'yoda' }))

Если вы используете node, вы увидите {$value: x}, хотя мы объявляли Container(x). Chrome отобразит название правильно, но это не важно для нас; пока мы понимаем, что из себя представляет «Контейнер», всё будет в порядке. В некоторых средах вы можете переопределить метод inspect, если хотите, но мы не будем сейчас настолько дотошными. В этой книге мы будем обозначать возвращаемое значение условно, как если бы мы на самом деле переопределили inspect, потому что Container(x) — гораздо более наглядно, чем {$value: x}, и полезно с педагогической точки зрения.

Прежде чем продолжить, давайте проясним несколько моментов:

  • Container — это объект с одним полем. Наш контейнер, как и множество других, содержит только одну вещь, но это ограничение характерно не для всех контейнеров. Мы произвольно назвали это свойство $value.

  • тип $value не должен быть ограничен одним конкретным типом, иначе наш Container утратит универсальность и вряд ли будет соответствовать названию.

  • будучи помещённым в «Контейнер», значение остаётся там насовсем. Мы могли бы получать доступ к ним, используя .$value, но такая практика только отдаляет нас от целей, ради которых создаются контейнеры.

Причины, по которым мы устанавливаем для контейнеров именно такие условия, скоро прояснятся — потерпите немного.

Мой первый функтор

Раз мы поместили значение (каким бы оно ни было) в контейнер, нам потребуется способ применения функций к нему.

// (a -> b) -> Container a -> Container b
Container.prototype.map = function (f) {
  return Container.of(f(this.$value));
};

Выглядит подобно функции map, определённой для массивов, только вместо [a] наша map определена для Container a. Работает так же:

Container.of(2).map(two => two + 2); 
// Container(4)

Container.of('flamethrowers').map(s => s.toUpperCase()); 
// Container('FLAMETHROWERS')

Container.of('bombs').map(append(' away')).map(prop('length')); 
// Container(10)

Стоит отметить важную вещь: мы можем работать со значением, не извлекая его из контейнера. Посредством map значение передаётся в функцию-отображение, что позволяет нам повозиться с ним; и сразу же возвращается на своё место, в Container. А поскольку в результате мы снова получаем Container (значения не покидают его просто так), мы снова можем использовать map, чтобы продолжить применять функции. Можем также по мере продолжения изменить тип значения, как демонстрирует третий пример.

Подождите-ка — если мы продолжаем применять map, выходит, что у нас получилась композиция! Что это ещё за математическая магия? Что ж, мы только что открыли для себя Функторы.

Функтор — это класс типов, для которых определена map и выполняются некоторые законы

(То есть, функтор — это не какой-то конкретный тип, но конкретный тип может являться функтором при выполнении условий. За выполнением законов для каждой конкретной реализации map должен проследить её разработчик, они сами себя не выполнят — прим. пер.).

Да, Functor — это нечто вроде интерфейса с контрактом, мы легко могли бы назвать его Mappable, но какой в этом fun?. Функторы определяются в теории категорий, и мы познакомимся с их математическим смыслом ближе к концу этой главы, а сейчас давайте сделаем так, чтобы использование этого интерфейса с причудливым названием стало для нас интуитивно понятным.

Какая причина должна заставить нас запрятать значение в контейнер, чтобы потом использовать map, добираясь до него? Ответ прояснится, если мы подберём более подходящий вопрос: что мы получим, если станем просить наш контейнер применять функции за нас? Получим абстракцию применения функции. Когда мы используем map, мы просим контейнер применить функцию-отображение к значению (и сделать это так, как определено для конкретного вида контейнера). На самом деле, это очень важная концепция.

Maybe Шрёдингера

cool cat, need reference

Функтор Container — довольно скучный. На самом деле, обычно он называется Identity и у него примерно та же роль, что и у функции id (и снова, между ними существует математическая связь, которую мы рассмотрим в своё время). Однако существуют и другие функторы (типы, подобные контейнеру), которые реализуют map, предоставляющую какое-либо особенное поведение при отображении (и, разумеется, также соблюдающую законы). Давайте определим такой прямо сейчас.

Более полная реализация приведена в Приложении B

class Maybe {
  static of(x) {
    return new Maybe(x);
  }

  get isNothing() {
    return this.$value === null || this.$value === undefined;
  }

  constructor(x) {
    this.$value = x;
  }

  map(fn) {
    return this.isNothing ? this : Maybe.of(fn(this.$value));
  }

  inspect() {
    return this.isNothing ? 'Nothing' : `Just(${inspect(this.$value)})`;
  }
}

Maybe похож на Container, к которому добавлена особенность: перед тем, как применить функцию к значению, map проверит его наличие. Такое поведение позволяет избежать раздражающих неудобств в случаях, когда вместо ожидаемого значения функция применяется к null или undefined (обратите внимание: реализация упрощена для того, чтобы сохранить объяснение простым).

Maybe.of('Malkovich Malkovich').map(match(/a/ig));
// Just(True)

Maybe.of(null).map(match(/a/ig));
// Nothing

Maybe.of({ name: 'Boris' }).map(prop('age')).map(add(10));
// Nothing

Maybe.of({ name: 'Dinah', age: 14 }).map(prop('age')).map(add(10));
// Just(24)

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

Обращение к map через . не делает код менее функциональным, и в нём нет ничего предосудительного. Однако по причинам, которые перечислены в первой части книги, нам стоит продолжить придерживаться бесточечного стиля. К тому же, имеющаяся у нас 'map' готова делегировать применение функций любому функтору, с которым будет иметь дело:

// map :: Functor f => (a -> b) -> f a -> f b
const map = curry((f, anyFunctor) => anyFunctor.map(f));

Замечательно! Мы можем продолжать использовать композицию, и map будет работать ожидаемо. Это справедливо и для map из библиотеки ramda. Мы будем использовать . для случаев, где это способствует пониманию, и бесточечный стиль там, где это удобно. Кстати, вы заметили? Я ввёл новые обозначения в сигнатуру типа. Functor f => говорит нам, что f должен быть функтором. Это несложно, но об этом стоило упомянуть.

Примеры использования

На практике мы обычно будем встречать Maybe в таких функциях, для которых вычисление результата возможно не всегда.

// safeHead :: [a] -> Maybe(a)
const safeHead = xs => Maybe.of(xs[0]);

// streetName :: Object -> Maybe String
const streetName = compose(map(prop('street')), safeHead, prop('addresses'));

streetName({ addresses: [] });
// Nothing

streetName({ addresses: [{ street: 'Shady Ln.', number: 4201 }] });
// Just('Shady Ln.')

safeHead подобна знакомой нам head, но безопасная на уровне типов. Любопытная вещь происходит, когда в коде используется Maybe — ведь, так или иначе, в отдельных случаях мы будем вынуждены сталкиваться с неудобными значениями null и undefined (которые по замыслу должны обозначать неопределённость, но приводят к поломкам, потому что используются неожиданно и неочевидно). Но функция safeHead честна с нами в отношении такой неопределённости, и она возвращает Maybe, чтобы мы тоже принимали неопределённость во внимание. При этом мы не просто информированы — нам придётся использовать map, чтобы продолжать работать со значением, пока оно скрыто за Maybe. В данном случае ответственность за проверку на null возложено на саму функцию safeHead, и мы можем не возлагать ответственность за проверку значений head в остальных частях приложения (что избыточно и противоречит принципу единственной ответственности). Такая организация кода в приложении — это как переход от хлипкой архитектуры бумаги и скрепок к дереву и гвоздям. Это гарантирует большую надёжность.

В других случаях функция может специально вернуть Nothing для того, чтобы сообщить о некорректном результате, несмотря на то, что технически его возможно вычислить.

// withdraw :: Number -> Account -> Maybe(Account)
const withdraw = curry((amount, { balance }) =>
  Maybe.of(balance >= amount ? { balance: balance - amount } : null));

// Это "гипотетическая" функция... Мы не будем определять её реализацию — просто представим, что она есть.
// updateLedger :: Account -> Account 
const updateLedger = account => account;

// remainingBalance :: Account -> String
const remainingBalance = ({ balance }) => `Your balance is $${balance}`;

// finishTransaction :: Account -> String
const finishTransaction = compose(remainingBalance, updateLedger);


// getTwenty :: Account -> Maybe(String)
const getTwenty = compose(map(finishTransaction), withdraw(20));

getTwenty({ balance: 200.00 }); 
// Just('Your balance is $180')

getTwenty({ balance: 10.00 });
// Nothing

withdraw откажется проводить списание и вернёт Nothing, если у нас окажется недостаточно денег. Эта функция тоже явно сообщает о том, что не каждое её применение может произвести нужный нам результат, и не оставляет нам другого выбора, кроме как mapить то, что она вернёт. Но, в отличие от предыдущего примера, мы возвращаем Nothing преднамеренно (используя null только для того, чтобы произвести такое значение), что останавливает наше приложение от совершения последующих действий. Важно отметить: если withdraw не приведёт к успешному результату, то map не станет выполнять ни одного последующего действия, а именно — finishTransaction. Это именно то поведение, которое нам нужно, поскольку мы предпочли бы не обновлять баланс счёта и не показывать новый остаток, если не получилось списать нужный объём средств.

Извлечение значения

Наше представление осталось бы неполным, если бы мы упустили тот факт, что нельзя просто так взять и «вернуть» что-либо из программы – нужно использовать некоторую функцию, которая передаст миру результат работы программы. Функцию, которая либо куда-то отправит получившееся значение в формате JSON, либо отобразит его на экране, либо сделает изменение в файловой системе или ещё что-нибудь «эффектное».

Можно сформулировать это как дзен-буддистский коан: «Если программа не имеет видимого эффекта, выполняется ли она вообще?». И, даже если так – выходит, она работает корректно только ради своего собственного удовлетворения? В этом случае есть подозрение, что на самом деле она отрабатывает пару тактов и снова отправляется спать...

Работа нашего приложения состоит в том, чтобы принимать, преобразовывать и перекладывать данные до тех пор, пока не наступит момент, когда нужно предъявить результат. И все преобразования можно проделывать при помощи map без необходимости извлекать значения из контейнера. Более того, пытаться грязными хаками извлечь значение из Maybe — распространённая ошибка (например, пытаясь добраться до соответствующего поля в объекте), Делать так – значит полагать, что возможное значение внутри внезапно материализовалось, и «всё было прощено». Это ошибочно, потому что значения может не оказаться вовсе. С того момента, как значение оказалось внутри Maybe, для всего последующего кода, как для кота Шрёдингера, существует 2 реальности одновременно. Если мы поддерживаем этот контекст до самого конца, то можем описывать нашу программу линейно, несмотря на логическое ветвление.

Тем не менее, мы сможем совершенно корректно извлекать значение, если в явном виде опишем продолжение программы для каждого из возможных значений в типе Maybe a: для Just a и Nothing (здесь – при помощи вспомогательной функции maybe).

// maybe :: b -> (a -> b) -> Maybe a -> b
const maybe = curry((v, f, m) => {
  if (m.isNothing) {
    return v;
  }

  return f(m.$value);
});

// getTwenty :: Account -> String
const getTwenty = compose(maybe('You\'re broke!', finishTransaction), withdraw(20));

getTwenty({ balance: 200.00 }); 
// 'Your balance is $180.00'

getTwenty({ balance: 10.00 }); 
// 'You\'re broke!'

Теперь мы либо вернём статическое значение (того же типа, что возвращает finishTransaction), либо успешно получим результат выполнения транзакции за пределами Maybe. Очевидно, что аналогом функции maybe в императивном стиле будет if/else, в то время как императивным аналогом функции map будет if (x !== null) { return f(x) }.

Работа с Maybe поначалу может ощущаться дискомфортной. Пользователи Swift и Scala поймут, о чём я – в этих языках Maybe повсеместно используется в стандартной библиотеке и носит названия Optional и Option, и компилятор заставляет выполнять проверку всегда (зачастую при том, что мы абсолютно уверены в наличии значения). Неудивительно, что большая часть людей находит это трудоёмким. Но постепенно это станет естественным, и тогда вы начнёте высоко ценить эту строгость. В конце концов, в большинстве случаев она будет нас спасать.

Не стремиться исключить ошибки – значит заниматься разработкой ненадёжного ПО. Это как старательно разукрашивать яйца, а затем швырнуть их на дорогу; это как строить дом престарелых из материалов, которыми не воспользовались бы даже три поросёнка. Надёжность всегда пойдёт на пользу нашим функциям, и Maybe даёт нам именно её.

Было бы упущением с моей стороны не упомянуть, что «настоящая» имплементация подразумевает, что Maybe представлен двумя частными случаями: один для содержания какого-то значения, а другой — для отсутствия (так, существует 2 способа произвести значение Maybe, т.е. у него есть 2 конструктора значений). Такая реализация позволяет реализовать функцию map параметрически полиморфной (как того требует функтор), и значения null и undefined могут быть возвращены наравне со всеми остальными. Вам чаще будет встречаться Maybe, который представлен значениями вроде Some(x) / None или Just(x) / Nothing, нежели такой Maybe, который проверяет на null помещаемое в него значение.

Чистая обработка ошибок

pick a hand... need a reference

Возможно, это вас шокирует, но конструкция throw/catch не является чистой. Когда возбуждается исключение, вызванная функция больше не сможет вернуть никакого значения (а любая чистая функция должна по определению). Вместо этого она поднимает тревогу и защищается от нас. С новым помощником Either наши функции смогут делать нечто лучшее, чем объявлять войну некорректным аргументам – вместо этого они смогут отвечать вежливым отказом. Давайте рассмотрим пример:

Полная реализация Either приведена в Приложении B

class Either {
  static of(x) {
    return new Right(x);
  }

  constructor(x) {
    this.$value = x;
  }
}

class Left extends Either {
  map(f) {
    return this;
  }

  inspect() {
    return `Left(${inspect(this.$value)})`;
  }
}

class Right extends Either {
  map(f) {
    return Either.of(f(this.$value));
  }

  inspect() {
    return `Right(${inspect(this.$value)})`;
  }
}

const left = x => new Left(x);

Left и Right – это частные случаи некоторого абстрактного типа Either (то, что данная реализация выглядит как наследование классов, не имеет значения, как не имеет значения и то, что мы опустили церемонию обращения к конструктору суперкласса – на идею наследования наши рассуждения опираться не будут). Давайте рассмотрим, как работают Left и Right:

Either.of('rain').map(str => `b${str}`); 
// Right('brain')

left('rain').map(str => `It's gonna ${str}, better bring your umbrella!`); 
// Left('rain')

Either.of({ host: 'localhost', port: 80 }).map(prop('host'));
// Right('localhost')

left('rolls eyes...').map(prop('host'));
// Left('rolls eyes...')

Left ведёт себя как подросток, он игнорирует наши попытки заставить его поработать с map. А Right будет работать в точности, как Container (a.k.a Identity). Сила состоит в возможности поместить сообщение об ошибке внутрь Left.

Предположим, у нас есть функция, для которой возможно неуспешное вычисление. Например, функция, вычисляющая возраст человека по дате рождения. Мы могли бы использовать Nothing для того, чтобы сообщить о неуспешном исходе и тем самым подтолкнуть к обработке этого случая в нашей программе, однако это окажется неинформативным, если для неудачи существует несколько возможных причин. Не исключено, что нам может потребоваться знать точную причину. Давайте реализуем такую возможность при помощи Either.

const moment = require('moment');

// getAge :: Date -> User -> Either(String, Number)
const getAge = curry((now, user) => {
  const birthDate = moment(user.birthDate, 'YYYY-MM-DD');

  return birthDate.isValid()
    ? Either.of(now.diff(birthDate, 'years'))
    : left('Birth date could not be parsed');
});

getAge(moment(), { birthDate: '2005-12-12' });
// Right(9)

getAge(moment(), { birthDate: 'July 4, 2001' });
// Left('Birth date could not be parsed')

Как и в случае с Nothing, мы останавливаем дальнейшую работу, когда возвращаем Left. Разница в том, что сейчас у нас есть подсказка о причине остановки вычисления. Обратите внимание на тип возвращаемого значения – Either(String, Number) – он содержит информацию о типах для частных случаев, которыми он может быть представлен: String для Left и Number для Right. Такая сигнатура довольно неформальна, поскольку мы условились не рассуждать в терминах наследования, но мы многое можем из неё понять. Она говорит нам о том, что в результате мы получим либо строку с сообщением об ошибке, либо число, означающее возраст.

// fortune :: Number -> String
const fortune = compose(concat('If you survive, you will be '), toString, add(1));

// zoltar :: User -> Either(String, _)
const zoltar = compose(map(console.log), map(fortune), getAge(moment()));

zoltar({ birthDate: '2005-12-12' });
// 'If you survive, you will be 10'
// Right(undefined)

zoltar({ birthDate: 'balloons!' });
// Left('Birth date could not be parsed')

В случае, когда поле birthDate содержит валидную дату, программа выведет нам своё «предсказание» о возрасте через год. В противном случае нам достанется контейнер Left, в котором в неизменном виде будет содержаться сообщение об ошибке парсинга. Это подобно «бросанию» ошибок, только осуществляется спокойно, без истерик.

В этом примере мы разделяем поток управления в зависимости от валидности даты, но такой код читается легко, потому что написан последовательно, как будто ветвлений нет, и нам не приходится карабкаться взглядом по фигурным скобкам условных выражений. Обычно мы бы вынесли console.log за пределы функции zoltar и выводили бы результат в каком-то другом месте, но сейчас это помогает нам наблюдать отличие варианта Right. Мы используем _ в сигнатуре типа Right для того, чтобы обозначить, что значение после console.log должно быть проигнорировано (В некоторых браузерах может потребоваться установить контекст для метода log, чтобы использовать его как функцию первого класса: console.log.bind(console)).

Пользуясь случаем, я хотел бы обратить ваше внимание на то, что fortune, несмотря на использование Either, совершенно ничего не знает о функторах и map. То же можно отметить и о finishTransaction из предыдущего примера. Можно сказать, что map делает из любой функции такую, которая может работать с функторами. Это называется поднятием (lifting) (это утверждение несколько неточное, но в последующих главах тема будет раскрыта полнее – прим. пер.). Как правило, лучше определять функции для обыкновенных типов данных, а затем map позаботится о том, чтобы они работали с нужным контейнером. Тогда функции останутся простыми, их будет легче повторно использовать и подстраивать под ситуацию.

Either одинаково хорошо подходит для обработки ошибок разного рода – начиная от валидаций и заканчивая критическими ошибками, после наступления которых приложение должно прекратить свою работу (например, из-за отсутствия нужных файлов или неисправностей с сетевым соединением). В качестве упражнения вы можете переделать примеры с Maybe на Either, чтобы предоставлять больше информации.

Я хотел бы исправить то, что оказал Either медвежью услугу, когда представил его как контейнер для сообщений об ошибках. На самом деле, Either позволяет выражать дизъюнкцию на типах (иначе говоря – логическое «или», ||). Он также выражает идею копродукта (Coproduct) из теории категорий. Это канонический тип-сумма (sum-type, disjoint union of sets): он носит такое название потому, что его мощность равна сумме мощностей типов, которые представляют собой его частные случаи (то есть, количество возможных значений типа Either A B – это количество возможных значений, произведённых конструкторами Left A и Right B, это количество возможных значений типа A + количество возможных значений типа B – прим. пер.). Подробности не будут рассматриваться в данной книге, но об этом определённо стоит почитать и ознакомиться со свойствами таких типов – вот великолепная статья. Запомнить стоит то, что Either может быть использован множеством способов, но как функтор он используется для обработки ошибок.

Как и в случае с Maybe, у нас есть функция either, которая ведёт себя схожим образом, но принимает 2 функции вместо одной. Тип возвращаемых значений у этих функций должен совпадать:

// either :: (a -> c) -> (b -> c) -> Either a b -> c
const either = curry((f, g, e) => {
  let result;

  switch (e.constructor) {
    case Left:
      result = f(e.$value);
      break;

    case Right:
      result = g(e.$value);
      break;

    // No Default
  }

  return result;
});

// zoltar :: User -> _
const zoltar = compose(console.log, either(id, fortune), getAge(moment()));

zoltar({ birthDate: '2005-12-12' });
// 'If you survive, you will be 10'
// undefined

zoltar({ birthDate: 'balloons!' });
// 'Birth date could not be parsed'
// undefined

Наконец-то нам пригодилась эта загадочная функция id. Она без изменений отдаст значение, которое может содержаться в Left, и оно будет передано в console.log. Мы сделали наше приложение для гадания более надёжным, начиная с getAge. Далее мы либо раскрываем пользователю страшную правду об ошибке, либо продолжаем ожидаемое от нас «гадание». Теперь мы готовы перейти к освоению совершенно другой разновидности функторов.

Эффект домино

dominoes.. need a reference

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

// getFromStorage :: String -> (_ -> String)
const getFromStorage = key => () => localStorage[key];

Если не поместить внутренности getFromStorage в отдельную функцию, её возвращаемое будет зависеть от внешнего окружения. А если надёжно завернуть эффект, мы будем всегда получать одинаковое возвращаемое значение при одинаковых аргументах – мы будем получать функцию, которая, будучи вызванной, будет возвращать определённый элемент из localStorage. И вот так просто (ну, может, ещё с парой молитв) мы очистили свою совесть, и всё было прощено.

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

class IO {
  static of(x) {
    return new IO(() => x);
  }

  constructor(fn) {
    this.$value = fn;
  }

  map(fn) {
    return new IO(compose(fn, this.$value));
  }

  inspect() {
    return `IO(${inspect(this.$value)})`;
  }
}

IO отличается от функторов, с которыми мы сталкивались ранее. Мы не рассматриваем $value как функцию, потому что это является деталью реализации, и для нас будет лучше вообще забыть об этом поле. Суть происходящего, как и в случае getFromStorage, в том, что IO откладывает выполнение нечистого действия, упаковывая его в функцию. Таким образом, мы рассматриваем IO как нечто, что содержит в себе результат выполнения нечистого действия, а не как саму упаковку. Это ясно из реализации функции of: мы конструируем значение типа IO(x), а использование функции в IO(() => x) – это просто деталь реализации, цель которой – отложить вычисление x.

Обратите внимание: для того, чтобы упростить чтение примеров кода, мы будем обозначать гипотетическое значение так, как будто оно содержится внутри IO. Но на практике вы никогда не можете знать заранее, каким будет значение. Это возможно только после того, как произойдёт взаимодействие с внешним окружением (т.е. после того, как вы позволите всем эффектам выполниться).

Рассмотрим IO в действии:

// ioWindow :: IO Window
const ioWindow = new IO(() => window);

ioWindow.map(win => win.innerWidth);
// IO(1430)

ioWindow
  .map(prop('location'))
  .map(prop('href'))
  .map(split('/'));
// IO(['http:', '', 'localhost:8000', 'blog', 'posts'])


// $ :: String -> IO [DOM]
const $ = selector => new IO(() => document.querySelectorAll(selector));

$('#myDiv').map(head).map(div => div.innerHTML);
// IO('I am some inner html')

Здесь ioWindow – это значение типа IO, к которому мы можем применять map, в то время как $ – это функция, которая вернёт IO только тогда, когда будет вызвана. Значения в комментариях указаны условно, чтобы передать смысл происходящего с IO, но в действительности все значения будут иметь вид { $value: [Function] }. Когда мы применяем map(f) к IO, мы добавляем функцию f в конец композиции, и эта композиция встаёт на место $value в новом значении IO. То есть, функции, применяемые с помощью map, не вызываются сразу, а добавляются в конец композиции, которую мы формируем шаг за шагом, как будто тщательно выстраивая последовательность из костей домино. Такой подход может напоминать приём проектирования «команда» (из книги «Банды четырех») или очередь.

А теперь остановитесь на минуту и прислушайтесь к своей «функторной интуиции». Даже не глядя на детали реализации, мы можем ясно представлять, что будет делать map для любого контейнера, какими бы особенностями и причудами он ни обладал. Эту ясность нам дают законы функтора, о которых мы поговорим ближе к концу главы. В общем, благодаря map мы можем играть со значениями, полученными «нечистым путём», не жертвуя чистотой своих функций.

Применяя map к IO, мы построили и отсрочили могучее нечистое вычисление, запуск которого, несомненно, потревожит окружающий мир. Мы держим зверя в клетке, но в определённый момент нам придётся выпустить его, чтобы начать вычисление. При этом для нас будет ценным продолжать рассматривать наши функции как чистые – и это возможно, если мы перекладываем ответственность за выполнение эффекта на вызывающий код. Давайте рассмотрим это на конкретном примере.

// url :: IO String
const url = new IO(() => window.location.href);

// toPairs :: String -> [[String]]
const toPairs = compose(map(split('=')), split('&'));

// params :: String -> [[String]]
const params = compose(toPairs, last, split('?'));

// findParam :: String -> IO Maybe [String]
const findParam = key => map(compose(Maybe.of, find(compose(eq(key), head)), params), url);

// -- Impure calling code ----------------------------------------------

// run it by calling $value()!
findParam('searchTerm').$value();
// Just(['searchTerm', 'wafflehouse'])

Вот так мы отмазались от запуска «эффектных» вычислений, возвращая url завёрнутым в IO (это является хорошим тоном при написании кода библиотек). Как вы могли заметить, мы легко можем содержать один контейнер в другом. В этом примере очень удобно оперировать IO(Maybe([x])) – он представляет из себя 3 функтора, вложенных один в другой (IO, Maybe и Array, при этом нам вряд ли понадобится применять map к массиву, ведь он используется здесь для хранения пары значений, а не для того, чтобы выразить недетерминированность результата – прим. пер.), что обосновано и весьма выразительно.

Одна деталь не даёт мне покоя и требует немедленного вмешательства: то, что внутри IO мы назвали $value – это, в действительности, никакое не «хранимое значение» и не приватное поле. Это самая настоящая чека от гранаты. Тот, кто решит воспользоваться результатом вычисления, должен будет эту чеку «выдернуть», и непостоянство результата должно быть очевидно из названия поля. Давайте переименуем его в unsafePerformIO.

class IO {
  constructor(io) {
    this.unsafePerformIO = io;
  }

  map(fn) {
    return new IO(compose(fn, this.unsafePerformIO));
  }
}

Так намного лучше. Клиентский код теперь должен будет выглядеть как findParam('searchTerm').unsafePerformIO(), что явно сообщает о намерениях.

IO будет нашим верным помощником, помогая нам укрощать дикие нечистые действия. Следующий тип, с которым мы познакомимся, похож на IO, но имеет совершенно другое предназначение.

Асинхронные задачи

Не секрет, что колбэки (обратные вызовы, callbacks) – это винтовая лестница в ад. Видимо, этот способ передачи управления разрабатывал Мауриц Корнелис Эшер. Работа в таком стиле является не столько продуктивным занятием, сколько испытанием: как долго мы сможем продолжать вкладывать колбэки в колбэки, прежде чем потеряем возможность понимать и изменять свой код? К счастью, существует намного более пригодный инструмент композиции асинхронного кода.

Мы будем использовать Data.Task (ранее – Data.Future) из великолепной библиотеки Folktale, разработанной Quildreen Motta (не будем приводить исходный код Task, потому что он не так-то прост, но ознакомиться с ним можно на GitHub). Вот примеры использования:

// -- Node readFile example ------------------------------------------

const fs = require('fs');

// readFile :: String -> Task Error String
const readFile = filename => new Task((reject, result) => {
  fs.readFile(filename, (err, data) => (err ? reject(err) : result(data)));
});

readFile('metamorphosis').map(split('\n')).map(head);
// Task('One morning, as Gregor Samsa was waking up from anxious dreams, he discovered that
// in bed he had been changed into a monstrous verminous bug.')


// -- jQuery getJSON example -----------------------------------------

// getJSON :: String -> {} -> Task Error JSON
const getJSON = curry((url, params) => new Task((reject, result) => {
  $.getJSON(url, params, result).fail(reject);
}));

getJSON('/video', { id: 10 }).map(prop('title'));
// Task('Family Matters ep 15')


// -- Default Minimal Context ----------------------------------------

// Мы легко можем поместить обыкновенные значения внутрь Task
Task.of(3).map(three => three + 1);
// Task(4)

Функции, названные reject и result – это колбэки для обработки ошибок и успешного результата, с помощью которых мы конструируем Task. Обратите внимание: работа со значениями, которые будут доступны «в будущем», сводится к использованию map и выглядит так, будто значение уже доступно, и мы им просто пользуемся. Думаю, сейчас map уже совсем понятен.

Если вы знакомы с Promise, то вам может показаться, что map – это аналог then, а Task выполняет работу Promise. Такую аналогию можно провести, хоть она и не точна. Не волнуйтесь, если вы не знаете Promise – мы не будем их использовать, потому что они не чистые (и имеют массу других недостатков, исчерпывающие сведения о которых даёт замечательная статья Aldwin Vlasblom «Broken Promises» – прим. пер.).

Подобно IO, Task будет терпеливо ждать, пока мы не попросим его начать вычисление. Это позволяет считать, что IO уже включён в Task, и нет необходимости помещать его туда вручную. Это сходство справедливо и в отношении map для Task – мы так же оставляем инструкции на будущее, будто помещаем список дел в капсулу времени. Такая вот высокотехнологичная прокрастинация.

Для того, чтобы запустить Task, мы должны вызвать метод fork. Он работает подобно unsafePerformIO, но, как подсказывает название, это разветвит поток управления, и следующие за fork инструкции будут выполняться сразу, не дожидаясь завершения Task (можно сформулировать иначе: выполнение Task не заблокирует текущий процесс). В разных средах и языках можно по-разному реализовать fork, в том числе и в отдельном потоке. В нашем случае это будет работать как обычный асинхронный вызов, и маховик event loop продолжит своё движение. Давайте рассмотрим примеры с fork:

// -- Pure application -------------------------------------------------
// blogPage :: Posts -> HTML
const blogPage = Handlebars.compile(blogTemplate);

// renderPage :: Posts -> HTML
const renderPage = compose(blogPage, sortBy(prop('date')));

// blog :: Params -> Task Error HTML
const blog = compose(map(renderPage), getJSON('/posts'));


// -- Impure calling code ----------------------------------------------
blog({}).fork(
  error => $('#error').html(error.message),
  page => $('#main').html(page),
);

$('#spinner').show();

После вызова fork, Task спешит раздобыть записи и отрисовать страницу. А тем временем мы можем отобразить некоторую анимацию ожидания, и вызов fork не заставит нас ждать ответа от сервера. В итоге мы либо отобразим ошибку, либо отрисуем страницу – зависит от результата getJSON.

Обратите внимание на то, насколько последовательным выглядит код. Мы просто читаем его снизу вверх и справа налево, хотя в действительности управление будет «перепрыгивать» с одной функции на другую. Такой код осмыслить намного проще, чем постоянно переключать внимание туда-обратно между колбэками и между блоками if/else и try/catch.

Ого, вы только взгляните! Этот Task ещё и Either проглотил! Ему пришлось так поступить, чтобы иметь возможность обрабатывать ошибки, которые произойдут в будущем, поскольку мы не сможем обработать их стандартным образом с try/catch. Ну и замечательно! Это значит, что Task «из коробки» предоставляет нам чистую обработку ошибок.

Современный JavaScript предоставляет нам не только Promise, но также синтаксис async/await и различные решения с применением функций-генераторов, что позволяет использовать конструкцию try/catch для обработки ошибок, но это приводит к написанию ещё менее композируемого и более неуклюжего императивного кода, чем c применением Promise – прим. пер.

Но даже при том, что у нас есть Task, IO и Either не останутся без работы. Позвольте мне привести вам несколько усложнённый и гипотетический пример – просто для того, чтобы проиллюстрировать мою мысль.

// Postgres.connect :: Url -> IO DbConnection
// runQuery :: DbConnection -> ResultSet
// readFile :: String -> Task Error String

// -- Pure application -------------------------------------------------

// dbUrl :: Config -> Either Error Url
const dbUrl = ({ uname, pass, host, db }) => {
  if (uname && pass && host && db) {
    return Either.of(`db:pg://${uname}:${pass}@${host}5432/${db}`);
  }

  return left(Error('Invalid config!'));
};

// connectDb :: Config -> Either Error (IO DbConnection)
const connectDb = compose(map(Postgres.connect), dbUrl);

// getConfig :: Filename -> Task Error (Either Error (IO DbConnection))
const getConfig = compose(map(compose(connectDb, JSON.parse)), readFile);


// -- Impure calling code ----------------------------------------------

getConfig('db.json').fork(
  logErr('couldn\'t read file'),
  either(console.log, map(runQuery)),
);

В этом примере мы по-прежнему пользуемся Either и IO в ветке, которая обрабатывает успешное выполнение readFile. Task берёт на себя заботу о нечистом асинхронном чтении файла, но мы по-прежнему успешно используем Either для проверки конфигурации и создаём соединение с базой через IO. Как видите, у нас по-прежнему есть место всем синхронным приёмам.

Можно долго продолжать, но по существу всё уже сказано. Всё просто, прямо как map.

На практике ваши задачи, скорее всего, будет состоять сразу из нескольких Task, и мы ещё не рассмотрели подходящие для этого инструменты. Не волнуйтесь, скоро мы изучим и монады, и всё остальное. А прямо сейчас мы должны разобраться с математической основой, благодаря которой всё это возможно.

Немного теории

Как было упомянуто ранее, функтор — это понятие из теории категорий, и оно подчиняется нескольким законам. Давайте изучим их.

// закон идентичности (identity)
map(id) === id;

// закон композиции (composition)
compose(map(f), map(g)) === map(compose(f, g));

Закон идентичности прост, но важен. Поскольку эти законы могут быть описаны кодом, мы можем применить их к функторам, о которых шла речь, и наглядно удостовериться в их соблюдении.

const idLaw1 = map(id);
const idLaw2 = id;

idLaw1(Container.of(2)); // Container(2)
idLaw2(Container.of(2)); // Container(2)

Как видите, они равны. Теперь давайте рассмотрим закон композиции.

const compLaw1 = compose(map(append(' world')), map(append(' cruel')));
const compLaw2 = map(compose(append(' world'), append(' cruel')));

compLaw1(Container.of('Goodbye')); // Container('Goodbye cruel world')
compLaw2(Container.of('Goodbye')); // Container('Goodbye cruel world')

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

Возможно, наше определение категории по-прежнему остаётся несколько нечётким. Вы можете рассуждать о категории как о сети из неких объектов и наборе морфизмов, соединяющих эти объекты. Так вот, функтор должен отображать одну категорию на другую, не разрывая эту сеть отношений между объектами. Если объект a находится в исходной категории C, и мы отображаем его в категорию D при помощи функтора F, то мы называем его F a. Пожалуй, диаграмма будет понятнее:

Categories mapped

Например, Maybe отображает нашу категорию типов и функций на категорию, где каждый объект может отсутствовать, и каждый морфизм содержит в себе проверку на null. Это достигается за счёт того, что каждая функция завёрнута в map, и каждый тип завёрнут в тип нашего функтора. Мы можем быть уверены, что каждый обычный тип и каждая функция будут продолжать композироваться в этом получившемся новом мире (технически это описание может являться не совсем точным, но для наших задач проще будет считать получившийся набор типов и функций отдельной категорией).

Показать отображение морфизма и соответствующих объектов можно с помощью такой диаграммы:

functor diagram

В дополнение к визуализации отображённого морфизма из одной категории в другую функтором F, мы видим, что диаграмма коммутирует, то есть, если следовать стрелкам, то все пути приведут к одному результату. Разные пути означают разное поведение, но мы всегда заканчиваем одним и тем же типом. Этот формализм дает нам принципиально новые способы рассуждать о чистых функциях — мы можем смело преобразовывать код в соответствии с формулами, не сталкиваясь с необходимостью обдумывать каждую деталь и каждый сценарий отдельно. Давайте рассмотрим конкретный пример.

// topRoute :: String -> Maybe String
const topRoute = compose(Maybe.of, reverse);

// bottomRoute :: String -> Maybe String
const bottomRoute = compose(map(reverse), Maybe.of);

topRoute('hi'); // Just('ih')
bottomRoute('hi'); // Just('ih')

Или визуально:

functor diagram 2

Мы можем беспрепятственно рефакторить код, пользуясь свойствами, которыми обладают все функторы.

Функторы могут быть вложены друг в друга:

const nested = Task.of([Either.of('pillows'), left('no sleep for you')]);

map(map(map(toUpperCase)), nested);
// Task([Right('PILLOWS'), Left('no sleep for you')])

Здесь nested представляет собой получаемый в будущем массив элементов, которые могут быть ошибками. Мы применяем map, пробираясь на один слой глубже с каждым применением. В этом коде у нас нет колбэков, if/else и циклов — ясный и чистый код. Однако нам приходится делать map(map(map(f))). А могли бы вместо этого сделать композицию функторов. Да, вам не показалось:

class Compose {
  constructor(fgx) {
    this.getCompose = fgx;
  }

  static of(fgx) {
    return new Compose(fgx);
  }

  map(fn) {
    return new Compose(map(map(fn), this.getCompose));
  }
}

const tmd = Task.of(Maybe.of('Rock over London'));

const ctmd = Compose.of(tmd);

const ctmd2 = map(append(', rock on, Chicago'), ctmd);
// Compose(Task(Just('Rock over London, rock on, Chicago')))

ctmd2.getCompose;
// Task(Just('Rock over London, rock on, Chicago'))

Теперь требуется только один map. Композиция функторов ассоциативна, и Container, который мы рассматривали в начале главы, на самом деле называется функтором Identity. А если у нас есть тождественный морфизм (identity) и ассоциативная композиция, мы получаем категорию. Конкретно в этой категории объектами являются другие категории, а морфизмами являются функторы, и этого обычно достаточно, чтобы мозг вспотел. Мы не будем зарываться слишком глубоко в эту тему, но будет не лишним оценить потенциал для архитектуры или хотя бы абстрактную красоту этого приёма.

Итог

Мы рассмотрели несколько разных функторов, но всего их существует бесконечное множество. Вот некоторые важные структуры данных, которые являются функторами, но не попали в наше обсуждение: деревья, списки, ассоциативные массивы, пары и многие другие... Потоки событий и Observable также являются функторами. Можно сделать какой-то тип функтором для того, чтобы скрывать детали его реализации, а можно для того, чтобы лучше смоделировать предметную область. Функторы окружают нас повсюду, и мы будем часто их использовать в этой книге.

А что делать с функторами, когда к значениям требуется применить функцию нескольких аргументов? А как насчёт того, чтобы выполнять последовательность нечистых или асинхронных действий? Мы овладели ещё не всем набором инструментов для работы в этом упакованном мире. В следующих главах мы это исправим и изучим монады.

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

Упражнения

Упражнение A

Используйте add и map для создания функции, которая увеличивает значение внутри функтора на единицу.

// incrF :: Functor f => f Int -> f Int  
const incrF = undefined;  

Упражнение B

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

const user = { id: 2, name: 'Albert', active: true };  

Используйте safeProp и head для того, что бы найти первую букву имени пользователя.

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

Упражнение C

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

// showWelcome :: User -> String
const showWelcome = compose(append('Welcome '), prop('name'));

// checkActive :: User -> Either String User
const checkActive = function checkActive(user) {
  return user.active
    ? Either.of(user)
    : left('Your account is not active');
};

напишите функцию, которая использует checkActive и showWelcome, чтобы предоставить доступ или вернуть ошибку.

// eitherWelcome :: User -> Either String String
const eitherWelcome = undefined;

Упражнение D

Теперь рассмотрим следующую функцию:

// validateUser :: (User -> Either String ()) -> User -> Either String User
const validateUser = curry((validate, user) => validate(user).map(_ => user));

// save :: User -> IO User
const save = user => new IO(() => ({ ...user, saved: true }));

Напишите функцию validateName, которая проверяет, что имя пользователя длиннее трёх символов, а в противном случае возвращает ошибку. Затем используйте either, showWelcome и save, чтобы написать функцию register, которая будет регистрировать и приветствовать пользователя, если он пройдёт валидацию.

Помните, что оба аргумента функции either должны возвращать значения одного типа.

// validateName :: User -> Either String ()
const validateName = undefined;

// register :: User -> IO String
const register = compose(undefined, validateUser(validateName));