13.03.2024

[javascript:perf] Сравнение .reduce, .map, .filter, for

Введение

Зачастую, решение задачи, которое возможно написать кодом на языке Javascript, может быть различным. Критерии, оценивающие производительность кода, также зачастую могут различаться. Кто-то обращает внимание на читаемость, потому что ему важен порог входа и простота решения. А кто-то, в ущерб читаемости, уделяет производительности больше внимания. 

Но в то же время, человек привыкает ко всему. И, если привыкнуть писать производительные решения на языке Javascript, то будет превалировать большее понимание того, что происходит на нижнем уровне, дабы то, что вы написали - работало.

Будем рассматривать задачи с применением массивов. Собственно, очень часто встает вопрос: 

«Какие методы и как их использовать?»

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

Предыстория

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

Да, старый добрый старичок for всем понятен и привычен. Поэтому впервые столкнувшись с решениями, которые усыпаны .reduce, можно столкнуться с проблемой читаемости и понимания всего того, что будет написано. Но, как было выше уже упомянуто - это вопрос времени, а точнее практики.

Итак, давайте сразу к коду. Начнем разбор.

Сравнения методов и циклов на задачах

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

Сперва заведем функцию замера. Назовем ее benchmark, аргументами будет callback-функция и собственно количество итераций (iterations):

function benchmark(callback, iterations) {
    const start = performance.now();
    for (let i = 0; i < iterations; i++) {
        callback();
    }
    const end = performance.now();

    // Получим время выполнения (в мс и сек)
    const duration = end - start;
    const seconds = duration / 1000;
    
    // Получим операций в секунду
    const opSec = 1 / seconds

    // Вывод в консоль замера
    // Для красоты оставим с 3 знаками после запятой
    console.log(
        `Время выполнения: ${duration.toFixed(3)} мс; ` +
        `Операций в секунду: ${opSec.toFixed(3)}`
    );
}

Сценарий: есть массив чисел, и мы хотим получить их сумму в одной переменной. Для чистоты экспериментов, будем использовать оператор var, и делать замеры на итерациях 10, 100, 1000, 10000, 100000.

Наш код с callback-функциями для benchmark:


const arr = [1, 2, 3, 4, 5];

// Вариант с методом reduce
const reduceBenchmark = () => {
    var result = arr.reduce((acc, num) => acc + num, 0);
};

// Вариант с методом map
const mapBenchmark = () => {
    var result = 0;
    arr.map(num => result += num);
};

// Вариант с методом filter
const filterBenchmark = () => {
    var result = 0;
    arr.filter(num => {
        result += num
    });
};

// Вариант с циклом for
const forBenchmark = () => {
    var result = 0;
    for (var i = 0; i < arr.length; i++) {
        result += arr[i];
    }
};
const forBenchmarkOpt = () => {
    var result = 0;
    for (var i = arr.length - 1; i > 0; i--) {
        result += arr[i];
    }
};

// Вариант с циклом for...of
const forOfBenchmark = () => {
    var result = 0;
    for (const num of arr) {
        result += num;
    }
};

Из практик по оптимизации для цикла for, лучше обратный проход, потому что достаточно только раз обратиться к arr.length. Поэтому приведен пример того как обычно будет использоваться for, и того, который даст выигрыш. 

Дальше дело за малым, задаем количество итераций и запускаем наши benchmark'и:

const iterations = 1000000

console.log('Бенчмарк для метода reduce:');
benchmark(reduceBenchmark, iterations);

console.log('Бенчмарк для метода map:');
benchmark(mapBenchmark, iterations);

console.log('Бенчмарк для метода filter:');
benchmark(filterBenchmark, iterations);

console.log('Бенчмарк для цикла for:');
benchmark(forBenchmark, iterations);

console.log('Бенчмарк для цикла forOpt:');
benchmark(forBenchmarkOpt, iterations);

console.log('Бенчмарк для цикла for...of:');
benchmark(forOfBenchmark, iterations);

Сделав по 3 замера на каждом числе итераций (10, 100, 1000, 10000, 100000) и, взяв среднее арифметическое по операциям в секунду, получаем следующий результат:

 10100100010000100000100000010000000
Метод reduce16021,6825437,705610,082238,50450,8657,8390,755
Метод map14243,6675187,4261115,528156,76840,5966,7310,616
Метод filter15666,8615164,289940,434173,97858,3087,440,728
Цикл for16351,7635080,3811125,238256,55253,7776,0990,613
Цикл forOpt14056,0146299,5191082,338290,15962,0266,9790,725
Цикл for..of17092,1657256,9071089,661213,69769,5048,6870,851