Розбираємося з оператором `for`

15 хв. читання

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

Байт-код

Почнімо з простого оператора for:

for a in b:
    c

Приклад з оператором for

Передача коду через модуль dis дає нам:

2           0 LOAD_GLOBAL              0 (b)
              2 GET_ITER
        >>    4 FOR_ITER                 8 (to 14)
              6 STORE_FAST               0 (a)

  3           8 LOAD_GLOBAL              1 (c)
             10 POP_TOP
             12 JUMP_ABSOLUTE            4
        >>   14 LOAD_CONST               0 (None)
             16 RETURN_VALUE

Розбір for a in b: c

Якщо ви подивитесь на вивід, то можете помітити, що інструкції opcode GET_ITER та FOR_ITER здаються специфічними для оператора for, тоді як всі інші інструкції opcode є загальними.

Вивчаючи GET_ITER у нескінченному циклі, здається, що здебільшого він викликає PyObject_GetIter(). Зазвичай (але не завжди) це еквівалентно вбудованій функції iter(). Ми також знаємо, що, мабуть, це буде щось спільне з next(), тож надамо визначення обох функцій далі в цьому дописі.

Якщо говорити про next(), opcode FOR_ITER — це семантично виклик __next__() для типу об'єкта (як представлено (*iter->ob_type->tp_iternext)(iter) у коді C). Це означає, що для розуміння роботи операторів потрібно розуміти і те, як працює iter() і next().

Вбудовані

Перш ніж ознайомлюватися з роботою iter() і next(), ми повинні розібрати два важливі терміни. Ітерований (iterable) — це контейнер, який може повертати вміщені елементи по одному. Ітератор — це об'єкт, який передає ці елементи. Отже, хоча кожен ітератор є концептуально ітерованим (і ви завжди повинні робити свої ітератори також ітерованими; це не дуже багато роботи), зворотне не буде істинним і не всі ітеровані є ітераторами самі по собі.

iter()

Ми зупинилися на визначенні цих слів, адже завдання iter()— взяти ітерований об'єкт і повернути його ітератор. Тепер те, що iter() вважає ітерованим об'єктом залежить від того, чи передаєте ви йому один або два аргументи.

iter(iterable)

Починаючи з семантики функції, коли є один аргумент, ми бачимо, що реалізація викликає PyObject_GetIter(), щоб отримати ітератор. Псевдокод для цієї функції, який ми зараз пояснимо, такий:

def iter(iterable):
\ttype_ = type(iterable)
    if hasattr(type_, "__iter__"):  # type_->tp_iter != NULL
        iterator = type_.__iter__(iterable)
        if hasattr(type(iterator), "__next__"):  # PyIter_Check
            return iterator
        else:
            raise TypeError
    elif hasattr(type_, "__getitem__"):  # PySequence_Check
        return SeqIter(iterable)  # PySeqIter_New
    else:
        raise TypeError

Псевдокод iter(iterable)

Перший крок — пошук спеціального методу __iter__() для ітерованого. Виклик цього призначений для повернення ітератора (який явно перевіряється на повернене значення). На рівні С визначення «ітератор» є об'єктом, який визначає __next__(); протоколи як ітерованого, так і ітератора також можна перевірити за допомогою їхніх необхідних класів collections.abc та issubclass() (цього не зроблено так у псевдокоді просто тому, що перевірка hasattr() ближча до того, як пишеться код С; у коді Python, мабуть, краще застосувати абстрактні базові класи).

Є примітка щодо __iter__(), яка повідомляє, що якщо для атрибуту класу встановлено значення None, він не буде застосовуватися (хоча авторові цих рядків не траплялося жодного явного коду, що виконує цю перевірку). Схоже, що така поведінка неявно підтримується завдяки тому, як це імплементовано, тобто None не викликається і не має відповідних методів, які перевіряються.

Другий крок — що ж відбувається, якщо __iter__() недоступний? У цьому разі перевіряється, чи маємо ми справу з послідовністю, шукаючи спеціальний метод __getitem__(). Якщо об'єкт виявляється послідовністю, повертається екземпляр PySeqIter_Type, чиєю наближеною реалізацією Python буде:

def _seq_iter(seq):
    """Yield the items of the sequence starting at 0."""
    index = 0
    while True:
        try:
            yield seq[index]
            index += 1
        except (IndexError, StopIteration):
            return

Реалізація PySeqIterType

«Наближеною», оскільки версія CPython підтримує pickling, і це спрощує життя.😁

Якщо все зазначене раніше не вдається виконати, тоді виникає TypeError.

iter(callable, sentinel)

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

def iter(callable, sentinel):
    if not hasattr(callable, "__call__"):  # PyCallable_Check
        raise TypeError
    return CallIter(callable, sentinel)  # PyCallIter_Type

Псевдокод для iter(callable, sentinel)

Як бачите, є перевірка щодо можливості викликати перший аргумент, і якщо так, тоді повертається екземпляр ітератора PyCallIter_Type. Оскільки функція настільки коротка, важливим питанням є те, що робить ітератор PyCallIter_Type?

Ця форма iter() застосовується, коли у вас є викликаний об'єкт без аргументу, який ви постійно викликаєте, доки не буде повернуто певне значення. Це означатиме, що більше нічого не надходить від викликаного.

