4306

Ітерований об'єкт, ітератор і генератор

У мові програмування Python об'єкт, що ітерується, ітератор і генератор — це різні поняття, які, до того ж, викликають велику кількість питань у розробників-початківців. У цій статті ми розглянемо, чим вони відрізняються, як реалізовані та як використовуються на практиці.

Ітератор Python

Ітератор – це механізм поелементного обходу даних. Фактично, він є об'єктом, який є результатом виклику методу __iter__ об'єкта, що ітерується. Його основне завдання полягає у відстеженні наступного елемента у послідовності. Іншими словами, ітератор «знає», який елемент у послідовності буде наступним, і може обробляти такі елементи по одному.

У Python існує два види ітераторів:

  • зовнішній - це класичний, він же pull-based ітератор, який використовується у випадках, коли процесом обходу елементів послідовності явно управляє клієнт, використовуючи метод __next__;
  • внутрішній - це push-based ітератор, який передається callback-функция. Він керується клієнтом, а передає йому повідомлення отримання наступного елемента.

Ітератори можуть використовуватися для різних цілей, наприклад, для надання доступу до вмісту об'єктів-агрегаторів, без розкриття їх внутрішнього подання, для забезпечення однотипного інтерфейсу, без використання різних структур та виконання кількох активних обходів одного об'єкта.

 

Логіку роботи ітератора можна продемонструвати схематично:

image15

 

  • Aggregate - це об'єкт, що ітерується, по якому може переміщатися ітератор.
  • Iterator – інтерфейс самого ітератора.
  • ConcreteAggregate - певна реалізація агрегату.
  • ConcreteIterator – реалізація ітератора для конкретного агрегату.
  • Client – клієнтський інтерфейс, який використовує Aggregate та ітератор для його обходу.

Приклад реалізації ітератора

На практиці реалізація ітераторів може відрізнятися, але основна ідея полягає в тому, що вони можуть виконувати обхід різних структур, векторів, дерев, хеш-таблиць та іншого, але при цьому надають однотипний інтерфейс з яким зручно працювати.

Як приклад реалізуємо класичний ітератор.

Абстрактні класи:

class Aggregate(abc.ABC):

    @abc.abstractmethod
    def iterator(self):
        """
        Возвращает итератор
        """
        pass


class Iterator(abc.ABC):
    def __init__(self, collection, cursor):
        self._collection = collection
        self._cursor = cursor

    @abc.abstractmethod
    def first(self):
        """
        Возвращает итератор к началу агрегата.
        Так же называют reset
        """
        pass

    @abc.abstractmethod
    def next(self):
        """
        Переходит на следующий элемент агрегата.
        Вызывает ошибку StopIteration, если достигнут конец последовательности.
        """
        pass

    @abc.abstractmethod
    def current(self):
        """
        Возвращает текущий элемент
        """
        pass

Реалізація ітератора для списку:

class ListIterator(Iterator):
    def __init__(self, collection, cursor):
        """
        :param collection: список
        :param cursor: индекс с которого начнется перебор коллекции.
        так же должна быть проверка -1 >= cursor < len(collection)
        """
        super().__init__(collection, cursor)

    def first(self):
        """
        Начальное значение курсора -1.
        Так как в нашей реализации сначала необходимо вызвать next 
         который сдвинет курсор на 1.
        """
        self._cursor = -1

    def next(self):
        """
        Если курсор указывает на послений элемент, то вызываем StopIteration,
        иначе сдвигаем курсор на 1
        """
        if self._cursor + 1 >= len(self._collection):
            raise StopIteration()
        self._cursor += 1

    def current(self):
        """
        Возвращаяем текущий элемент
        """
        return self._collection[self._cursor]

Реалізація агрегату:

class ListCollection(Aggregate):
    def __init__(self, collection):
        self._collection = list(collection)

    def iterator(self):
        return ListIterator(self._collection, -1)

Далі ми можемо створити об'єкт колекції, а потім обійти всі елементи з використанням ітератора.

Для цього пишемо такий код:

collection = (1, 2, 5, 6, 8)
aggregate = ListCollection(collection)
itr = aggregate.iterator()

