Ітерація в Python: детальний огляд

21 хв. читання

Поведінка циклу for в Python відрізняється від типової поведінки for в інших мовах програмування. У статті зануримось в логіку роботи for в Python, щоб краще зрозуміти його особливості.

Підводні камені циклів

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

Проблема 1: Подвійний цикл

Припустимо, у нас є перелік чисел та генератор, який повертає квадрати згаданих чисел:

>>> numbers = [1, 2, 3, 5, 7]
>>> squares = (n**2 for n in numbers)

Ми можемо передати об'єкт генератора в конструктор tuple, щоб перетворити його на кортеж:

>>> tuple(squares)
(1, 4, 9, 25, 49)

Якщо ми візьмемо той самий об'єкт генератора та передамо його у функцію sum, ми будемо очікувати суму визначених чисел, тобто 88.

>>> sum(squares)
0

Натомість ми отримаємо 0.

Проблема 2: Перевірка наявності

Повернемось до того самого переліку чисел та об'єкта генератора:

>>> numbers = [1, 2, 3, 5, 7]
>>> squares = (n**2 for n in numbers)

Якщо ми запитаємо, чи містить генератор squares значення 9, Python поверне true. Але якщо ми повторимо наші дії, отримаємо протилежний результат, тобто false.

>>> 9 in squares
True
>>> 9 in squares
False

Проблема 3: Розпакування

Припустимо, у словнику є дві пари «ключ-значення»:

>>> counts = {'apples': 2, 'oranges': 1}

Розпакуємо його, використавши деструктуруюче присвоєння:

>>> x, y = counts

Можна було б очікувати, що після розпакування словника ми отримаємо такі ж пари «ключ-значення» або помилку.

Проте з розпакуванням словників зовсім інша ситуація: ми отримуємо значення ключів:

>>> x
'apples'

Повернемося до цих підводних каменів трохи пізніше, а поки розберемо деякі основні моменти.

Цикл for в Python

В Python немає традиційного циклу for. Для прикладу з'ясуємо, як працює for в інших мовах програмування.

Стандартний цикл for в стилі мови програмування C на JavaScript:

let numbers = [1, 2, 3, 5, 7];
for (let i = 0; i < numbers.length; i += 1) {
    print(numbers[i])
}

JavaScript, C, C++, Java, PHP та багато інших мов програмування мають такий вид циклу for. Зовсім інша ситуація з Python.

В Python немає традиційного циклу for, як в стилі С. Є певний цикл, який ми називаємо for, однак працює він як foreach. Поглянемо, який вигляд це матиме в коді:

numbers = [1, 2, 3, 5, 7]
for n in numbers:
    print(n)

На відміну від традиційного циклу for, в Python у нас немає змінної індексу. А це означає, що немає також її ініціалізації, перевірки умови та інкременту/декременту індексу. В циклі for на Python це все зроблено за нас.

Тож реалізація циклу for в Python відрізняється від традиційного стилю C.

Ітератори та послідовності

Тепер, коли ми познайомились з безіндексним циклом for в Python, наведемо деякі визначення.

Ітерованим (iterable) в Python називають все, що можна обійти циклом for. І навпаки: все, що можна обійти циклом, називається ітерованим.


for item in some_iterable:
    print(item)

Послідовності — дуже популярний тип ітерованих об'єктів. Списки, кортежі та рядки — все це послідовності.

>>> numbers = [1, 2, 3, 5, 7]
>>> coordinates = (4, 5, 7)
>>> words = "hello there"

Послідовності мають певні фічі: вони можуть мати індекс, який починається з 0 та закінчується на значенні, яке на 1 менше за довжину послідовності. Послідовності мають довжину, їх також можна обрізати. Списки, кортежі та всі інші послідовності працюють так само.

>>> numbers[0]
1
>>> coordinates[2]
7
>>> words[4]
'o'

В Python багато речей є ітерованими, проте не всі ітеровані об'єкти стосуються послідовностей (наприклад: сети, словники, файли та генератори).

>>> my_set = {1, 2, 3}
>>> my_dict = {'k1': 'v1', 'k2': 'v2'}
>>> my_file = open('some_file.txt')
>>> squares = (n**2 for n in my_set)

Тож все, що можна обійти циклом for, є ітерованим, а послідовності — підмножина ітерованих об'єктів.

Цикли for в Python не використовують індекси

Ви могли б подумати, що під капотом цикл for в Python використовує індекси. Спробуємо вручну пробігтися ітерованим об'єктом, використовуючи цикл while та індекси:

numbers = [1, 2, 3, 5, 7]
i = 0
while i < len(numbers):
    print(numbers[i])
    i += 1

Для послідовностей такий підхід працює, однак з усім іншим — ні.

Якщо ми б спробували вручну обійти сет, використовуючи індекси, ми б отримали помилку:

>>> fruits = {'lemon', 'apple', 'orange', 'watermelon'}
>>> i = 0
>>> while i < len(fruits):
...     print(fruits[i])
...     i += 1
...
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
TypeError: 'set' object does not support indexing

Сети не належать до послідовностей, тому не підтримують індекси.

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

Сила ітераторів для циклів

Тож ми переконалися, що цикл for в Python не використовує індекси під капотом. Натомість там використовуються ітератори.

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

Поглянемо, як все працює на практиці. Нехай у нас буде три ітеровані об'єкти: сет, кортеж та рядок:

>>> numbers = {1, 2, 3, 5, 7}
>>> coordinates = (4, 5, 7)
>>> words = "hello there"

Ми можемо отримати ітератор з кожного об'єкту, викликавши вбудовану в Python функцію iter. Передамо туди ітерований об'єкт і отримаємо ітератор — у цьому випадку не важливо, якого типу ітерований об'єкт ми передали.

>>> iter(numbers)
<set_iterator object at 0x7f2b9271c860>
>>> iter(coordinates)
<tuple_iterator object at 0x7f2b9271ce80>
>>> iter(words)
<str_iterator object at 0x7f2b9271c860>

Коли ми отримали ітератор, все, що ми можемо зробити з ним — отримати наступний елемент, передавши ітератор до вбудованої функції next.

>>> numbers = [1, 2, 3]
>>> my_iterator = iter(numbers)
>>> next(my_iterator)
1
>>> next(my_iterator)
2

Ітератори зберігають стан, тобто якщо ви вже отримали з нього елемент, він зникає.

Якщо ви викличете next для ітератора, а більше елементів не буде, ви отримаєте виключення StopIteration:

>>> next(iterator)
3
>>> next(iterator)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

Тож ви можете отримати ітератор з будь-якого ітерованого об'єкта. А єдине його призначення — отримання наступного елемента за допомогою функції next. Якщо значення закінчились, проте ви знову викликаєте next, буде згенеровано виключення StopIteration.

Як використовувати цикли без for

Ми вже засвоїли, як працюють ітератори, а також функції iter та next, тож спробуймо вручну обійти ітерований об'єкт без використання циклу for.

Для цього спробуємо перетворити цикл for на while:

def funky_for_loop(iterable, action_to_do):
    for item in iterable:
        action_to_do(item)

Для цього ми:

  1. Отримали ітератор з переданого iterable.
  2. Періодично отримували наступний елемент.
  3. Виконували код в тілі for, якщо наступний елемент було отримано успішно.
  4. Зупиняли наш цикл, якщо отримували виключення StopIteration.
def funky_for_loop(iterable, action_to_do):
    iterator = iter(iterable)
    done_looping = False
    while not done_looping:
        try:
            item = next(iterator)
        except StopIteration:
            done_looping = True
        else:
            action_to_do(item)

Ми щойно перевинайшли for, використавши while та ітератори.

Код вище досить добре описує логіку, за якою працюють цикли під капотом у Python. Якщо ви зрозумієте, як працюють вбудовані функції iter та next для циклічного обходу, то зрозумієте не лише, як працює for у Python, а і як працюють всі форми циклів над ітерованими об'єктами.

Протокол ітератора — це інший спосіб вказати,як циклічний обхід ітерованих об'єктів працює у Python. Тобто це визначення того, як застосовуються функції iter та next в Python. Усі форми ітерацій в Python керуються саме цим протоколом.

Протокол ітератора використовується циклом for(як ми вже бачили):

for n in numbers:
    print(n)

Деструктуруюче присвоєння також застосовує цей протокол:

x, y, z = coordinates

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

a, b, *rest = numbers
print(*numbers)

А також багато інших вбудованих функцій керуються ним:

unique_numbers = set(numbers)

Все в Python, що працює з ітерованими об'єктами, може певною мірою використовувати протокол ітератора.

Генератори є ітераторами

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

Тоді для вас є новини: в Python дуже поширена робота з ітераторами напряму.

Об'єкт squares тут — генератор:

>>> numbers = [1, 2, 3]
>>> squares = (n**2 for n in numbers)

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

>>> next(squares)
1
>>> next(squares)
4

Та якщо ви колись використовували генератори до цього, то, мабуть, знаєте, що можете обійти їх циклічно:

>>> squares = (n**2 for n in numbers)
>>> for n in squares:
...     print(n)
...
1
4
9

Ми вже засвоїли: якщо ви можете обійти щось циклом у Python — це ітерований об'єкт. Тож генератори є ітераторами, до того ж вони ітеровані. Що тут відбувається?

Особливості ітераторів

Вище ми вже трохи розібралися з принципом роботи ітераторів, однак не зауважили одну дуже важливу річ: ітератори є ітерованими, тобто їх можна обходити циклічно.

З цього випливає, що ми можемо отримати ітератор з ітератора, використовуючи вже відому функцію iter:

>>> numbers = [1, 2, 3]
>>> iterator1 = iter(numbers)
>>> iterator2 = iter(iterator1)

Тобто кожен раз, коли ми викликаємо iter для ітератора, він повертає себе ж:

>>> iterator1 is iterator2
True

Ітератори — ітеровані, до того ж вони є власними ітераторами.

def is_iterator(iterable):
    return iter(iterable) is iterable

Досі не заплутались?

Повторимо терміни.

Термін ітерований означає, що ви можете циклічно обходити об'єкт. Ітератор виконує обхід ітерованих об'єктів.

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

>> numbers = [1, 2, 3, 5, 7]
>>> iterator = iter(numbers)
>>> len(iterator)
TypeError: object of type 'list_iterator' has no len()
>>> iterator[0]
TypeError: 'list_iterator' object is not subscriptable

З погляду Python-розробників усе, що ми можемо робити з ітераторами — передавати їх як аргумент функції next для циклічного обходу.

>>> next(iterator)
1
>>> list(iterator)
[2, 3, 5, 7]

Але якщо ми захочемо повторити наш обхід вдруге, зрештою нічого не отримаємо.

>>> list(iterator)
[]

Ви можете вважати ітератори лінивими ітерованими об'єктами, які можна обійти циклічно лише один раз.

Об'єкт Ітерований? Ітератор?
Ітерований ✔️
Ітератор ✔️ ✔️
Генератор ✔️ ✔️
Список ✔️

Як можна зрозуміти з наведеної таблиці: ітеровані об'єкти не завжди ітератори, проте ітератори — завжди ітеровані.

Повний протокол ітератора

З'ясуємо, як працюють ітератори з погляду Python.

Як вже було з'ясовано, ітерований об'єкт можна передати в iter та отримати ітератор.

Ітератори:

  • Можна передати в функцію next, щоб отримати наступний елемент або виключення StopIteration, якщо наступного елемента немає.
  • Можна передати у функцію iter та повернути самого себе.

У зворотний бік ці твердження також працюють:

  1. Все, що можна передати в iter без TypeError — ітероване;
  2. Все, що можна передати в next без TypeError — ітератор;
  3. Все, що повертає себе ж після передачі в iter — ітератор.

Ітератори та ліниві ітеровані об'єкти

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

Ітератори усюди

Ви вже бачили багато ітераторів в Python. Як було зазначено вище, генератори — також ітератори. Багато вбудованих класів в Python — також ітератори. Наприклад, об'єкти enumerate та reversed.

>>> letters = ['a', 'b', 'c']
>>> e = enumerate(letters)
>>> e
<enumerate object at 0x7f112b0e6510>
>>> next(e)
(0, 'a')

У Python 3 об'єкти zip, map та filter — також ітератори:

>>> numbers = [1, 2, 3, 5, 7]
>>> letters = ['a', 'b', 'c']
>>> z = zip(numbers, letters)
>>> z
<zip object at 0x7f112cc6ce48>
>>> next(z)
(1, 'a')

Те ж саме і з об'єктами файлів:

>>> next(open('hello.txt'))
'hello world\
'

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

Створення власного ітератора

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

class square_all:
    def __init__(self, numbers):
        self.numbers = iter(numbers)
    def __next__(self):
        return next(self.numbers) ** 2
    def __iter__(self):
        return self

Проте нічого не відбуватиметься, поки ми не почнемо циклічно обходити екземпляр цього класу.

Тут у нас є нескінченно довгий ітерований count. Ви можете побачити, що square_all приймає count, не обходячи його в циклі повністю:

>>> from itertools import count
>>> numbers = count(5)
>>> squares = square_all(numbers)
>>> next(squares)
25
>>> next(squares)
36

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

def square_all(numbers):
    for n in numbers:
        yield n**2

Ця функція-генератор еквівалентна створеному вище класу і, по суті, працює вона так само.

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

Інший спосіб реалізувати такий ітератор — вираз генератора.

def square_all(numbers):
    return (n**2 for n in numbers)

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

Як ітератори можуть покращити ваш код

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

Ліниві ітератори та підрахунок суми

Поглянемо на цикл for, який підраховує суму усіх оплачуваних годин в Django queryset:

hours_worked = 0
for event in events:
    if event.is_billable():
        hours_worked += event.duration

Те ж саме, але з використанням виразу генератора для лінивих обчислень:

billable_times = (
    event.duration
    for event in events
    if event.is_billable()
)

hours_worked = sum(billable_times)

Помітьте, як змінився вигляд нашого коду.

З лінивим ітератором у нас з'явилась можливість створити змінну billable_times. До того ж тепер ми можемо використовувати функцію sum. В першому прикладі ми не могли використовувати її, тому що у нас не було ітерованого об'єкта, який ми могли б передати в неї.

Тобто ми бачимо, як ітератори дозволяють фундаментально змінити спосіб структурування нашого коду.

Ліниві ітератори та вихід з циклу

Цей код роздруковує перші десять рядків файлу логів:

for i, line in enumerate(log_file):
    if i >= 10:
        break
    print(line)

А в цьому прикладі ми робимо те ж саме, але використовуючи функцію itertools.islice для лінивого варіанту:

from itertools import islice

first_ten_lines = islice(log_file, 10)
for line in first_ten_lines:
    print(line)

Змінна first_ten_lines є ітератором. Тобто використання ітератора дозволило нам створити змінну з певного фрагменту коду. Так ми отримуємо більш читабельний та зрозумілий код.

Ба більше, ми також позбавились необхідності використовувати break, тому що islice робить все за нас.

Ви можете знайти ще більше допоміжних функцій для ітераторів у itertools стандартної бібліотеки, а також у сторонніх бібліотеках, на зразок boltons та more-itertools.

Створення власних хелперів ітерації

Ви можете знайти допоміжні функції для циклів у стандартній та сторонніх бібліотеках, однак ви також можете створити власну.

Такий код створює список різниць між сусідніми значеннями в послідовності:

current = readings[0]
for next_item in readings[1:]:
    differences.append(next_item - current)
    current = next_item

Помітили додаткову змінну, якій ми присвоюємо значення перед кожним циклом? Також зверніть увагу, що цей код працює лише з об'єктами, до яких застосовується slice, на зразок послідовностей. Якби readings був генератором, об'єктом zip чи будь-яким іншим типом ітератора, такий код би не працював.

Напишемо допоміжну функцію, щоб виправити таку ситуацію.

В цьому фрагменті функція-генератор повертає нам поточний та наступний елемент:

def with_next(iterable):
    """Yield (current, next_item) tuples for each item in iterable."""
    iterator = iter(iterable)
    current = next(iterator)
    for next_item in iterator:
        yield current, next_item
        current = next_item

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

В цьому фрагменті ми використовуємо створену допоміжну функцію, замість того щоб вручну відстежувати next_item:

differences = []
for current, next_item in with_next(readings):
    differences.append(next_item - current)

Зверніть увагу, що тепер наш код не містить рядка з визначенням next_item — цей функціонал за нас тепер робить функція-генератор with_previous.

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

differences = [
    (next_item - current)
    for current, next_item in with_next(readings)
]

Підводні камені циклів: повторний огляд

На цьому етапі ми вже готові повернутися до прикладів, наведених на початку статті, та з'ясувати, що ж там насправді відбувалось:

Проблема 1: Вичерпання ітератора

Тут у нас є об'єкт-генератор squares:

>>> numbers = [1, 2, 3, 5, 7]
>>> squares = (n**2 for n in numbers)

Якщо ми передамо цей генератор в конструктор tuple, то отримаємо кортеж з цих елементів.

>>> numbers = [1, 2, 3, 5, 7]
>>> squares = (n**2 for n in numbers)
>>> tuple(squares)
(1, 4, 9, 25, 49)

Якщо ми спробуємо тепер обчислити суму чисел з генератора, отримаємо 0.

>>> sum(squares)
0

Наразі наш генератор пустий: ми вичерпали його. Якщо ми знову спробуємо зробити з нього кортеж, він буде пустим:

>>> tuple(squares)
()

Генератори є ітераторами. А ітератори можуть бути використані лише один раз.

Проблема 2: Часткове використання ітератора

Повернемося до нашого об'єкта генератора squares:

>>> numbers = [1, 2, 3, 5, 7]
>>> squares = (n**2 for n in numbers)

Якщо ми перевіримо, чи міститься значення 9 в нашому генераторі, отримаємо True:

>>> 9 in squares
True

Проте якщо повторимо ту саму дію, отримаємо False:

>>> 9 in squares
False

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

>>> numbers = [1, 2, 3, 5, 7]
>>> squares = (n**2 for n in numbers)
>>> 9 in squares
True
>>> list(squares)
[25, 49]

Коли ми перевіряємо ітератор на наявність певного значення, ми частково вичерпуємо його значення, оскільки немає іншого способу дізнатися вміст ітератора, окрім як обійти його циклічно.

Проблема 3: Розпакування — це ітерація

Коли ви циклічно обходите словник, ви отримуєте ключі:

>>> counts = {'apples': 2, 'oranges': 1}
>>> for key in counts:
...     print(key)
...
apples
oranges

Ви також отримуєте ключі, коли розпаковуєте словник:

>>> x, y = counts
>>> x, y
('apples', 'oranges')

Циклічний обхід ґрунтується на протоколі ітератора. А розпакування словника — насправді те ж саме, що й циклічний обхід словника. Обидва способи використовують протокол ітератора, тому ви отримуєте однаковий результат.

Ключові моменти та ресурси

  • Послідовності — ітеровані, але не всі ітеровані об'єкти належать до послідовностей.
  • Ітеровані об'єкти неможливо обійти двічі, знайти їх довжину чи проіндексувати.
  • Якщо ви хотіли б зробити лінивий ітерований об'єкт, зверніться до генераторів та виберіть підхід або з функцією-генератором, або з генератором-виразом.
  • Варто пам'ятати, що будь-який тип ітерації в Python покладається на протокол ітератора. Якщо зрозумієте його, не матимете труднощів з циклами в Python.

Деякі корисні ресурси для більш детального ознайомлення:

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

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

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

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