Глава 7. Классы и итераторы

Добавлено 6 мая 2020 в 22:29

Восток есть Восток, а Запад есть Запад, и они никогда не встретятся.

Редьярд Киплинг

Погружение

Итераторы – это «секретный соус» Python 3. Они повсюду, лежат в основе всего, просто всегда вне поля зрения. Генераторы списков, словарей, множеств – это простая форма итераторов. Генераторы – это тоже просто простая форма итераторов. Функция, которая возвращает (yields) значения, является хорошим, компактным способом построения итератора без создания итератора. Позвольте мне показать вам, что я имею под этим в виду.

Помните генератор чисел Фибоначчи? Вот набросок того, как мог бы выглядеть аналогичный итератор (скачать файл fibonacci2.py):

class Fib:
    '''Итератор, который возвращает числа в последовательности Фибоначчи'''

    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? Что такое класс?

7.2 Определение классов

Python – полностью объектно-ориентированный язык программирования, то есть вы можете определять свои собственные классы, наследовать новые классы от своих или встроенных классов, и создавать экземпляры классов, которые уже определили.

Определить класс в Python просто. Также как и в случае с функциями, отдельное объявление интерфейса не требуется. Вы просто определяете класс и начинаете программировать. Определение класса в Python начинается с зарезервированного слова class, за которым следует имя (идентификатор) класса. Формально, это всё, что необходимо, в случае, когда класс не должен быть унаследован от другого класса.

class PapayaWhip:  ①
    pass           ②
  1. Строка 1. Определенный выше класс имеет имя PapayaWhip и не наследует никакой другой класс. Имена классов, как правило, пишутся с большой буквы, НапримерВотТак, но это всего лишь соглашение, а не требование.
  2. Строка 2. Вы, наверное, уже догадались, что каждая строка в определении класса имеет отступ, также как и в случае с функциями, оператором условного перехода if, циклом for или любым другим блоком кода. Первая строка без отступа будет находиться вне блока class.

Класс PapayaWhip не содержит определений методов или атрибутов, но с точки зрения синтаксиса, тело класса не может оставаться пустым. В таких случаях используется оператор pass. В языке Python pass – зарезервированное слово, которое означает: «идем дальше, здесь ничего нет». Это оператор, не делающий ровным счетом ничего, но, тем не менее, являющийся удобным решением, когда вам нужно сделать заглушку для функции или класса.

Оператор pass в языке Python является аналогом пустого множества или фигурных скобок ({}) в языках Java и C++.

Многие классы наследуются от других классов, но не этот. Многие классы определяют свои методы, но не этот. Класс в Python не обязан иметь ничего, кроме имени. В частности, людям знакомым с C++ может показаться странным, что у класса в Python отсутствуют в явном виде конструкторы и деструкторы. Несмотря на то, что это не является обязательным, класс в Python может иметь нечто, похожее на конструктор: метод __init__().

7.2.1 Метод __init__()

В следующем примере демонстрируется инициализация класса Fib, с помощью метода __init__().

class Fib:
    '''Итератор, который возвращает числа в последовательности Фибоначчи'''  ①

    def __init__(self, max):                                                 ②
  1. Строка 2. Классы, по аналогии с модулями и функциями, могут (и должны) иметь строки документации (docstrings).
  2. Строка 4. Метод __init__() вызывается сразу же после создания экземпляра класса. Было бы заманчиво, но формально неверно, считать его «конструктором» класса. Заманчиво, потому что он напоминает конструктор класса в языке C++: внешне (общепринято, что метод __init__() должен быть первым методом, определенным для класса) и в действии (это первый блок кода, исполняемый в контексте только что созданного экземпляра класса). Но это неверно, потому что на момент вызова __init__() объект уже фактически создан, и у вас уже есть корректная ссылка на этот новый экземпляр класса.

