Skip to content

Latest commit

 

History

History
303 lines (197 loc) · 26.6 KB

ch12-ru.md

File metadata and controls

303 lines (197 loc) · 26.6 KB

Глава 12: Проходя сквозь препятствия

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

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

Типы застряли друг в друге

Давайте рассмотрим пример:

// readFile :: FileName -> Task Error String

// firstWords :: String -> String
const firstWords = compose(intercalate(' '), take(3), split(' '));

// tldr :: FileName -> Task Error String
const tldr = compose(map(firstWords), readFile);

map(tldr, ['file1', 'file2']);
// [Task('hail the monarchy'), Task('smash the patriarchy')]

В этом примере мы читаем несколько файлов и получаем массив Task, с которым неясно, как поступить. Как нам применить fork к каждой из них? Было бы здорово, если бы мы могли просто поменять типы местами так, чтобы получить Task Error [String] вместо [Task Error String]. Тогда у нас было бы единственное значение «из будущего», которое содержало бы сразу все результаты. Это соответствовало бы нашим асинхронным потребностям намного больше, чем оперирование несколькими значениями, которые поступят к нам в том порядке, в котором им заблагорассудится.

Вот еще один пример непростой ситуации:

// getAttribute :: String -> Node -> Maybe String
// $ :: Selector -> IO Node

// getControlNode :: Selector -> IO (Maybe (IO Node))
const getControlNode = compose(map(map($)), map(getAttribute('aria-controls')), $);

Посмотрите на этих IO, которым так хочется оказаться вместе! Было бы просто замечательно соединить их, позволить им потанцевать, прижавшись друг к другу, но увы — Maybe стоит между ними, как сопровождающий на выпускном. Наилучшим действием в этом случае было бы сместить их положение так, чтобы они оказались рядом, благодаря чему сигнатура могла бы быть упрощена до IO (Maybe Node).

Типовый Фэн-шуй

Интерфейс Traversable состоит из двух великолепных функций: sequence и traverse.

Давайте реорганизуем наши типы, используя sequence:

sequence(List.of, Maybe.of(['the facts'])); // [Just('the facts')]
sequence(Task.of, new Map({ a: Task.of(1), b: Task.of(2) })); // Task(Map({ a: 1, b: 2 }))
sequence(IO.of, Either.of(IO.of('buckle my shoe'))); // IO(Right('buckle my shoe'))
sequence(Either.of, [Either.of('wing')]); // Right(['wing'])
sequence(Task.of, left('wing')); // Task(Left('wing'))

Видите, что здесь произошло? Тип был вывернут наизнанку. Внутренний функтор оказался снаружи, а наружний — внутри. Важно разобраться в требованиях, которые sequence предъявляет к своим аргументам. Вот они:

// sequence :: (Traversable t, Applicative f) => (a -> f a) -> t (f a) -> f (t a)
const sequence = curry((of, x) => x.sequence(of));

Давайте начнем со второго аргумента. Это должен быть Traversable, в котором «содержится» Applicative, что звучит довольно ограничивающе, но обычно это условие несложно выполнить. Это и есть t (f a), который превращается в f (t a). Весьма выразительно, не так ли?

А вот первый аргумент — это функция, которая помогает нам сконструировать значение (тот самый of). Она нужна для того, чтобы вывернуть значения, которые не пожелают mapиться. На самом деле, наличие его как аргумента sequence — это просто костыль, который нужен только в нетипизированном языке, где компилятор не сможет подобрать подходящую функцию, исходя только из типа.

Используя sequence, мы можем переставлять типы точно и ловко, будто канатоходец. Но как это работает? Давайте, к примеру, рассмотрим реализацию для типа Either:

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

Ах да, поскольку $value является функтором (а оно должно быть, как минимум, аппликативным функтором), мы можем просто применить map к $value, отображая Either.of, а затем вернуть его, таким образом «перепрыгивая» через тип.

Вы могли заметить, что мы полностью проигнорировали of, переданный в аргументах. Он передается для случая, когда отображение не к чему применять, как в случае Left:

class Left extends Either {
  // ...
  sequence(of) {
    return of(this);
  }
}

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

Ассортимент эффектов

Различный порядок подталкивает нас к тому, чтобы видеть в типах разный смысл. К примеру, [Maybe a] — коллекция возможно имеющихся значений, а Maybe [a] — это возможно имеющаяся коллекция значений. Первое означает, что мы толерантны к отсутствию некоторых значений, и нас вполне удовлетворят те, что имеются. Второе же выражает подход «всё или ничего». Подобным образом Either Error (Task Error a) мог бы выражать валидацию на стороне клиента, а Task Error (Either Error a) — валидацию на стороне сервера. Реорганизация типов даёт нам возможность получать разные эффекты.

// fromPredicate :: (a -> Bool) -> a -> Either e a

// partition :: (a -> Bool) -> [a] -> [Either e a]
const partition = f => map(fromPredicate(f));

