Skip to content

Latest commit

 

History

History
327 lines (222 loc) · 25.4 KB

ch05-ru.md

File metadata and controls

327 lines (222 loc) · 25.4 KB

Глава 05: Использование композиции

Скрещивание функций

Вот функция compose:

const compose = (...fns) => (...args) => fns.reduceRight((res, fn) => [fn.call(null, ...res)], args)[0];

.. Не пугайтесь! Это compose 9000-го уровня. Для того, чтобы разобраться в ней, давайте отложим в сторону вариадную реализацию (предназначенную для использования с различным количеством аргументов) и рассмотрим более простую форму, которая может составлять две функции вместе. Как только compose2 станет вам понятна, вы сможете рассуждать о ней более абстрактно и считать, что она просто работает для любого числа функций (мы даже можем доказать это)!

Вот более дружелюбный compose для вас, дорогие читатели:

const compose2 = (f, g) => x => f(g(x));

f и g — функции, а x — значение, которое последовательно передается через каждую из них, как по «трубе» («2» в названии относится к количеству аргументов, это распространенная практика в именовании функций-комбинаторов — прим. пер.).

Композиция напоминает скрещивание функций. Вы, как селекционер, выбираете две функции с качествами, которые хотите совместить, и скрещиваете их, чтобы вывести совершенно новый «вид». Вот как можно использовать compose:

const toUpperCase = x => x.toUpperCase();
const exclaim = x => `${x}!`;
const shout = compose(exclaim, toUpperCase);

shout('send in the clowns'); // "SEND IN THE CLOWNS!"

Композиция двух функций (как действие) возвращает новую функцию. Это закономерно: сочетание двух элементов некоторого типа (в данном случае функций) должно произвести новую единицу того же типа. Вы же не ждёте, что, соединив две части конструктора Lego, вы получите матрёшку? Для этого существует теоретическое обоснование, и композиция подчиняется законам, с которыми мы познакомимся в своё время.

В нашем определении compose функция g выполнится перед f. Таким образом, данные передаются в функции справа налево. Этот вариант написания функций читается куда проще, чем несколько вложенных вызовов. Без композиции код выглядел бы так:

const shout = x => exclaim(toUpperCase(x));

Вместо того, чтобы читать код «изнутри наружу», мы читаем код «справа налево», что, я полагаю, является шагом в левом направлении (буу!). Давайте взглянем на пример, в котором последовательность имеет значение:

const head = x => x[0];
const reverse = reduce((acc, x) => [x].concat(acc), []);
const last = compose(head, reverse);

last(['jumpkick', 'roundhouse', 'uppercut']); // 'uppercut'

Функция reverse перевернёт список, а head заберёт из него первый элемент. Их композиция представляет из себя рабочую, хоть и неэффективную функцию last. Последовательность применения функций здесь очевидна. Мы могли бы определить и такую композицию, которая работает слева направо, но в нынешнем виде compose точнее передаёт своё математическое определение. Так и есть — композиция прямо как из книг по математике. Пожалуй, пришло время взглянуть на свойство, которое будет справедливо для композиции любых функций.

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

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

compose(toUpperCase, compose(head, reverse));

// или
compose(compose(toUpperCase, head), reverse);

Поскольку порядок применения композиции не имеет значения, результат будет одинаковым. Это свойство позволяет нам написать вариадную compose (предназначенную для использования с различным количеством аргументов):

// ранее нам пришлось бы использовать композицию дважды, но, поскольку она ассоциативна, 
// мы можем передать в `compose` сколько угодно функций и позволить ей решить, как сгруппировать их
const arg = ['jumpkick', 'roundhouse', 'uppercut'];
const lastUpper = compose(toUpperCase, head, reverse);
const loudLastUpper = compose(exclaim, toUpperCase, head, reverse);

lastUpper(arg); // 'UPPERCUT'
loudLastUpper(arg); // 'UPPERCUT!'

Применение свойства ассоциативности обеспечивает такую гибкость и уверенность в том, что результат будет эквивалентным. Несколько более сложное вариадное определение compose вы можете найти в приложении к этой книге, и оно является вполне нормальным; вы можете встретить такое в библиотеках вроде lodash, underscore, и ramda.

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

