Как стать автором
Обновить

Тычем палкой в итераторы

Время на прочтение5 мин
Количество просмотров11K

Итераторы – мощные и очень полезные составляющие Python. Цель этой статьи в их изучении подручными инструментами. Сразу предупреждаю, что в исследовательском арсенале у меня не швейцарский нож, а палка-копалка. Как известно, она предназначена для разрыхления почвы, но для нескольких любопытных находок достаточно и этого.

Итерируемый объект (iterable) и итератор (iterator) – тесно связанные понятия. Я не буду касаться всех деталей, благо вот тут и тут можно получить исчерпывающее представление об этих явлениях. Нужно лишь иметь абстрактное представление об итерируемости и итераторах.

Итерируемый объект – это объект, который можно передать в функцию iter() и получить к нему итератор. Обычно итерируемые объекты – это какие-то коллекции, например, последовательности. У них есть элементы, и эти элементы можно перебрать как бусины. Это и есть суть итерируемости. Но непосредственно сам себя объект не перебирает. Он лишь предоставляет такую возможность для другого вида объектов – итераторов. Итератор – это объект, который непосредственно осуществляет перебор итерируемого объекта.

Исследовать итераторы мы будем функциями sys.getsizeof и sys.getrefcount. В исследовании поучаствуют не все существующие итераторы, а лишь создаваемые для list, str, tuple, set, frozenset, dict, range, bytes, bytearray.

Итератор копирует данные?

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

Создадим два списка разного размера и функцией iter() породим итераторы к ним:

# Создаём списки
list_small = [1, 2, 3, 4]
list_big = [
  '1', '2', '3', '4', '5', 'q', 'w', 'e',
  'r', 't', 'y', 'u', 'i', 'o', 'p', 'a',
  's', 'd', 'f', 'g', ':', '"', ';'
]

# Создаем итераторы для списков
iter_list_small = iter(list_small)
iter_list_big = iter(list_big)

При помощи функции sys.getsizeof() узнаем размер списков и их итераторов в байтах:

import sys

# Узнаем размер списков
sys.getsizeof(list_small) = [1, 2, 3, 4]  # 120 байт
sys.getsizeof(list_big)  # 240 байт

# Узнаём размер итераторов
sys.getsizeof(iter_list_small)  # 48 байт
sys.getsizeof(iter_list_big)  # 48 байт

Размер в байтах у списков различается в два раза (120 и 240 байт), но при этом их итераторы имеют одинаковый размер (48 байт). Если увеличить или уменьшить список, то размер итератора останется прежним – 48 байт.

Несложно заметить три закономерности:

  • размер итератора меньше чем размер объекта, к которому он создан;

  • размер итератора не зависит от размера итерируемого объекта;

  • размер итератора фиксирован.

Следовательно, итераторы – это лишь интерфейсы, обеспечивающие обход итерируемого объекта. Они ничего в себя не копируют.

Ещё кое-что про итераторы

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

Давайте рассмотрим это на примере. Вначале узнаем, какой первый элемент вернёт итератор iter_list_small. Затем изменим второй элемент в списке list_small, после чего вернем ещё один элемент через итератор:

next(iter_list_small)  # этот вызов вернёт цифру 1
list_small[1] = 'a'  # заменили в списке list_small цифру 2 на строку 'a'
next(iter_list_small)  # этот вызов вернет строку 'a'

Как видно, итератор был создан для списка [1, 2, 3, 4] и начал возвращать его элементы. Но после того как мы заменили второй элемент в списке на 'a' и обратились к итератору, он вернул нам уже элемент из нового состояния списка.

Следовательно, итератор не запоминает состояние объекта, к которому он был создан – он только знает как вызвать следующий элемент этого объекта.

Но давайте экспериментировать дальше! Удалим список list_small:

del list_small

А теперь попытаемся вернуть ещё один элемент через итератор этого списка:

next(iter_list_small)