// validate :: (a -> Bool) -> [a] -> Either e [a]
const validate = f => traverse(Either.of, fromPredicate(f));

В этом примере рассмотрены две разные функции: одна основана на применении map, другая — на traverse. Первая — partition — произведёт для нас массив значений Left или Right в соответствии с предикатом. Такое поведение полезно в том случае, если мы планируем что-либо делать со значениями Left, а не просто отфильтровывать их сразу же при получении. А вторая функция validate вернёт нам либо Left с первым элементом, который не соответствует предикату, либо Right со списком всех элементов, если предикату удовлетворяют все. Выбирая другой порядок типов, мы получаем другое поведение.

Давайте посмотрим, как функция traverse определена для List, чтобы разобраться в работе validate.

traverse(of, fn) {
    return this.$value.reduce(
      (f, a) => fn(a).map(b => bs => bs.concat(b)).ap(f),
      of(new List([])),
    );
  }

Она просто применяет reduce к этому списку. Функция, с которой производится свёртка — это (f, a) => fn(a).map(b => bs => bs.concat(b)).ap(f), и выглядит она несколько страшно, поэтому давайте рассмотрим её подробнее.

  1. reduce(..., ...)

    Вспомним сигнатуру reduce :: [a] -> ((f, a) -> f) -> f -> f. Первый аргумент — это объект, в отношении которого вызывается метод reduce. Он представляет собой список элементов. Второй аргумент — это функция, которая может из f (значения аккумулятора) и a (результата итерирования) произвести новое значение аккумулятора.

  2. of(new List([]))

    Первоначальное значение аккумулятора — это of(new List([])), в нашем случае типом этого значения будет Right([]) :: Either e [a]. Обратите внимание: тип результата тоже будет Either e [a].

  3. fn :: Applicative f => a -> f a

    В нашем случае fn — это частично применённая fromPredicate(f) :: a -> Either e a. Применяя её к следующему аргументу, мы получим:

    fn(a) :: Either e a

  4. .map(b => bs => bs.concat(b))

    Если полученное значение будет Right, то Either.map передаст значение в функцию и вернёт новый Right с результатом. В нашем примере такая функция получит один аргумент (b), и вернёт другую функцию (bs => bs.concat(b), в которой b будет доступно благодаря замыканию).

    fn(a).map(b => bs => bs.concat(b)) :: Either e ([a] -> [a])

  5. .ap(f)

    Вспомним, что по условию f является аппликативным функтором, следовательно мы можем применить функцию bs => bs.concat(b) к любому значению bs :: [a] в этом f. К счастью, f доступно для нас из первоначального значения аккумулятора и имеет тип f :: Either e [a], что останется неизменным и после применения bs => bs.concat(b). Если f — это Right, то применяется bs => bs.concat(b), что порождает новое значение Right, где в список добавлен новый элемент. А если f — это Left, получившийся в результате прошлой итерации, то таким же будет и результирующее значение.

    fn(a).map(b => bs => bs.concat(b)).ap(f) :: Either e [a]

Это поистине чудесное преобразование реализуется в List.traverse всего за 6 строк кода благодаря of, map и ap, поэтому будет работать для любого аппликативного функтора. Это великолепный пример того, как эти абстракции могут помочь в написании очень универсального кода, требующего минимального количества предположений (которые, кстати, могут быть объявлены и проверены на уровне типов!).

Вальс типов

Пришло время снова заглянуть в наши примеры и навести в них порядок.

// readFile :: FileName -> Task Error String

// firstWords :: String -> String
const firstWords = compose(intercalate(' '), take(3), split(' '));

// tldr :: FileName -> Task Error String
const tldr = compose(map(firstWords), readFile);

traverse(Task.of, tldr, ['file1', 'file2']);
// Task(['hail the monarchy', 'smash the patriarchy']);

Используя traverse вместо map, нам удалось собрать вместе все эти непослушные Task в один аккуратный массив результатов. Это сработало как Promise.all() (если вам доводилось его использовать), за исключением того, что traverse — это не какая-то специальная и единственная в своём роде функция; нет — она доступна для каждого traversable типа. Такие математические интерфейсы, как правило, отражают большинство преобразований, которые мы хотели бы произвести, и позволяют делать это универсальным способом, способствующим взаимодействию и композиции, а не подталкивая к разработке разрозненных библиотек, в которых для очередного типа все функции изобретаются заново.

Давайте напоследок наведём порядок в последнем примере.

// getAttribute :: String -> Node -> Maybe String
// $ :: Selector -> IO Node

// getControlNode :: Selector -> IO (Maybe Node)
const getControlNode = compose(chain(traverse(IO.of, $)), map(getAttribute('aria-controls')), $);

Вместо map(map($)) мы применим chain(traverse(IO.of, $)), что вывернет для нас типы по мере отображения, а затем соединит вместе два IO.

