Пориньте у Python 3/Класи та ітератори

Захід є Захід, а Схід є Схід, і їм не зійтися вдвох
Редьярд Кіплінг

Занурення

ред.

Ітератори це "таємний соус" мови Pythоn 3. Вони всюди, в основі всього, просто зазвичай невидимі. Вирази над структурами - лише проста форма ітераторів. Генератори - лише проста форма ітераторів. Функції що використовують yield це лише гарний, компактний спосіб побудови ітератора без побудови ітератора. Давайте я покажу що я під цим маю на увазі.

Пам'ятаєте генератор чисел Фібоначчі? Ось побудований з нуля ітератор:

class Fib:
    '''iterator that yields numbers in the Fibonacci sequence'''

    def __init__(self, max):
        self.max = max

    def __iter__(self):
        self.a = 0
        self.b = 1
        return self

    def __next__(self):
        fib = self.a
        if fib > self.max:
            raise StopIteration
        self.a, self.b = self.b, self.a + self.b
        return fib

Щож давайте розбирати по рядку за раз.

class Fib:

class? Що таке клас?


* * *


Опис класів

ред.

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

Описувати класи в Python просто. Як і з функціями, немає окремого опису інтерфейсу. Просто оголосіть клас і починайте кодити. Клас в Python починається з зарезервованого слова class, за яким іде ім'я класу. Технічно, це все що потрібно, тому що клас не обов'язково повинен наслідуватись від іншого класу.

class PapayaWhip:

Цей клас називається PapayaWhip[1], і не наслідується від інших класів. Імена класів зазвичай пишуться з великої букви, подібно до КожнеСловоЯкУЦьомуОтут, але це лише домовленість, а не вимога.

    pass

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


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

Інструкція pass в Python аналогічна парі порожніх фігурних дужок ({}) в Java чи C.

Багато класів наслідуються від інших класів, але даний ні. Багато класів описують методи, але цей ні. Взагалі немає нічого що клас в Python обов'язково повинен мати, окрім імені. Наприклад програмісти C++ можуть здивуватись що класи в Python не мають явних конструкторів та деструкторів. Щоправда хоча це й не обов'язково, класи в Python можуть мати щось подібне до конструктора: метод __init__().


Метод __init__()

ред.

Цей приклад показує ініціалізацію класу Fib з використанням методу __init__.

class Fib:
    '''iterator that yields numbers in the Fibonacci sequence'''

Класи можуть (і повинні б) мати документацію, так само як модулі та функції.

    def __init__(self, max):

Метод __init__() викликається негайно після того як створюється екземпляр класу. Спокусливо називати цей метод "конструктором" класу, але це технічно невірно. Це спокусливо тому що він виглядає як конструктор в C++ (за домовленістюю, метод __init__() повинен бути першим в списку методів класу), поводиться як конструктор (це перший код що виконується в новоствореному екземплярі класу), і навіть звучить як конструктор. Але це не вірно, тому що об'єкт було створено ще до того як було викликано метод __init__(), і в нас вже є діюче посилання на новостворений екземпляр класу.

Перший аргумент будь-якого методу класу, включаючи метод __init__(), це завжди посилання на поточний екземпляр класу, за домовленістю його називають self. Цей аргумент замінює роль зарезервованого слова this в C++ чи Java, але self не є зарезервованим словом в Python, просто домовленістю з іменування. Тим не менш, будь-ласка не називайте його якось інакше ніж self, це дуже сильна домовленість.

У всіх методах класу, self посилається на екземпляр метод якого викликали. Але в особливому випадку метода __init__() екземпляр чий метод був викликаним також є новоствореним об'єктом. І хоча при описі метода необхідно описувати параметр self явно, його не потрібно передавати при виклику метода, Python передасть його для нас автоматично.


* * *


Створення екземплярів класу

ред.

Створення екземплярів класу в Python досить прямолінійне. Щоб створити екземпляр класу, просто викличте клас так наче б він був функцією, передавши як аргументи все що необхідно функції __init__(). Значення яке буде повернутим і буде новоствореним об'єктом.

>>> import fibonacci2
>>> fib = fibonacci2.Fib(100)

Ми створюємо екземпляр класу Fib (описаного в модулі fibonacci2) і присвоюємо новостворений екземпляр змінній fib. Ми передаємо один параметр, 100, який буде переданий як аргумент max в метод __init__().

>>> fib
<fibonacci2.Fib object at 0x00DB8810>

fib тепер екземпляр класу Fib.

>>> fib.__class__
<class 'fibonacci2.Fib'>

Кожен екземпляр класу має вбудований атрибут __class__, який посилається на клас об'єкта. Програмісти Java можливо знайомі з класом Class, який містить методи на зразок getName() таd getSuperclass() щоб отримувати метаінформацію про об'єкт. В Python такі дані доступні через атрибути, але ідея загалом така сама.

>>> fib.__doc__
'iterator that yields numbers in the Fibonacci sequence'

Можна звертатись до документації екземпляра так само як і з функціями чи модулями. Всі екземпляри класу діляться спільним рядком документації.

В Python щоб створити екземпляр, просто викличте клас як функцію. Не потрібно явного оператора new як в C++ чи Java.


* * *


Змінні екземпляра

ред.

І перейдемо до наступного рядка класу:

class Fib:
    def __init__(self, max):
        self.max = max

Що таке self.max? Це змінна екземпляра. Це зовсім інша змінна ніж max, яка була передана як аргумент в метод __init__(). self.max "глобальна" для екземпляра. Це означає що доступ до неї можна отримати з інших методів.

class Fib:
    def __init__(self, max):
        self.max = max

self.max створюється в методі __init__()...

    .
    .
    .
    def __next__(self):
        fib = self.a
        if fib > self.max:

... і використовується з метода __next__().

Змінні екземпляра пов'язані лише з одним екземпляром класу. Наприклад якщо ми створимо два екземпляри класу Fib з різними максимальними значеннями, вони пам'ятатимуть кожен про свою змінну.

>>> import fibonacci2
>>> fib1 = fibonacci2.Fib(100)
>>> fib2 = fibonacci2.Fib(200)
>>> fib1.max
100
>>> fib2.max
200


* * *


Ітератор Фібоначчі

ред.
Всі три із згадуваних методів класу, __init__, __iter__, та __next__, починаються та закінчуються парою символів підкреслювання (_). Чому так? В цьому немає ніякої магії, але зазвичай це означає що це "спеціальні методи". Єдине що "спеціальне" в спеціальних методах - це те що вони не викликаються напряму, Python викликає їх коли ви використовуєте якийсь інший синтаксис на класі чи на його екземплярі. Більше про спеціальні методи.

Тепер ви готові до того щоб навчитись створювати ітератор. Ітератор це просто клас який описує метод __iter__().

class Fib:

Щоб створити ітератор з нуля, Fib повинен бути класом а не функцією.

    def __init__(self, max):
        self.max = max

"Виклик" Fib(max) це насправді створення екземпляру цього класу і виклик його метода __init__() з аргументом max. Метод __init__() зберігає максимальне значення як змінну екземпляру щоб інші методи могли використати її пізніше.

    def __iter__(self):
        self.a = 0
        self.b = 1
        return self

Метод __iter__() викликається щоразу як хтось викликає iter(fib). (Як ви побачите назабаром, цикл for може викликати це автоматично, але ви також можете зробити це вручну.) Піля виконання ініціалізації перед початком ітерації (в даному випадку встановлення значень лічильників self.a та self.b), метод __iter__() може повернути будь-який об'єкт що реалізує метод __next__(). В цьому (та й у більшості інших) випадку, __iter__() просто повертає self, так як цей клас реалізує власний метод __next__().

def __next__(self):
        fib = self.a

Метод __next__() викликається щоразу як хтось викликає next() на ітераторі чи екземплярі класу. Це стане більш зрозумілим за хвилину.

        if fib > self.max:
            raise StopIteration

Коли метод __next__() генерує виняток StopIteration це сигналізує тому хто його викликав про те що ітерація вичерпалась. На відміну від більшості інших винятків, це не помилка. це нормальна ситуація яка просто означає що ітератор більше не має значень для генерації. Якщо тим хто викликав next() є цикл for, він просто завершить ітерації і передасть потік виконання далі. (Іншими словами він просто проковтне виняток.) Цей маленький шматочок магії насправді є ключем до використання ітератоів в циклах for.

        self.a, self.b = self.b, self.a + self.b
        return fib

Щоб згенерувати наступне значення, метод ітератора __next__() просто повертає його за допомогою return. Він не використовує yield тому що це просто синтаксичний цукор який використовується лише в генераторах. Метод __next__ - метод ітератора а не генератор.

Вже добряче заплутались? Прекрасно. Давайте поглянемо як викликати цей ітератор:

>>> from fibonacci2 import Fib
>>> for n in Fib(1000):
...     print(n, end=' ')
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987

Але ж цей виклик точно такий самий! Байт в байт ідентичний з тим як ми викликали генератор чисел Фібоначчі (за винятком того що тепер він називається з великої букви). Але як?

З циклами for пов'язано трішки магії. Ось що відбувається:

  • Цикл for викликає Fib(1000), як написано. Це дає екземпляр класу Fib. Будемо називати його fib_inst.
  • Таємно, і дуже хитро, цикл for викликає iter(fib_inst), який повертає об'єкт ітератора. Давайте будемо називати його fib_iter. В даному випадку, fib_iter == fib_inst, тому що метод __iter__() повертає self, але цикл for про це не знає (чи взагалі не цікавиться).
  • Щоб "проітеруватись" ітератором, цикл for викликає next(fib_iter), який викликає метод __next__() на об'єкті fib_iter який робить обчислення наступного числа Фібоначчі і повертає значення. Цикл for бере це значення і присвоює його n, після чого виконує тіло циклу з даним значенням n.
  • Звідки цикл for знає коли зупинитись? Радий що ви спитали! Коли next(fib_iter) кидає виняток StopIteration, цикл for ковтає виняток і завершується. (Будь-який інший звичайний виняток прорветься і буде оброблятись як зазвичай.) А де ми бачили виняток StopIteration? Звичайно в методі __next__()!


* * *


Ітератор правил утворення множини

ред.
iter(f) викликає f.__iter__
next(f) викликає f.__next__

Тепер час для завершення. Давайте перепишемо генератор правил утворення множини як ітератор.

class LazyRules:
    rules_filename = 'plural6-rules.txt'

    def __init__(self):
        self.pattern_file = open(self.rules_filename, encoding='utf-8')
        self.cache = []

    def __iter__(self):
        self.cache_index = 0
        return self

    def __next__(self):
        self.cache_index += 1
        if len(self.cache) >= self.cache_index:
            return self.cache[self.cache_index - 1]

        if self.pattern_file.closed:
            raise StopIteration

        line = self.pattern_file.readline()
        if not line:
            self.pattern_file.close()
            raise StopIteration

        pattern, search, replace = line.split(None, 3)
        funcs = build_match_and_apply_functions(
            pattern, search, replace)
        self.cache.append(funcs)
        return funcs

rules = LazyRules()

Цей клас реалізує методи __iter__() та __next__(), тому він може використовуватись як ітератор. Потім ми створюємо його екземпляр та відкриваємо для нього файл з правилами. Це відбувається лише раз при імпорті.

Давайте спробуємо розібратись у всьому по кусочку за раз.


class LazyRules:
    rules_filename = 'plural6-rules.txt'

    def __init__(self):
        self.pattern_file = open(self.rules_filename, encoding='utf-8')

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

        self.cache = []

Після відкриття файла з шаблонами, ми ініціалізуємо кеш. Ми використаємо його пізніше (в методі __next__()) протягом того як будемо читати рядки з файла з шаблонами.


Перед тим як продовжити давайте поближче глянемо на змінну rules_filename. Вона не описується всередині метода __iter__(). Насправді вона не описується всередині будь-якого метода. Вона описується на рівні класу. Це змінна класу, і хоча доступ до неї подібний на доступ до змінних екземпляра (через self.rules_filename), але вона спільна для всіх екземплярів класу LazyRules.


>>> import plural6
>>> r1 = plural6.LazyRules()
>>> r2 = plural6.LazyRules()
>>> r1.rules_filename 
'plural6-rules.txt'
>>> r2.rules_filename
'plural6-rules.txt'

Кожен екземпляр класу успадковує атрибут rules_filename зі значенням описаним всередині класу.

>>> r2.rules_filename = 'r2-override.txt'
>>> r2.rules_filename
'r2-override.txt'
>>> r1.rules_filename
'plural6-rules.txt'

Зміна значення цього атрибуту не змінює його в інших екземплярах...

>>> r2.__class__.rules_filename
'plural6-rules.txt'

... як і не змінює атрибут класу. Ви можете отримати доступ до атрибуту класу (на противагу атрибутам окремих екземплярів) використовуючи спеціальний атрибут __class__ для доступу до самого класу.

>>> r2.__class__.rules_filename = 'papayawhip.txt'
>>> r1.rules_filename
'papayawhip.txt'

Якщо ми змінюємо атрибут класу, він змінюється для всіх екземплярів які все ще його наслідують (як наприклад тут r1).

>>> r2.rules_filename
'r2-overridetxt'

Екзеплярів які переозначили атрибут (як r2 в цьому прикладі) це не стосується.


Тепер повернемось до наших баранів.


  def __iter__(self):

Метод __iter__() буде викликаним щоразу як хтось, наприклад цикл for викликатиме iter(rules).

        self.cache_index = 0
        return self

Одна річ яку можен метод __iter__() повинен робити - повертати ітератор. В даному випадку він повертає self що сигналізує про те що клас описує метод __next__() який потурбується про повернення значень під час ітерації.


    def __next__(self):

Метод __next__() викликається щоразу, як хтось (наприклад цикл for) викликає next(rules). Цей метод буде зрозумілішим якщо ми почнемо розглядати його з кінця і просуватись до початку, тому давайте так і зробимо.

        .
        .
        .
        pattern, search, replace = line.split(None, 3)
        funcs = build_match_and_apply_functions(        
            pattern, search, replace)
        self.cache.append(funcs)                        
        return funcs

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

Єдина відмінність в тому, що перед тим як повернути функції (що зберігаються в кортежі func) ми збираємось зберегти їх в self.cache.

Тепер рухаємось назад...

    def __next__(self):
        .
        .
        .
        line = self.pattern_file.readline()
        if not line:
            self.pattern_file.close()
            raise StopIteration
        .
        .
        .

