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

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

f3452de77a8978115df12cf1de914d88.jpg

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

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

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

e6270f414ce31e00cbb00e3671d2552d.jpg

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

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

Создадим два списка разного размера и функцией 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.getsaizof мы узнаем размер в байтах итераторов, созданных для 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 байт. Ровно столько же, сколько итераторы для последовательностей. Совпадение?… Наверное.

© Habrahabr.ru