Две парадигмы

Когда мы говорим об определенном языке программирования или стиле программирования как о «функциональном» или «императивном», мы имеем в виду парадигмы языка программирования. Эти парадигмы являются классификациями языков программирования, и обе эти парадигмы существуют в Javascript.

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

Императивное программирование состоит из серии команд, которые изменяют состояние системы. Часто говорят, что императивное программирование фокусируется на описании того, как работает программа.

Функциональные методы в JavaScript, на которых мы сосредоточимся, — это операции со списками: map, filter и reduce. Логика для них определяется передачей функции в качестве параметра, известной как лямбда-функция.

Аргументы за и против

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

Сравнивать подобное с подобным

map

В JavaScript функция map создает новый массив с результатами выполнения лямбда-функции для каждого элемента массива. Например, мы можем удвоить каждый элемент в массиве следующим образом:

const array = [1, 2, 3];
const newArray = array.map(number => number * 2);
console.log(newArray);
// [2, 4, 6]

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

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

const array = [1, 2, 3];
for (let i = 0; i < array.length; i++) {
    array[i] = array[i] * 2;
}
console.log(array);
// [2, 4, 6]

filter

Функция filter сравнивает каждый элемент с функцией оценки, и если функция оценки возвращает true для этого элемента, он остается в массиве, если нет, то отбрасывается. Например:

const array = [1, 2, 3];
const newArray = array.filter(number => number < 3);
console.log(newArray);
// [1, 2]

Эквивалентная операция в императивном стиле будет следующей, обратите внимание на перевернутое условие:

const array = [1, 2, 3];
for (let i = 0; i < array.length; i++) {
    if (array[i] > 3) {
        array.splice(i, 1);
    }
}
console.log(array);
// [1, 2]

Мы используем здесь splice, потому что ключевое слово delete не закрывает создаваемый им пробел, вместо этого оставляя значение undefined и сохраняя длину массива прежней. Первый аргумент для splice — это начальный индекс, а второй — количество удаляемых элементов. Как и другие императивные методы, splice изменяет массив на месте.

reduce

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

В этом конкретном случае лямбда-функция известна как редуктор, и по мере продвижения по массиву мы постепенно модифицируем и создаем «аккумулятор», одно выходное значение. На этот раз сигнатура лямбда-функции немного отличается: первым элементом является accumulator, а вторым элементом является текущий элемент, который мы рассматриваем. Кроме того, сам reduce принимает не только функцию редуктора, но и необязательное начальное значение. Это важно в определенных сценариях, когда ваши операции могут привести к нежелательному результату из-за приведения типов.

У нас есть следующий пример, где мы хотим суммировать весь массив:

const array = [1, 2, 3, 4, 5];
const sum = array.reduce((accumulator, next) => accumulator + next);
console.log(sum);
// 15

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

При первом выполнении, поскольку мы не указали начальное значение, начальное значение accumulator по умолчанию равно первому значению в массиве, то есть 1. next — следующий элемент массива, поэтому 2. Теперь мы заявляем, что хотим, чтобы следующее значение accumulator — возвращаемое функцией значение — было результатом accumulator + next, что, конечно же, равно 3. Этот процесс продолжается до тех пор, пока массив не будет исчерпан.

╔══════════════════╦═════════════╦══════╦══════════════╗
║    Iteration     ║ Accumulator ║ Next ║ Return value ║
╠══════════════════╬═════════════╬══════╬══════════════╣
║ First iteration  ║           1 ║    2 ║            3 ║
║ Second iteration ║           3 ║    3 ║            6 ║
║ Third iteration  ║           6 ║    4 ║           10 ║
║ Fourth iteration ║          10 ║    5 ║           15 ║
╚══════════════════╩═════════════╩══════╩══════════════╝

В отличие от map, где введенный массив всегда имеет ту же длину, что и выходной массив, если ваш выходной массив может иметь другую длину или фактически другой тип по сравнению с введенным массивом, вам, скорее всего, понадобится reduce.

Эквивалентная программа в императивном стиле будет выглядеть следующим образом:

let sum = 0;
const array = [1, 2, 3, 4, 5];
for (let i = 0; i < array.length; i++) {
    sum += array[i];
}
console.log(sum);
// 15

Здесь мы заполняем значение счетчика, увеличивая его на значение, содержащееся в массиве.