Законы и архитектурный беспорядок

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

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

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

Следуйте за мной, у нас тут есть несколько законов, с которыми необходимо разобраться.

Идентичность

const identity1 = compose(sequence(Identity.of), map(Identity.of));
const identity2 = Identity.of;

// test it out with Right
identity1(Either.of('stuff'));
// Identity(Right('stuff'))

identity2(Either.of('stuff'));
// Identity(Right('stuff'))

Здесь всё довольно ясно. Если мы поместим Identity в наш функтор, а затем вывернем его наизнанку при помощи sequence, то должны получить такой же результат, как если бы просто поместили его снаружи. Мы возьмём Either в качестве подопытного, поскольку на нём будет просто проверить выполнение закона. Вас могло удивить, что мы используем именно Identity, хотя могли бы использовать любой другой функтор. Вспомните: категория определяется морфизмами между объектами, ассоциативной композицией и тождественным морфизмом. В категории функторов натуральные преобразования — это морфизмы, а Identity, как ни странно, тождество. Функтор Identity имеет настолько же важное значение в процессе демонстрации выполнения законов, как и функция compose. На самом деле, нам пора перестать ходить вокруг да около и вооружиться функтором Compose из восьмой главы:

Композиция

const comp1 = compose(sequence(Compose.of), map(Compose.of));
const comp2 = (Fof, Gof) => compose(Compose.of, map(sequence(Gof)), sequence(Fof));

// Test it out with some types we have lying around
comp1(Identity(Right([true])));
// Compose(Right([Identity(true)]))

comp2(Either.of, Array)(Identity(Right([true])));
// Compose(Right([Identity(true)]))

Этот закон требует от композиции предсказуемого поведения: если мы поменяем порядок построения композиции функторов, нас не должны поджидать никакие сюрпризы, поскольку композиция тоже является функтором. Мы выбрали для проверки произвольные значения true, Right, Identity и Array. Существуют способы автоматизировать проверку выполнения законов с помощью property-based тестирования; этой цели служат такие библиотеки, как quickcheck или jsverify.

Из вышеупомянутого закона естественным образом следует возможность совмещать проходы (fuse traversals), что здорово сказывается на производительности.

Naturality

const natLaw1 = (of, nt) => compose(nt, sequence(of));
const natLaw2 = (of, nt) => compose(sequence(of), map(nt));

// для проверки закона возьмём произвольное естественное преобразование и уже привычные нам функторы Identity и Either.

// maybeToEither :: Maybe a -> Either () a
const maybeToEither = x => (x.$value ? new Right(x.$value) : new Left());

natLaw1(Maybe.of, maybeToEither)(Identity.of(Maybe.of('barlow one')));
// Right(Identity('barlow one'))

natLaw2(Either.of, maybeToEither)(Identity.of(Maybe.of('barlow one')));
// Right(Identity('barlow one'))

Это похоже на закон идентичности. Если мы сначала вывернем типы, а затем к внешнему типу применим естественное преобразование, то должны получить тот же результат, как если бы сначала отобразили естественное преобразование, а затем вывернули типы.

Естественным следствием этого закона является:

traverse(A.of, A.of) === A.of;

Что также полезно для производительности.

Итог

Traversable — это мощный интерфейс, который даёт нам возможность реорганизовывать типы так же легко, как если бы мы могли мыслями двигать мебель и менять интерьер. Мы можем получать различные эффекты в разном порядке, а также разглаживать неприятные «морщины» из типов, которые препятствуют применению join. В следующей главе мы изучим один из самых мощных интерфейсов в функциональном программировании, а может быть и во всей алгебре: Глава 13: Monoids bring it all together.

Упражнения

Упражнение A

Доступны следующие элементы:

// httpGet :: Route -> Task Error JSON

// routes :: Map Route Route
const routes = new Map({ '/': '/', '/about': '/about' });

Используйте интерфейс traversable, чтобы изменить сигнатуру типа getJsons на Map Route Route -> Task Error (Map Route JSON).

// getJsons :: Map Route Route -> Map Route (Task Error JSON)
const getJsons = map(httpGet);

Упражнение B

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

// validate :: Player -> Either String Player
const validate = player => (player.name ? Either.of(player) : left('must have name'));

Используя traversable и функцию validate, перепишите функцию startGame и её сигнатуру, чтобы начать игру только при условии, что все игроки валидны.

// startGame :: [Player] -> [Either Error String]
const startGame = compose(map(always('game started!')), map(validate));

Упражнение C

А теперь нам доступны некоторые вспомогательные функции для работы с файловой системой:

// readfile :: String -> Task Error String
// readdir :: String -> Task Error [String]

Используйте traversable для того, чтобы вывернуть типы и соединить вместе вложенные Tasks и Maybe.

// readFirst :: String -> Task Error (Task Error (Maybe String))
const readFirst = compose(map(map(readfile('utf-8'))), map(safeHead), readdir);