# обход коллекции
while True:
    try:
        itr.next()
    except StopIteration:
        break
    print(itr.current())

Оскільки ми створили метод first, що скидає ітератор у початковий стан, ми можемо використовувати цей ітератор знову:

# возвращаем итератор в исходное состояние
itr.first()

while True:
    try:
        itr.next()
    except StopIteration:
        break
    print(itr.current())

Ітерований об'єкт Python

Об'єкт, що ітерується, можна описати як послідовність елементів, яка може бути представлена ​​в будь-якому вигляді: файлом, рядком, списком, кортежем або будь-якою іншою структурою даних, елементи якої можна перебирати.

По суті, об'єктами, що ітеруються, є всі об'єкти, від яких вбудована функція iter() може отримати оператор.

Важливо, що це можуть бути ті об'єкти, які реалізують метод __iter__. Справа в тому, що для отримання ітератора функція iter() насамперед викликає метод __iter__, а якщо він не реалізований - перевіряє наявність методу __getitem__ і вже на його основі створює ітератор. При цьому TypeError викликається тільки в тому випадку, коли в об'єкті не реалізовано жодного з цих методів.

У мові програмування Python об'єкти, що ітеруються, представлені класом collections.abc.Iterator:

class Iterator(Iterable):

    __slots__ = ()

    @abstractmethod
    def __next__(self):
        'Return the next item from the iterator. When exhausted, raise StopIteration'
        raise StopIteration

    def __iter__(self):
        return self

    @classmethod
    def __subclasshook__(cls, C):
        if cls is Iterator:
            return _check_methods(C, '__iter__', '__next__')
        return NotImplemented

Тут варто дати деякі пояснення:

  • __next__ - повертає наступний доступний у послідовності елемент, а якщо він не був виявлений, то повертає виняток StopIteration.
  • __iter__ — повертає елемент self, що дозволяє використовувати ітератор там, де очікується робота з об'єктом, що ітерується, наприклад у циклі for.
  • __subclasshook__ — перевіряє клас на наявність методів __iter__ та __next__.

Підводячи межу можна сказати, що ітератор у мові Python - це будь-який об'єкт, який реалізує метод __next__ без аргументів і повертає наступний елемент послідовності або помилку StopIteration, що сигналізує про закінчення послідовності. При цьому він сам реалізує метод __iter__, через що сам є об'єктом, що ітерується.

Як приклад створимо об'єкт, що ітерується, на основі списку, а також його ітератор:

class ListIterator(collections.abc.Iterator):
    def __init__(self, collection, cursor):
        self._collection = collection
        self._cursor = cursor

    def __next__(self):
        if self._cursor + 1 >= len(self._collection):
            raise StopIteration()
        self._cursor += 1
        return self._collection[self._cursor]

class ListCollection(collections.abc.Iterable):
    def __init__(self, collection):
        self._collection = collection

    def __iter__(self):
        return ListIterator(self._collection, -1)

Варіанти роботи:

collection = [1, 2, 5, 6, 8]
aggregate = ListCollection(collection)

for item in aggregate:
    print(item)

print("*" * 50)

itr = iter(aggregate)
while True:
    try:
        print(next(itr))
    except StopIteration:
        break

Щоб після закінчення ітерації функція next() не повертала помилку StopIteration, ми можемо передати до неї другий аргумент:

itr = iter(aggregate)
while True:
    item = next(itr, None)
    if item is None:
        break
    print(item)

Також варто додати, що вбудовану функцію iter() можна викликати з двома аргументами, що дозволить створити ітератор із об'єкта, що викликається. У разі перший аргумент є викликаним об'єктом, а другий виступає ролі обмежувача. Наприклад, з функції повертає рандомні значення від 1 до 6 ми можемо зробити ітератор, який повертатиме значення, доки це не виявиться цифра 6:

Приклад коду:

from random import randint

def d6():
    return randint(1, 6)

for roll in iter(d6, 6):
    print(roll)

Генератори в Python