Тут деякі особливі трюки з файлами. Метод readline() (зауважте, назва в однині, а не readlines()) читає рівно один рядок з відкритого файлу. Якщо точніше - наступний рядок. (Файлові об'єкти теж ітератори! Всюди ітератори)

Якщо readline() ще має рядки які можна читати, то це не порожні рядки. Навіть якщо файл і містить порожній рядок, то він буде зберігатись як один символ '\n' (повернення каретки). Якщо змінній line справді присвоїться порожній рядок, це означатиме що рядків в файлі більше нема.

Коли ми досягнемо кінця файла ми повинні його закрити, і згенерувати магічний виняток StopIteration. Ми написали такий код тому що нам потрібно отримати функції для наступного правила. А ці функції утворюються на основі прочитаного рядка. А якщо прочитаного рядка нема, значить і нема значень які потрібно повертати, тому це кінець ітерації. (♫ Кінець бенкету... ♫)

Далі назад...

    def __next__(self):
        self.cache_index += 1
        if len(self.cache) >= self.cache_index:
            return self.cache[self.cache_index - 1]

        if self.pattern_file.closed:
            raise StopIteration
        .
        .
        .

self.cache повинен бути списком функцій які потрібні нам для застосування правил. self.cache_index слідкує за тим який з елементів цього списку потрібно буде повертати наступним. Якщо ми ще не вичерпали кеш (тобто довжина self.cache більша за self.cache_index), то ми можемо взяти значення з кеша! Ура! Ми можемо повернути готові функції замість того щоб створювати їх з нуля.

З іншого боку, якщо значень в кеші нема, і файловий об'єкт закритий (що може трапитись далі в методі, в коді який ми бачили раніше), значить ми нічого більше не зможемо зробити. Якщо файл закритий, це означає що він вичерпався, ми вже прочитали всі його рядки, і побудували всі функції на їх основі. Файл вичерпався, кеш вичерпався, я виснажився. Стояти, що? Зачекайте тут, ми майже закінчили.

Якщо все підсумувати, ось що відбувається коли:

  • Коли модуль імпортується, він створює один екземпляр класу LazyRules названий rules, який відкриває файл шаблонів але не читає з нього.
  • Коли його запитують про першу пару функцій, він перевіряє кеш, але з'ясовує що кеш порожній. Тому він читає рядок з файлу шаблонів, створює функції з шаблонів і записує їх в кеш.
  • Давайте для прикладу скажеом що перші функції підійшли. Якщо так, тоді нові функції не будуються і нові рядки не читаються з файлу.
  • Давайте для прикладу також скажемо що користувач викликає функцію plural() знову для того щоб створити множину для іншого слова. Цикл for в функції plural() викличе iter(rules), який обнулить індекс кешу, але не чіпатиме відкритий файловий об'єкт.
  • Далі цикл for попросить значення з ітератора rules, який застосує свій метод __next__(). Тільки цього разу в кеші вже буде одна пара функцій що відповідають шаблонам в першому рядку файла. Так як вони вже були створені при утворенні множини для попереднього слова, їх буде взято з кешу. Індекс кешу збільшиться, а відкритий файл взагалі не буде зачеплено.
  • Тепер давайте для прикладу скажемо що цього разу перше правило не підійшло. Тоді цикл for спитає про наступне значення з rules. Це викличе метод __next__() вдруге. Цього разу кеш вичерпано - бо в ньому був лише один елемент, а нам потрібен другий, тому метод __next__() продовжить роботу далі, і прочитає наступний рядок з файлу, побудувавши відповідні функції та запам'ятавши їх в кеші.
  • Цей процес читання побудови й запам'ятовування продовжуватиметься доти доки правила що читаються з файла не застосовуватимуться до слова для якого ми намагаємось утворити множину. Якщо ми знайдемо підходяще правило ще до кінця файлу, ми просто його використаємо і зупинимось тримаючи файл відкритим. Посилання на файл залишатиметься, чекаючи наступної команди readline(). Тим часом кількість елементів в кеші буде більшати, і якщо нам буде потрібно утворити множину для ще одного слова, кожен з елементів кешу буде випробуваний до того як буде прочитаний наступний рядок файлу.

От ми й досягли множинної нірвани.

  1. Мінімальна ціна запуску. Єдине що відбувається при імпорті, це створення одного екземпляру класу та відкриття файлу (але не читання з нього).
  2. Максимальна продуктивність. В попередньому прикладі ми читали ввесь файл і будували функції динамічно для кожного слова заново. В цій версії ми запам'ятовуємо функці як тільки вони утворюються, і в найгіршому випадку ми читаємо файл з правилами лише раз, не залежно від того скільки слів нам потрібно буде опрацювати.
  3. Відокремлення коду й даних. Всі шаблони зберігаються в окремому файлі. Код це код, а дані це дані, і їм не зійтися вдвох.

Це справді нірвана? Ну, і так і ні. Є дещо що потрібно мати на увазі при роботі з класом LazyRules: файл шаблонів відкривається (в методі __init__()) і залишається відкритим поки не буде досягнено останнє правило. Python колись закриє файл коли програма завершуватиметься, чи після того як останній екземпляр класу LazyRules буде знищено, але все одно до цього моменту може пройти багато часу. Якщо цей клас є частиною довго працюючого процесу, інтерпретатор Python може ніколи не завершити свою роботу, і об'єкт класу LazyRules може ніколи не бути знищений. Є способи обійти це. Замість того щоб відкривати файл в методі __init__() і залишати його відкритим читаючи по одному правилу за раз, можна відкрити файл, прочитати всі правила і негайно його закрити. Або можна відкрити файл, прочитати одне правило, зберегти позицію за допомогою метода tell(), закрити файл, потім перевідкрити його і використати метод seek() щоб продовжити читати з того місця де завершили. Або можна не хвилюватись про це і просто залишити файл відкритим як ми й зробити в цьому випадку. Програмування це проектування, а суть проектування в компромісах та обмеженнях. Занадто довго відкритий файл може бути проблемою, більш складний код може бути проблемою. Що є більшою проблемою залежить від команди що розробляє вашу програму, вашої програми і середовища в якому вона працюватиме.


* * *


Для подальшого читання

ред.

Примітки

ред.
  1. Колір що утворюється при змішуванні пюре папаї з ванільним морозивом чи йогуртом. Або світло-оранжевий українською. :)

Замикання та генератори · Детальніше про ітератори