[Перевод] Функциональный JavaScript: что такое функции высшего порядка и зачем они нужны?
«Функции высшего порядка» — это одна из тех фраз, которыми часто разбрасываются. Но редко кто может остановиться и объяснить, что это такое. Возможно, вы уже знаете, что называют функциями высшего порядка. Но как мы используем их в реальных проектах? Когда и почему они бывают полезны? Можем ли мы с их помощью манипулировать DOM? Или люди, которые используют эти функции, просто хвастаются? Быть может, они бессмысленно усложняют код?
Раньше я считал, что функции высшего порядка полезны. Теперь я считаю их самым важным свойством JavaScript как языка. Но прежде чем мы это обсудим, давайте сначала разберёмся, что же такое функции высшего порядка. И начнём мы с функций в качестве переменных.
Функции как объекты первого класса
В JavaScript есть не меньше трёх способов (всего их больше) написать новую функцию. Во-первых, можно написать объявление функции:
// Take a DOM element and wrap it in a list item element.
function itemise(el) {
const li = document.createElement('li');
li.appendChild(el);
return li;
}
Надеюсь, вам всё понятно. Также вы, вероятно, знаете, что можно написать выражение функции:
const itemise = function(el) {
const li = document.createElement('li');
li.appendChild(el);
return li;
}
И наконец, есть ещё один способ написать ту же функцию — в качестве стрелочной функции:
const itemise = (el) => {
const li = document.createElement('li');
li.appendChild(el);
return li;
}
В данном случае все три способа равноценны. Хотя это не всегда бывает так, на практике у каждого способа небольшие отличия, связанные с тем, что происходит с магией конкретного ключевого слова и метками в трассах стека.
Но обратите внимание, что последние два примера присваивают функцию переменной. Это выглядит мелочью. Почему бы и не присвоить функцию переменной? Но это очень важно. Функции в JavaScript относятся к «первому классу». Поэтому мы можем:
- Присваивать функции переменным.
- Передавать функции в качестве аргументов другим функциям.
- Возвращать функции из других функций.
Это чудесно, но какое отношение всё это имеет к функциям высшего порядка? Обратите внимание на два последних пункта. Скоро мы к ним вернёмся, а пока давайте разберём несколько примеров.
Мы увидели присвоение функций переменным. А что насчёт передачи их в качестве параметров? Давайте напишем функцию, которую можно использовать с DOM-элементами. Если выполнить document.querySelectorAll()
, то в ответ получим не массив, а NodeList
. У NodeList
нет метода .map()
, как у массивов, поэтому напишем так:
// Apply a given function to every item in a NodeList and return an array.
function elListMap(transform, list) {
// list might be a NodeList, which doesn't have .map(), so we convert
// it to an array.
return [...list].map(transform);
}
// Grab all the spans on the page with the class 'for-listing'.
const mySpans = document.querySelectorAll('span.for-listing');
// Wrap each one inside an element. We re-use the
// itemise() function from earlier.
const wrappedList = elListMap(itemise, mySpans);
Здесь мы передаём функцию itemise
в качестве аргумента функции elListMap
. Но можем использовать elListMap
не только для создания списков. К примеру, с её помощью можно добавить класс в набор элементов:
function addSpinnerClass(el) {
el.classList.add('spinner');
return el;
}
// Find all the buttons with class 'loader'
const loadButtons = document.querySelectorAll('button.loader');
// Add the spinner class to all the buttons we found.
elListMap(addSpinnerClass, loadButtons);
elLlistMap
берёт другую функцию в качестве параметра и преобразует. То есть мы можем использовать elListMap
для решения разных задач.
Мы рассмотрели пример передачи функций в качестве параметров. Теперь поговорим о возврате функции из функции. Как это выглядит?
Сначала напишем обычную старую функцию. Нам нужно взять список элементов li
и обернуть в ul
. Легко:
function wrapWithUl(children) {
const ul = document.createElement('ul');
return [...children].reduce((listEl, child) => {
listEl.appendChild(child);
return listEl;
}, ul);
}
А если потом у нас будет куча элементов-параграфов, которые нам захочется обернуть в div
? Без проблем, напишем для этого ещё одну функцию:
function wrapWithDiv(children) {
const div = document.createElement('div');
return [...children].reduce((divEl, child) => {
divEl.appendChild(child);
return divEl;
}, div);
}
Работает отлично. Однако эти две функции очень похожи, разница лишь в родительском элементе, который мы создали.
Теперь мы могли бы написать функцию, которая берёт два параметра: тип родительского элемента и список дочерних элементов. Но есть и другой вариант. Мы можем создать функцию, возвращающую функцию. Например:
function createListWrapperFunction(elementType) {
// Straight away, we return a function.
return function wrap(children) {
// Inside our wrap function, we can 'see' the elementType parameter.
const parent = document.createElement(elementType);
return [...children].reduce((parentEl, child) => {
parentEl.appendChild(child);
return parentEl;
}, parent);
}
}
Поначалу это может выглядеть немного сложно, так что давайте разделим код. Мы создали функцию, которая всего лишь возвращает другую функцию. Но эта возвращаемая функция помнит параметр elementType
. И потом, когда мы вызываем возвращённую функцию, ей уже известно, какой элемент нужно создавать. Поэтому можно создать wrapWithUl
и wrapWithDiv
:
const wrapWithUl = createListWrapperFunction('ul');
// Our wrapWithUl() function now 'remembers' that it creates a ul element.
const wrapWithDiv = createListWreapperFunction('div');
// Our wrapWithDiv() function now 'remembers' that it creates a div element.
Эту хитрость, когда возвращённая функция «помнит» о чём-то, называют замыканием. Подробнее можно почитать о них здесь. Замыкания невероятно удобны, но пока что мы не будем о них думать.
Итак, мы разобрали:
- Присвоение функции переменной.
- Передачу функции в качестве параметра.
- Возвращение функции из другой функции…
В общем, функции первого класса — штука приятная. Но при чём тут функции высшего порядка? Давайте рассмотрим определение.
Что такое функция высшего порядка?
Определение: это функция, которая берёт функцию в качестве аргумента или возвращает функцию в качестве результата.
Знакомо? В JavaScript это функции первого класса. То есть «функции высшего порядка» обладают точно такими же преимуществами. Иными словами, это просто вычурное название для простой идеи.
Примеры функций высшего порядка
Если начать искать, то начинаешь везде замечать функции высшего порядка. Самыми распространёнными являются функции, которые принимают другие функции в качестве параметров.
Функции, принимающие другие функции в качестве параметров
Когда вы передаёте callback, вы используете функцию высшего порядка. Во фронтенд-разработке они встречаются повсеместно. Одна из самых распространённых — метод .addEventListener()
. Мы используем его, когда хотим выполнить действия в ответ на какие-то события. К примеру, я хочу сделать кнопку, выдающую предупреждение:
function showAlert() {
alert('Fallacies do not cease to be fallacies because they become fashions');
}
document.body.innerHTML += ``;
const btn = document.querySelector('.js-alertbtn');
btn.addEventListener('click', showAlert);
Здесь мы создали функцию, показывающую предупреждение, добавили на страницу кнопку и передали функцию showAlert()
в качестве аргумента в btn.addEventListener()
.
Также мы встречаем функции высшего порядка, когда используем методы итерации массивов: например, .map()
, .filter()
и .reduce()
. Как в функции elListMap()
:
function elListMap(transform, list) {
return [...list].map(transform);
}
Также функции высшего порядка помогают работать с задержками и таймингом. Функции setTimeout()
и setInterval()
помогают управлять тем, когда исполняются функции. Например, если нужно через 30 секунд убрать класс highlight
, можно это сделать так:
function removeHighlights() {
const highlightedElements = document.querySelectorAll('.highlighted');
elListMap(el => el.classList.remove('highlighted'), highlightedElements);
}
setTimeout(removeHighlights, 30000);
Повторюсь, мы создали функцию и передали её другой функции в качестве аргумента.
Как видите, в JavaScript часто встречаются функции, принимающие другие функции. И вы наверняка их уже используете.
Функции, возвращающие функции
Функции такого вида встречаются не столь часто, как предыдущие. Но они тоже полезны. Один из лучших примеров — функция maybe (). Я адаптировал вариант из книги JavaScript Allongé:
function maybe(fn)
return function _maybe(...args) {
// Note that the == is deliberate.
if ((args.length === 0) || args.some(a => (a == null)) {
return undefined;
}
return fn.apply(this, args);
}
}
Вместо того, чтобы разбираться в работе кода, давайте сначала посмотрим, как его можно применять. Посмотрим опять на функцию elListMap()
:
// Apply a given function to every item in a NodeList and return an array.
function elListMap(transform, list) {
// list might be a NodeList, which doesn't have .map(), so we convert
// it to an array.
return [...list].map(transform);
}
Что будет, если случайно передать в elListMap()
null или неопределённое значение? Мы получим TypeError и падение текущей операции, какой бы она ни была. Избежать этого можно с помощью функции maybe()
:
const safeElListMap = maybe(elListMap);
elListMap(x => x, null);
// ← undefined
Вместо падения функция вернёт undefined
. А если бы мы передали это в другую функцию, защищённую maybe()
, то в ответ снова получили бы undefined
. С помощью maybe()
можно защитить любое количество функций, это гораздо проще написания миллиарда выражаений if
.
Функции, возвращающие функции, также часто встречаются в мире React. Например, connect()
.
И что дальше?
Мы увидели несколько примеров использования функций высшего порядка. И что дальше? Что они могут дать нам такого, чего мы без них не получим?
Чтобы ответить на этот вопрос, давайте рассмотрим ещё один пример — встроенный метод массива .sort()
. Да, у него есть недостатки. Он меняет массив вместо того, чтобы возвращать новый. Но давайте пока об этом забудем. Метод .sort()
является функцией высшего порядка, он принимает другую функцию в качестве одного из параметров.
Как это работает? Если мы хотим отсортировать массив чисел, сначала нужно создать функцию сравнения:
function compareNumbers(a, b) {
if (a === b) return 0;
if (a > b) return 1;
/* else */ return -1;
}
Теперь отсортируем массив:
let nums = [7, 3, 1, 5, 8, 9, 6, 4, 2];
nums.sort(compareNumbers);
console.log(nums);
// 〕[1, 2, 3, 4, 5, 6, 7, 8, 9]
Можно сортировать и списки чисел. Но какая от этого польза? Насколько часто у нас есть список чисел, который нужно сортировать? Не часто. Обычно мне нужно сортировать массив объектов:
let typeaheadMatches = [
{
keyword: 'bogey',
weight: 0.25,
matchedChars: ['bog'],
},
{
keyword: 'bog',
weight: 0.5,
matchedChars: ['bog'],
},
{
keyword: 'boggle',
weight: 0.3,
matchedChars: ['bog'],
},
{
keyword: 'bogey',
weight: 0.25,
matchedChars: ['bog'],
},
{
keyword: 'toboggan',
weight: 0.15,
matchedChars: ['bog'],
},
{
keyword: 'bag',
weight: 0.1,
matchedChars: ['b', 'g'],
}
];
Допустим, я хочу отсортировать этот массив по весу каждой записи. Я мог бы написать с нуля новую функцию сортировки. Но зачем, если можно создать новую функцию сравнения:
function compareTypeaheadResult(word1, word2) {
return -1 * compareNumbers(word1.weight, word2.weight);
}
typeaheadMatches.sort(compareTypeaheadResult);
console.log(typeaheadMatches);
// 〕[{keyword: "bog", weight: 0.5, matchedChars: ["bog"]}, … ]
Можно написать функцию сравнения для любого вида массивов. Метод .sort()
нам помогает: «Если дадите мне функцию сравнения, я отсортирую любой массив. Не волнуйтесь о его содержимом. Если дадите функцию сортировки, я это отсортирую». Поэтому нам не нужно самостоятельно писать алгоритм сортировки, мы сосредоточимся на гораздо более простой задаче сравнения двух элементов.
Теперь представим, что мы не используем функции высшего порядка. Мы не можем передать функцию методу .sort()
. Нам придётся писать новую функцию сортировки каждый раз, когда нужно отсортировать массив другого вида. Или придётся переизобретать то же самое с указателями функций или объектами. В любом случае получится очень неуклюже.
Однако у нас есть функции высшего порядка, которые позволяют отделить функцию сортировки от функции сравнения. Допустим, сообразительный разработчик браузера обновил .sort()
, чтобы тот использовал более быстрый алгоритм. Тогда ваш код только выиграет, вне зависимости от того, что находится внутри сортируемых массивов. И эта схема верна для целого набора функций массивов высшего порядка.
Это приводит нас к такой идее. Метод .sort()
абстрагирует задачу сортировки от содержимого массива. Это называется «разделение обязанностей» (separation of concerns). Функции высшего порядка позволяют создавать абстракции, которые без них были бы очень громоздки или вообще невозможны. А на создание абстракций приходится 80% работы программных инженеров.
Когда мы рефакторим код, чтобы убрать повторы, мы создаём абстракции. Видим паттерн и заменяем его абстрактным представлением. В результате код становится более осмысленным и простым в понимании. По крайней мере, такова цель.
Функции высшего порядка — мощный инструмент создания абстракций. И с абстракциями связан целый раздел математики, теория категорий. Точнее, теория категорий посвящена поиску абстракций абстракций. Иными словами, речь идёт о поиске паттернов паттернов. И за последние 70 лет умные программисты позаимствовали оттуда немало идей, которые превратились в свойства языков и библиотеки. Если мы выучим эти паттерны паттернов, то иногда сможем заменять большие куски кода. Или упрощать сложные проблемы до элегантных комбинаций из простых строительных блоков. Эти блоки — функции высшего порядка. Поэтому они столь важны, они дают нам мощный инструмент для борьбы со сложностью нашего кода.
Дополнительные материалы про функции высшего порядка:
Вероятно, вы уже используете функции высшего порядка. В JavaScript это так легко, что мы даже не задумываемся. Но лучше знать, о чём говорят люди, когда произносят эту фразу. Это же не сложно. Но за простой идеей таится большая сила.
Если вы опытны в функциональном программировании, то могли заметить, что я использовал не чистые функции и некоторые… многословные имена функций. Это не потому, что не слышал про нечистые функции или общие принципы функционального программирования. И я не пишу такой код в production. Я старался подобрать практические примеры, которые будут понятны новичкам. Иногда приходилось идти на компромиссы. Если интересно, то я уже писал о функциональной чистоте и общих принципах функционального программирования.