Тычем палкой в итераторы
Итераторы — мощные и очень полезные составляющие 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.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 байт. Ровно столько же, сколько итераторы для последовательностей. Совпадение?… Наверное.