const loudLastUpper = compose(exclaim, toUpperCase, head, reverse);

// или
const last = compose(head, reverse);
const loudLastUpper = compose(exclaim, toUpperCase, last);

// или
const last = compose(head, reverse);
const angry = compose(exclaim, toUpperCase);
const loudLastUpper = compose(angry, last);

// и ещё множество вариантов...

Нет правильных или неправильных решений — мы просто соединяем детальки нашего конструктора, как нам нравится. Обычно лучше группировать функции так, чтобы их можно было переиспользовать, как last и angry. Если вы знакомы с книгой Мартина Фаулера «Рефакторинг», вы можете узнать в этом приём «извлечение функции» (ранее — «извлечение метода»), за исключением того, что в нашем случае нет какого-либо состояния объекта, о котором пришлось бы беспокоиться.

Бесточечный стиль

Бесточечный стиль (он же pointfree — стиль отсутствия ссылок, комбинаторный стиль) подразумевает функции, которые никогда не упоминают данные, с которыми они работают. Этот стиль образуется сочетанием функций первого класса, каррирования и композиции.

// не бесточечный стиль, поскольку мы упоминаем данные: word
const snakeCase = word => word.toLowerCase().replace(/\s+/ig, '_');

// бесточечный стиль
const snakeCase = compose(replace(/\s+/ig, '_'), toLowerCase);

Заметили, как мы частично применили replace? Мы передаём данные от функции к функции, каждая из которых принимает только один аргумент. Каррирование позволяет нам подготовить каждую функцию так, чтобы она ожидала только данные, производила с ними действия и передавала их дальше. Ещё стоит отметить, что нам не нужны данные для построения нашей функции в бесточечной версии, тогда как в обычной версии нам требуется объявить word, прежде чем сделать хоть что-то.

Давайте взглянем на ещё один пример.

// не бесточечный стиль, поскольку мы упоминаем данные: name
const initials = name => name.split(' ').map(compose(toUpperCase, head)).join('. ');

// бесточечный стиль
// Обратите внимание: вместо `join` мы используем функцию `intercalate`, определённую в приложении к книге (функция `join` предназначена для других задач и будет подробно рассмотрена в Главе 09)
const initials = compose(intercalate('. '), map(compose(toUpperCase, head)), split(' '));

initials("hunter stockton thompson"); // 'H. S. T'

Итого: бесточечный стиль может помочь нам убрать излишнее именование значений, что сохранит краткость и обобщённость. Это также индикатор функционального кода, поскольку он делает очевидным, что некий конкретный фрагмент состоит только из небольших функций, которые принимают и возвращают данные. К примеру, не получится просто так использовать цикл while в композиции. Однако имейте в виду, что бесточечный код — это обоюдоострый меч, и иногда он может сделать намерение одного разработчика неочевидным для других. Не весь функциональный код обязан быть бесточечным, и это нормально. Мы будем придерживаться этого стиля по мере возможности, а в остальных случаях будем использовать обычные функции.

Дебаггинг

Распространённой ошибкой является попытка использовать функцию двух аргументов (например, map) в композиции, забыв сперва частично применить её.

// неверно — мы передаём массив в функцию angry, получаем непонятно что, а затем частично применяем к этому map
const latin = compose(map, angry, reverse);

latin(['frog', 'eyes']); // ошибка

// правильно — каждая функция готова принять только один аргумент
const latin = compose(map(angry), reverse);

latin(['frog', 'eyes']); // ['EYES!', 'FROG!'])

Если у вас возникнут проблемы с отладкой композиции, вы можете воспользоваться полезной (хоть и нечистой) функцией trace, чтобы наблюдать за происходящим.

const trace = curry((tag, x) => {
  console.log(tag, x);
  return x;
});

const dasherize = compose(
  intercalate('-'),
  toLower,
  split(' '),
  replace(/\s{2,}/ig, ' '),
);

dasherize('The world is a vampire');
// TypeError: Cannot read property 'apply' of undefined