Первым аргументов любого метода класса, включая метод __init__(), всегда является ссылка на текущий экземпляр класса. Общепринято, что имя этого аргумента – self. Данный аргумент выполняет роль зарезервированного слова this в C++ и Java, но, тем не менее, в Python self не является зарезервированным словом, это просто соглашение об именовании. Тем не менее, пожалуйста, не называйте этот аргумент как-либо еще.

Во всех методах класса self ссылается на экземпляр, чей метод был вызван. Но в случае метода __init__() self ссылается на только что созданный объект. И, хотя вам необходимо явно указывать self при определении метода, при вызове этого не требуется; Python добавит его для вас автоматически.

7.3 Создание экземпляров

Для создания нового экземпляра класса в Python необходимо вызвать класс, как если бы он был функцией, передав необходимые аргументы для метода __init__(). В качестве возвращаемого значения мы получим только что созданный объект.

>>> import fibonacci2
>>> fib = fibonacci2.Fib(100)  ①
>>> fib                        ②
<fibonacci2.Fib object at 0x00DB8810>
>>> fib.__class__              ③
<class 'fibonacci2.Fib'>
>>> fib.__doc__                ④
'Итератор, который возвращает числа в последовательности Фибоначчи'
  1. Строка 2. Вы создаете новый экземпляр класса Fib (определенный в модуле fibonacci2) и присваиваете только что созданный объект переменной fib. Единственный переданный аргумент, 100, соответствует именованному аргументу max в методе __init__() класса Fib.
  2. Строка 3.fib теперь является экземпляром класса Fib.
  3. Строка 5. Каждый экземпляр класса имеет встроенный атрибут __class__, который указывает на класс объекта. Java программисты могут быть знакомы с классом Class, который содержит методы getName() и getSuperclass(), используемые для получения информации об объекте. В Python метаданные такого рода доступны через соответствующие атрибуты, но идея используется та же самая.
  4. Строка 7. Вы можете получить строку документации (docstring) класса, по аналогии с функцией и модулем. Все экземпляры класса имеют одну и ту же строку документации.

Для создания нового экземпляра класса в Python, просто вызовите класс, как если бы он был функцией, явные операторы, как, например, new в С++ или Java, в языке Python отсутствуют.

7.4 Переменные экземпляра

Перейдем к следующей строке:

class Fib:
    def __init__(self, max):
        self.max = max        ①
  1. Строка 3. Что такое self.max? Это переменная экземпляра. Она не имеет ничего общего с переменной max, которую мы передали в метод __init__() в качестве аргумента. self.max является «глобальной» для всего экземпляра. Это значит, что вы можете получить доступ к ней из других методов.
class Fib:
    def __init__(self, max):
        self.max = max        ①
    .
    .
    .
    def __next__(self):
        fib = self.a
        if fib > self.max:    ②
  1. Строка 3.self.max определена в методе __init__()...
  2. Строка 9. …и использована в методе __next__().

Переменные экземпляра связаны только с одним экземпляром класса. Например, если вы создадите два экземпляра класса Fib с разными максимальными значениями, каждый из них будет помнить только свое собственное значение.

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

7.5 Итератор Фибоначчи

Теперь вы готовы узнать, как создать итератор. Итератор – это обычный класс, который определяет метод __iter__().

Все три этих метода класса (__init__, __iter__ и __next__) начинаются и заканчиваются парой символов подчеркивания (_). Почему? В этом нет ничего волшебного, но обычно это означает, что это «специальные методы». Единственное «специальное» в специальных методах – это то, что они не вызываются напрямую; Python вызывает их, когда вы используете какой-то другой синтаксис для класса или экземпляра класса. Подробнее о специальных методах читайте в приложении B.

Скачать файл fibonacci2.py.

