Вероятно, вы наслышаны о существовании императивного и декларативного подходов к написанию кода.
Также, вероятно, вы в курсе, чем они отличаются. В общих чертах: императивный код описывает в подробностях, как мы хотим получить результат, а декларативный - что мы хотим получить в качестве результата.
Данная статья посвящена разнице между этими подходами при работе с массивами в JavaScript. Тому, какие трудности возникают при выборе между тем или иным подходом, и как на них повлияло развитие языка.
Простой пример
Имеется список кинорежиссёров. У каждого режиссёра есть поле movies, представляющее собой список их грядущих фильмов. У фильма может быть как известна дата выхода (releaseDate), так и нет. Каждый фильм характеризуется числом ждущих его людей (waitlistNumber).
const directors = [
{
id: 1,
name: "Квентин Тарантино",
movies: [
{ id: 1, name: "Кинокритик", releaseDate: "2025", waitlistNumber: 18 }
]
},
{
id: 2,
name: "Гай Ричи",
movies: [
{ id: 2, name: "Министерство неджентльменских дел", releaseDate: "2024-05-13", waitlistNumber: 50 },
{ id: 3, name: "В серой зоне", releaseDate: undefined, waitlistNumber: 9 },
]
},
{
id: 3,
name: "Мартин Скорсезе",
movies: [
{ id: 4, name: "Вэйджер", releaseDate: undefined, waitlistNumber: 8 }
]
}
];
Необходимо получить суммарное количество людей, ожидающих фильмы с уже известной датой выхода.
Первым порывом может стать написание императивного кода, использующего конструкции, которые есть и в большинстве других языков:
let result = 0;
for (director of directors) {
for (movie of director.movies) {
if (movie.releaseDate !== undefined) {
result += movie.waitlistNumber;
}
}
}
Я бы хотел обратить внимание на следующие 3 аспекта этой задачи и то, как они решаются кодом выше:
- Возникла необходимость проитерироваться по всем фильмам. Так как они хранятся во вложенных массивах, пришлось это делать вложением одного цикла for в другой.
- Есть некоторая аккумулирующая переменная (result), значение которой может меняться во время итерации.
- Не каждое число людей, ждущих фильм, а следовательно, и сам фильм, должны учитываться в конечном результате. Для этого, тело внутреннего цикла состоит из утверждения со всем известным if.
Абстрагируясь от данного примера, довольно не трудно заметить, что похожими аспектами могут обладать и многие другие задачи. А что, если создать набор операций специально для таких задач, который бы позволил не дублировать многократно логику, не связанную с задачей напрямую (например, описывающую, как именно мы будем итерироваться по вложенным массивам)?
Для этого у массивов существуют соответствующие методы. Перепишем изначальный код на вариант с их применением:
const result = directors
.flatMap(director => director.movies) // Дай мне список всех фильмов, которые сможешь найти у режиссёров
.filter(movie => movie.releaseDate !== undefined) // Оставь в нём только те фильмы, у которых есть дата выхода
.reduce((acc, curr) => acc + curr.waitlistNumber, 0); // Наконец, верни суммарное число ожидающих их людей
Используя эти методы, мы не только избавляемся от ранее упомянутого дублирования, но и, что куда важнее, добиваемся декларативности: код читается по-другому в сравнении с первоначальным решением, и, по общепризнанному мнению, лучше.
Так в чём проблема? Используй методы массива и радуйся своему декларативному коду
Несмотря на описанные преимущества, переписанный код также привнёс нежелательные сайд-эффекты:
- Ненужные аллокации. Вызовы некоторых методов (map, flatMap, filter и др.) создают новые массивы, которые будут храниться до тех пор, пока до них не доберётся сборщик мусора.
- Несколько циклов там, где можно было обойтись одним. В нашем примере, filter проитерируется по массиву и вернёт новый, по которому, в свою очередь, с самого начала начнёт итерироваться reduce. Но ведь в императивном варианте мы выполняли обе операции за один цикл!
Это те компромиссы, на которые часто приходится идти в угоду читабельности кода.
Вообще, читабельность - довольно субъективная характеристика. Есть люди, которые настраивают линтеры в своих проектах таким образом, чтобы запретить использование традиционных циклов. В противовес им есть и такие, кто не готов идти на вышеперечисленные жертвы и всегда пишет императивный код, в том числе и для циклов.
А всё-таки, можно ли писать ванильный JavaScript так, чтобы он считался декларативным и был лишён ранее упомянутых недостатков?
Вспомогательные методы итераторов
Статья на случай, если забыли, что из себя представляет итератор (который, кстати, неявно использовался в императивном варианте в цикле for of).
Некоторое время назад было внесено предложение о добавлении вспомогательных методов для итераторов в спецификацию языка. Опуская подробности, это предложение было одобрено и на момент написания статьи находится в стадии имплементации авторами движков. Например, в V8 эти методы уже есть, а значит, в консоли актуальной версии Chrome вы уже можете их испытать.
Суть в том, что помимо прочего, у итераторов появились методы, аналогичные методам массива, где имеет место перебор значений. Финальный код для нашего примера приобретает следующий вид:
const result = directors
.values() // <- герой дня
.flatMap(director => director.movies)
.filter(movie => movie.releaseDate !== undefined)
.reduce((acc, curr) => acc + curr.waitlistNumber, 0);
Вызов values вернёт итератор для массива. Каждый последующий вызов до reduce возвращает новый итератор, представляющий собой ленивое преобразование по отношению к предыдущему итератору. Такие преобразования будут применены в момент вызова метода next финального итератора. В нашем случае, это происходит внутри последнего вспомогательного метода в цепочке reduce.
Такой подход избавляет нас от необходимости в выделении памяти для промежуточных массивов и сводит все вычисления в один редьюсер.
Тесты
Для большей наглядности приведу довольно грубый тест, сравнивающий скорость выполнения кода с применением методов массивов и методов итераторов.
Тестирование выполнялось в среде Node.js 22.2.0., в основе которой лежит ранее упомянутый движок V8. Благодаря ему, также, удалось получить сведения о состоянии кучи во время выполнения программы.
Для теста генерировался массив режиссёров из 100 и 1000 элементов. Для простоты генерации, количество фильмов у каждого режиссёра соответствовало этому размеру. В сумме 10000 и 1000000 фильмов соответственно (и на какой из них идти?).
Результаты:
Array, 10_000: 0.789ms
Iterator, 10_000: 0.566ms
Array, 1_000_000: 41.398ms
Iterator, 1_000_000: 27.705ms
В нашем примере для достаточно больших массивов вариант с итераторами работает чуть быстрее.
Память процесса после теста с миллионом фильмов (получена с помощью process.memoryUsage()):
- С использованием методов массивов:
{
rss: 185434112,
heapTotal: 145080320,
heapUsed: 108048504,
external: 1066155,
arrayBuffers: 10515
}
- С использованием методов итераторов:
{
rss: 142163968,
heapTotal: 102465536,
heapUsed: 77588496,
external: 1066155,
arrayBuffers: 10515
}
Информация приведена в байтах. Нас интересуют общий размер кучи (heapTotal) и размер используемой памяти в куче (heapUsed).
Из тестов видно, что новый подход помогает добиться существенного снижения размеров кучи за счёт отсутствия выделения памяти под промежуточные массивы.
Итог
Были рассмотрены два основных подхода к написанию кода для работы с массивами, их преимущества и недостатки. В пользу одного из них, мной был описан достаточно молодой способ с применением итераторов. Однако, использовать его на практике, на мой взгляд, пока рано. На момент написания статьи далеко не все браузеры (снова ты отличился, Firefox) поддерживают вспомогательные методы итераторов (caniuse):
Однако, достаточно очевидно, что уже в обозримом будущем появится возможность писать более эффективный декларативный код.
P.S. Самый первый императивный вариант по результатам тестирования всё равно оказался самым быстрым. Название - кликбэйт, пишите как считаете нужным.