Каррирование и композиция в JavaScript

0
665
views
javascript logo

Хочешь проверить свои знания по JS?

Подпишись на наш канал с тестами по JS в Telegram!

×

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

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

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

Каррирование

Каррированные функции работают практически так же, как любые другие, но подходим мы к ним немного иначе.

Допустим, нам нужна функция, вычисляющая расстояние между двумя точками. Например, между {x1, y1} и {x2, y2}. Немного математики (но ничего сверхсложного):

Формула для вычисления расстояния между двумя точками. В основе формулы лежит теорема Пифагора.

Вызов обычной функции может выглядеть как-то так:

const distance = (start, end) => Math.sqrt( Math.pow(end.x-start.x, 2) + Math.pow(end.y-start.y, 2) );

console.log( distance( {x:2, y:2}, {x:11, y:8} );
// logs 10.816653826391969

Каррирование заставляет функцию принимать один параметр за раз. Поэтому вместо вызова distance(start, end) мы вызываем функцию так: distance(start)(end). Каждый параметр передается отдельно, и каждый вызов функции возвращает другую функцию, пока не будут переданы все параметры.

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

const distance = function(start){
  // we have a closed scope here, but we'll return a function that
  //  can access it - effectively creating a "closure".
  return function(end){
    // now, in this function, we have everything we need. we can do
    //  the calculation and return the result.
    return Math.sqrt( Math.pow(end.x-start.x, 2) + Math.pow(end.y-start.y, 2) );
  }
}

console.log( distance({x:2, y:2})({x:11, y:8});
// logs 10.816653826391969 again

Кажется, здесь проделано ужасно много работы для достижения того же результата! Мы можем немного сократить эту запись, используя стрелочные функции ES6.

const distancewithCurrying = 
        (start) => 
          (end) => Math.sqrt( Math.pow(end.x-start.x, 2) +
                              Math.pow(end.y-start.y, 2) );

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

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

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

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

Сила каррированных функций в возможности их комбинирования и композиции.

Игра «Capture the flag»

Вернемся к нашей формуле для вычисления расстояния. Допустим, мы пишем игру «Capture the flag» («Захват флага»). Нам может пригодиться возможность легко и быстро вычислять, на каком расстоянии от флага находится каждый игрок.

У нас может быть массив игроков, каждый из которых будет иметь координаты {x, y}. А когда у вас есть массив значений {x, y}, переиспользуемая функция может быть крайне удобна.

Давайте остановимся на этой идее на минутку:

const players = [
  {
    name: 'Alice',
    color: 'aliceblue',
    position: { x: 3, y: 5}
  },{
    name: 'Benji',
    color: 'goldenrod',
    position: { x: -4, y: -4}
  },{
    name: 'Clarissa',
    color: 'firebrick',
    position: { x: -2, y: 8}
  }
];
const flag = { x:0, y:0};

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

// Given those, let's see if we can find a way to map 
//  out those distances! Let's do it first with the first
//  distance formula.
const distances = players.map( player => distance(flag, player.position) );
/***
 * distances == [
 *   5.830951894845301, 
 *   5.656854249492381, 
 *   8.246211251235321
 * ]
 ***/

// using a curried function, we can create a function that already
//  contains our starting point.
const distanceFromFlag = distanceWithCurrying(flag);
// Now, we can map over our players to extract their position, and
//  map again with that distance formula:
const curriedDistances = players.map(player=>player.position)
                                .map(distanceFromFlag)
/***
 * curriedDistances == [
 *   5.830951894845301, 
 *   5.656854249492381, 
 *   8.246211251235321
 * ]
 ***/

Здесь мы использовали нашу функцию distanceCurried, чтобы применить один параметр — стартовую позицию. Возвращается функция, принимающая другой параметр — конечную позицию.

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

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

Композиция каррированных функций

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

Давайте посмотрим, как можно скомпонировать каррированные функции в более крупные.

Для начала — немного подготовительной работы. Array.prototype.filter(), функция-фильтр из ES6, позволяет нам определить колбэк-функцию, принимающую значение(я) и возвращающую true или false. Пример:

// исходный массив,
const ages = [11, 14, 26, 9, 41, 24, 108];
// функция-фильтр. Принимает input, возвращает true/false.
function isEven(num){
  if(num%2===0){
    return true;
  } else {
    return false;
  }
}
// или в ES6-стиле:
const isEven = (num) => num%2===0 ? true : false;
// применяем:
console.log( ages.filter(isEven) );
// [14, 26, 24, 108]

Отфильтровываем четные числа из массива чисел.

Функция isEven написана очень специфичным образом: она принимает значение (или значения, если мы, например, хотим включить индекс массива), осуществляет какие-то внутренние действия и возвращает true или false. И так каждый раз.

Это сама суть фильтрующей колбэк-функции, хотя это и не эксклюзивно для фильтров: Array.prototype.every и Array.prototype.some используют один и тот же стиль. Функция обратного вызова применяется к каждому элементу массива, она принимает какое-то значение и возвращает true или false.

Создаем более продвинутые фильтры

Давайте создадим еще какие-нибудь фильтры, только более продвинутые. Мы немного абстрагируем наши функции, чтобы сделать их более переиспользуемыми.

Это могут быть, например, такие полезные функции, как isEqualTo или isGreaterThan. Они более продвинутые, так как принимают два обязательных значения. Одно выступает как основа для сравнения (пускай будет comparator). А второе — значение, которое поступает из массива и будет сравниваться с comparator (пускай будет value). Код:

// мы пишем функцию, принимающую значение...
function isEqualTo(comparator){
  // и эта функция *возвращает* функцию, принимающую второе значение
  return function(value){
    // и мы просто сравниваем эти два значения.
    return value === comparator;
  }
}
// again, in ES6:
const isEqualTo = (comparator) => (value) => value === comparator;

С этого момента я буду придерживаться версии ES6, если только не будет уважительной причины расширить код до классической версии.

Идем дальше:

const isEqualTo = (comparator) => (value) => value === comparator;
const isGreaterThan = (comparator) => (value) => value > comparator;

// and in application:
const isSeven = isEqualTo(7);
const isOfLegalMajority = isGreaterThan(18);

Здесь первые две функции — каррированные. Они ожидают один параметр и возвращают функцию, которая, в свою очередь, тоже ожидает один параметр.

Основываясь на этих двух функциях с одним параметром, мы выполняем сравнение. Следующие две функции — isSeven и isOfLegalMajority — просто реализации этих двух функций.

Пока мы ничего сложного не получили, и усложнения придется подождать еще немного:

// функция просто инвертирует значение: true <=> false
const isNot = (value) => !value;

const isNotEqual = (comparator) => (value) => isNot( isEqual(comparator)(value) );
const isLessThanOrEqualTo = (comparator) => (value) => isNot( isGreaterThan(comparator)(value) );

Здесь у нас функция-утилита isNot, которая просто инвертирует истинность значения. С ее помощью мы сможем сравнивать большие куски. Мы сможем принимать наши comparator и value, прогонять их через функцию isEqual, а затем применять к значению isNot, чтобы сказать isNotEqual.

Это начало сравнения, и, скажем честно, выглядит оно предельно глупо. Какой смысл писать все это, чтобы получить это:

// все эти строительные блоки...
const isGreaterThan = (comparator) => (value) => value > comparator;
const isNot = (value) => !value;
const isLessThanOrEqualTo = (comparator) => (value) => isNot( isGreaterThan(comparator)(value) );

// просто чтобы получить вот это?
const isTooYoungToRetire = isLessThanOrEqualTo(65)

// и в реализации:
const ages = [31, 24, 86, 57, 67, 19, 93, 75, 63];
console.log(ages.filter(isTooYoungToRetire)

// ну и чем это чище, чем это:
console.log(ages.filter( num => num <= 65 ) )

«Итоговый результат довольно аналогичный. Он ничего нам не экономит. Фактически, подготовка этих трех функций заняла куда больше времени, чем простое выполнение сравнения!»

Это правда, тут не поспоришь. Но это только маленький кусочек большой головоломки.

  • Во-первых, мы пишем гораздо более самодокументирующийся код. Используя выразительные имена функций, мы можем с первого взгляда уловить, что мы фильтруем ages для значений isTooYoungToRetire. Мы не видим математическую часть, но видим описание.
  • Во-вторых, используя очень маленькие, атомарные функции, мы можем тестировать каждый кусочек изолированно. Таким образом мы можем быть уверены, что они выполняют именно то, что должны, и каждый раз одинаково. Позже, при переиспользовании этих функций, мы будем точно знать, что они рабочие. Это избавит нас от необходимости тестировать каждую отдельную часть, когда сложность нашей функции начнет расти.
  • В-третьих, создавая абстрактные функции, мы позже сможем применить их в других проектах. Собственная библиотека функциональных компонентов — очень хороший актив, я настоятельно советую завести и поддерживать такую.

С учетом всего вышесказанного, мы можем просто взять эти маленькие функции и начать комбинировать их во все большие куски. Давайте попробуем. Имея isGreaterThan и isLessThan, можно написать прекрасную функцию isInRange!

const isInRange = (minComparator) 
                 => (maxComparator)
                   => (value) => isGreaterThan(minComparator)(value)
                              && isLessThan(maxComparator)(value)

const isTwentySomething = isInRange(19)(30);

Прекрасно: теперь у нас есть средство для проверки нескольких условий одним махом. Но, если посмотреть на этот код, он не кажется самодокументирующимся. && в середине — это просто ужасно! Но мы можем кое-что с этим сделать.

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

const and = (conditions) = 
             (value) => conditions.every(condition => condition(value) )

const isInRange = (min)
                 =>(max) 
                  => and([isGreaterThan(min), isLessThan(max) ])

Итак, функция and принимает любое количество функций-фильтров и возвращает true, если все функции при заданном значении возвращают true. Функция isInRange в конце делает то же самое, что и предыдущая, но кажется более читабельной и самодокументирующейся.

В дальнейшем это позволит нам комбинировать любое количество функций. Предположим, мы хотим получить четные числа между 20 и 40. Мы можем просто скомбинировать нашу функцию isEven с функцией isInRange при помощи and, и все будет просто работать.

Итоги

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

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

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

Перевод статьи «How to Use Currying and Composition in JavaScript».

ОСТАВЬТЕ ОТВЕТ

Please enter your comment!
Please enter your name here