Функции — тот элемент языка, с которым вы чаще всего будете иметь дело в будущем. В JavaScript они повсюду. И хотя вы уже научились создавать и использовать их, настоящая мощь функций раскроется в курсе JS: коллекции, когда вы пройдете весь необходимый для этого материал. В дальнейшем при работе с асинхронным кодом вам понадобится мастерское владение функциями, так как на этом этапе от их количества начнет рябить в глазах.
Ниже я покажу вам пример, в котором нет ни одного нового синтаксического элемента, но если ваш опыт программирования ограничивается парой начальных курсов Хекслета, вы вряд ли сможете его понять:
const f = (x = 5) => y => x + y(3);
f()(x => 7 + x); // => 15
Буквально несколько строк, но очень высокая плотность кода. К концу курса вы сможете не только понять этот код, но и научитесь так писать. Для этого нужно соблюсти несколько условий:
- Практически каждый курс на Хекслете содержит дополнительные задания (челенджи), которые помогают закрепить пройденный материал. Их нужно проходить, чтобы набить руку. К предыдущему курсу таких задач довольно много, поэтому я рекомендую остановиться и пойти выполнить хотя бы штук 5, если вы до сих пор этого не сделали.
- Привыкайте к новому синтаксису функций, даже если сейчас сложно. Уверяю вас, это всего лишь дело привычки. Хекслет не просто дает новые знания, но и старается формировать хорошие привычки.
- Рекурсия станет центральной темой в ближайших нескольких курсах. Если у вас возникли сложности с ней, то самое время их победить. Попробуйте пройти челенджи предыдущего курса, используя только рекурсию, или попросите помощи в нашем комьюнити.
- Ни в коем случае не двигайтесь вперед, пока не поняли тот урок, который проходите. Вероятность того, что вы поймете его потом, крайне мала. Скорее всего всё станет еще сложнее, так как каждый следующий урок использует знания предыдущего. Если вам что-то непонятно, пишите в «Вопросы и ответы», и мы поможем вам продвинуться.
Темы, которые мы рассмотрим в рамках этого курса:
- Значение по умолчанию
- Функции как объекты первого рода
- Функции высшего порядка
- Сложность функции
- Частичное применение
- Каррирование
Параллельно с курсом рекомендую посматривать в Википедию и знакомиться с соответствующими понятиями.
Практика в этом курсе с одной стороны достаточно простая и короткая, но порой мозговыносящая, так как придется жонглировать функциями в совершенно необычной форме. Она по-прежнему в основном ориентирована на арифметические операции, так как ваш текущий багаж знаний не позволяет рассматривать более сложные и интересные примеры, но это продлится недолго — уже со следующего курса мы переключимся на них.
JS: Функции → Guard Expression
Одни функции устроены сложнее других. Иногда так происходит в силу объективных причин (необходимая сложность). Иногда — в силу особенностей писавшего её программиста (случайная сложность). И хотя нельзя однозначно описать эту сложность, существуют способы, позволяющие хотя бы частично её оценить.
Цикломатическая сложность
Цикломатическая сложность — это структурная, или топологическая, мера сложности компьютерной программы, разработанная Томасом Дж. Маккейбом в 1976 году.
Цикломатическая сложность части программного кода — количество линейно независимых маршрутов через программный код. Если исходный код не содержит никаких точек ветвления или циклов, то сложность равна единице, поскольку есть только один маршрут через код.
Если код имеет единственный оператор if
, содержащий простое условие, то существует два пути через код: один, если условие оператора if
имеет значение true
, и один — если false
.
Такую оценку можно применять как в целом к программе, так и к отдельным функциям.
// Complexity: 1
const sum = (a, b) => a + b;
sum(1, 3); // => 4
// Complexity: 2
const abs = n => (n >= 0 ? n : -n);
abs(10); // => 10
abs(-3); // => 3
В примере выше у функции sum
цикломатическая сложность равна единице, а у функции abs
— двойке, так как она содержит ветвление, а значит два независимых пути выполнения.
Чем больше возможных путей выполнения, тем сложнее функцию понять, отладить и модифицировать. Очевидно, что, с одной стороны, функции нужно дробить, а с другой — описывать логику программы так, чтобы не появлялись лишние пути. Даже опытные разработчики часто сталкиваются с этой проблемой.
Линтеры многих языков измеряют показатель сложности и сигнализируют, если он, скажем, больше 5 для одной функции.
Guard Expression
Подход, который я опишу, также называемый «паттерном», помогает лучше структурировать функцию и иногда сократить цикломатическую сложность. Рассмотрим пример:
const f = (age, sex) => {
if (age >= 18) {
if (sex === 'male') {
return 'yes';
} else if (sex === 'female') {
return 'no';
}
}
return null;
}
В этой функции вам все должно быть знакомо. Она принимает на вход возраст и пол. Для людей, старше 18, в зависимости от пола возвращает строчку “yes” или “no”. Для всех остальных — “null”. В целом, с этой функцией все нормально, но кое-что можно улучшить.
Условие “вернуть null, если младше 18 лет” гораздо более простое и очевидное. Оно не подразумевает дальнейшего разветвления и сформулировано просто. Этим можно воспользоваться и произвести рефакторинг (улучшение работающего кода без изменения функциональности) таким образом, чтобы это условие отрабатывало первым.
const f = (age, sex) => {
if (age < 18) {
return null;
}
if (sex === 'male') {
return 'yes';
} else if (sex === 'female') {
return 'no';
}
}
Обратите внимание на то, что уровень вложенности понизился. Основная логика находится вне условных конструкций. В такой реализации функции сложнее ошибиться: все, что пишется ниже guard expression (первая проверка в данном случае), попадает под требование “от 18 и старше”, а в первом примере код для этого условия нужно не забывать вставить внутрь соответствующего условия.
Нам уже знаком такой подход. Терминальные условия в рекурсиях выглядят точно также.
В будущих практиках и в реальной жизни он встречается повсеместно. Используйте его осознанно и понижайте энтропию.
JS: Функции → Параметры по умолчанию
Опишем функцию greeting
, которая печатает на экран приветствие:
const greeting = name => console.log(`Hi, ${name}!`);
greeting('John');
// => Hi, John!
Но что произойдёт, если при вызове функции передать ей меньшее число аргументов, чем у неё установлено параметров? Давайте посмотрим:
greeting();
// => Hi, undefined!
Правило здесь простое: параметры, которым “не досталось” аргумента, автоматически инициализируются значением undefined
. Проиллюстрируем это подробнее на примере функции, которая принимает на вход три параметра и распечатывает их значения:
const foo = (a, b, c) => {
const output = `a - ${a}; b - ${b}, c - ${c}`;
console.log(output);
};
foo('I', 'am', 'here');
// a - I; b - am, c - here
foo('I', 'am');
// a - I; b - am, c - undefined
foo('I');
// a - I; b - undefined, c - undefined
foo();
// a - undefined; b - undefined, c - undefined
Бывают ситуации, когда заранее неизвестно будет ли вообще передан в ходе выполнения кода функции аргумент или будет ли переданный аргумент содержать осмысленное значение (отличное от значения undefined
). В таких случаях удобно предусмотреть какое-нибудь дефолтное значение для параметра.
Допустим, мы хотим, чтобы при вызове функции greeting
без параметров, она выводила сообщение Hi, anonymous!
.
Тогда мы можем реализовать её следующим образом:
const greeting = name => console.log(`Hi, ${name ? name : 'anonymous'}!`);
Однако, JavaScript поддерживает механизм значений по умолчанию, позволяющий немного упростить код:
const greeting = (name = 'anonymous') => console.log(`Hi, ${name}!`);
greeting();
// => Hi, anonymous!
Работает он следующим образом. Если параметр не передан, то подставляется значение по умолчанию (то, что справа от оператора =
). Если параметр передан, то переприсваивания не происходит. Параметр будет равен тому значению, которое было фактически передано в функцию.
const pow = (base, exp = 2) => base ** exp;
pow(5); // => 25
pow(5, 2); // => 25
pow(5, 1); // => 5
Кроме того, в значениях по умолчанию можно использовать идентификаторы:
const f = (x = Math.PI) => {};
И даже вызовы функций:
const f = (x, y = Math.sqrt(x)) => {};
Но не стоит злоупотреблять этим способом. Значения по умолчанию не всегда подходят для полноценного программирования.
Примечание
В этом уроке мы рассмотрели случай, когда в функцию передаётся меньше параметров, чем она ожидает. У вас может возникнуть вопрос, что будет, если при вызове передать большее количество параметров, чем установлено в определении функции?
Ответ: ничего не произойдёт они будут просто проигнорированы.
Дополнительные материалы
JS: Функции → Выполнение функций
Вспомним несколько важных понятий из прошлого курса:
Выражение — код, выполнение которого возвращает значение. Инструкция — код, представляющий собой команду.
- Выражения — вычисляются.
- Инструкции — исполняются.
К выражениям относятся:
- Вызов функции
- Арифметические и логические операции
- Тернарный оператор
- и другие
К инструкциям относятся:
for
while
break
return
if
- и другие
Необходимо знать разницу между ними — это позволит лучше понимать, как могут взаимодействовать друг с другом различные конструкции языка.
Возьмем условный оператор if
. В некоторых языках он представлен инструкцией, в некоторых — выражением. Посмотрите на код ниже и подумайте, возможно ли такое в JavaScript и почему:
const value = if (something) {
one
} else {
two
};
Такой код в JavaScript невозможен только по одной простой причине: if
— это инструкция, а не выражение .
Но такой код возможен и часто используется в языках вроде Ruby или Python. Зачем же нужен if
, если есть тернарный оператор, который как раз и является выражением?
Дело вот в чем. Если бы if
был выражением, то тернарный оператор стал бы попросту не нужен, несмотря на то, что он является более лаконичной заменой if
. Но и тернарный оператор бывает неудобен в тех ситуациях, когда вычисление слишком большое и не помещается в одну строку, а его результат должен быть записан в одну и ту же константу или переменную в каждой из веток. В такой ситуации как раз пригодился бы if
как выражение.
Этот пример ярко иллюстрирует тот факт, что конструкции языка, представленные выражением, делают язык гибче, а решения с их использованием делают код лаконичнее. Другими словами, язык становится выразительнее . Кроме описанного выше, у выражений есть еще одно огромное преимущество: они могут комбинироваться друг с другом и вкладываться друг в друга.
Ниже я продемонстрирую варианты комбинирования выражений, но с акцентом на функции:
Арифметические операции
const r1 = 5;
const r2 = 5 + 8;
const r3 = 5 + 8 - Math.PI;
const r4 = 5 + 8 - Math.PI * Math.sqrt(16);
const r5 = f1() + f2() * f3();
Вопросы могут возникнуть только в последней строчке. Насколько это допустимо? Вызов функции — это выражение, возвращающее результат, так что этот код допустим на 100%. Если функция вернет значение, неподходящее для сложения, то может возникнуть ошибка, но это будет логическая ошибка, а не синтаксическая.
Примечание: предополагается, что используемые в примерах этого урока функции и константы (например, f2 или isEditing ) ранее где-то были определены. Их определение мы убрали из примеров, чтобы не отвлекать от главного.
Логические операции
Все то же самое можно делать и с логическими выражениями:
const r1 = true;
const r2 = true || false;
const r3 = true || false && isEditing;
const r4 = true || false && isEditing || isEmpty(data);
const r5 = f1() || f2() && f3();
Из-за слабой типизации подобный код работать будет вообще всегда, даже если функции возвращают не true
или false
, но пользоваться этим не стоит. (Типизация рассматривалась в уроке предыдущего курса).
Аргументы
А теперь чуть более сложный пример. Когда мы вызываем функцию, то в аргументах ожидается выражение: func(<expression>, <expression>, ...)
. А из этого следует, что мы можем сделать так:
const r1 = f();
const r2 = f(5);
const r3 = f(5 + Math.PI);
const r4 = f(5 + Math.PI - cube(number));
const r5 = f(f1(f2(n3, f3(n1, n2))), f4());
Если последний вызов вам кажется сложным, то вспомните вот что. Когда мы изучали русский язык в школе, то постоянно делали грамматический разбор предложения, в котором выделяли существительное, подлежащее, сказуемое, различные обороты и многое другое. Здесь нужно сделать то же самое. Очень важно уметь разбивать в уме сложное выражение на составные.
Пройдемся по примеру выше. f(f1(f2(n3, f3(n1, n2))), f4())
содержит в аргументах два выражения:
f4()
f1(f2(n3, f3(n1, n2)))
- этот вызов содержит один аргумент — вызов функции:
f2(n3, f3(n1, n2))
,- который в свою очередь содержит два аргумента:
n3
и вызов функцииf3(n1, n2)
.
- который в свою очередь содержит два аргумента:
Порядок выполнения
Осталось понять, в какой последовательности происходят эти вызовы. JavaScript считается энергичным языком, то есть языком с аппликативным порядком вычисления, а это значит, что аргументы вычисляются до того, как попадают внутрь функций.
Как видно, вычисление идет с самого глубокого уровня слева направо.
JS: Функции → Объекты первого класса
Продолжая тему предыдущего урока, познакомимся с понятием “first-class citizen” или “объекты первого класса”.
Объектами первого класса в контексте конкретного языка программирования называются элементы, которые могут быть переданы как параметр, возвращены из функции или присвоены переменной. Другими словами, речь идет обо всем, что может быть данными. Самые простые типы данных — это числа и строки. Как вы потом увидите, все остальные типы данных также являются объектами первого класса.
А теперь посмотрите внимательно на этот код: const x = () => console.log('hey')
. Ничего необычного, вы видели и писали подобное множество раз. Если вы считаете, что в этом коде создается функция x
, на самом деле это не так. Здесь происходят следующие две операции:
- Создание функции:
() => console.log('hey')
- Создание константы со значением в виде функции:
const x =
Этот момент нужно хорошо прочувствовать. Минимальное определение функции, которое только возможно, выглядит так: () => {}
. Это пустая функция с пустым телом, которая не делает ничего. Присваивать ее константе или нет — вопрос отдельный. Вполне допустимо написать и выполнить подобную программу: (a, b) => a + b;
. Попробуйте поэкспериментировать в REPL.
Из примеров выше можно сделать вывод, что функция — тоже данные, ведь ее можно присвоить константе. Рассмотрим это утверждение на практике:
const identity = v => v; // функция: v => v, константа: identity
console.log(identity(10)); // => 10
const z = identity;
console.log(z === identity); // => true
const x = 5;
console.log(z(x) === identity(x)); // => true
Выше определена функция, которую обычно называют identity
. Она возвращает то значение, которое было ей передано. На практике такая функция нужна редко, но она очень хорошо подходит для демонстрации. Далее я создал новую константу, которую связал с той же функцией.
Главный вывод, который можно сделать из кода выше, заключается в том, что определение функции (не вызов!) — это выражение, а значит оно возвращает значение, а именно — функцию. А раз определение функции — выражение, возвращающее функцию, то мы можем попробовать вызвать ее без создания промежуточной константы:
// Определяем функцию v = v и тут же вызываем ее
(v => v)('run'); // => run
// Тот же код с использованием промежуточной константы.
// Попробуйте мысленно заменить `identity` на `v => v`, тогда
// получится (v => v)('run'). С выражениями так можно поступать всегда.
// const identity = v => v;
// identity('run'); // => run
Скобки вокруг определения функции — не какой-то особый синтаксис. Здесь они используются с той же целью, что и в арифметических операциях — для группировки. Без них выражение v => v('run')
приобретает совсем другой смысл. В этом случае в нем создается функция, принимающая на вход другую функцию v
и вызывающая ее внутри с аргументом 'run'
.
Попробуем усложнить:
identity(v => v)('run'); // => run
// (v => v)(v => v)('run') // => run
Первым идет пример вызова функции по идентификатору, а во втором примере я заменил идентификатор на определение функции, сделав подстановку. Результат получился тот же самый. Еще раз посмотрите на этот шаблон (<тут определение функции>)()
. Попробуйте самостоятельно разобрать пример ниже:
((a, b) => a + b)(3, 2); // => 5
// const sum = (a, b) => a + b;
// sum(3, 2); // => 5
Теперь попробуем использовать функции как данные:
const sqrt = identity(Math.sqrt);
console.log(sqrt === Math.sqrt); // true
sqrt(4); // => 2
В первой строчке вызывается функция identity
, в которую передается Math.sqrt
. Результатом этого вызова будет все та же функция Math.sqrt
.
Здесь мы видим сразу два новых аспекта: передача функции как аргумента и возврат функции как значения. Функции, которые принимают на вход другие функции или возвращают другие функции, называются функциями высшего порядка . В функциональных языках большинство задач, связанных с обработкой данных, работают именно через них. JavaScript в этом смысле ведет себя точно также.
В следующем примере внутрь функции передается другая функция, определяемая в аргументах. В комментариях показан альтернативный способ через создание константы с функцией.
const sum = identity((a, b) => a + b);
sum(3, 5); // => 8
// const f = (a, b) => a + b;
// const sum = identity(f);
// sum(3, 5); // => 8
Подобная передача функций в функции с определением прямо в аргументах встречается повсеместно в реальном коде. Как правило, те функции, которые передаются в аргументах, нужны только здесь и сейчас.
Возникает вопрос: есть ли имя у функций, определенных подобным образом? Имени нет даже у такой функции const f = () => {}
. Мы просто связали константу с функцией, но сама функция ничего про константу не знает. Звучит слегка безумно, но это так. Ведь мы можем взять и связать эту функцию уже с другой константой. По этой причине такие функции часто называют анонимными. Другое распространенное название — лямбда-функция. Своим названием лямбда-функция обязана лямбда-исчислению (математическая формальная система, легшая в основу языков семейства lisp). Только в отличие от языков программирования, “лямбды” в лямбда-исчислении — всегда функции от одного аргумента, поэтому общее с функциями из js у них, в первую очередь, анонимность, и то, что они являются объектами первого класса.
Попробуем сделать что-нибудь полезное. Иногда встречается задача, в рамках которой нужно применить одну и ту же функцию несколько раз, например, так: Math.sqrt(Math.sqrt(16))
. Создав функцию высшего порядка, можно упростить эту задачу. Рассмотрим пример с двойным применением одноаргументной функции:
const callTwice = (f, arg) => f(f(arg));
callTwice(Math.sqrt, 16); // => 2
callTwice(x => x ** 2, 3); // => 81
// const f = x => x ** 2;
// f(f(3));
callTwice
применяет переданную функцию к своему аргументу два раза. Если расписать подробнее, то происходит следующее:
const res1 = f(arg);
const res2 = f(res1);
return res2;
JS: Функции → Лексическое окружение (LexicalEnvironment)
Понимание работы функций в немалой степени зависит от знания некоторых деталей реализации языка. К таким деталям относятся окружения (Environment). Разговор о них шел в соответствующем уроке «Введения в программирование». Советую освежить память, пересмотрев тот урок или перечитав конспект. После этого урока вы будете еще лучше разбираться в окружениях.
Познакомимся с термином «словарь». Словарь — это набор пар «ключ - значение» . Зная ключ, можно получить значение. Точно так же, как и при работе с обычным бумажным словарем, где ключ — это слово, а значение — определение слова. В данном уроке важно только концептуальное понимание, без конкретных реализаций.
Каждый раз, когда в программе вызывается функция, внутри интерпретатора создается специальный словарь LexicalEnvironment (лексическое окружение), привязанный к этому вызову. Все определения констант, переменных и прочего внутри функции автоматически записываются в словарь. Имя определения (идентификатор, то есть имя константы, переменной и так далее) становится ключом, а значение определения становится значением в словаре. К таким определениям относятся аргументы, константы, функции, переменные и т.д. Лексическое окружение — это хранилище для данных в памяти и механизм для извлечения этих данных при обращении.
В примере ниже в комментариях показано состояние словаря перед выполнением каждой строчки кода. Не забывайте, что наполнение словаря происходит при вызове функции, а не при определении.
const showWarning = (field) => {
// LexicalEnvironment = { field: 'email' }
const warning = `verify your ${field}, please`;
// LexicalEnvironment = { warning: 'verify your email, please', field: 'email' }
console.log(warning);
}
showWarning('email'); // => verify your email, please
Код console.log(warning)
активизирует поиск значения идентификатора warning
в лексическом окружении.
В процессе выполнения функции значения переменных могут меняться, что сразу же отражается в лексическом окружении. После выполнения функции её лексическое окружение уничтожается, а занятая им память освобождается.
Из этого поведения есть исключение — возврат функции. В следующем уроке мы рассмотрим связанный с ним механизм так называемых «замыканий». Ранее мы разбирали его в «Введении в программирование».
Окружение есть не только у функций. Любой идентификатор, определенный на уровне модуля, попадает в лексическое окружение модуля. Кроме того, существует и глобальное окружение. Благодаря ему мы с легкостью используем в JS такие функции, как console.log
или Math.sqrt
, даже особо не задумываясь, откуда они берутся.
const number = 5;
const square = () => number ** 2;
square(); // => 25
Такой код работает — и это для нас не секрет, но как он вяжется с механизмом окружений? А вот как: интерпретатор производит поиск значения идентификатора не только в локальном лексическом окружении (в том, где используется идентификатор), но и во внешнем окружении . Поиск начинается с локального окружения, и если в нём не найден нужный идентификатор, то просмотр идет дальше, вплоть до уровня модуля, а затем и до глобального уровня.
Внешним окружением по отношению к функции считается окружение, в котором функция была объявлена (а не вызвана!). Если разбить пример выше на два файла, то разница станет очевидной.
Так сработает:
// module1.js
const number = 5;
export const square = () => number ** 2;
// module2.js
import { square } from './module1';
square(); // => 25
А так нет:
// module1.js
export const square = () => number ** 2;
// module2.js
import { square } from './module1';
const number = 5;
square(); // => ReferenceError: number is not defined
Если подумать логически, так и должно быть. Представьте: если бы сработал второй вариант, то автоматически это бы означало, что вы можете случайно создать имя переменной, совпадающее с именем переменной внутри функции, написанной другим человеком. Как при этом будет работать код — предположить невозможно.
Попробуйте самостоятельно ответить на вопрос: сработает ли такой код, в котором константа определена позже её использования внутри функции?
const square = () => number ** 2;
const number = 5;
square(); // => 25
Ответ: сработает.
Окружение — это не «всё, что было объявлено до функции, в которой я использую эти объявления». Не важно, что number
появился позже использования внутри функции. Главное, что вызов функции square
происходит позже определения number
, а значит к этому времени идентификатор уже был добавлен в окружение, внутри которого была создана функция square
.
Переменные
Когда мы работаем с константами, всё просто. Нет изменений — нет проблем. В случае с переменными ситуация становится сложнее.
const square = () => number ** 2;
let number = 5;
square(); // => 25
number = 3;
square(); // => 9
Изменение переменной следует читать как «изменение значения ключа в окружении». Соответственно, обращение к number
всегда вернет последнее присвоенное значение. Завязка на переменные, описанная в коде выше, должна восприниматься как абсолютное зло. Она порождает неявные зависимости, сложный код и отладку. Функция автоматически перестает быть чистой, так как начинает зависеть от внешнего контекста.
Вложенные функции
Напомню вам слегка модифицированный код курса «Введение в программирование»:
const factorial = (n) => {
const iter = (counter, acc) => {
if (counter > n) {
return acc;
}
return iter(counter + 1, counter * acc);
};
return iter(1, 1);
};
factorial(5); // => 120
В этом коде реализовано вычисление факториала с применением итеративного процесса. Внутри функции factorial
определяется внутренняя функция iter
, которая накапливает аккумулятор, вызываясь рекурсивно. Условие выхода из рекурсии — попытка посчитать число большее, чем нужно.
В этой проверке используется переменная n
, которая явно в iter
не передавалась. Но благодаря тому, как работают окружения, любые функции (в том числе и вложенные), определенные внутри factorial
, имеют к ней доступ. Как видно из кода, n
используется как константа, а значит такое использование абсолютно безопасно.
Перекрытие (Shadowing)
Перекрытием называется ситуация, когда во внутреннем окружении создается идентификатор с таким же именем, как и во внешнем. Причем не важно, что это: аргумент функции, константа или переменная.
const f = (coll) => {
const iter(item, coll) => {
// using coll
}
// ...
}
Несмотря на то, что сам код остается рабочим, перекрытие больше не позволяет обратиться к идентификатору из внешнего окружения, ведь поиск всегда происходит сначала в локальном окружении , а уже затем во внешних. Но еще большей проблемой является то, что такой код сложнее в анализе. Глядя на него недостаточно видеть имена, нужно также учитывать их контекст, так как одно и то же имя на разных строках может означать разные вещи. Если запустить линтер для подобного кода, то он укажет на перекрытие как на плохую практику программирования. Подробнее об этом можно прочитать в правилах Eslint.
JS: Функции → Возврат функций из функций
По своему опыту могу сказать, что возврат функций из функций вызывает наибольшие сложности у новичков. И дело даже не в том, что возврат сложен сам по себе, а в том, что поначалу очень сложно понять, зачем это может понадобится. В реальной жизни эта техника используется часто, причем как в JS, так и во многих других языках. Функции, принимающие на вход функции, которые возвращают функции — обычное дело для любого кода на js.
Для закрепления материала пройдитесь по нему два раза. Первый раз просто бегло прочитайте, второй раз изучите внимательно, проверяя каждую строчку кода на сервисе repl.it.
Начнем погружение с уже пройденного материала:
const identity = v => v;
identity('wow'); // => wow
const sum = identity((a, b) => a + b);
sum(1, 8); // => 9
Функции — это такие же данные, как числа или строки, поэтому функции можно передавать в другие функции в виде аргументов, а также возвращать из функций. Мы даже можем определить функцию внутри другой функции и вернуть её наружу. И в этом нет ничего удивительного. Константы можно создавать где угодно.
const generateSumFinder = () => {
const sum = (a, b) => a + b; // создали функцию
return sum; // и вернули её
};
const sum = generateSumFinder(); // sum теперь — функция, которую вернула функция generateSumFinder
sum(1, 5); // => 6 // sum складывает числа
Можно даже обойтись без промежуточного создания константы:
// вызвали функцию, которая возвращает функцию,
// и тут же вызвали возвращенную функцию
generateSumFinder()(1, 5); // => 6
// ((a, b) => a + b)(1, 5)
Всегда, когда видите подобные вызовы f()()()
, знайте: функции возвращаются !
Теперь посмотрим, как еще можно описать функцию generateSumFinder
:
// предыдущий вариант для сравнения
// const generateSumFinder = () => {
// const sum = (a, b) => a + b;
// return sum;
// };
// новый вариант
const generateSumFinder = () => (a, b) => a + b;
Для понятности можно расставить скобки:
const generateSumFinder = () => ((a, b) => a + b);
Определение функции обладает правой ассоциативностью. Все, что находится справа от =>
, считается телом функции . Количество вложений никак не ограничено. Вполне можно встретить и такие варианты:
const sum = x => y => z => x + y + z;
// расставим скобки для того чтобы увидеть как функции вложены друг в друга
// const sum = x => (y => (z => x + y + z));
sum(1)(3)(5); // => 9
Ту же функцию можно представить другим способом, вынеся каждую функцию в свою собственную константу. Этот способ полезен как мысленный эксперимент, чтобы понять где заканчивается одна и начинается другая функция, но сам по себе он не заработает, потому что теряется замыкание.
const inner1 = z => x + y + z;
const inner2 = y => inner1;
const sum = x => inner2;
Попробуем последовательно пройтись по вызовам функции выше, чтобы понять, как получается результат. После каждого вызова (кроме последнего) возвращается новая функция, в которую подставлено значение из внешней функции за счет замыкания.
sum(1)(3)(5); // => 9
const sum1 = x => y => z => x + y + z;
// sum(1);
const sum2 = y => z => 1 + y + z; // inner2
// sum(1)(3)
const sum3 = z => 1 + 3 + z; // inner1
// sum(1)(3)(5)
const sum4 = 1 + 3 + 5; // => 9
Как видно выше, sum1
, sum2
и sum3
— это функции, а sum4
уже число, так как были вызваны все внутренние функции.
Давайте распишем все функции:
const sum = x => y => z => x + y + z;
// const sum = x => (y => (z => x + y + z));
- Функция
sum
принимаетx
и возвращает функцию, которая- принимает
y
и возвращает функцию, которая- принимает
z
и возвращает функцию, которая- возвращает сумму x + y + z
- принимает
- принимает
Попробуем развить идею функции callTwice
из предыдущего урока. Напишем функцию generate
, которая не применяет функцию сразу, а генерирует новую.
const generate = f => arg => f(f(arg));
// const generate = f => (arg => f(f(arg)));
Функция generate
принимает функцию в качестве аргумента и возвращает новую функцию. Внутри новой функции переданная изначально функция вызывается два раза:
Создадим функцию f1
. Она будет той функцией, которую вернет generate
если передать ей функцию Math.sqrt
(она вычисляет квадратный корень числа).
Получается, f1
— это функция, которая принимает число и возвращает корень корня — Math.sqrt(Math.sqrt(x))
:
const f1 = generate(Math.sqrt);
f1(16); // => 2
// generate(Math.sqrt)(16);
Ещё пример: передадим в функцию generate
новую функцию на ходу, без предварительного создания. Переданная функция возводит число в квадрат.
const f2 = generate(x => x ** 2);
f2(4); // => 256
// generate(x => x ** 2)(4);
Теперь функция f2
возводит число в квадрат два раза: (42)2.
Функция generate
имеет такое имя не просто так. Дело в том, что возврат функции порождает каждый раз новую функцию при каждом вызове, даже если тела этих функций совпадают:
const f1 = generate(x => x ** 2);
const f2 = generate(x => x ** 2);
console.log(f1 === f2); // => false
Поэтому про любую функцию, которая возвращает функцию можно сказать что она генерирует функцию. Запомнить довольно просто, если вы где-то слышите или читаете что происходит генерация функций, значит кто-то их возвращает.
Замыкание
Работа практически всех описанных примеров базировалась на одном интересном свойстве, которое называется «замыкание». О нем говорилось в предыдущем курсе, но пришло время освежить память.
const generateDouble = f => arg => f(f(arg));
const f1 = generateDouble(Math.sqrt);
Когда generateDouble
закончила работу и вернула новую функцию, экземпляр функции generateDouble
исчез, уничтожился вместе с используемыми внутри аргументами.
Но та функция, которую вернула generateDouble
все еще использует аргумент. В обычных условиях он бы навсегда исчез, но тут он «запомнился» или «замкнулся» внутри возвращенной функции. Технически внутренняя функция, как и любая другая в JS, связана со своим лексическим окружением, которое не пропадает, даже если функция покидает это окружение.
Функция, которая была возвращена из generateDouble
, называется замыканием . Замыкание — это функция, «запомнившая» часть окружения, где она была задана. Функция замыкает в себе идентификаторы (все, что мы определяем) из лексической области видимости.
В СИКП дается прекрасный пример на понимание замыканий. Представьте себе, что мы проектируем систему, в которой нужно запомнить пароль пользователя, а потом проверять его, когда пользователь будет заново заходить. Можно смоделировать функцию savePassword
, которая принимает на вход пароль и возвращает предикат, то есть функцию, возвращающую true или false, для его проверки. Посмотрите, как это выглядит:
const secret = 'qwerty';
// Возвращается предикат.
const isCorrectPassword = savePassword(secret);
// Теперь можно проверять
console.log(isCorrectPassword('wrong password')); // => false
console.log(isCorrectPassword('qwerty')); // => true
А вот как выглядит код функции savePassword
:
const savePassword = password => passwordForCheck => password === passwordForCheck;
Возврат функций в реальном мире (Debug)
Логгирование — неотъемлемая часть разработки. Для понимания того, что происходит внутри кода, используют специальные библиотеки, с помощью которых можно логгировать (выводить) информацию о проходящих внутри процессах, например в файл. Типичный лог веб-сервера, обрабатывающего HTTP запросы выглядит так:
[ DEBUG] [2015-11-19 19:02:30.836222] accept: HTTP/1.1 GET - / - 200, 4238
[ INFO] [2015-11-19 19:02:32.106331] config: server has reload its config in 200 ms
[WARNING] [2015-11-19 19:03:12.176262] accept: HTTP/1.1 GET - /info - 404, 829
[ ERROR] [2015-11-19 19:03:12.002127] accept: HTTP/1.1 GET - /info - 503, 829
В js самой популярной библиотекой для логгирования считается Debug. Вот как выглядит ее вывод:
Обратите внимание на левую часть каждой строки. Debug для каждой выводимой строчки использует так называемый неймспейс, некоторую строчку, которая указывает принадлежность выводимой строчки к определенной подсистеме или части кода. Он используется для фильтрации, когда логов становится много. Другими словами, можно указать “выводи сообщения только для http”. А вот как это работает:
import debug from 'debug';
const logHttp = debug('http');
const logHandler = debug('handler');
logHttp('hello!');
logHttp('i am from http');
logHandler('hello from handler!');
logHandler('i am from handler');
Что приведет к такому выводу:
http hello! +0ms
http i am from http +2ms
handler hello from handler! +0ms
handler i am from handler +1ms
Получается, что импортированный debug
— это функция, которая принимает на вход неймспейс в виде строки и возвращает другую функцию, которая уже используется для логгирования.
JS: Функции → Частичное применение
Частичное применение функций, техника основанная на возможности возвращать функции из других функций. Допустим у нас есть функция sum
суммирующая три числа.
const sum = (a, b, c) => a + b + c;
sum(4, 3, 1); // 8
sum(1, 1, 1); // 3
Частичное применение позволяет на основе старой функции, создать новую, которая “частично применена”. Для начала вспомним что такое применение функции. Математики никогда не говорят что функция вызывается с некоторыми аргументами, вместо этого они говорят что функция была применена к этим аргументам. В примере выше функция sum
была применена к трем аргументам 4
, 3
и 1
. Такое применение можно назвать полным, то есть в функцию было передано столько аргументов сколько и ожидалось. Здесь возникает вопрос, а можно было по другому? Да можно.
Частичное применение, техника которую проще всего объяснить в отрыве от языков программирования, на языке близком к математике (для простоты назову его Ha
). Представьте что наша функция выше, имеет вот такое определение:
-- Слева имя функции, затем идет список параметров
-- разделенных пробелами и после знака => тело функции
sum a b c => a + b + c
А ее вызов выглядит так:
-- Скобок и запятых нет, но это равносильно sum(4, 3, 1) в js
sum 4 3 1 -- 8
sum 1 1 1 -- 3
Ha
необычный язык, если внутри него вызвать функцию с неполным набором параметров, то в отличии от js он не вызовет саму функцию (или не упадет с ошибкой как во многих других языках). Он вернет новую функцию, которая “частично применена”.
-- sum2 новая функция, полученная частичным применением функции sum к числу четыре
-- Применение "частичное" потому что в функцию sum передается только один параметр, а не три
sum2 = sum 4
sum2 1 1 -- 6
sum2 3 2 -- 9
Другими словами, такой вызов создает новую функцию, которая работает точно так же как и исходная, но, при этом, часть ее аргументов как будто уже подставлены. То есть наш вызов sum2 1 1
в действительности приведет к вызову sum 4 1 1
. Этот трюк работает с любым количеством аргументов. Посмотрите на частичное применение двух аргументов:
sum3 = sum 1 1
-- sum3 принимает только один аргумент
sum3 2 -- 4
sum3 1 -- 3
Арифметика аргументов очень простая. Если исходная функция принимала 3 параметра, то после частичного применения одного параметра, новая функция будет принимать на вход два параметра (2 + 1 = 3), если частично применились два параметра, то новая функция принимает на вход 1 параметр. А можно ли частично применить три параметра для функции которая принимает на вход 3 параметра? Конечно нет, ведь это будет обычный вызов функции со всеми параметрами. Довольно несложно догадаться как выглядело бы определение функций sum2
и sum3
если бы мы их описали явно:
sum a b c => a + b + c
sum2 b c => 4 + b + c
sum3 c => 1 + 1 + c
Ha
настолько необычный язык, что позволяет частично применять то что уже было частично применено:
-- Создаем первую функцию с 3 - 1 аргументами
sum4 = sum 2
-- Создаем вторую функцию с 2 - 1 аргументами
sum5 = sum4 3
-- Вызываем то что получилось. В реальности вычисляется 2 + 3 + 1
sum5 1 -- 6
sum5 4 -- 9
В js эта возможность не встроена прямо в сам язык, но ее можно реализовать с помощью дополнительной функции:
const sum = (a, b, c) => a + b + c;
// Берем исходную функцию sum и отдаем ее в функцию partialApply
const sumWithFour = partialApply(sum, 4);
sumWithFour(3, 1); // 8
sumWithFour(10, 3); // 17
const sumWithFourAndTwo = partialApply(sumWithFour, 2);
sumWithFourAndTwo(3); // 9
Функция partialApply
принимает на вход исходную функцию и параметры которые нужно применить, а возвращает новую, частично примененную функцию.
Вот как может выглядеть реализация partialApply
для функций от трёх аргументов (таких как sum
):
const partialApply = (fn, arg1) => (arg2, arg3) => fn(arg1, arg2, arg3);
Немного примеров из реальности. Предположим, что мы пишем программу, вычисляющую зарплату людей разных специальностей по всему миру. Для этого используется функция getAverageSalary
.
const salary1 = getAverageSalary('programmer', 'spain');
const salary2 = getAverageSalary('programmer', 'russia');
const salary3 = getAverageSalary('programmer', 'usa');
Первым параметром функция принимает на вход название профессии, вторым — страну, на выходе — годовая зарплата. Все довольно примитивно, но часть данных дублируется. Вычисляется зарплата программистов в разных странах, что приводит к постоянному вызову функции с первым параметром programmer
. В этом нет ничего криминального, но существует способ устранить подобное повторение. Убрать с помощью введения дополнительной константы.
const job = 'programmer'
const salary1 = getAverageSalary(job, 'spain');
const salary2 = getAverageSalary(job, 'russia');
const salary3 = getAverageSalary(job, 'usa');
Название профессии больше не повторяется, но нам по-прежнему приходится подставлять его в каждый вывод. Здесь на помощь приходит так называемое частичное применение функции .
const getProgrammersSalaryByCountry = partialApply(getAverageSalary, 'programmer');
const salary1 = getProgrammersSalaryByCountry('spain');
const salary2 = getProgrammersSalaryByCountry('russia');
const salary3 = getProgrammersSalaryByCountry('usa');
Принцип действия частичного применения функции основан на генерации (в нашем случае с помощью функции partialApply
) новой функции оборачивающей старую, причем так, что она принимает на вход меньшее количество аргументов. Для нашей задачи мы применили один аргумент, но это не обязательно. Применять можно любое число аргументов исходной функции (но, естественно, не все, ведь в таком случае мы получим обычный вызов функции). И хотя пример выше выглядит слегка искусственным, в реальной жизни этот прием применяется часто. Причем нередко частично применяется больше одного аргумента за раз.
Тело функции getProgrammersSalaryByCountry
выглядит предсказуемо. Внутри вызывается исходная функция с подставленным в аргумент значением.
const getProgrammersSalaryByCountry = country => getAverageSalary('programmer', country);
А partialApply
для функций от двух аргументов, реализуется способом очень похожим на реализацию flip
.
const partialApply = (fn, arg1) => arg2 => fn(arg1, arg2);
То есть функция partialApply
возвращает (генерирует) функцию, которая внутри себя замыкает два параметра: fn
и arg1
.
Частичное применение для JS — практически как воздух для живых организмов. Из-за асинхронной природы JS часто бывают ситуации, когда одни параметры функции задаются в одном месте, а другие в другом, так как они становятся доступны только после выполнения асинхронной операции. Поскольку вы еще не обладаете достаточными знаниями для понимания соответствующего кода, я не буду приводить более сложных примеров, чтобы не сбивать вас с толку, но уверяю, что в дальнейших курсах все, что изучалось здесь, будет использоваться на полную катушку.
JS: Функции → Каррирование
Каррирование — очень интересная техника, позволяющая сильно упростить использование частичного применения на практике. Многие путают эти понятия, но, как вы увидите далее, они обозначают совершенно разные вещи.
В предыдущем уроке нам приходилось писать функцию-обертку для применения.
const getProgrammersSalaryByCountry =
country => getAverageSalary('programmer', country);
const salary1 = getProgrammersSalaryByCountry('spain');
const salary2 = getProgrammersSalaryByCountry('russia');
const salary3 = getProgrammersSalaryByCountry('usa');
Eсли бы наша функция getAverageSalary
была каррирована, то все было бы значительно проще. Каррирование — это процесс превращения функции от n
аргументов в цепочку вложенных n
-функций от одного аргумента. Соответственно, каррированная функция — это множество функций от одного аргумента.
Предположим, что у нас есть функция const sum = (a, b, c) => a + b + c
, которая складывает три числа. Тогда ее каррированная версия будет выглядеть так: const sum2 = a => b => c => a + b + c
, а использование таким: sum2(5)(10)(-2)
. То же самое, разложенное по функциям:
const sum2 = (a) => {
return (b) => {
return (c) => {
return a + b + c;
};
};
};
Посмотрите внимательно на определение a => b => c => a + b + c
. Эта запись очень краткая и одновременно очень емкая. Суммарное количество функций считается очень легко, оно равно сумме всех стрелок =>
. Для лучшего понимания можно добавить скобки a => (b => (c => a + b + c))
. Для каждого определения функции все, что находится справа от стрелки, является ее телом, каким бы сложным оно не было. Поначалу такая запись может помочь понять, как друг в друга вложены функции, но со временем, когда вы привыкните к обычной записи, она будет больше мешать.
С другой стороны, если нам нужно каррировать существующую функцию без ее реализации, то делается это так: const sum2 = a => b => c => originalSum(a, b, c)
. То есть создается цепочка вложенных функций, в которой количество вложений равно количеству аргументов исходной функции и в конце которой происходит вызов оригинальной функции.
Разберем происходящее по шагам.
const sum = a => b => c => a + b + c;
const sum1 = sum(10); // sum1 = b => c => 10 + b + c
const sum2 = sum1(3); // sum2 = c => 10 + 3 + c
const result = sum2(0); // result = 10 + 3 + 0
console.log(result); // 13
То же самое происходит и при таком вызове: sum(10)(3)(0)
, разница только в том, что вызовы происходят без создания промежуточных констант.
И задача для самоконтроля. Сколько раз нужно вызвать цепочку функций const greeting = () => () => () => () => console.log('Hey!')
, чтобы дойти до конца? Обязательно попробуйте прямо сейчас выполнить это задание на repl.it.
Теперь вернемся к нашей функции расчета зарплаты в ее каррированном виде. Представим, что теперь у нас в распоряжении две вложенных функции от одного аргумента: const getAverageSalary = job => country => /* body */
:
const salary1 = getAverageSalary('programmer')('spain');
const salary2 = getAverageSalary('programmer')('russia');
const salary3 = getAverageSalary('programmer')('usa');
Попробуем частично применить:
const getProgrammersSalaryByCountry = getAverageSalary('programmer');
const salary1 = getProgrammersSalaryByCountry('spain');
const salary2 = getProgrammersSalaryByCountry('russia');
const salary3 = getProgrammersSalaryByCountry('usa');
Применение в реальной жизни
В функциональных языках, подобных haskell, вопросов об использовании каррированных функций просто не встает. Они там используются постоянно, начиная с самых азов. Это одна из причин, почему стоит учить подобные языки, даже если вы не будете писать на них программы. В императивных языках такое происходит значительно реже, но не в JS. JS по своей сути очень близок к функциональным языкам. Изначально он должен был быть написан на Scheme, языке из семейства Lisp, и функции в нем занимают центральное место. Каррирование используется в различных библиотеках и иногда является их ключевой “фичей”, как, например, здесь: https://github.com/substantial/updeep.