def _call_iter(callable, sentinel):
    while True:
        val = callable()
        if val == sentinel:
            return
        else:
            yield val

Приклад того, де це може бути корисним, якщо ви читаєте файл частинами, зупиняючись, коли повертається b"":

with open(path, "rb") as file:
    for chunk in iter(lambda: file.read(chunk_size), b""):
        ...

Якщо ми об'єднаємо все разом і зробимо це правильно, виразом iter() буде:

def iter(obj, /, sentinel=_NOTHING,):
    """Return an iterator for the object.

    If 'sentinel' is unspecified, the first argument must either be an iterable
    or a sequence. If the argument is a sequence, an iterator will be returned
    which will index into the argument starting at 0 and continue until
    IndexError or StopIteration is raised.

    With 'sentinel' specified, the first argument is expected to be a callable
    which takes no arguments. The returned iterator will execute the callable on
    each iteration until an object equal to 'sentinel' is returned.
    """
    # Python/bltinmodule.c:builtin_iter
    obj_type = builtins.type(obj)
    if sentinel is _NOTHING:
        # Python/abstract.c:PyObject_GetIter
        try:
            __iter__ = _mro_getattr(obj_type, "__iter__")
        except AttributeError:
            try:
                _mro_getattr(obj_type, "__getitem__")
            except AttributeError:
                raise TypeError(f"{obj_type.__name__!r} is not iterable")
            else:
                return _seq_iter(obj)
        else:
            iterator = __iter__(obj)
            # Python/abstract.c:PyIter_Check
            iterator_type = builtins.type(iterator)
            try:
                _mro_getattr(iterator_type, "__next__")
            except AttributeError:
                raise TypeError(
                    f"{obj_type.__name__!r}.__iter__() returned a non-iterator of type {builtins.type(__iter__)!r}"
                )
            else:
                return __iter__(obj)
    else:
        # Python/object.c:PyCallable_Check
        try:
            _mro_getattr(obj_type, "__call__")
        except AttributeError:
            raise TypeError(f"{obj_type.__name__!r} must be callable")
        else:
            return _call_iter(obj, sentinel)

Реалізація для iter()

next()

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

def next(iterator, /, default=_NOTHING):
    """Return the next value from the iterator by calling __next__().

    If a 'default' argument is provided, it is returned if StopIteration is
    raised by the iterator.
    """
    # Python/bltinmodule.c:builtin_next
    iterator_type = builtins.type(iterator)
    try:  # Python/abstract.c:PyIter_Check
        __next__ = _mro_getattr(iterator_type, "__next__")
    except AttributeError:
        raise TypeError(f"{iterator_type.__name__!r} is not an iterator")
    else:
        try:
            val = __next__(iterator)
        except StopIteration:
            if default is _NOTHING:
                raise
            else:
                return default
        else:
            return val

Реалізація для next()

Семантика

Кожне значення, що повертається ітератором ітерованого об'єкта, присвоюється цілі циклу (іноді це називають варіантом циклу). Оператори у тілі циклу запускаються і це повторюється, поки не виконаються всі ітерації. Якщо є умова else для циклу for, тоді він виконується, якщо не настає умова спрацювання оператора break. Переривання роботи циклу за допомогою break дає нам змогу:

  1. Отримати ітератор ітерованого.
  2. Присвоїти значення next ітератора цілі циклу.
  3. Виконати умову тіла циклу.
  4. Повторювати виконання умови, доки не виконаються всі ітерації.
  5. Якщо є умова else, а умова спрацювання оператора break не настає, виконувати умову else.

Схоже на цикл while, чи не так?

  1. Отримати ітератор за допомогою iter().
  2. Викликати next() та визначати результат цілі циклу.
  3. Поки виклик next не спричиняє спрацювання StopIteration, виконувати умову тіла циклу.
  4. Повторювати, доки необхідно.
  5. Запустити умову else за необхідності.

for без умови else

Почнемо з простішого випадку без умови else. У цьому разі ми можемо перенести:

for a in b:
    c

Приклад циклу for

до:

_iter = iter(b)
while True:
    try:
        a = next(_iter)
    except StopIteration:
        break
    else:
        c
del _iter

Приклад циклу for, розгорнутий

Якщо подивитись уважно, то це такий собі приклад циклу for. Це, безумовно, значно більше писанини, та було б головним болем писати це все щоразу, коли потрібно виконати ітерації, але цикл працюватиме.

for з умовою else

Ви могли зауважити, що ми застосовували оператор break у нашому розв'язанні вище, через що умова else циклу while завжди пропускається. Як зробити, щоб умова else спрацьовувала?

Спочатку оновімо наш приклад:

for a in b:
    c
else:
    d

Приклад циклу for з умовою else

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

_iter = iter(b)
_looping = True
while _looping:
    try:
        a = next(_iter)
    except StopIteration:
        _looping = False
        continue
    else:
        c
else:
    d
del _iter, _looping

Таке розв'язання має досить зручний побічний ефект. Ми можемо покладатись на власну умову else у циклі while та його семантику, щоб отримати те, що ми шукаємо для циклу for!

І це все! Ми можемо застосувати цикл while для впровадження циклів for без помітних семантичних змін (крім тимчасових змінних).

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 4K
Приєднався: 11 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація