null

JavaScript. Методы массива у итератора или последний гвоздь в крышку гроба императивного кода.

Вероятно, вы наслышаны о существовании императивного и декларативного подходов к написанию кода.

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

Данная статья посвящена разнице между этими подходами при работе с массивами в 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 аспекта этой задачи и то, как они решаются кодом выше:

  1. Возникла необходимость проитерироваться по всем фильмам. Так как они хранятся во вложенных массивах, пришлось это делать вложением одного цикла for в другой.
  2. Есть некоторая аккумулирующая переменная (result), значение которой может меняться во время итерации.
  3. Не каждое число людей, ждущих фильм, а следовательно, и сам фильм, должны учитываться в конечном результате. Для этого, тело внутреннего цикла состоит из утверждения со всем известным if.

Абстрагируясь от данного примера, довольно не трудно заметить, что похожими аспектами могут обладать и многие другие задачи. А что, если создать набор операций специально для таких задач, который бы позволил не дублировать многократно логику, не связанную с задачей напрямую (например, описывающую, как именно мы будем итерироваться по вложенным массивам)?

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

const result = directors
  .flatMap(director => director.movies) // Дай мне список всех фильмов, которые сможешь найти у режиссёров
  .filter(movie => movie.releaseDate !== undefined) // Оставь в нём только те фильмы, у которых есть дата выхода 
  .reduce((acc, curr) => acc + curr.waitlistNumber, 0); // Наконец, верни суммарное число ожидающих их людей

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

Так в чём проблема? Используй методы массива и радуйся своему декларативному коду

Несмотря на описанные преимущества, переписанный код также привнёс нежелательные сайд-эффекты:

  1. Ненужные аллокации. Вызовы некоторых методов (map, flatMap, filter и др.) создают новые массивы, которые будут храниться до тех пор, пока до них не доберётся сборщик мусора.
  2. Несколько циклов там, где можно было обойтись одним. В нашем примере, 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()):

  1. С использованием методов массивов:

    {
      rss: 185434112,
      heapTotal: 145080320,
      heapUsed: 108048504,
      external: 1066155,
      arrayBuffers: 10515
    }

  2. С использованием методов итераторов:

    {
      rss: 142163968,
      heapTotal: 102465536,
      heapUsed: 77588496,
      external: 1066155,
      arrayBuffers: 10515
    }

Информация приведена в байтах. Нас интересуют общий размер кучи (heapTotal) и размер используемой памяти в куче (heapUsed).

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

Итог

Были рассмотрены два основных подхода к написанию кода для работы с массивами, их преимущества и недостатки. В пользу одного из них, мной был описан достаточно молодой способ с применением итераторов. Однако, использовать его на практике, на мой взгляд, пока рано. На момент написания статьи далеко не все браузеры (снова ты отличился, Firefox) поддерживают вспомогательные методы итераторов (caniuse):

Однако, достаточно очевидно, что уже в обозримом будущем появится возможность писать более эффективный декларативный код.

P.S. Самый первый императивный вариант по результатам тестирования всё равно оказался самым быстрым. Название - кликбэйт, пишите как считаете нужным.

Назад