Удивительно, но итератор продолжил работать и вернул цифру 3, хоть исходного списка уже вроде и нет...

На самом деле объект списка в памяти остался. Выполнив del list_small мы удалили лишь имя list_small  и ссылку, связанную с этим именем. А сам объект списка [1, 'a', 3, 4] остался в памяти, но почему?

В Python есть очень полезный сборщик мусора, который удаляет ненужные объекты из памяти. Самыми ненужными он считает объекты, на которые совсем нет ссылок.

Чуть выше мы доказали, что итератор не копирует в себя данные для работы. Сопоставив эту информацию с тем, как работает сборщик мусора, можно предположить, что итератор хранит в себе ссылку на итерируемый объект. Но давайте это докажем при помощи функции sys.getrefcount, которая возвращает число ссылок на объект:

# Создадим новый список
new_list = [1, 2, 3, 4, 5]

# Проверим число ссылок на новый список
sys.getrefcount(new_list)  # 2 ссылки (одна из них от getrefcount)

# Создадим итератор нового списка
iter_new_list = iter(new_list)

# Проверим число ссылок теперь
sys.getrefcount(new_list)  # число ссылок увеличилось до 3

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

Чей итератор самый маленький?

К любым итерируемым объектам можно создать итераторы. Давайте анализировать их дальше. При помощи функции sys.getsizeof мы узнаем размер в байтах итераторов, созданных для list, str, tuple, set, frozenset, dict, range, bytes, bytearray. К каждому из этих объектов будет создан итератор соответствующего "сорта". Например, к списку создастся list_iterator, а к range – range_iterator и т.д.

Ранее мы уже узнали, что размер итератора никак не зависит от размера итерируемого объекта, к которому он создан – итератор для списка из одного элемента имеет такой же размер в байтах, как итератор для списка из тысячи элементов. Более того, итераторы для пустых объектов будут такого же размера. Но для чистоты эксперимента давайте работать с "пустыми" объектами:

my_str = ''
my_list = []
my_tuple = ()
my_bytes = b''
my_bytearray = bytearray()
my_set = set()
my_frozenset = frozenset()
my_dict = dict()
my_range = range(0)

Теперь для каждого из этих объектов создадим итератор через iter() и узнаем его размер в байтах через sys.getsizeof():

sys.getsizeof(iter(my_str))  # 48 байт
sys.getsizeof(iter(my_list))  # 48 байт
sys.getsizeof(iter(my_tuple))  # 48 байт
sys.getsizeof(iter(my_bytes))  # 48 байт
sys.getsizeof(iter(my_bytearray))  # 48 байт
sys.getsizeof(iter(my_set))  # 64 байт
sys.getsizeof(iter(my_frozenset))  # 64 байт
sys.getsizeof(iter(my_dict))  # 72 байт
sys.getsizeof(iter(my_range))  # 32 байт

И что с размерами?

По размеру в байтах эти итераторы разделяются на 4 чётких группы:

  • 48 байт (итераторы для str, list, tuple, bytes, bytearray);

  • 64 байта (итераторы для set, frozenset);

  • 72 байта (итератор для dict);

  • 32 байта (итератор для range).

Размер итератора косвенно указывает на схожесть внутреннего устройства объектов в каждой группе. Строка (str) – это в первую очередь последовательность, как и list, tuple, bytes, bytearray. В сущности str это последовательность из односимвольных "строк". Поэтому не совсем корректно говорить "есть строки и последовательности" – строка это вид последовательности.

Больше всех удивил итератор для range. Он оказался самым "маленьким" – всего 32 байта. А сам объект range (даже если у него заданы все три параметра) занимает 48 байт. Ровно столько же, сколько итераторы для последовательностей. Совпадение?.. Наверное.

Теги:
Хабы:
Всего голосов 9: ↑8 и ↓1+11
Комментарии19

Публикации

Истории

Работа

Data Scientist
78 вакансий
Python разработчик
119 вакансий

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань