Базовый арсенал Frontend-разработчика
February 20

Итераторы в JavaScript

В этой статье рассмотрим механизм итераторов. Что это такое, какие они бывают, как их применять и как создать свои собственные.

Основные понятия

Чтобы разобраться в механизме итерации, давайте взглянем на следующие интерфейсы.

interface Iterable {
  [Symbol.iterator]: () => Iterator;
}

interface Iterator {
  next: () => IteratorResult;
  
  return?: (value?: any) => IteratorResult;
  
  throw?: (exception?: any) => IteratorResult;
}

interface IteratorResult {
  done?: boolean;
  value?: any;
}

Итак, объект считается итерируемым, если он реализует интерфейс Iterable. Другими словами, если он содержит метод [Symbol.iterator] - функцию, возвращающую, непосредственно, объект-итератор.

Итератор - это простой (ordinary) объект, который содержит метод next(). Остальные два метода в интерфейсе Iterator не являются обязательными. Метод next() обязан вернуть объект IteratorResult.

Суть итерируемости заключается в том, что такой объект может быть перебран циклами for..of и for..in.

for (const item of [1, 2, 3]) {
    console.log(item);
}
// 1
// 2
// 3

Встроенные итераторы

Механизм итераций является одним из самых широко используемых в JavaScript. Язык буквально пронизан встроенными итерируемыми объектами. К ним относятся: Array, TypedArray, Map, Set, WeakMap, WeakSet, String.

// Array
for (const item of new Array(1, 2, 3)) {
    console.log(item);
}
// 1
// 2
// 3

// TypedArray
for (const item of new Int8Array(3)) {
    console.log(item);
}
// 0
// 0
// 0

// Map
for (const item of new Map([[1, 'one'], [2, 'two'], [3, 'three']])) {
    console.log(item);
}
// [1, 'one']
// [2, 'two']
// [3, 'three']

// Set
for (const item of new Set([1, 2, 3])) {
    console.log(item);
}
// 1
// 2
// 3

// String
for (const item of "abc") {
    console.log(item);
}
// a
// b
// c

Кроме JavaScript, некоторые объекты Web API так же реализуют интерфейс Iterable. Например, в стандарте DOM, примерами таких объектов являются NodeList и DOMTokenList.

// NodeList
for (const item of document.querySelectorAll("div")) {
    console.log(item);
}
// Node
// Node
// ...

// DOMTokenList
const block = document.createElement("div");
block.classList.add("classA", "classB")

for (const item of block.classList) {
    console.log(item)
}
// classA
// classB

Собственные итераторы

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

Итак, как уже было сказано выше, объект считается итерируемым, если его прототип содержит метод [Symbol.iterator](). В противном случае, при попытке пропустить такой объект через цикл, будет сгенерировано исключение.

const obj = {};

for (const item of obj) {
    console.log(item);
}

// Uncaught TypeError: obj is not iterable

Теперь добавим метод-итератор в наш объект.

const obj = {
    [Symbol.iterator]: () => {},
};

for (const item of obj) {
    console.log(item);
}

// Uncaught TypeError: Result of the Symbol.iterator method is not an object

Теперь, формально, объект считается итерируемым, но пропустить его через цикл в таком виде все равно не получится, так как метод-итератор не возвращает объект, реализующий интерфейс Iterator, как того требует протокол.

next()

Согласно протоколу, итератор должен быть простым объектом, содержащим, как минимум, один метод next(). Сам метод next() может принимать аргументы, но строго обязательства в этом нет. Спецификация ECMA-262 гарантирует, что все встроенные пользователи итерируемых объектов вызывают этот метод без аргументов. Однако, в редких случаях, аргументы могут быть полезны при ручном вызове итератора.

const obj = {
    [Symbol.iterator]: () => {
        return {
            next: () => {},
        };
    },
};

for (const item of obj) {
    console.log(item);
}

// Uncaught TypeError: Iterator result undefined is not an object

В примере выше мы добавили метод next(), но сам этот метод все еще не возвращает правильный IteratorResult.

IteratorResult - это, так же простой объект, имеющий два свойства, done и value.

Свойство done является булевым и выражает завершение процесса итерации. Другими словами, пока объект может итерироваться дальше, его итератор возвращает done: false, достигнув последнего шага итерации, итератор должен вернуть done: true, что будет являться сигналом для пользователя итерируемого объекта (например, цикла) к завершению процесса (выходу из цикла).

Свойство value - непосредственно значение, ассоциированное с данным шагом итерации. Ограничений на формат возвращаемого значения нет.

Как я только что упоминал, если итератор вернул done: true, итерирование будет считаться завершенным, следующий шаг итерации не случится, а значит, в значении value, в этом случае, смысла больше нет, соответственно, и передавать его вместе с done: true, не обязательно.

Стоит сказать, что спецификация ECMA-262 к вопросу итераторов подошла крайне либерально. На самом деле, возвращать ни свойство done, ни свойство value вовсе не обязательно. Для обоих предусмотрены значения по умолчанию (false и undefined соответственно). Т.е. фактически, чтобы наш итерируемый объект наконец заработал, итератору достаточно вернуть просто пустой объект.

const obj = {
    [Symbol.iterator]: () => {
        return {
            next: () => {
                return {}; // { done: false, value: undefined }
            },
        };
    },
};

for (const item of obj) {
    console.log(item);
}

// Осторожно!!! Данный код ведет в бесконечный цикл

Однако, пользы от такого итератора мало. Более того, так как итератор никогда не вернет done: true, весь код уйдет в бесконечный цикл.

Итак, давайте, все таки, опишем функциональный итератор по всем правилам.

const obj = {
    [Symbol.iterator]: () => {
        let i = 0;
        
        return {
            next: () => {
                return {
                    done: i >= 3,
                    value: i++,
                };
            },
        };
    },
};

for (const item of obj) {
    console.log(item);
}

// 0
// 1
// 2

В данном примере мы последовательно возвращаем числа 0, 1, 2. Выход из цикла осуществляется по достижении i === 3.

return()

В ряде случаев, процесс итерации может быть принудительно остановлен тем или иным образом из вне. В таком случае, пользователь итерируемого объекта должен вызвать метод итератора return(), если он существует.

const obj = {
    [Symbol.iterator]: () => {
        let i = 0;
        
        return {
            next: () => {
                return {
                    done: i >= 3,
                    value: i++,
                };
            },
            
            return: () => {
                console.log("Return", i);
                return {
                    done: i >= 3,
                    value: i,
                }
            }
        };
    },
};

for (const item of obj) {
    console.log(item);
    
    if (item === 1) {
        break;
    }
}
// 0
// 1
// Return 2

Как и метод next(), метод return() обязан вернуть объект IteratorResult. Данный метод является, своего рода, колбэком в момент принудительного прерывания итерации. Предполагается, что здесь могут быть выполнены специфические операции, необходимые по звершении процесса. Например, отвязка прослушивателей или очистка памяти.

throw()

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

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

Будучи вызванным, метод throw() должен "выкинуть" переданное ему исключение, дабы остановить дальнейший процесс итерации. Опять же, такое поведение является рекомендованным и ожидаемым, но не обязательным. Автор итератора вправе самостоятельно определить дальнейшую логику.

const obj = {
  [Symbol.iterator]: () => {
    let i = 0;
    
    return {
      next: () => {
        return {
          done: i >= 3,
          value: i++,
        };
      },
      
      throw: (exception) => {
        console.log("Thrown:", exception);
        
        if (exception) {
          throw exception;
        }
        
        return {
          done: true,
        };
      },
    };
  },
};

for (const item of obj) {
  console.log(item);
  
  if (item === 1) {
    obj[Symbol.iterator]().throw(`Reached ${item}`);
  }
}

// 0
// 1
// Thrown: Reached 1
// Uncaught Reached 1

Чаще всего, метод throw() применяется в асинхронных итераторах в паре с генераторами. Об этом далее.

Ручной вызов итератора

Как уже стало понятным, основным пользователем итерируемых объектов являются конструкции for..of и for..in. Эти встроенные пользователи самостоятельно реализуют протокол итерации. Однако, иногда есть необходимость в создании своего собственного пользователя или, просто в проходе процесса итерации в ручном режиме. Как мы видели в последнем примере, к итератору можно обратиться напрямую и пройти весь цикл самостоятельно.

