Глава 7. Классы и итераторы
Восток есть Восток, а Запад есть Запад, и они никогда не встретятся.
Погружение
Итераторы – это «секретный соус» 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. Определенный выше класс имеет имя
PapayaWhip
и не наследует никакой другой класс. Имена классов, как правило, пишутся с большой буквы, НапримерВотТак, но это всего лишь соглашение, а не требование. - Строка 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): ②
- Строка 2. Классы, по аналогии с модулями и функциями, могут (и должны) иметь строки документации (docstrings).
- Строка 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__ ④
'Итератор, который возвращает числа в последовательности Фибоначчи'
- Строка 2. Вы создаете новый экземпляр класса
Fib
(определенный в модулеfibonacci2
) и присваиваете только что созданный объект переменнойfib
. Единственный переданный аргумент, 100, соответствует именованному аргументуmax
в методе__init__()
классаFib
. - Строка 3.
fib
теперь является экземпляром классаFib
. - Строка 5. Каждый экземпляр класса имеет встроенный атрибут
__class__
, который указывает на класс объекта. Java программисты могут быть знакомы с классомClass
, который содержит методыgetName()
иgetSuperclass()
, используемые для получения информации об объекте. В Python метаданные такого рода доступны через соответствующие атрибуты, но идея используется та же самая. - Строка 7. Вы можете получить строку документации (docstring) класса, по аналогии с функцией и модулем. Все экземпляры класса имеют одну и ту же строку документации.
Для создания нового экземпляра класса в Python, просто вызовите класс, как если бы он был функцией, явные операторы, как, например, new
в С++ или Java, в языке Python отсутствуют.
7.4 Переменные экземпляра
Перейдем к следующей строке:
class Fib:
def __init__(self, max):
self.max = max ①
- Строка 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: ②
- Строка 3.
self.max
определена в методе__init__()
... - Строка 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.
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. Чтобы построить итератор с нуля,
Fib
должен быть классом, а не функцией. - Строка 2. «Вызов»
Fib(max)
на самом деле создает экземпляр этого класса и вызывает его метод__init__()
сmax
. Метод__init__()
сохраняет максимальное значение как переменную экземпляра, чтобы другие методы могли обращаться к ней позже. - Строка 5. Метод
__iter__()
вызывается всякий раз, когда кто-то вызываетiter(fib)
. (Как вы увидите через минуту, циклfor
вызовет его автоматически, но также вы можете вызвать его самостоятельно.) После выполнения инициализации начала итерации (в данном случае сброс двух наших счетчиковself.a
иself.b
) метод__iter__()
может вернуть любой объект, который реализует метод__next__()
. В этом случае (и в большинстве случаев)__iter__()
просто возвращаетself
, поскольку данный класс реализует свой собственный метод__next__()
. - Строка 10. Метод
__next__()
вызывается всякий раз, когда кто-то вызываетnext()
на итераторе экземпляра класса. Эти слова будут иметь больше смысла через минуту. - Строка 13. Когда метод
__next__()
вызывает исключениеStopIteration
, это сигнализирует вызывающей стороне, что итерация исчерпана. В отличие от большинства исключений, это не ошибка; это нормальное условие, означающее, что у итератора больше нет значений для генерации. Если вызывающий является цикломfor
, он заметит это исключениеStopIteration
и корректно выйдет из цикла (другими словами, он поглотит исключение). Это небольшое волшебство на самом деле является ключевым для использования итераторов в циклахfor
. - Строка 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 Итератор правил образования множественного числа
Теперь пришло время для финала. Давайте перепишем генератор правил образования множественного числа в виде итератора.
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 = [] ②
- Строка 5. Когда мы создаем экземпляр класса
LazyRules
, мы открываем файл шаблонов, но ничего из него не читаем (это будет позже). - Строка 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'
- Строка 4. Каждый экземпляр класса наследует атрибут
rules_filename
со значением, определенным классом. - Строка 8. Изменение значения атрибута в одном экземпляре не влияет на другие экземпляры ...
- Строка 13. … и не меняет атрибут класса. Вы можете получить доступ к атрибуту класса (в отличие от атрибута отдельного экземпляра), используя специальный атрибут
__class__
для доступа к самому классу. - Строка 15. Если вы измените атрибут класса, это повлияет на все экземпляры, которые всё еще наследуют это значение (здесь это
r1
). - Строка 18. На экземпляры, которые переопределили этот атрибут (здесь это
r2
), это не повлияет.
А теперь вернемся к нашему классу.
def __iter__(self): ①
self.cache_index = 0
return self ②
- Строка 1. Метод
__iter__()
будет вызываться каждый раз, когда кто-то, скажем, циклfor
, вызываетiter(rules)
. - Строка 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. Метод
__next__()
вызывается всякий раз, когда кто-то, скажем, циклfor
, вызываетnext(rules)
. Этот метод будет понятен, только если мы начнем с конца и будем разбирать его в обратном направлении. Давайте так и сделаем. - Строка 6. Последняя часть данной функции должна выглядеть как минимум знакомой. Функция
build_match_and_apply_functions()
не изменилась; она такая же, как всегда. - Строка 8. Единственное отличие состоит в том, что перед возвратом функций совпадения и применения правила (которые хранятся в кортеже
funcs
) мы собираемся сохранить их вself.cache
.
Двигаемся назад...
def __next__(self):
.
.
.
line = self.pattern_file.readline() ①
if not line: ②
self.pattern_file.close()
raise StopIteration ③
- Строка 5. Метод
readline()
(примечание: единственное, а не множественное числоreadlines()
) читает из открытого файла только одну строку. А именно следующую строку. (Файловые объекты тоже итераторы!) - Строка 6. Если для чтения
readline()
имелась строка,line
не будет пустой строкой. Даже если файл содержит пустую строку,line
будет строкой из одного символа '\n' (возврат каретки). Еслиline
– действительно пустая строка, это означает, что в файле больше нет строк для чтения. - Строка 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 ②
.
.
.
- Строка 4.
self.cache
будет списком функций, который нам нужен поиска совпадений и применения отдельных правил (по крайней мере, это должно звучать знакомо).self.cache_index
отслеживает, какой кэшированный элемент мы должны вернуть следующим. Если мы еще не исчерпали кэш (то есть, если длинаself.cache
больше, чемself.cache_index
), то мы выполнили удачное обращение в кэш! Ура! Мы можем вернуть функции совпадения и применения из кэша вместо того, чтобы создавать их с нуля. - Строка 7. С другой стороны, если мы не получим попадание из кэша, и файловый объект будет закрыт (что может произойти дальше в методе, как вы видели в предыдущем фрагменте кода), то мы ничего больше не сможем сделать. Если файл закрыт, это означает, что мы исчерпали его – мы уже прочитали каждую строку из файла шаблонов, и мы уже создали и кэшировали функции совпадения и применения для каждого шаблона. Файл исчерпан; кэш исчерпан. Погодите, что? Держитесь, мы почти закончили
Собрав всё вместе, мы увидим, что и когда происходит:
- Когда модуль импортируется, он создает один экземпляр класса
LazyRules
, называемыйrules
, который открывает файл шаблонов, но не читает из него. - Когда запрашиваются первые функции совпадения и применения, он проверяет свой кэш, но обнаруживает, что тот пуст. Таким образом, он считывает одну строку из файла шаблонов, создает функции совпадения и применения из этих шаблонов и кэширует их.
- Допустим, ради примера, что первое правило подошло. Если это так, никакие дополнительные функции совпадения и применения не создаются, и никакие дополнительные строки не считываются из файла шаблонов.
- Кроме того, ради примера, предположим, что вызывающая сторона снова вызывает функцию
plural()
, чтобы образовать множественное число для другого слова. Циклfor
в функцииplural()
вызоветiter(rules)
, который сбросит индекс кэша, но не сбросит открытый файловый объект. - В первый раз цикл
for
запросит значение из объектаrules
, который вызовет свой метод__next__()
. Однако на этот раз кэш уже содержит одну пару функций совпадения и применения, соответствующих шаблонам в первой строке файла шаблонов. Поскольку они были созданы и кэшированы в ходе образования множественного числа предыдущего слова, то они извлекаются из кэша. Индекс кэша увеличивается, и открытый файл не затрагивается. - Допустим, ради примера, что первое правило на этот раз не подходит. Таким образом, цикл
for
возвращается на начало и запрашивает отrules
другое значение. Это вызывает метод__next__()
во второй раз. На этот раз кэш исчерпан (он содержит только один элемент, а мы просим второй), поэтому метод__next__()
продолжает выполняться. Он читает из открытого файла следующую строку, создает из шаблонов функции совпадения и применения и кэширует их. - Этот процесс чтения, создания и кэширования будет продолжаться до тех пор, пока правила, считываемые из файла шаблонов, не подойдут для слова, которое мы пытаемся получить во множественном числе. Если мы находим подходящее правило до конца файла, то мы просто используем его и останавливаемся, оставляя файл открытым. Указатель позиции в файле будет оставаться там, где мы прекратили чтение, ожидая следующую команду
readline()
. Тем временем в кэше теперь находится больше элементов, и если мы начнем всё сначала, пытаясь преобразовать в множественное число новое слово, то перед чтением следующей строки из файла шаблонов сначала будет проверен каждый из этих элементов в кэше.
Мы достигли нирваны в образовании существительных в множественном числе.
- Минимальные затраты при запуске. Единственное, что происходит при импорте, – создание экземпляра одного класса и открытие файла (но не чтение из него).
- Максимальная производительность. Предыдущий пример считывал файл и динамически создавал функции каждый раз, когда вам нужно было преобразовать слово во множественное число. Эта версия будет кэшировать функции после их создания, и в худшем случае она будет считывать файл шаблонов только один раз, независимо от того, сколько слов вы преобразовываете.
- Разделение кода и данных. Все шаблоны хранятся в отдельном файле. Код – это код, а данные – это данные, и они никогда не встретятся.
Это действительно нирвана? И да, и нет. В примере LazyRules нужно учитывать следующее: файл шаблонов открывается (во время __init__()
) и остается открытым до получения последнего правила. Python в конечном итоге закроет файл при выходе или после уничтожения последнего экземпляра класса LazyRules
, но, тем не менее, это может занять много времени. Если данный класс является частью продолжительного процесса Python, интерпретатор Python может никогда не завершить работу, а объект LazyRules
может никогда не быть уничтожен.
Есть способы обойти это. Вместо того чтобы открывать файл во время __init__()
и оставлять его открытым, пока вы читаете правила по одной строке за раз, вы можете открыть файл, прочитать все правила и сразу же закрыть файл. Или вы можете открыть файл, прочитать одно правило, сохранить позицию в файле с помощью метода tell()
, закрыть файл, а затем снова открыть его и использовать метод seek()
, чтобы продолжить чтение с того места, где вы остановились. Или вы можете не беспокоиться и просто оставить файл открытым, как в этом примере. Программирование – это проектирование, а проектирование – это компромиссы и ограничения. Оставить файл открытым на слишком долго может стать проблемой; усложнение кода может стать проблемой. Какая из этих проблем является большей, зависит от вашей команды разработчиков, вашего приложения и среды выполнения.