[Перевод] Итерируемые объекты и итераторы: углублённое руководство по JavaScript
Эта статья представляет собой углублённое введение в итерируемые объекты (iterables) и итераторы (iterators) в JavaScript. Моя главная мотивация к её написанию заключалась в подготовке к изучению генераторов. По сути, я планировал позднее поэкспериментировать с комбинированием генераторов и хуками React. Если вам это интересно, то следите за моим Twitter или YouTube!
Вообще-то я планировал начать со статьи про генераторы, но вскоре стало очевидно, что о них сложно рассказывать без хорошего понимания итерируемых объектов и итераторов. На них мы сейчас и сосредоточимся. Буду исходить из того, что вы ничего не знаете по этой теме, но при этом мы значительно углубимся в неё. Так что если вы что-то знаете об итерируемых объектах и итераторах, но не чувствуете себя достаточно уверенно при их использовании, эта статья вам поможет.
Введение
Как вы заметили, мы обсуждаем итерируемые объекты и итераторы. Это взаимосвязанные, но разные концепции, так что при чтении статьи обращайте внимание, о какой из них идёт речь в конкретном случае.
Начнём с итерируемых объектов. Что это такое? Это нечто, что может быть итерировано, например:
for (let element of iterable) {
// do something with an element
}
Обратите внимание, что здесь мы рассматриваем только циклы for ... of
, которые появились в ES6. А циклы for ... in
— это более старая конструкция, к которой мы совсем не будем обращаться в этой статье.
Теперь вы могли подумать: «Ладно, эта итерируемая переменная — просто массив!» Верно, массивы являются итерируемыми. Но сейчас в нативном JavaScript существуют и другие структуры, которые можно использовать в цикле for ... of
. То есть помимо массивов есть и иные итерируемые объекты.
Например, мы можем итерировать Map
, появившиеся в ES6:
const ourMap = new Map();
ourMap.set(1, 'a');
ourMap.set(2, 'b');
ourMap.set(3, 'c');
for (let element of ourMap) {
console.log(element);
}
Этот код выведет на экран:
[1, 'a']
[2, 'b']
[3, 'c']
То есть переменная element
на каждом этапе итерации хранит массив из двух элементов. Первый из них — ключ, второй — значение.
То, что мы смогли использовать цикл for ... of
для итерирования Map
, доказывает, что Map
«ы являются итерируемыми. Повторюсь: в циклах for ... of
могут использоваться только итерируемые объекты. То есть, если что-то работает с этим циклом, то оно является итерируемым объектом.
Забавно, что конструктор Map
опционально принимает итерируемые объекты пар ключ-значение. То есть это альтернативный способ конструирования такого же Map
:
const ourMap = new Map([
[1, 'a'],
[2, 'b'],
[3, 'c'],
]);
А поскольку Map
является итерируемым объектом, мы можем очень легко создавать его копии:
const copyOfOurMap = new Map(ourMap);
Теперь у нас есть два разных Map
, хотя они хранят одинаковые ключи с одинаковыми значениями.
Итак, мы увидели два примера итерируемых объектов — массив и ES6 Map
. Но мы пока не знаем, как они обрели возможность быть итерируемыми. Ответ прост: существуют ассоциированные с ними итераторы. Будьте внимательны: итераторы, не итерируемые.
Каким образом итератор ассоциирован с итерируемым объектом? Просто итерируемый объект должен содержать функцию в своём свойстве Symbol.iterator
. При её вызове функция должна возвращать итератор для этого объекта.
Например, можно извлечь итератор массива:
const ourArray = [1, 2, 3];
const iterator = ourArray[Symbol.iterator]();
console.log(iterator);
Этот код выводит в консоль Object [Array Iterator] {}
. Теперь мы знаем, что у массива есть ассоциированный итератор, который является каким-то объектом.
А что такое итератор?
Всё просто. Итератор — объект, содержащий метод next
. При вызове этого метода он должен возвращать:
- следующее значение в последовательности значений;
- информацию о том, закончил ли итератор генерировать значения.
Давайте протестируем это, вызвав метод next
у итератора нашего массива:
const result = iterator.next();
console.log(result);
В консоли увидим объект { value: 1, done: false }
. Первый элемент созданного нами массива — 1, и здесь она появилась в качестве значения. Также мы получили информацию, что итератор ещё не завершился, то есть мы можем пока вызывать функцию next
и получать какие-то значения. Давайте попробуем! Вызовем next
ещё два раза:
console.log(iterator.next());
console.log(iterator.next());
Получили один за другим { value: 2, done: false }
и { value: 3, done: false }
.
В нашем массиве только три элемента. Что будет, если снова вызвать next
?
console.log(iterator.next());
На этот раз мы увидим { value: undefined, done: true }
. Это говорит о том, что итератор завершён. Нет смысла снова вызывать next
. Если это делать, от раз за разом мы будем получать объект { value: undefined, done: true }
. done: true
означает остановку итерирования.
Теперь можно понять, что делает for ... of
под капотом:
- первый метод
[Symbol.iterator]()
вызывается для получения итератора; - метод
next
циклически вызывается применительно к итератору, пока мы не получимdone: true
; - после каждого вызова
next
в теле цикла используется свойствоvalue
.
Напишем всё это в коде:
const iterator = ourArray[Symbol.iterator]();
let result = iterator.next();
while (!result.done) {
const element = result.value;
// do some something with element
result = iterator.next();
}
Этот код эквивалентен такому:
for (let element of ourArray) {
// do something with element
}
Можете убедиться в этом, например, вставив console.log(element)
вместо комментария // do something with element
.
Создаём собственный итератор
Теперь мы знаем, что такое итерируемые объекты и итераторы. Возникает вопрос: «Можно ли написать собственные экземпляры?»
Безусловно!
В итераторах нет ничего таинственного. Это лишь объекты с методом next
, ведущие себя особым образом. Мы уже выяснили, какие нативные значения в JS являются итерируемыми. Объекты среди них не упоминали. Действительно, они не итерируются нативно. Рассмотрим такой объект:
const ourObject = {
1: 'a',
2: 'b',
3: 'c'
};
Если его итерировать с помощью for (let element of ourObject)
, то получим ошибку object is not iterable
.
Давайте напишем собственные итераторы, сделав такой объект итерируемым!
Для этого придётся пропатчить прототип Object
своим методом [Symbol.iterator]()
. Поскольку патчинг прототипа — плохая практика, создадим свой класс, расширив Object
:
class IterableObject extends Object {
constructor(object) {
super();
Object.assign(this, object);
}
}
Конструктор нашего класса берёт обычный объект и копирует его свойства в итерируемый объект (хотя на самом деле он ещё не является итерируемым!).
Создадим итерируемый объект:
const iterableObject = new IterableObject({
1: 'a',
2: 'b',
3: 'c'
})
Чтобы сделать класс IterableObject
действительно итерируемым, нам нужен метод [Symbol.iterator]()
. Добавим его.
class IterableObject extends Object {
constructor(object) {
super();
Object.assign(this, object);
}
[Symbol.iterator]() {
}
}
Теперь можно писать настоящий итератор!
Мы уже знаем, что это должен быть объект с методом next
. С этого и начнём.
class IterableObject extends Object {
// same as before
[Symbol.iterator]() {
return {
next() {}
}
}
}
После каждого вызова next
нужно возвращать объект вида { value, done }
. Сделаем его с выдуманными значениями.
class IterableObject extends Object {
// same as before
[Symbol.iterator]() {
return {
next() {
return {
value: undefined,
done: false
}
}
}
}
}
Учитывая такой итерируемый объект:
const iterableObject = new IterableObject({
1: 'a',
2: 'b',
3: 'c'
})
мы будем выводить пары ключ-значение, аналогично тому, как делает итерирование ES6 Map
:
['1', 'a']
['2', 'b']
['3', 'c']
В нашем итераторе в значении property
мы будем хранить массив [key, valueForThatKey]
. Обратите внимание, что по сравнению с предыдущими этапами это наше собственное решение. Если бы мы хотели написать итератор, возвращающий только ключи или только значения свойств, то без проблем могли бы это сделать. Просто сейчас решили возвращать пары ключ-значение.
Нам понадобится массив вида [key, valueForThatKey]
. Проще всего получить с помощью метода Object.entries
. Можем использовать его прямо передо созданием объекта итератора в методе [Symbol.iterator]()
:
class IterableObject extends Object {
// same as before
[Symbol.iterator]() {
// we made an addition here
const entries = Object.entries(this);
return {
next() {
return {
value: undefined,
done: false
}
}
}
}
}
Возвращаемый в методе итератор благодаря JavaScript-замыканию получит доступ к переменной entries
.
Также нам нужна переменная состояния. Она будет говорить нам, какая пара ключ-значение должна быть возвращена при следующем вызове next
. Добавим её:
class IterableObject extends Object {
// same as before
[Symbol.iterator]() {
const entries = Object.entries(this);
// we made an addition here
let index = 0;
return {
next() {
return {
value: undefined,
done: false
}
}
}
}
}
Обратите внимание, что мы объявили переменную index
с let
, потому что знаем, что планируем обновлять её значение после каждого вызова next
.
Теперь мы готовы вернуть фактическое значение в методе next
:
class IterableObject extends Object {
// same as before
[Symbol.iterator]() {
const entries = Object.entries(this);
let index = 0;
return {
next() {
return {
// we made a change here
value: entries[index],
done: false
}
}
}
}
}
Это было просто. Мы лишь использовали переменные entries
и index
для доступа к правильной паре ключ-значение из массива entries
.
Теперь нужно разобраться со свойством done
, потому что сейчас оно всегда будет false
. Можно сделать ещё одну переменную кроме entries
и index
, и обновлять её после каждого вызова next
. Но есть способ ещё проще. Будем проверять, вышел ли index
за пределы массива entries
:
class IterableObject extends Object {
// same as before
[Symbol.iterator]() {
const entries = Object.entries(this);
let index = 0;
return {
next() {
return {
value: entries[index],
// we made a change here
done: index >= entries.length
}
}
}
}
}
Наш итератор завершается, когда переменная index
равна или больше длины entries
. Например, если у entries
длина 3, то он содержит значения под индексами 0, 1 и 2. И когда переменная index
равна или больше 3, это означает, что значений больше не осталось. Мы закончили.
Этот код почти работает. Осталось добавить только одно.
Переменная index
начинается со значения 0, но… мы её не обновляем! Тут всё не так просто. Нам нужно обновлять переменную после того, как мы вернули { value, done }
. Но когда мы его вернули, метод next
немедленно останавливается, даже если есть какой-то код после выражения return
. Но мы можем создать объект { value, done }
, хранить его в переменной, обновлять index
и только потом возвращать объект:
class IterableObject extends Object {
// same as before
[Symbol.iterator]() {
const entries = Object.entries(this);
let index = 0;
return {
next() {
const result = {
value: entries[index],
done: index >= entries.length
};
index++;
return result;
}
}
}
}
После наших изменений класс IterableObject
выглядит так:
class IterableObject extends Object {
constructor(object) {
super();
Object.assign(this, object);
}
[Symbol.iterator]() {
const entries = Object.entries(this);
let index = 0;
return {
next() {
const result = {
value: entries[index],
done: index >= entries.length
};
index++;
return result;
}
}
}
}
Код прекрасно работает, но он стал довольно запутанным. Это потому, что здесь показан более умный, но менее очевидный способ обновления index
после создания объекта result
. Мы можем просто инициализировать index
со значением -1! И хотя он обновляется до возвращения объекта из next
, всё будет прекрасно работать, потому что первое обновление заменит -1 на 0.
Так и сделаем:
class IterableObject extends Object {
// same as before
[Symbol.iterator]() {
const entries = Object.entries(this);
let index = -1;
return {
next() {
index++;
return {
value: entries[index],
done: index >= entries.length
}
}
}
}
}
Как видите, теперь нам не нужно жонглировать очерёдностью создания объекта result
и обновления index
. В ходе второго вызова index
будет обновлён до 1, а мы вернём другой результат, и т. д. Всё работает так, как мы и хотели, а код выглядит гораздо проще.
Но как нам проверить корректность работы? Можно вручную запустить метод [Symbol.iterator]()
для создания экземпляра итератора, а затем напрямую проверить результаты вызовов next
. Но можно поступить гораздо проще! Выше было сказано, что любой итерируемый объект можно вставить в цикл for ... of
. Давайте так и сделаем, попутно журналируя значения, возвращаемые нашим итерируемым объектом:
const iterableObject = new IterableObject({
1: 'a',
2: 'b',
3: 'c'
});
for (let element of iterableObject) {
console.log(element);
}
Работает! Вот что выводится в консоли:
[ '1', 'a' ]
[ '2', 'b' ]
[ '3', 'c' ]
Круто! Мы начали с объекта, который нельзя было использовать в циклах for ... of
, потому что нативно они не содержат встроенных итераторов. Но мы создали свой IterableObject
, который имеет ассоциированный самописный итератор.
Надеюсь, что теперь вы видите потенциал итерируемых объектов и итераторов. Это механизм, позволяющий создавать собственные структуры данных для совместной работы с функциями JS вроде циклов for ... of
, причём они работают точно так же, как нативные структуры! Это очень полезная возможность, которая в определённых ситуациях позволяет сильно упростить код, особенно если вы планируете часто итерировать свои структуры данных.
Кроме того, мы можем настраивать, что именно должны возвращать такие итерации. Сейчас наш итератор возвращает пары ключ-значение. А если нам нужны только значения? Легко, просто перепишем итератор:
class IterableObject extends Object {
// same as before
[Symbol.iterator]() {
// changed `entries` to `values`
const values = Object.values(this);
let index = -1;
return {
next() {
index++;
return {
// changed `entries` to `values`
value: values[index],
// changed `entries` to `values`
done: index >= values.length
}
}
}
}
}
И всё! Если теперь запустить цикл for ... of
, то увидим в консоли:
a
b
c
Мы вернули одни лишь значения объектов. Всё это доказывает гибкость самописных итераторов. Вы можете заставить их возвращать что угодно.
Итераторы как… итерируемые объекты
Люди очень часто путают итераторы и итерируемые объекты. Это ошибка, и я старался аккуратно разделить эти два понятия. Подозреваю, мне известна причина, по которой люди так часто их путают.
Оказывается, что итераторы… иногда являются итерируемыми объектами!
Что это означает? Как вы помните, итерируемый объект — это объект, с которым ассоциирован итератор. В каждом нативном JavaScript-итераторе есть метод [Symbol.iterator]()
, возвращающий ещё один итератор! А это делает первый итератор итерируемым объектом.
Проверить это можно, если взять итератор, возвращённый из массива, и вызвать применительно к нему [Symbol.iterator]()
:
const ourArray = [1, 2, 3];
const iterator = ourArray[Symbol.iterator]();
const secondIterator = iterator[Symbol.iterator]();
console.log(secondIterator);
После запуска этого кода вы увидите Object [Array Iterator] {}
. То есть итератор не только содержит другой итератор, ассоциированный с ним, так это ещё и тоже массив.
Если сравнить оба итератора с помощью ===,
то окажется, что они совершенно одинаковы:
const iterator = ourArray[Symbol.iterator]();
const secondIterator = iterator[Symbol.iterator]();
// logs `true`
console.log(iterator === secondIterator);
Поначалу вам может показаться странным поведение итератора, который является собственным итератором. Но это очень полезная фича. Вы не можете воткнуть голый итератор в цикл for ... of
, он принимает только итерируемый объект — объект с методом [Symbol.iterator]()
.
Однако ситуация, когда итератор является собственным итератором (а следовательно итерируемым объектом), скрывает проблему. Поскольку нативные JS-итераторы содержат методы [Symbol.iterator]()
, вы можете не задумываясь передавать их напрямую в циклы for ... of
.
В результате этот фрагмент:
const ourArray = [1, 2, 3];
for (let element of ourArray) {
console.log(element);
}
и этот:
const ourArray = [1, 2, 3];
const iterator = ourArray[Symbol.iterator]();
for (let element of iterator) {
console.log(element);
}
работают без проблем и делают одно и то же. Но зачем кому-то напрямую использовать подобные итераторы в циклах for ... of
? Иногда это просто неизбежно.
Во-первых, вам может потребоваться создать итератор безо принадлежности к какому-нибудь итерируемому объекту. Мы рассмотрим этот пример ниже, и это не такая уж редкость. Иногда нам просто не нужнен сам итерируемый объект.
И было бы очень неудобно, если бы наличие голого итератор означало, что вы не можете использовать его в for ... of
. Конечно, можно сделать это вручную с помощью метода next
и, к примеру, цикла while
, но мы видели, для этого приходится писать довольно много кода, причём повторяющегося.
Решение простое: если вы хотите избежать шаблонного кода и использовать итератор в цикле for ... of
, придётся делать итератор итерируемым объектом.
С другой стороны, мы также довольно часто получаем итераторы из других методов, не только из [Symbol.iterator]()
. К примеру, ES6 Map
содержит методы entries
, values
и keys
. Все они возвращают итераторы.
Если бы нативные JS-итераторы не были ещё и итерируемыми объектами, вы не могли бы использовать эти методы напрямую в циклах for ... of
, вот так:
for (let element of map.entries()) {
console.log(element);
}
for (let element of map.values()) {
console.log(element);
}
for (let element of map.keys()) {
console.log(element);
}
Этот код работает, потому что итераторы, возвращённые методами, являются и итерируемыми объектами. В противном случае пришлось бы, скажем, обёртывать результат вызова map.entries()
в какой-нибудь дурацкий итерируемый объект. К счастью, нам это делать не нужно.
Считается хорошей практикой делать свои итераторы-итерируемые объекты. Особенно если они будут возвращены из иных методов, а не из [Symbol.iterator]()
. Сделать итератор итерируемым объектом очень просто. Покажу на примере итератора IterableObject
:
class IterableObject extends Object {
// same as before
[Symbol.iterator]() {
// same as before
return {
next() {
// same as before
},
[Symbol.iterator]() {
return this;
}
}
}
}
Мы создали метод [Symbol.iterator]()
под методом next
. Сделали этот итератор своим собственным итератором, просто возвращая this
, то есть он возвращает сам себя. Выше мы уже видели, как ведёт себя итератор-массив. Этого достаточно, чтобы наш итератор работал в циклах for ... of
даже напрямую.
Состояние итератора
Теперь должно быть очевидно, что у каждого итератора есть ассоциированное с ним состояние. Например, в итераторе IterableObject
мы хранили состояние — переменную index
— в виде замыкания. И обновляли её после каждого этапа итерации.
А что будет после завершения процесса итерации? Итератор становится бесполезен и можно (следует!) удалить его. В том, что это происходит, можно убедиться даже на примере нативных JS-объектов. Возьмём итератор массива и попробуем дважды запустить его в цикле for ... of
.
const ourArray = [1, 2, 3];
const iterator = ourArray[Symbol.iterator]();
for (let element of iterator) {
console.log(element);
}
for (let element of iterator) {
console.log(element);
}
Вы могли ожидать, что в консоли дважды отобразятся числа 1
, 2
и 3
. Но результат будет такой:
1
2
3
Почему?
Давайте вручную вызовем next
после завершения цикла:
const ourArray = [1, 2, 3];
const iterator = ourArray[Symbol.iterator]();
for (let element of iterator) {
console.log(element);
}
console.log(iterator.next());
Последний журнал выводит в консоли { value: undefined, done: true }
.
Вот оно что. После завершения цикла итератор переходит в состояние «done». Теперь он всегда будет возвращать объект { value: undefined, done: true }
.
Есть ли способ «сбросить» состояние итератора, чтобы второй раз использовать его в for ... of
? В некоторых случаях — возможно, но смысла в этом нет. Поэтому [Symbol.iterator]
является методом, а не просто свойством. Можно вызвать метод снова и получить другой итератор:
const ourArray = [1, 2, 3];
const iterator = ourArray[Symbol.iterator]();
for (let element of iterator) {
console.log(element);
}
const secondIterator = ourArray[Symbol.iterator]();
for (let element of secondIterator) {
console.log(element);
}
Теперь всё работает так, как ожидается. Давайте разберёмся, почему работает многократный прямой циклический проход по массиву:
const ourArray = [1, 2, 3];
for (let element of ourArray) {
console.log(element);
}
for (let element of ourArray) {
console.log(element);
}
Все циклы for ... of
используют разные итераторы! После завершения итератор и цикла этот итератор больше уже не используется.
Итераторы и массивы
Поскольку мы используем итераторы (хоть и не напрямую) в циклах for ... of
, они могут выглядеть обманчиво похожими на массивы. Но есть два важных отличия. Итератор и массив используют концепции жадных и ленивых значений. Когда вы создаёте массив, то в любой конкретный момент времени у него есть определённая длина, а его значения уже инициализированы. Конечно, вы можете создать массив вообще без значений, но речь о другом. Я хочу сказать, что невозможно создать массив, инициализирующий свои значения только после того, как вы обратитесь к ним, записав array[someIndex]
. Быть может, возможно обойти этого с помощью прокси или иных ухищрений, но по умолчанию массивы в JavaScript так себя не ведут.
И когда говорят, что у массива есть длина, имеют в виду, что эта длина конечна. В JavaScript не бывает бесконечных массивов.
Эти два качества указывают на жадность массивов.
А итераторы ленивые.
Чтобы показать это, создадим два своих итератора: первый будет бесконечным, в отличие от конечных массивов, а второй будет инициализировать свои значения только тогда, когда они будут запрошены пользователем итератора.
Начнём с бесконечного итератора. Звучит пугающе, но создать его очень просто: итератор начинается с 0 и на каждом этапе возвращает следующее число в последовательности. Вечно.
const counterIterator = {
integer: -1,
next() {
this.integer++;
return { value: this.integer, done: false };
},
[Symbol.iterator]() {
return this;
}
}
И всё! Мы начали со свойства integer
, равного -1. При каждом вызове next
мы увеличиваем его на 1 и возвращаем в объекте в качестве value
. Обратите внимание, что мы снова воспользовались вышеупомянутой хитростью: начали с -1, чтобы в первый раз вернуть 0.
Также взгляните на свойство done
. Оно всегда будет false. Этот итератор не кончается!
Кроме того, мы сделали итератор итерируемым объектом, дав ему простую реализацию [Symbol.iterator]()
.
И последнее: это случай, о котором я упоминал выше — мы создали итератор, но для работы ему не нужен итерируемый «родитель».
Теперь попробуем этот итератор в цикле for ... of
. Нужно лишь не забыть остановить цикл в какой-то момент, иначе код будет исполняться вечно.
for (let element of counterIterator) {
if (element > 5) {
break;
}
console.log(element);
}
После запуска мы увидим в консоли:
0
1
2
3
4
5
Мы действительно создали бесконечный итератор, возвращающий столько чисел, сколько пожелаете. И сделать его было очень легко!
Теперь напишем итератор, который не создаёт значения, пока они не будут запрошены.
Ну… мы уже его сделали!
Вы заметили, что в любой конкретный момент counterIterator
хранит только одно число свойства integer
? Это последнее число, возвращённое при вызове next
. И это та самая ленивость. Итератор потенциально может вернуть любое число (точнее, положительное целое). Но он создаёт их только тогда, когда они нужны: при вызове метода next
.
Это может выглядеть довольно бесполезным. Ведь числа создаются быстро и не занимают много места в памяти. Но если вы работаете с очень большими объектами, занимающими много памяти, то иногда замена массивов на итераторы может быть очень полезной, ускорив программу и сэкономив память.
Чем больше объект (или чем дольше он создаётся), тем больше выгода.
Другие способы использования итераторов
Пока что мы потребляли итераторы только в цикле for ... of
или вручную, с помощью метода next
. Но это не единственные способы.
Мы уже видели, что конструктор Map
принимает итерируемые объекты в качестве аргумента. Вы также можете с помощью метода Array.from
легко преобразовать итерируемый объект в массив. Но будьте осторожны! Как я говорил, ленивость итератора иногда может быть большим преимуществом. Преобразование в массив лишает ленивости. Все значения, возвращаемые итератором, начинают инициализироваться немедленно, а затем помещаются в массив. Это означает, что если мы попробуем преобразовать бесконечный counterIterator
в массив, то это приведёт к катастрофе. Array.from
будет исполняться вечно без возвращения результата. Так что перед преобразованием итерируемого объекта/итератора в массив нужно убедиться в безопасности операции.
Любопытно, что итерируемые объекты также хорошо сочетаются со spread-оператором (...
). Помните, что это работает аналогично Array.from
, когда все значения итератора генерируются сразу. Например, с помощью spread-оператора можно создать свою версию Array.from
. Просто применим оператор к итерируемому объекту, а затем положим значения в массив:
const arrayFromIterator = [...iterable];
Также можно получить из итерируемого объекта все значения и применить их к функции:
someFunction(...iterable);
Заключение
Надеюсь, теперь вам понятен заголовок статьи «Итерируемые объекты и итераторы». Мы узнали, что они собой представляют, чем различаются, как их использовать и как создавать свои экземпляры. Теперь мы полностью готовы работать с генераторами. Если вы хорошо разобрались в итераторах, то переход к следующей теме не вызовет трудностей.