const obj = {
  [Symbol.iterator]: () => {
    let i = 0;
    
    return {
      next: () => {
        return {
          done: i >= 5,
          value: i++,
        };
      },
      
      return: () => {
        return {
          done: true,
          value: i,
        };
      },
      
      throw: (exception) => {
        if (exception) {
          throw exception;
        }
        
        return {
          done: true,
        };
      },
    };
  },
};

// получаем итератор посредством вызова метода-итератора
const iterator = obj[Symbol.iterator]();

// первый шаг итреации
let result = iterator.next();
console.log(result.value);

// остальные шаги будут вызываться в цикле, пока итератор
// не вернет `done: true`
while (!result.done) {
  const result = iterator.next();
  console.log(result.value);
  
  if (isSuccess(result.value)) {
    // по необходимости, мы можем остановить процесс итареции
    iterator.return();
    break;
  }
  
  if (isFailed(result.value)) {
    // или выкинуть исключение
    iterator.throw(new Error("failed"));
  }
}

Самоитерируемые объекты

Мы уже познакомились с двумя понятиями: итерируемый объект (Iterable), и итератор (Iterator). До сих пор мы рассматривали обе сущности в отдельности. Следующая техника позволяет объединить все в один самоитерируемый объект.

const obj = {
  i: 0,
  
  [Symbol.iterator]() {
    return this;
  },
  
  next() {
    return {
      done: this.i >= 3,
      value: this.i++,
    };
  },
  
  return() {
    return {
      done: true,
      value: this.i,
    };
  },
  
  throw(exception) {
    if (exception) {
      throw exception;
    }
    
    return {
      done: true,
    };
  },
};

for (const item of obj) {
  console.log(item);
}

В этом примере, в методе-итераторе мы, в качестве итератора вернули ссылку на сам итерируемый объект, таки образом, obj одновременно является и итератором и итерируемым объектом.

Похожим образом может выглядеть и итерируемый класс.

class Obj {
  i = 0;
  
  [Symbol.iterator]() {
    return this;
  }
  
  next() {
    return {
      done: this.i >= 3,
      value: this.i++,
    };
  }
  
  return() {
    return {
      done: true,
      value: this.i,
    };
  }
  
  throw(exception) {
    if (exception) {
      throw exception;
    }
    
    return {
      done: true,
    };
  }
}

const obj = new Obj();

for (const item of obj) {
  console.log(item);
}

Асинхронные итераторы

До сих пор мы говорили только о синхронных итераторах. В июне 2018-го была опубликована 9-я редакция спецификации ECMA-262. В этой версии были анонсированы асинхронные итераторы и протокол AsyncIterator.

По своей сути, асинхронный итератор мало чем отличается от синхронного. Давайте взглянем на следующие интерфейсы.

interface AsyncIterable {
  [Symbol.asyncIterator]: () => AsyncIterator;
}

interface AsyncIterator {
  next: () => Promise<IteratorResult>;
  
  return?: (value?: any) => Promise<IteratorResult>;
  
  throw?: (exception?: any) => Promise<IteratorResult>;
}

Итак, основная разница между синхронным и асинхронным итерируемыми объектами в том, что асинхронный должен иметь метод-итератор [Symbol.asyncIterator], (вместо [Symbol.iterator], как в случае с синхронным). А в качестве возвращаемых значений методами next(), return() и throw() ожидается Promise.

Сами по себе асинхронные итераторы небыли бы так удобны в использовании, если бы спецификация не предусмотрела новую структуру - асинхронный цикл for..await..of.

const obj = {
  [Symbol.asyncIterator]: () => {
    let i = 0;
    
    return {
      next: () => {
        return Promise.resolve({
          done: i >= 3,
          value: i++,
        });
      },
      
      return: () => {
        return Promise.resolve({
          done: true,
          value: i,
        });
      },
      
      throw: (exception) => {
        if (exception) {
          return Promise.reject(exception);
        }
        
        return Promise.resolve({
          done: true,
        });
      },
    };
  },
};

(async () => {
  for await (const item of obj) {
    console.log(item);
    
    if (item === 1) {
      await obj[Symbol.asyncIterator]().throw(`Reached ${1}`);
    }
  }
})();

Асинхронные генераторы

Еще одним важным нововведение 9-ой редакции ECMA-262 стали асинхронные генераторы, тесно связанные с асинхронными итераторами.

Простой асинхронный генератор может выглядеть следующим образом.

async function* gen() {
  yield 0;
  yield 1;
  yield 2;
}

(async () => {
  for await (const item of gen()) {
    console.log(item);
  }
})();

// 0
// 1
// 2

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

async function* gen() {
  yield 0;
  yield 1;
  yield 2;
}

const iterator = gen();

(async () => {
  console.log(await iterator.next());
  console.log(await iterator.next());
  console.log(await iterator.next());
  console.log(await iterator.next());
})();

// {value: 0, done: false}
// {value: 1, done: false}
// {value: 2, done: false}
// {value: undefined, done: true}

В том числе, остановить процесс итерации.

async function* gen() {
  yield 0;
  yield 1;
  yield 2;
}

const iterator = gen();

(async () => {
  console.log(await iterator.next());
  console.log(await iterator.return("My return reason"));
})();

// {value: 0, done: false}
// {value: 'My return reason', done: true}

Или выкинуть исключение.

async function* gen() {
  yield 0;
  yield 1;
  yield 2;
}

const iterator = gen();

(async () => {
  console.log(await iterator.next());
  console.log(await iterator.throw("My error"));
})();

// {value: 0, done: false}
// Uncaught (in promise) My error

Async-from-Sync итератор

Async-from-Sync итератор - это асинхронный итератор, полученный из синхронного посредством абстрактной операции CreateAsyncFromSyncIterator. Напомню, абстрактная операция - это внутренний механизм языка JavaScript. В обычно режиме такие операции не доступны внутри исполняемого контекста. Однако, например, движок V8 дает возможность воспользоваться этими операциями при включенном флаге --allow-natives-syntax.

> v8-debug --allow-natives-syntax iterator.js
// iterator.js
const syncIterator = {
  [Symbol.iterator]() {
    return this;
  },
  
  next() {
    return {
      done: true,
      value: Promise.resolve(1),
    };
  },
};

const asyncIterator = %CreateAsyncFromSyncIterator(syncIterator);

console.log(asyncIterator);
// [object Async-from-Sync Iterator]

console.log(asyncIterator[Symbol.asyncIterator]);
// function [Symbol.asyncIterator]() { [native code] }

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

  • Текущее окружение является асинхронным
  • Итерируемый объект не имеет метода [Symbol.asyncIterarot]
  • Итерируемый объект имеет метод [Symbol.iterator]

Проще всего продемонстрировать этот процесс можно на генераторах.

function* syncGen() {
  yield 1;
  yield 2;
  yield Promise.resolve(3);
  yield Promise.resolve(4);
  yield 5;
}

for (const item of syncGen()) {
  console.log(item);
}

// 1
// 2
// Promise
// Promise
// 5

Данный синхронный генератор на 3 и 4 шагах вернет неразрешенные промисы. Однако, асинхронный пользователь for..await..of сможет преобразовать эти шаги в асинхронные.

function* syncGen() {
  yield 1;
  yield 2;
  yield Promise.resolve(3);
  yield Promise.resolve(4);
  yield 5;
}

(async () => {
  for await (const item of syncGen()) {
    console.log(item);
  }
})();

// 1
// 2
// 3
// 4
// 5

Такая же картина будет и с обычными синхронными итераторами.

const syncIterator = {
  i: 0,
  
  [Symbol.iterator]() {
    return this;
  },
  
  next() {
    const done = this.i >= 3;

    if (this.i === 1) {
      return {
        done: false,
        value: Promise.resolve(this.i++),
      };
    }

    return {
      done,
      value: this.i++,
    };
  },
};

for (const item of syncIterator) {
  console.log(item);
}

// 0
// Promise
// 2

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

const syncIterator = {
  i: 0,
  
  [Symbol.iterator]() {
    return this;
  },
  
  next() {
    const done = this.i >= 3;

    if (this.i === 1) {
      return {
        done: false,
        value: Promise.resolve(this.i++),
      };
    }

    return {
      done,
      value: this.i++,
    };
  },
};

(async () => {
  for await (const item of syncIterator) {
    console.log(item);
  }
})();

// 0
// 1
// 2


Мои телеграмм-каналы:

EN - https://t.me/frontend_almanac
RU - https://t.me/frontend_almanac_ru

English version: https://blog.frontend-almanac.com/iterators