Из-за того, как типы оцениваются в JavaScript, мы можем внести небольшое изменение, чтобы получить принципиально другой результат. Инфиксный оператор + перегружен в JavaScript, что означает, что он может выполнять более одной операции в зависимости от аргументов. Предыдущий пример передал тип Number в левой части операции +, поэтому он оценивался как сложение. Однако если мы изменим начальное значение на '' (пустая строка), так что левая часть теперь имеет тип String, операция оценивается как конкатенация строк, при этом значение next преобразуется в String на каждом этапе. Таким образом, у нас есть код:

const array = [1, 2, 3, 4, 5];
const sum = array.reduce((accumulator, next) => accumulator + next, '');
// '12345'

Итак, серия итераций выглядит следующим образом:

╔══════════════════╦═════════════╦══════╦═══════════════╗
║    Iteration     ║ Accumulator ║ Next ║ Return value  ║
╠══════════════════╬═════════════╬══════╬═══════════════╣
║ First iteration  ║ ''          ║    1 ║ '1'           ║
║ Second iteration ║ '1'         ║    2 ║ '12'          ║
║ Third iteration  ║ '12'        ║    3 ║ '123'         ║
║ Fourth iteration ║ '123'       ║    4 ║ '1234'        ║
║ Fifth iteration  ║ '1234'      ║    5 ║ '12345'       ║
╚══════════════════╩═════════════╩══════╩═══════════════╝

Мой любимый способ использования сокращения — использовать его для создания объектов. Текущее предложение в JavaScript для обработки объектов как итерируемых выглядит тусклым, и reduce, возможно, является единственным прилично выразительным способом перехода от одного типа к другому без явного принуждения или промежуточных шагов. К сожалению, он работает только в одном направлении, от Array до Object. Несмотря на это, он обычно позволяет создавать очень элегантные решения. Например, возьмем задачу подсчета количества раз, когда определенная строка встречается в массиве.

const array = ['a', 'b', 'a', 'c', 'b', 'a'];
const tally = array.reduce((accumulator, next) => ({
    ...accumulator,
    [next]: accumulator[next] + 1 || 1
}), {});
console.log(tally);
// { a: 3, b: 2, c: 1 }

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

Немного поясню: чтобы получить возвращаемое значение лямбда-функции, мы распространяем текущий аккумулятор на возвращаемое значение, чтобы не потерять предыдущие данные, а затем указываем, что свойство со значением next, т.е. 'a', 'b' или 'c' — следует либо увеличить, если он уже существует, либо создать экземпляр со значением 1.

Хорошее время для использования for

breaking

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

Скажем, ради аргумента, вы не хотите, чтобы какой-либо элемент был больше 100, и вы знали, что ваш массив отсортирован в порядке возрастания, вы могли бы break — выйти из цикла — на итерации, где вы нарушите это условие.

const array = [1, 2, 3, 49, 51, 53];
for (let i = 0; i < array.length; i++) {
    if (array[i] > 100 / 2) {
        break;
    }
    array[i] = array[i] * 2;
}
console.log(array);
// [2, 4, 6, 98, 51, 53]

Чанкинг

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

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

const monthData = [/* ... */]
for (let i = 0; i < monthData.length; i += 12) {
    /* do something */
}

Учитывая другие элементы

Более уместно использовать императивный стиль, когда нам интересно посмотреть на соседние элементы или любые другие элементы в массиве.

Например, скажем, мы анализируем список показаний и хотим иметь возможность идентифицировать внезапные всплески или падения, которые определяются как увеличение или уменьшение некоторого произвольного значения по сравнению с предыдущими значениями. Если бы мы хотели получить доступ к соседним элементам в императивном стиле, мы бы расширили использование нотации с квадратными скобками, и таким образом решение было бы единообразным и непротиворечивым. Однако, если бы мы попытались сделать то же самое в функциональном стиле, мы обнаружили бы, что нам все еще нужно реализовать нотацию с квадратными скобками, и поэтому теперь мы смешиваем два способа доступа к нашим элементам массива. Затем мы можем использовать это обозначение в квадратных скобках для третьего (или четвертого при использовании сокращения) параметра лямбда-функции, но этим мы искушаем нарушение неизменности. Или мы можем использовать его в массиве, как он определен в верхней области видимости, но при этом мы выходим за пределы аккуратного и автономного контекста лямбда-функции и нарушаем его.

Подведение итогов

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

Думаете, ClearScore – это интересное место для работы? Мы нанимаем: clear-score.workable.com