Перевод статьи Michał Piotrkowski: Higher-order functions in Lodash.
В этой статье я хочу объяснить концепцию функций высшего порядка и как они повсеместно представлены в моей любимой JavaScript библиотеке - Lodash.
Функции высшего порядка - это отличный способ сделать код более гибким и переиспользуемым, а также более декларативным. Перед тем, как перейти к определению, давайте взглянем на простой пример. Мы определим функцию, перемножающую два числа:
function multiply(a, b) {
return a * b;
}
Давайте немного поиграем с этой функцией:
> multiply(21, 2)
< 42
Предположим, что мы обычно используем её для удвоения переданного числа. Тогда мы можем создать вспомогательную функцию следующим образом:
function double(v) {
return multiply(v, 2);
}
Теперь мы можем легко удваивать значения:
> double(5)
< 10
Перед нами пример классического делегирования функции (одна функция делегирует другой).
Однако это не единственный возможный способ достижения подобного результата. В языках, в которых функции являются объектами первого класса (таких как JavaScript), существует другой, более функциональный, способ определить функцию double()
. Он известен как частичное применение функции.
Согласно Википедии:
Частичное применение — процесс фиксации части аргументов функции, который создает другую функцию, меньшей арности.
С частичным применением мы можем создать функцию double()
следующим образом:
var double = partial(multiply, 2);
partial()
берет функцию, переданную в первом аргументе, фиксирует некоторые её параметры конкретными значениями и возвращает новую функцию меньшей арности (с меньшим числом параметров). Давайте предположим, как может выглядеть реализация функции partial
в JavaScript.
function partial(fn) {
var fixed = [].slice.apply(arguments, [1]); /* 1 */
return function() { /* 2 */
var args = fixed.concat([].slice.apply(arguments));
return fn.apply(this, args); /* 3 */
};
}
Наша функция берет и сохраняет в локальной переменной (fixed
) все параметры, кроме первого (1). Затем она возвращает новую функцию (2), вызывающую исходную функцию fn
со списком параметров, в который в начало добавлены зафиксированные параметры, сохраненные в переменной fixed
(3). Эта реализация на чистом JavaScript упрощена, тем не менее она довольно мощная.
Но подождите: обратите внимание на уродливую реализацию вызовов slice.apply()
. Они необходимы, потому что объект arguments
в JavaScript - это не настоящий массив, поэтому он не имеет метода slice
, так что мы используем Function.prototype.apply()
.
Но если мы используем ECMAScript 2016 (ES6), мы можем упростить код, используя оператор rest:
function partial(fn, ...args) {
return function(...newArgs) {
return fn.apply(this, args.concat(newArgs));
};
}
Однако, если мы хотим придерживаться ES5, мы можем переписать функцию, используя возможности Lodash. Везде, где в качестве параметра ожидается массив, Lodash принимает arguments
как параметр функции. Вы также можете легко конвертировать любой похожий на массив объект в настоящий массив с использованием _.toArray(). Наша улучшенная реализация будет выглядеть так:
function partial(fn) {
var fixed = _.tail(arguments);
return function() {
return fn.apply(this, _.concat(fixed, arguments));
};
}
Но к счастью, мы не обязаны писать собственную реализацию partial
, так как Lodash уже имеет собственную: _.partial()
. Более того, как вы можете увидеть в следующем примере, она более мощная, чем наш упрощенный пример.
Скажем, у нас есть функция divide()
, которая делит одно число на другое:
function divide(a, b) {
return a / b;
}
Теперь мы хотим переиспользовать нашу функцию divide(a, b)
, чтобы создать новую функцию half(n)
, которая делит данное число пополам, подобно нашему предыдущему сценарию. На первый взгляд, она будет похожа на наш пример с multiple
/double
. Однако код...
var half = _.partial(divide, 2);
> half(4);
< 0.5
... возвращает, как вы видите, неверный результат.
Это происходит, потому что порядок параметров неверный (умножение является коммутативной операцией). Теперь мы хотим фиксировать второй параметр и оставить первый параметр свободным. Мы не можем сделать это с нашей простой реализацией, но, к счастью, авторы Lodash предусмотрели это и предоставили решение для подобных случаев. В Lodash мы можем пропускать фиксацию параметра, используя заместитель (placeholder) следующим образом:
var half = _.partial(divide, _, 2);
var invert = _.partial(divide, 1);
Этим способом мы создали две новые функции: одну, которая делит пополам и другую, которая инвертирует:
> half(5);
< 2.5
> invert(5);
< 0.2
Lodash предлагает ещё одну функцию, похожую на _.partial()
. Эта функция называется _.curry(). Давайте опробуем её:
> var divideC = _.curry(divide);
> divideC(4, 2)
< 2
> divideC(4)(2)
< 2
> divideC(4)
< [Function]
> var half = divideC(_, 2);
> half(4)
< 2
После преобразования функции с помощью _.curry()
мы получаем совершенно новую функцию, накапливающую и фиксирующую параметры для последующих вызовов, пока все ожидаемые параметры не будут указаны - в таком случае исходная функция будет вызвана. Параметры могут быть указаны один за другим или по несколько сразу. Как вы видите, вы можете также пропускать параметры, используя заместитель _
, так же как и в _.partial()
.
_.curry()
более мощная функция, чем _.partial()
, но она также имеет некоторые ограничения. Взглянем на следующий пример:
> parseInt('123')
< 123
> var parseIntC = _.curry(parseInt);
> parseIntC('123')
< [Function]
> parseIntC('123')(10)
< 123
Что здесь происходит? ParseInt(string, radix=10)
имеет второй, необязательный параметр. Lodash не может указать, какую арность на самом деле имеет функция, и полагает, что арность основывается на свойстве Function.prototype.length
, равном числу параметров, указанному в определении функции. Похожие ситуации возникают, когда функции принимают переменное число параметров и используют объект arguments
(так называемые вариативные параметры). Это может приводить к крайне неожиданным и подверженным ошибкам результатам. В таких случаях рекомендуется указать точную арность функции при каррировании:
> var parseIntC = _.curry(parseInt, 1);
> parseIntC('123')
< 123
_.partial()
и _.curry()
- это отличные примеры функций высшего порядка, поскольку они обе принимают и возвращают функции. Функции высшего порядка - это функции, принимающие (в качестве параметров) и/или возвращающие другие функции.
Вся библиотека Lodash полна функций высшего порядка. Самые примечательные из них: _.identity(), _.negate(), _.memoize(), _.constant(), _.property(), _.iteratee(), _.matches(), _.conforms(), _.overSome(), _.overEvery(), _.flow().
Если вы используете Lodash (или планируете использовать) ежедневно, полезно знать об их существовании, так как они значительно уменьшают количество кода. Кроме того, они улучшают его читаемость. Я не буду вдаваться в детали описания каждой из этих функций, поскольку Lodash docs отлично с этим справляются. Я только сделаю одно исключение: _.flow()
. Функция flow
- одна из самых используемых во всей библиотеке. Она позволяет составлять новые функции из цепочки других функций, указывая их одну за другой. Результат (возвращаемое значение) каждой функции в этой последовательности становится входным параметром для следующей функции. Это похоже на оператор pipe (|
) в Linux bash. В математике это классическая композиция функции:
_.flow([f, g, h])(x) <=> f(g(h(x)))
Спасибо _.flow()
, что теперь так легко собирать новые функции из существующих:
var sumAll = _.flow([_.concat, _.flattenDeep, _.sum]);
_.sum(1, 2, [3, 4]);
> 0
sumAll(1, 2, [3, 4]);
> 10
_.flow()
похож на _.chain(), однако, в отличие от _.chain()
, фиксирующей данные при первом вызове, результатом _.flow()
является функция, принимающая данные в конце. Это означает, что она может быть присвоена переменной или передана как параметр, позволяя эффективно переиспользовать её для различных наборов данных.
Ниже вы можете увидеть код, который подсчитывает 5 стран с крупнейшими городами в мире. Я использовал нотацию стрелочных функций из ES2015 для краткости:
var cities = require('./cities.json');
_(cities)
.filter(c => c.population >= 5000000)
.countBy(c => c.country)
.toPairs()
.map(c => _.zipObject(['country', 'numOfCities'], c))
.orderBy(c => c.numOfCities, 'desc')
.take(5)
.value();
cities.json содержит данные про 91 крупнейший город в мире. Данные про население были взяты из Википедии.
Теперь давайте используем _.partial()
и _.curry()
, чтобы переписать этот пример:
var greatherThan = threshold => _.partial(_.gte, _, threshold);
var populationGreatherThan = threshold => _.conforms({ population: greatherThan(threshold) });
var zipObject = _.curry(_.zipObject);
_(cities)
.filter(populationGreatherThan(5000000))
.countBy(_.property('country'))
.toPairs()
.map(zipObject(['country', 'numOfCities']))
.orderBy(_.property('numOfCities'), 'desc')
.take(5)
.value();
Также мы можем определить var greatherThan = _.curryRight(_.gte)
. _.curryRight()
похожа на _.curry()
, но она фиксирует параметры в обратном порядке (начиная с последнего).
Более того, для функций, принимающих перебирающий
(iteratee) аргумент (как _.map()
, _.countBy()
, _.groupBy()
), Lodash автоматически оборачивает перебирающий
аргумент с помощью функции _.iteratee()
, которая, в конечном счете, для параметров типа string
делегирует функции _.property()
. Так что наш код может быть упрощен и далее:
var greatherThan = _.curryRight(_.gte)
var populationGreatherThan = threshold => _.conforms({ population: greatherThan(threshold) });
var zipObject = _.curry(_.zipObject);
_(cities)
.filter(populationGreatherThan(5000000))
.countBy('country')
.toPairs()
.map(zipObject(['country', 'numOfCities']))
.orderBy('numOfCities', 'desc')
.take(5)
.value();
Когда я научился обращаться с _.curry()
и _.partial()
, я заметил, что почти все время я каррирую большую часть функций. Кроме того, я стараюсь пропускать первый параметр (или использовать вариант *Right()
вышеупомянутых функций).
Затем я наткнулся на вариацию lodash/fp
библиотеки Lodash, предлагающей более функциональный стиль, экспортируя объект lodash
с методами, обернутыми таким образом, чтобы создавать неизменяемые авто-каррируемые перебирающие-в-начале (iteratee-first) методы c данными в конце.
Lodash/fp
, в основном, предлагает следующие изменения:
- каррируемые функции: все функции являются каррируемыми по умолчанию;
- фиксированная арность: все функции имеют фиксированную арность, что решает показанную ранее проблему с каррированием. Любые функции, имеющие необязательные параметры, делятся на две отдельные функции (например,
_.curry(fn, arity?)
делится на_.curry(fn)
и_.curryN(fn, arity)
); - переставленные параметры: параметры функций переставлены, так что данные принимаются в качестве последнего параметра, потому что в реальной жизни наибольшее число времени вы хотите зафиксировать перебирающие параметры и оставить параметры для данных свободными;
- неизменяемые параметры: функции не изменяют переданные параметры, но возвращают измененные копии объектов;
- ограниченные перебирающие функции обратного вызова: имеют арность сведенную к 1, так что они избегают проблем с каррированием (пункт 2);
- больше никаких цепочек: формирование цепочек функций с помощью _.chain() или _() больше не поддерживается (вместо этого можно использовать _.flow()).
Для более детального описания каждого изменения обратитесь к Lodash FP guide. Вкратце, все эти изменения выливаются в гораздо более декларативный, меньше подверженный ошибкам, свободный от шаблонов код.
В стиле lodash/fp
в очередной раз переписанный пример будет выглядеть так:
_.flow([
_.filter(_.conforms({ population: _.gte(_, 5000000) })),
_.countBy('country'),
_.toPairs,
_.map(_.zipObject(['country', 'numOfCities'])),
_.orderBy('numOfCities', 'desc'),
_.take(5)
])(cities);
Как вы можете видеть, здесь больше нет вызовов _.curry()
, поскольку функции каррированы по умолчанию. Вызов _.chain()
был заменён на _.flow()
, а параметр cities
передан в конце. Если мы сохраним результат _.flow()
в переменную, то позже мы сможем переиспользовать её для различных данных из cities
.
Понимание функций высшего порядка, особенно _.partial()
и _.curry()
, является ключевым, если мы хотим получить наибольшую пользу от функциональных библиотек, таких как Lodash.
В моей следующей статье я покажу, что Lodash - это библиотека не только для манипулирования списками.
Я покажу, как функции высшего порядка и модификаторы могут улучшить читабельность и, в то же время, уменьшить число строк кода.
Слушайте наш подкаст в iTunes и SoundCloud, читайте нас на Medium, контрибьютьте на GitHub, общайтесь в группе Telegram, следите в Twitter и канале Telegram, рекомендуйте в VK и Facebook.