К этому моменту вы уже видели в нашем Контейнерном Цирке, как мы укротили свирепого функтора, подчиняя его нашей воле и заставляя его выполнять любые операции, какие нам заблагорассудится. Вы были поражены талантом жонглирования множеством опасных эффектов одновременно, используя применение функций для сбора результатов. Были приведены в изумление, когда контейнеры исчезали в воздухе, соединяясь вместе. На побочном шоу побочных эффектов мы видели контейнеры составленными в композицию. И совсем недавно мы расширили границы естественного и преобразовали один тип в другой прямо на ваших глазах.
А теперь, для нашего следующего трюка, мы изучим проходы. Мы увидим, как типы парят друг над другом, как если бы они были воздушными гимнастами, держащими в своих руках наши значения. Мы будем крутить эффектами, как креслами на вращающемся аттракционе. Когда наши контейнеры переплетутся, как конечности акробата, мы сможем использовать этот интерфейс для того, чтобы выровнять их и привести в порядок. Мы будем свидетелями разных эффектов в самом разном порядке. Подайте мои шаровары и свисток, мы начинаем!
Давайте рассмотрим пример:
// readFile :: FileName -> Task Error String
// firstWords :: String -> String
const firstWords = compose(join(' '), 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)
, и выглядит она несколько страшно, поэтому давайте рассмотрим её подробнее.
-
reduce(..., ...)
Вспомним сигнатуру
reduce :: [a] -> ((f, a) -> f) -> f -> f
. Первый аргумент — это объект, в отношении которого вызывается методreduce
. Он представляет собой список элементов. Второй аргумент — это функция, которая может изf
(значения аккумулятора) иa
(результата итерирования) произвести новое значение аккумулятора. -
of(new List([]))
Первоначальное значение аккумулятора — это
of(new List([]))
, в нашем случае типом этого значения будетRight([]) :: Either e [a]
. Обратите внимание: тип результата тоже будетEither e [a]
. -
fn :: Applicative f => a -> f a
В нашем случае
fn
— это частично применённаяfromPredicate(f) :: a -> Either e a
. Применяя её к следующему аргументу, мы получим:fn(a) :: Either e a
-
.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])
-
.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(join(' '), 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), что здорово сказывается на производительности.
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 (ещё не опубликована в оригинальной книге).
Доступны следующие элементы:
// 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);
Теперь в нашем распоряжении есть следующая валидирующая функция:
// 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));
А теперь нам доступны некоторые вспомогательные функции для работы с файловой системой:
// 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);