Генератори за своєю суттю є тими самими ітераторами, лише з допомогою ітерувати об'єкт можна лише один раз. Це з тим, що де вони зберігають отримані значення пам'яті, а генерують елементи «на лету».

Генератори можна використовувати з різними мовними конструкціями, які дають можливість перебирати елементи об'єкта, що ітерується — наприклад, за допомогою циклу for. Однак у переважній більшості випадків вони створюються як окремі функції, але, при цьому, повертають значення не через традиційний return, а за допомогою ключового слова yield.

Як приклад розглянемо просту функцію-генератор:

def generator_function():
    for i in range(10):
        yield i
        
for item in generator_function():
    print (item)
    
# Вывод: 0
# 1
# 2
# 3
# 4
# 5
# 6
# 7
# 8
# 9

Основний плюс генераторів полягає у дуже низькому споживанні ресурсів. Завдяки цьому їх часто використовують для розрахунку великих наборів результатів, де виділення пам'яті одночасного зберігання всіх результатів недоцільно.

Щоб стало зрозуміло про що йдеться, створимо генератор, який вважає числа Фібоначчі:

# generator version
def fibon (n):
    a = b = 1
    for i in range(n):
        yield a
        a, b = b, a + b

Навіть якщо значення n ми поставимо велике число, наприклад 1000000, то використовуючи генератор ми можемо не переживати надмірне споживання ресурсів, і це вплине працювати решти коду. Для порівняння, цей алгоритм можна реалізувати за допомогою звичайної функції. 

Виглядатиме це так:

def fibon(n):
    a = b = 1
    result = []
    for i in range(n):
        result.append(a)
        a, b = b, a + b
    return result

У такому разі ми отримаємо величезне навантаження на ресурси пристрою, і якщо при розробці програми орієнтуватися на такі конструкції, то проблеми з продуктивністю йому гарантовані.

Також варто зазначити, що будь-яка функція Python, у тілі якої зустрічається слово yield, називається генераторною функцією, і при виклику повертає об'єкт-генератор, з яким можна працювати як з будь-яким об'єктом, що ітерується. Розглянемо роботу yield трохи докладніше. 

Для цього як приклад напишемо такий код:

def gen_fun():
    print('block 1')
    yield 1
    print('block 2')
    yield 2
    print('end')

for i in gen_fun():
    print(i)

# block 1
# 1
# block 2
# 2
# end

Робота цього генератора відбуватиметься так:

  1. Насамперед при виклику gen_fun створюється об'єкт-генератор.
  2. Цикл for викликає функцію iter() з об'єктом gen_fun та отримує ітератор цього генератора.
  3. Цикл викликає функцію next(), яка перебиратиме елементи об'єкта доти, доки не отримає відповідь StopIteration.

При цьому при кожному виклику next() виконання функції триває з того моменту, де було завершено в останній раз і триває до наступного yield.

Генераторний вираз

Генераторний вираз це спрощений з погляду синтаксису спосіб створити генератор, не визначаючи і викликаючи функцію. Такий підхід зручно використовувати для генерації колекцій та їх нескладних перетворень. 

Виглядають такі вирази як генератори списків, тільки вони укладені у круглі дужки замість квадратних:

[x * x for x in range(10)]
# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
[x * x for x in range(10)]
# <generator object <genexpr> at 0x7fe76f7e5db0>

Як бачимо, у першому випадку код генерує діапазон чисел, а в другому створює об'єкт generator object, який є ітератором. Таким чином ми можемо відкласти обчислення елементів послідовності до тих пір, поки в них не виникне потреба, чим знову ж таки знижуємо навантаження на ресурси.

При цьому варто враховувати, що генераторні вирази - це в першу чергу вирази, з усіма обмеженнями.

Вивчення Python у SpaceLAB

Лабораторія SpaceLAB - це онлайн-школа, де ви можете безкоштовно освоїти IT-професію, що затребувана, з перспективою подальшого працевлаштування. На курсі Python ви не тільки освоїте теоретичну частину, але й поринете в реалізацію практичних завдань під кураторством досвідчених менторів - розробників компанії AVADA-MEDIA, що діють.