Что-то здесь не так. Давайте воспользуемся trace:

const dasherize = compose(
  intercalate('-'),
  toLower,
  trace('after split'),
  split(' '),
  replace(/\s{2,}/ig, ' '),
);

dasherize('The world is a vampire');
// after split [ 'The', 'world', 'is', 'a', 'vampire' ]

Точно! Нам нужно использовать toLower вместе с map, поскольку мы имеем дело с массивом.

const dasherize = compose(
  intercalate('-'),
  map(toLower),
  split(' '),
  replace(/\s{2,}/ig, ' '),
);

dasherize('The world is a vampire'); // 'the-world-is-a-vampire'

Функция trace позволяет нам увидеть (в целях отладки), что из себя представляют данные на определенный момент времени. В языках вроде Haskell и PureScript тоже есть подобные функции, упрощающие разработку.

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

Теория категорий

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

теория категорий

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

В теории категорий мы оперируем понятием... категории. Категория определяется как коллекция со следующими свойствами и составляющими:

  • коллекция объектов
  • коллекция морфизмов
  • для пары морфизмов определена композиция
  • для каждого объекта задан тождественный морфизм

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

Коллекция объектов

В роли объектов будут типы данных, такие как: String, Boolean, Number, Object. Мы часто рассматриваем тип данных как набор возможных значений — например, тип данных Boolean как множество значений [true, false] или Number как множество всех возможных числовых значений. Рассматривать типы как множества удобно, потому что это позволяет применять к ним теорию множеств.

Коллекция морфизмов

Морфизмами будут наши любимые чистые функции.

Для пары морфизмов определена композиция

Это, как вы могли догадаться, и есть наша новая игрушка — compose. Неслучайно мы обсуждали, что функция compose ассоциативна, так как это свойство обязано быть соблюдено для любой композиции в теории категорий.

Так выглядит композиция в категории:

композиция в категории, 1

композиция в категории, 2

А так выглядит композиция этих функций f и g в коде:

const g = x => x.length;
const f = x => x === 4;
const isFourLetterWord = compose(f, g);

Для каждого объекта задан тождественный морфизм (identity)

Давайте объявим полезную функцию id, которая будет принимать аргумент и просто возвращать его обратно:

const id = x => x;

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

Функция id обязана сочетаться с композицией. Вот свойство, которое в категории обязательно должно выполняться для каждой унарной функции f (т.е. определённой для одного аргумента. Арность функции — это количество её аргументов — прим. пер.):

// тождество (identity)
compose(id, f) === compose(f, id) === f;

Напоминает свойство нейтрального элемента для обычных чисел, не так ли? Если это не сразу понятно, задержитесь на этом моменте. Оцените «безрезультатность» этой id. Скоро мы будем встречать id повсеместно, но пока что мы смотрим на id как на функцию, значение которой можно легко заменить аргументом. Это пригодится для написания бесточечного кода.

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

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

Итог

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

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

Самое время опробовать наши знания на практике. Давайте напишем приложение.

Глава 06: Пример приложения

Упражнения

В каждом из упражнений мы подразумеваем под Car объект следующей формы:

{
  name: 'Aston Martin One-77',
  horsepower: 750,
  dollar_value: 1850000,
  in_stock: true,
}

Упражнение A

Используйте compose() для того, чтобы переписать функцию.

const isLastInStock = (cars) => {  
  const lastCar = last(cars);  
  return prop('in_stock', lastCar);  
};  

Упражнение B

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

const average = xs => reduce(add, 0, xs) / xs.length;

Используйте функцию average для того, чтобы отрефакторить averageDollarValue с помощью композиции

const averageDollarValue = (cars) => {  
  const dollarValues = map(c => c.dollar_value, cars);  
  return average(dollarValues);  
};  

Упражнение C

Проведите рефакторинг, приведите fastestCar к бесточечному стилю, используя compose() и другие функции. Подсказка: вам может пригодиться функция append.

const fastestCar = (cars) => {  
  const sorted = sortBy(car => car.horsepower);  
  const fastest = last(sorted);  
  return concat(fastest.name, ' is the fastest');  
};