class Fib:                                        ①
    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                                ⑥
  1. Строка 1. Чтобы построить итератор с нуля, Fib должен быть классом, а не функцией.
  2. Строка 2. «Вызов» Fib(max) на самом деле создает экземпляр этого класса и вызывает его метод __init__() с max. Метод __init__() сохраняет максимальное значение как переменную экземпляра, чтобы другие методы могли обращаться к ней позже.
  3. Строка 5. Метод __iter__() вызывается всякий раз, когда кто-то вызывает iter(fib). (Как вы увидите через минуту, цикл for вызовет его автоматически, но также вы можете вызвать его самостоятельно.) После выполнения инициализации начала итерации (в данном случае сброс двух наших счетчиков self.a и self.b) метод __iter__() может вернуть любой объект, который реализует метод __next__(). В этом случае (и в большинстве случаев) __iter__() просто возвращает self, поскольку данный класс реализует свой собственный метод __next__().
  4. Строка 10. Метод __next__() вызывается всякий раз, когда кто-то вызывает next() на итераторе экземпляра класса. Эти слова будут иметь больше смысла через минуту.
  5. Строка 13. Когда метод __next__() вызывает исключение StopIteration, это сигнализирует вызывающей стороне, что итерация исчерпана. В отличие от большинства исключений, это не ошибка; это нормальное условие, означающее, что у итератора больше нет значений для генерации. Если вызывающий является циклом for, он заметит это исключение StopIteration и корректно выйдет из цикла (другими словами, он поглотит исключение). Это небольшое волшебство на самом деле является ключевым для использования итераторов в циклах for.
  6. Строка 15. Для выдачи следующего значения метод итератора __next__() просто возвращает его. Не используйте здесь yield; это просто синтаксический сахар, который применяется только при использовании генераторов. Поскольку здесь вы создаете свой собственный итератор с нуля, используйте вместо него return.

Еще не совсем запутались? Отлично. Давайте посмотрим, как вызвать этот итератор:

>>> 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, а затем выполняет тело цикла for для этого значения n.
  • Как цикл for знает, когда остановиться? Я рад, что вы спросили! Когда next(fib_iter) вызовет исключение StopIteration, цикл for поглотит исключение и изящно завершит работу. (Любое другое исключение будет пропускаться и подниматься как обычно.) И где вы видели исключение StopIteration? Конечно в методе __next__()!

7.6 Итератор правил образования множественного числа

Теперь пришло время для финала. Давайте перепишем генератор правил образования множественного числа в виде итератора.

Скачать файл plural6.py.

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, 2)
        funcs = build_match_and_apply_functions(
            pattern, search, replace)
        self.cache.append(funcs)
        return funcs

rules = LazyRules()

Поскольку данный класс реализует __iter__() и __next__(), его можно использовать в качестве итератора. Затем мы создаем экземпляр класса и назначаем его переменной rules. Это происходит только один раз при импорте.

