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

Комментарии (18)


  1. amarao
    23.02.2022 15:25
    +2

    Добавлю ещё одну важную вещь. Часто функции хотят работать с итератором на входе. А пользователи хотят передавать туда что попало (коллекцию, словарь, итерейбл, итератор, etc). И есть трюк, который сделал мой код шелковистым:

    for x in iter(argument):
     ...

    Трюк тут состоит в том, что iter от iterator - это итератор (no-op), а iter от всего остального - это итератор по ним! Т.е. используя iter мы делаем функцию всеядной, готовой сожрать любой генератор, итерейбл, итератор, список, строку, set и т.д.


    1. mayorovp
      23.02.2022 16:39
      +4

      Конкретно в цикле for-in вызов iter писать нет смысла, оператор цикла сам итератор получит.


      1. amarao
        23.02.2022 17:07

        Да, у меня был код с next(), правда. И именно там iter в начале позволил получить инвариант.


        1. mayorovp
          23.02.2022 19:41
          +1

          Ну, если у вас код с next — то вызывать iter надо не столько для получения инварианта, сколько для запуска итерации.


          Как-то слабо представляю код, который ожидает итератора, но для удобства может принимать и коллекции. Чаще всего логично что код ожидает коллекции, но для удобства может принять и итератор.


      1. kai3341
        23.02.2022 17:11

        Это понятно. Очевидно, намеренно простой код используется для иллюстрации

        Ваша лодка готова, капитан!


    1. GBR-613
      24.02.2022 14:05

      А зачем? И коллекции, и словари - они все и так iterables, для них всех и так можно использовать `for x in ...`.


  1. inso
    23.02.2022 17:06
    +2

    В двух абзацах документации информации больше чем в статье.


    1. kazakovmaksim Автор
      23.02.2022 19:13

      Цель статьи не в документации протокола итераторов. Цель статьи показать как можно исcледовать объекты в Python методом черного ящика, делая маленькие выводы самостоятельно, чтобы сделать освоение языка более интересным и продуктивным. Это полезно для тех, кто только начинает изучать Python. Иными словами, целевая аудитория в первую очередь новички. Но за комментарий спасибо!


  1. igorzakhar
    23.02.2022 17:54

    Лучшее объяснение этой темы, лично для меня, в книге "Python. К вершинам мастерства" Л. Рамальо, глава 14.


    1. kazakovmaksim Автор
      23.02.2022 19:09

      Полностью согласен! Более того, для меня это одна из интереснейших книг о Python в принципе. Но целью моей статьи было показать как можно производить исследовательскую активность для анализа объектов. На мой взгляд, это полезно для тех, кто только начинает изучать Python - так его изучение можно сделать продуктивнее и интереснее.


  1. MentalBlood
    23.02.2022 19:04
    +1

    Как-то невежливо тыкать палкой в то что хорошо документировано


    1. kazakovmaksim Автор
      23.02.2022 19:25

      :) Зависит от целей. Цель статьи показать как можно исследовать объекты, чтобы изучение языка было более продуктивным и интересным. Целевая аудитория новички.


  1. kt97679
    24.02.2022 06:55
    +1

    На моей ubuntu 20.04 я вижу, что размер объекта итератор, созданного на основе range, равен не 32 байта, а 48:

    $ python -V
    Python 3.9.2
    $ uname -a
    Linux joy 5.4.0-99-generic #112-Ubuntu SMP Thu Feb 3 13:50:55 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux
    $ python -c 'import sys; print(sys.getsizeof(range(0)))'
    48
    $ 
    


    1. kazakovmaksim Автор
      24.02.2022 12:21
      +1

      Это размер непосредственно объекта range - арифметической прогрессии (про это написанно в конце статьи), а не итератора.

      А вот так будет сколько?

      print(sys.getsizeof(iter(range(0))))


      1. kt97679
        24.02.2022 17:24
        +1

        Прошу прощения, скопировал не ту строку:

        $ python -c 'import sys; print(sys.getsizeof(iter(range(0))))'
        48
        $ 
        


        1. kazakovmaksim Автор
          24.02.2022 17:54

          Посмотрел на линуксе, действительно 48.

          А на винде - 32.

          Спасибо за информацию!


  1. DX28
    25.02.2022 09:06
    -1

    По-моему Вы немного все смешали в кучу и получили коллизию.

    (зависимость рождаемости от количества гнезд аиста)

    Смотрите 1) есть питоновский встроенный list. Который хранит ссылки на объекты, поэтому размер его не зависит от размеров объектов внутри.

    2) list так же реализует внутри себя методы next, iter которые интерпретатор Питона

    воспринимает как необходимость работы с ним как с итерируемым объектом

    3) вы создаете итератор как ссылку на объект что и заложенов природе так как существует п2 а именно метод iter

    То есть свойства итератора как неизменяемого по памяти в Вашем случае это частный случай

    и не свойства итератора. Можно создать свой итератор который будет пухнуть при вызове метода next.


    1. kazakovmaksim Автор
      25.02.2022 13:16

      list НЕ реализует внутри себя методы next. Это легко проверить, вызвав my_list.__next__() или передав список в функцию next(). --> AttributeError: 'list' object has no attribute 'next'.

      list лист, как итерабельный объект реализует метод __iter__, который возвращает каждый раз новый итератор. А вот именно итератор и реализует метод __next__. И ещё итератор реализует и метод __iter__, но возвращает сам себя (это для удобства сделано). Итерируемый объект (итераторы не берем во внимание т.к. они итерируемы только формально и для удобства) НЕ ДОЛЖНЫ реализовывать метод next - это приводит ко серьезным ошибкам (вроде того, что объект можно будет обойти лишь одноразово).

      Есть в Python вполне конкретный патерн под названием Iterator. Про него я и писал. А если создать свой итератор и заставить его "распухать", то это получится сделать только потому что всё в питоне - объекты и с ними можно творить что хочешь.