Как преобразовать функции JavaScript в генераторы, эффективно использующие память

Ключевое слово yield используется в JavaScript для приостановки выполнения функций. При повторном вызове функции ее выполнение продолжается с последнего оператора yield.

Функция, возвращающая (yields) значения, является генератором. Сравнительная иллюстрацию запуска функции и функциональности генератора:

Функции и генераторы в JavaScript

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

Как превратить функцию в генератор

Для этого нужно:

  • Добавить символ звездочки (*) после ключевого слова function.
  • Использовать yield вместо return.

Пример

Создадим обычную функцию square (), которая возводит в квадрат массив чисел:

function square(numbers){
  let result = [];
  for (const n of numbers){
    result.push(n * n);
  }
  return result;
}
    
const numbers = [1, 2, 3, 4, 5];
const squared_numbers = square(numbers);

console.log(squared_numbers);

Результат:

[1, 4, 9, 16, 25]

Превратим эту функцию в генератор:

Вместо добавления в массив значений квадратов, выводим (yield) их по одному:

function* square(numbers){
  for (const n of numbers){
    yield n * n;
  }
}
    
const numbers = [1, 2, 3, 4, 5];
const squared_numbers = square(numbers);

console.log(squared_numbers);

Выход:

: [object Generator]

В результате мы пока не получаем массив возведенных в квадрат чисел, потому что squared_numbers  —  это объект-генератор. И он еще не производил никаких вычислений.

А теперь смотрите, как можно получать из объект-генератора возведенные в квадрат числа.

next() method

Генератор не сохраняет числа в памяти. Вместо этого он их последовательно вычисляет и по одному выдает. После выдачи значения объект-генератор приостанавливается до запроса следующего значения.

Чтобы получить возведенные в квадрат числа из приведенного выше примера, нужно запросить у объект-генератора следующее значение, используя метод next ().

Запросим генератор вычислить квадрат первого числа.

console.log(squared_numbers.next())

Результат:

{
  value:1,
  done:false
}

Этот объект результата имеет два атрибута:

  • value —  результат возведения в квадрат первого из чисел массива.
  • done —  сообщает о завершении функции генератора.

На этом этапе после возведения в квадрат первого числа объект-генератор был приостановлен.

Позволим генератору вычислить остальные значения, вызывая next() четыре раза:

console.log(squared_numbers.next().value)
console.log(squared_numbers.next().value)
console.log(squared_numbers.next().value)
console.log(squared_numbers.next().value)

Выход:

4
9
16
25

Генератор возвел в квадрат все числа. Если вызвать next () еще раз:

console.log(squared_numbers.next())

То получим объект, в котором value не определено (undefined), а done верно (true).

{
  value: undefined,
  done: true
}

И это естественно, потому что генератор достиг конца массива. Работа генератора прекращена.

Теперь понятно, как работает генератор и как заставить его вычислять значения.

Посмотрим, как можно создать цикл без неудобного метода next ().

Get rid of next()

Использование функции next() демонстрирует действие генераторов, но для работы с объект-генераторами ее вызывать не нужно. Вместо этого можно использовать цикл for-of, очень похожий на перебор массива чисел.

Например, повторим приведенный выше пример с генератором, используя цикл for-of:

function* square(numbers){
  for (const n of numbers){
    yield n * n;
  }
}
    
const numbers = [1, 2, 3, 4, 5];
const squared_numbers = square(numbers);

for (const n of squared_numbers){
  console.log(n);
}

Результат:

1
4
9
16
25

Синтаксически это похоже на организацию цикла в обычном массиве чисел.

К тому же это удобно, так как не нужно самим вызывать next(). Цикл for-of знает, как это сделать за нас. А также он позаботится о том, чтобы генератор не вызывал next (), когда закончатся значения для возведения в квадрат.

Генераторы и массивы  —  сравнение по времени исполнения

Давайте сравним генераторы и функции по времени выполнения.

Возьмем для примера массив из десяти чисел и двух функций:

  • Функция data_array () случайным образом выбирает число из массива n раз.
  • Функция генератора data_generator() также случайным образом выбирает число из массива n раз.

Этот код сравнивает время выполнения этих функций для построения набора из 1 миллиона случайно выбранных чисел. Не беспокойтесь о деталях реализации:

const range = (start, end) => {
    const length = end - start;
    return Array.from({ length }, (_, i) => start + i);
}

const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

function data_array(n){
  let result = [];
  for (const i of range(0, n)) {
    result.push(numbers[Math.floor(Math.random() * numbers.length)]);
  }
  return result;
}

function* data_generator(n){
  for (const i of range(0, n)) {
    yield numbers[Math.floor(Math.random() * numbers.length)];
  }
}

const t_array_start = performance.now();
const rand_array = data_array(1000000);
const t_array_end = performance.now();

const t_gen_start = performance.now();
const rand_gen = data_generator(1000000);
const t_gen_end = performance.now();

const t_gen = t_gen_end - t_gen_start;
const t_array = t_array_end - t_array_start;

console.log(`Array creation took ${t_array} milli seconds`);
console.log(`Generator creation took ${t_gen} milli seconds `);

console.log(`The generator is ${t_array / t_gen} times faster`);

Результат:

Создание массива заняло 121.39999997615814 мс
Создание генератора заняло 0.10000002384185791 мс
Генератор работает в 1213.9997103214955 раз быстрее

Генератор можно создать намного быстрее, чем массив чисел. Это связано с тем, что весь миллион чисел массива должен храниться в памяти. Генератор же ничего не сохраняет в памяти. Он даже не начинает выбирать числа до тех пор, пока для него не будет вызван метод next ().

Следует отметить, что этот пример нельзя считать полностью объективным. Генератор не использует данные. Таким образом, в отношении производительности такое сравнение не имеет смысла. Но оно наглядно демонстрирует, что создание генератора практически не занимает время и не требует использования памяти.

Примеры использования генераторов в JavaScript

Бесконечные потоки

Поскольку объекты-генераторы (итераторы) не хранят значения, они позволяют создавать бесконечный поток значений.

Например, создадим бесконечный генератор, который производит все числа после начальной точки:

function* infinite_values(start){
  let current = start;

  while(true){
    yield current;
    current += 1;
  }
}

Этот генератор выдает значения от начала (start) до бесконечности.

Выполним его с начальной точки 10:

const infinite_nums = infinite_values(10);

for (const num of infinite_nums){
  console.log(num);
}

Результатом будет бесконечный поток чисел, начиная с 10:

10
11
12
13
14
15
.
.
.

Синтаксически infinite_nums представляется действительно бесконечным массивом чисел. На самом деле это просто генератор с бесконечным циклом.

Упростим создание итераторов

Создающим свои итераторы программистам знакомы проблемы с кодом.

Для примера создадим итератор, который печатает три строки:

const helloIterator = {
  [Symbol.iterator]() {
    let count = 0;
    return {
      next() {
        count++;
        if (count === 1) {
          return { value: 'Hello', done: false};
        } else if (count === 2) {
          return { value: 'World', done: false};
        } else if (count === 3) {
          return { value: 'Its me', done: false};
        }
        return { value: '', done: true };
      }
    }
  },
}

for (const val of helloIterator) {
  console.log(val);
}

Выход:

Hello
World
Its me

Сделать код намного чище позволяет функция генератора:

function * iterableObj() {
  yield 'Hello';
  yield 'World';
  yield 'Its me'
}

for (const val of iterableObj()) {
  console.log(val);
}

Выход:

Hello
World
Its me

Заключение

yield —  это ключевое слово в JavaScript, превращающее функцию в генератор.

В отличие от массива генератор не хранит значения. Он имеет только текущее значение и знает, как получить следующее. Что позволяет говорить об экономии памяти.

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

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Artturi Jalli: How To Transform JavaScript Functions Into Memory-Efficient Generators

Предыдущая статьяОбработка сигналов в операционных системах семейства Unix на Golang
Следующая статьяСкрейпинг PDF с нуля на Python: библиотеки tabula-py и Pandas