Давайте разберем данный класс более подробно.

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

    def __init__(self):
        self.pattern_file = open(self.rules_filename, encoding='utf-8')  ①
        self.cache = []                                                  ②
  1. Строка 5. Когда мы создаем экземпляр класса LazyRules, мы открываем файл шаблонов, но ничего из него не читаем (это будет позже).
  2. Строка 6. После открытия файла шаблонов инициализируем кэш. Мы будем использовать этот кэш позже (в методе __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'
>>> 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'
>>> r2.__class__.rules_filename = 'papayawhip.txt'  ④
>>> r1.rules_filename
'papayawhip.txt'
>>> r2.rules_filename                               ⑤
'r2-overridetxt'
  1. Строка 4. Каждый экземпляр класса наследует атрибут rules_filename со значением, определенным классом.
  2. Строка 8. Изменение значения атрибута в одном экземпляре не влияет на другие экземпляры ...
  3. Строка 13. … и не меняет атрибут класса. Вы можете получить доступ к атрибуту класса (в отличие от атрибута отдельного экземпляра), используя специальный атрибут __class__ для доступа к самому классу.
  4. Строка 15. Если вы измените атрибут класса, это повлияет на все экземпляры, которые всё еще наследуют это значение (здесь это r1).
  5. Строка 18. На экземпляры, которые переопределили этот атрибут (здесь это r2), это не повлияет.

А теперь вернемся к нашему классу.

    def __iter__(self):       ①
        self.cache_index = 0
        return self           ②
  1. Строка 1. Метод __iter__() будет вызываться каждый раз, когда кто-то, скажем, цикл for, вызывает iter(rules).
  2. Строка 3. Единственное, что должен делать каждый метод __iter__(), – это возвращать итератор. В данном случае он возвращает self, что означает, что данный класс определяет метод __next__(), который позаботится о возвращении значений на протяжении итерации.
    def __next__(self):                                 ①
        .
        .
        .
        pattern, search, replace = line.split(None, 2)
        funcs = build_match_and_apply_functions(        ②
            pattern, search, replace)
        self.cache.append(funcs)                        ③
        return funcs
  1. Строка 1. Метод __next__() вызывается всякий раз, когда кто-то, скажем, цикл for, вызывает next(rules). Этот метод будет понятен, только если мы начнем с конца и будем разбирать его в обратном направлении. Давайте так и сделаем.
  2. Строка 6. Последняя часть данной функции должна выглядеть как минимум знакомой. Функция build_match_and_apply_functions() не изменилась; она такая же, как всегда.
  3. Строка 8. Единственное отличие состоит в том, что перед возвратом функций совпадения и применения правила (которые хранятся в кортеже funcs) мы собираемся сохранить их в self.cache.

Двигаемся назад...

    def __next__(self):
        .
        .
        .
        line = self.pattern_file.readline()  ①
        if not line:                         ②
            self.pattern_file.close()
            raise StopIteration              ③
  1. Строка 5. Метод readline() (примечание: единственное, а не множественное число readlines()) читает из открытого файла только одну строку. А именно следующую строку. (Файловые объекты тоже итераторы!)
  2. Строка 6. Если для чтения readline() имелась строка, line не будет пустой строкой. Даже если файл содержит пустую строку, line будет строкой из одного символа '\n' (возврат каретки). Если line – действительно пустая строка, это означает, что в файле больше нет строк для чтения.
  3. Строка 8. Когда мы дойдем до конца файла, мы должны закрыть файл и вызвать волшебное исключение StopIteration. Помните, мы дошли до этого момента, потому что нам нужны были функции совпадения и применения для следующего правила. Следующее правило идет в следующей строке файла... но следующей строки нет! Поэтому у нас нет значения, которое необходимо вернуть. Итерация окончена.

Двигаемся назад к началу метода __next__()

    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                         ②
        .
        .
        .
  1. Строка 4. self.cache будет списком функций, который нам нужен поиска совпадений и применения отдельных правил (по крайней мере, это должно звучать знакомо). self.cache_index отслеживает, какой кэшированный элемент мы должны вернуть следующим. Если мы еще не исчерпали кэш (то есть, если длина self.cache больше, чем self.cache_index), то мы выполнили удачное обращение в кэш! Ура! Мы можем вернуть функции совпадения и применения из кэша вместо того, чтобы создавать их с нуля.
  2. Строка 7. С другой стороны, если мы не получим попадание из кэша, и файловый объект будет закрыт (что может произойти дальше в методе, как вы видели в предыдущем фрагменте кода), то мы ничего больше не сможем сделать. Если файл закрыт, это означает, что мы исчерпали его – мы уже прочитали каждую строку из файла шаблонов, и мы уже создали и кэшировали функции совпадения и применения для каждого шаблона. Файл исчерпан; кэш исчерпан. Погодите, что? Держитесь, мы почти закончили

Собрав всё вместе, мы увидим, что и когда происходит:

  • Когда модуль импортируется, он создает один экземпляр класса LazyRules, называемый rules, который открывает файл шаблонов, но не читает из него.
  • Когда запрашиваются первые функции совпадения и применения, он проверяет свой кэш, но обнаруживает, что тот пуст. Таким образом, он считывает одну строку из файла шаблонов, создает функции совпадения и применения из этих шаблонов и кэширует их.
  • Допустим, ради примера, что первое правило подошло. Если это так, никакие дополнительные функции совпадения и применения не создаются, и никакие дополнительные строки не считываются из файла шаблонов.
  • Кроме того, ради примера, предположим, что вызывающая сторона снова вызывает функцию plural(), чтобы образовать множественное число для другого слова. Цикл for в функции plural() вызовет iter(rules), который сбросит индекс кэша, но не сбросит открытый файловый объект.
  • В первый раз цикл for запросит значение из объекта rules, который вызовет свой метод __next__(). Однако на этот раз кэш уже содержит одну пару функций совпадения и применения, соответствующих шаблонам в первой строке файла шаблонов. Поскольку они были созданы и кэшированы в ходе образования множественного числа предыдущего слова, то они извлекаются из кэша. Индекс кэша увеличивается, и открытый файл не затрагивается.
  • Допустим, ради примера, что первое правило на этот раз не подходит. Таким образом, цикл for возвращается на начало и запрашивает от rules другое значение. Это вызывает метод __next__() во второй раз. На этот раз кэш исчерпан (он содержит только один элемент, а мы просим второй), поэтому метод __next__() продолжает выполняться. Он читает из открытого файла следующую строку, создает из шаблонов функции совпадения и применения и кэширует их.
  • Этот процесс чтения, создания и кэширования будет продолжаться до тех пор, пока правила, считываемые из файла шаблонов, не подойдут для слова, которое мы пытаемся получить во множественном числе. Если мы находим подходящее правило до конца файла, то мы просто используем его и останавливаемся, оставляя файл открытым. Указатель позиции в файле будет оставаться там, где мы прекратили чтение, ожидая следующую команду readline(). Тем временем в кэше теперь находится больше элементов, и если мы начнем всё сначала, пытаясь преобразовать в множественное число новое слово, то перед чтением следующей строки из файла шаблонов сначала будет проверен каждый из этих элементов в кэше.

Мы достигли нирваны в образовании существительных в множественном числе.

  1. Минимальные затраты при запуске. Единственное, что происходит при импорте, – создание экземпляра одного класса и открытие файла (но не чтение из него).
  2. Максимальная производительность. Предыдущий пример считывал файл и динамически создавал функции каждый раз, когда вам нужно было преобразовать слово во множественное число. Эта версия будет кэшировать функции после их создания, и в худшем случае она будет считывать файл шаблонов только один раз, независимо от того, сколько слов вы преобразовываете.
  3. Разделение кода и данных. Все шаблоны хранятся в отдельном файле. Код – это код, а данные – это данные, и они никогда не встретятся.

Это действительно нирвана? И да, и нет. В примере LazyRules нужно учитывать следующее: файл шаблонов открывается (во время __init__()) и остается открытым до получения последнего правила. Python в конечном итоге закроет файл при выходе или после уничтожения последнего экземпляра класса LazyRules, но, тем не менее, это может занять много времени. Если данный класс является частью продолжительного процесса Python, интерпретатор Python может никогда не завершить работу, а объект LazyRules может никогда не быть уничтожен.

Есть способы обойти это. Вместо того чтобы открывать файл во время __init__() и оставлять его открытым, пока вы читаете правила по одной строке за раз, вы можете открыть файл, прочитать все правила и сразу же закрыть файл. Или вы можете открыть файл, прочитать одно правило, сохранить позицию в файле с помощью метода tell(), закрыть файл, а затем снова открыть его и использовать метод seek(), чтобы продолжить чтение с того места, где вы остановились. Или вы можете не беспокоиться и просто оставить файл открытым, как в этом примере. Программирование – это проектирование, а проектирование – это компромиссы и ограничения. Оставить файл открытым на слишком долго может стать проблемой; усложнение кода может стать проблемой. Какая из этих проблем является большей, зависит от вашей команды разработчиков, вашего приложения и среды выполнения.

7.7 Материалы для дальнейшего чтения

Источник:

  • Mark Pilgrim. Dive Into Python 3

Теги

PythonВысокоуровневые языки программированияОбучениеПрограммированиеЯзыки программирования

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.