Книга: Чистый Python. Тонкости программирования для профи
Назад: 3. Эффективные функции
Дальше: 5. Общие структуры данных Python

4. Классы и ООП

4.1. Сравнения объектов: is против ==

Когда я был мальчишкой, у наших соседей жили кошки-близняшки. Внешне они были очень похожи — одинаковая темно-серая шерсть и одинаковый пронизывающий взгляд зеленых глаз. Отбросив некоторые индивидуальные особенности, на глаз вы бы их не различили. Но, конечно, они были двумя разными кошками, двумя отдельными существами, несмотря на то что выглядели одинаково.

Это подводит меня к разнице в смысле между понятиями «равенство» и «тождество». И эта разница крайне важна для понимания того, как ведут себя операторы сравнения Python is и ==.

Оператор == выполняет сравнение путем проверки на равенство: если бы эти кошки были объектами Python и мы сравнивали их оператором ==, то в качестве ответа мы получили бы, что «обе кошки равны».

Однако оператор is сравнивает идентичности: если бы мы сравнивали наших кошек оператором is, то в качестве ответа мы получили бы, что «это две разные кошки».

Но прежде чем я запутаюсь в этом кошачьем клубке, давайте взглянем на небольшой реальный код Python.

Прежде всего, мы создадим новый объект-список и назовем его a, а затем определим еще одну переменную (b), которая указывает на тот же самый объект-список:

>>> a = [1, 2, 3]

>>> b = a

Давайте изучим эти две переменные. Мы видим, что они указывают на внешне идентичные списки:

>>> a

[1, 2, 3]

>>> b

[1, 2, 3]

Когда мы сравним эти два объекта-списка на равенство при помощи оператора ==, мы получим ожидаемый результат, поскольку эти два объекта-списка выглядят одинаково:

>>> a == b

True

Однако этот результат не говорит о том, указывают ли a и b в действительности на тот же самый объект. Конечно, мы знаем, что это так, потому что мы определили их ранее, но предположим, что мы не знаем, — тогда как можно было бы это узнать?

Ответ на этот вопрос следует искать в сравнении обеих переменных оператором is. Это сравнение подтверждает, что обе переменные в действительности указывают на один объект-список:

>>> a is b

True

Давайте посмотрим, что происходит, когда мы создаем идентичную копию нашего объекта-списка. Это можно сделать, вызвав list() с существующим списком в качестве аргумента, чтобы создать копию, которую мы назовем c:

>>> c = list(a)

И снова вы увидите, что только что созданный нами новый список выглядит идентичным объекту-списку, на который указывают a и b:

>>> c

[1, 2, 3]

А вот теперь начинается самое интересное. Давайте сравним нашу копию списка c с первоначальным списком a, использовав для этого оператор ==. Какой ответ вы ожидаете увидеть?

>>> a == c

True

О’кей. Надеюсь, что вы как раз этого и ожидали. Данный результат говорит следующее: c и a имеют одинаковое содержимое. Python их считает равными. Но вот вопрос: указывают ли они в действительности на один и тот же объект? Давайте это выясним при помощи оператора is:

>>> a is c

False

О-па! И вот тут мы получаем другой результат. Python говорит, что c и a указывают на два разных объекта, несмотря на то что их содержимое может быть одинаковым.

Итак, чтобы подытожить, давайте попробуем разложить разницу между is и == на два коротких определения:

• Выражение is дает True, если две переменные указывают на тот же самый (идентичный) объект.

• Выражение == дает True, если объекты, на которые ссылаются переменные, равны (имеют одинаковое содержимое).

Всякий раз, когда вам придется решать, применять оператор is или оператор ==, просто вспомните про кошек-близняшек (в принципе, сойдут и собаки). Если вы это будете делать, то у вас все будет в порядке.

4.2. Преобразование строк (каждому классу по __repr__)

Когда вы определяете собственный класс в Python и затем пытаетесь напечатать один из его экземпляров в консоли (или проверить его в сеансе интерпретатора), вы получаете относительно неудовлетворительный результат. Принятое по умолчанию поведение с преобразованием в строковое значение в стиле «to-string» является примитивным и испытывает недостаток в подробностях:

class Car:

     def __init__(self, color, mileage):

         self.color = color

         self.mileage = mileage

 

>>> my_car = Car('красный', 37281)

>>> print(my_car)

<__console__.Car object at 0x109b73da0>

>>> my_car

<__console__.Car object at 0x109b73da0>

По умолчанию вы получаете лишь строковое значение, содержащее имя класса и идентификатор экземпляра объекта (который в Python является адресом объекта в оперативной памяти). Это лучше, чем ничего, но не очень-то полезно.

Вы можете попытаться найти обходной путь, непосредственно распечатав атрибуты класса или даже добавив в классы собственный метод to_string():

>>> print(my_car.color, my_car.mileage)

красный 37281

Общая идея совершенно верная, но она игнорирует договоренности об именовании и встроенные механизмы, которые Python использует для обработки того, как объекты представляются в виде строк.

Вместо того чтобы строить свой собственный механизм преобразования строк, будет гораздо лучше, если вы добавите в свой класс дандер-методы __str__ и __repr__. Они представляют собой питоновский способ управления тем, как объекты преобразовываются в строковые значения в различных ситуациях.

Давайте взглянем, как эти методы работают на практике. Для начала мы добавим метод __str__ в класс Car, который мы определили ранее:

class Car:

     def __init__(self, color, mileage):

         self.color = color

         self.mileage = mileage

     def __str__(self):

         return f'{self.color} автомобиль'

Если сейчас попробовать напечатать или проинспектировать экземпляр Car, то вы получите другой, слегка улучшенный результат:

>>> my_car = Car('красный', 37281)

>>> print(my_car)

'красный автомобиль'

>>> my_car

<__console__.Car object at 0x109ca24e0>

Инспектирование объекта Car в консоли по-прежнему дает предыдущий результат, содержащий идентификатор объекта. Однако распечатка объекта показала строку, возвращенную добавленным нами методом __str__.

Метод __str__ является одним из дандер-методов Python (с двойным подчеркиванием), и он вызывается, когда вы пытаетесь преобразовать объект в строковое значение посредством различных доступных способов:

>>> print(my_car)

красный автомобиль

>>> str(my_car)

'красный автомобиль'

>>> '{}'.format(my_car)

'красный автомобиль'

При надлежащей реализации __str__ вам не придется переживать по поводу печати атрибутов объектов непосредственно или написания отдельной функции to_string(). Это питоновский способ управлять преобразованием строк.

Между прочим, некоторые разработчики предпочитают называть дандер-методы Python «магическими». Но эти методы никоим образом магическими не являются. То, что имена этих методов начинаются и оканчиваются символами двойного подчеркивания, является всего-навсего согласованным правилом именования, которое выделяет их как ключевые функциональные средства языка Python. Он также помогает избежать конфликтов из-за совпадения имен с вашими собственными методами и атрибутами. Конструктор объектов __init__ соблюдает то же самое правило, и в этом нет ничего волшебного или загадочного.

Не бойтесь использовать дандер-методы Python — они призваны вам помогать.

Метод __str__ против __repr__

Нужно сказать, что наша история преобразования строк на этом не заканчивается. Вы заметили, что осмотр объекта my_car в сеансе интерпретатора по-прежнему дает этот странный результат <Car object at 0x109ca24e0>?

Это произошло, потому что фактически имеется два дандер-метода, которые управляют тем, как объекты преобразовываются в строковые значения в Python 3. Первый, __str__, и вы только что с ним познакомились. Второй, __repr__, и характер его работы аналогичен методу __str__, но он используется в других ситуациях. (В Python 2.x также имеется метод __unicode__, которого я коснусь чуть позже.)

Ниже приведен простой эксперимент для «обкатки» ситуации, когда используется метод __str__ или __repr__. Давайте переопределим наш автомобильный класс таким образом, чтобы он содержал оба дандер-метода для преобразования в строковое значение с результатами, которые легко различить:

class Car:

     def __init__(self, color, mileage):

         self.color = color

         self.mileage = mileage

     def __repr__(self):

         return '__repr__ для объекта Car'

     def __str__(self):

         return '__str__ для объекта Car'

Теперь, когда вы поэкспериментируете с приведенными выше примерами, вы поймете, какой метод управляет результатом преобразования строк в каждом случае:

>>> my_car = Car('красный', 37281)

>>> print(my_car)

__str__ для объекта Car

>>> '{}'.format(my_car)

'__str__ для объекта Car'

>>> my_car

__repr__ для объекта Car

Этот эксперимент подтверждает, что в результате инспектирования объекта в сеансе интерпретатора Python просто печатается результат выполнения метода __repr__ объекта.

Интересно отметить, что в контейнерах, таких как списки и словари, для представления содержащихся в них объектов всегда используется результат метода __repr__, даже если вызвать функцию str с самим контейнером:

str([my_car])

'[__repr__ для объекта Car]'

Что касается ручного выбора между обоими методами преобразования строк, например, чтобы яснее выразить замысел вашего программного кода, то лучше всего использовать встроенные функции str() и repr(). Их применение предпочтительнее прямых вызовов метода __str__ или __repr__ объекта, поскольку они воспринимаются лучше и дают тот же самый результат:

>>> str(my_car)

'__str__ для объекта Car'

>>> repr(my_car)

'__repr__ для объекта Car'

Даже по завершении этого исследования вам, возможно, будет любопытно узнать, какова «реальная» разница между методами __str__ и __repr__. На вид они оба служат одной и той же цели, поэтому может быть не ясно, когда использовать каждый из них.

В случае таких вопросов обычно неплохо взглянуть на то, что делает стандартная библиотека Python. Самое время поставить еще один эксперимент. Мы создадим объект datetime.date и выясним, каким образом в нем используются методы __repr__ и __str__ для управления преобразованием строк:

>>> import datetime

>>> today = datetime.date.today()

Результат метода __str__ объекта даты должен быть прежде всего удобочитаемым. Он призван возвращать легко воспринимаемое человеком сжатое текстовое представление — то, что вы спокойно можете показать пользователю. По этой причине, когда мы вызываем функцию str() с объектом даты, мы получаем нечто похожее на формат даты по ISO:

>>> str(today)

'2017-02-02'

В случае с методом __repr__ идея состоит в том, что его результат должен быть прежде всего однозначным. Результирующее строковое значение больше предназначено для разработчиков как средство отладки. И в связи с этим он должен максимально четко выражать то, чем этот объект является. Именно поэтому при вызове функции repr() с объектом вы получите более подробный результат. Он даже будет содержать полное имя модуля и класса:

>>> repr(today)

'datetime.date(2017, 2, 2)'

Возвращаемое методом __repr__ строковое значение можно скопировать и вставить в консоль интерпретатора и исполнить его как допустимый фрагмент кода Python, чтобы воссоздать оригинальный объект даты. Этот изящный подход и хороший целевой ориентир следует иметь в виду при написании своих собственных функций repr.

С другой стороны, я полагаю, что довольно-таки трудно найти применение такой функции на практике. Она не будет стоить затраченных на нее усилий и просто создаст для вас дополнительную работу. Мое эмпирическое правило заключается в том, чтобы делать свои строки __repr__ однозначными и полезными для разработчиков, но я не рассчитываю, что они смогут восстанавливать полное состояние объекта.

Почему каждый класс нуждается в __repr__

Если опустить метод __str__, то Python в поисках __str__ отыграет назад к результату __repr__. По этой причине я рекомендую добавлять в свои классы всегда, по крайней мере, метод __repr__. Это обеспечит полезный результат преобразования строк почти во всех случаях при минимуме работы по его реализации.

Ниже показано, как можно быстро и эффективно добавить в свои классы элементарную поддержку преобразования строк. Для нашего класса Car мы могли бы начать с приведенного ниже метода __repr__:

def __repr__(self):

     return f'Car({self.color!r}, {self.mileage!r})'

Обратите внимание на то, что я использую флаг преобразования !r, тем самым гарантируя, что в выводимом строковом значении вместо str(self.color) и str(self.mileage) будут использованы repr(self.color) и repr(self.mileage).

Это работает безупречно, но оборотной стороной является то, что мы повторили имя класса в форматной строке. Для того чтобы избежать этого повторения, здесь можно применить трюк с использованием атрибута __calss__.__name__ объекта. Данный атрибут всегда будет зеркально отображать имя класса в виде строки.

Преимущество состоит в том, что вам не придется модифицировать реа­лизацию метода __repr__, когда имя класса изменится. Это позволяет беспрепятственно придерживаться принципа DRY, то есть «не повторяйся»:

def __repr__(self):

     return (f'{self.__class__.__name__}('

             f'{self.color!r}, {self.mileage!r})')

Оборотной стороной этой реализации является то, что форматная строка довольно длинная и громоздкая. Но при условии выверенного форматирования вы сможете сохранить свой исходный код аккуратным и соответствующим правилам PEP 8.

Во время инспектирования объекта или непосредственного вызова функции repr() с объектом при наличии приведенной выше реализации метода __repr__ мы получаем полезный результат:

>>> repr(my_car)

'Car(red, 37281)'

Печать объекта или вызов функции str() с этим объектом возвращают то же самое строковое значение, потому что заданная по умолчанию реализация __str__ просто вызывает метод __repr__:

>>> print(my_car)

'Car(red, 37281)'

>>> str(my_car)

'Car(red, 37281)'

Убежден, что этот подход обеспечивает наибольшую эффективность и скромный объем работы по его реализации. Более того, этот подход опирается на типовой шаблон в стиле формы для печенья, который можно применять, не особо задумываясь. По этой причине я всегда стремлюсь добавлять в свои классы элементарную реализацию метода __repr__.

Ниже показан законченный пример для Python 3 с дополнительной реализацией метода __str__:

class Car:

     def __init__(self, color, mileage):

         self.color = color

         self.mileage = mileage

     def __repr__(self):

         return (f'{self.__class__.__name__}('

                 f'{self.color!r}, {self.mileage!r})')

     def __str__(self):

         return f'{self.color} автомобиль'

Отличия Python 2.x: __unicode__

В Python 3 имеется один тип данных на все случаи жизни для представления текста: str. Он содержит символы Юникода и может представлять большинство систем письменности в мире.

В Python 2.x для строковых данных используется другая модель данных. Для представления текста служат два типа: str, который ограничен на­бором символов ASCII, и unicode, который эквивалентен типу str Python 3.

Вследствие этой разницы в Python 2 существует еще один дандер-метод в составе методов управления преобразованием строк: __unicode__. В Python 2 __str__ возвращает байты, тогда как __unicode__ возвращает символы.

По своим замыслу и целям метод __unicode__ является более новым и предпочтительным методом управления преобразованием строк. Кроме того, имеется сопровождающая его встроенная функция unicode(). Она вызывает соответствующий дандер-метод подобно тому, как работают функции str() и repr().

Чем дальше, тем лучше. Но все станет намного причудливее, когда вы посмотрите на правила вызова методов __str__ и __unicode__ в Python 2.

Инструкция print и функция str() вызывают метод __str__. Встроенная в Python 2 функция unicode() вызывает метод __unicode__, если он существует; в противном случае отыгрывает назад к методу __str__ и декодирует результат в системную кодировку текста.

По сравнению с Python 3 эти особые случаи несколько усложняют правила преобразования текста. Но есть способ все снова упростить в практическом плане. Юникод является предпочтительным и перспективным способом работы с текстом в программах Python.

Поэтому в Python 2.x я в целом рекомендовал бы размещать весь свой код форматирования строк внутрь метода __unicode__, а затем создавать реализацию заглушки __str__, которая возвращает представление в виде Юникода в кодировке UTF-8:

def __str__(self):

     return unicode(self).encode('utf-8')

Заглушка __str__ будет одинаковой для большинства классов, ее вы просто можете копипастить повсюду, где это необходимо (либо разместить ее в базовом классе, где это имеет смысл). Тогда весь ваш код преобразования строк, который предназначен для использования не разработчиками, будет лежать в методе __unicode__.

Приведем законченный пример для Python 2.x:

class Car(object):

     def __init__(self, color, mileage):

         self.color = color

         self.mileage = mileage

     def __repr__(self):

         return '{}({!r}, {!r})'.format(

             self.__class__.__name__,

             self.color, self.mileage)

     def __unicode__(self):

         return u'{self.color} автомобиль'.format(

             self=self)

 

    def __str__(self):

         return unicode(self).encode('utf-8')

Ключевые выводы

• Управлять преобразованием строк в своих собственных классах можно, используя дандер-методы __str__ и __repr__.

• Результат метода __str__ должен быть удобочитаемым. Результат ме­то­да __repr__ должен быть однозначным.

• В свои классы всегда следует добавлять метод __repr__. По умолчанию реализация метода __str__ просто вызывает метод __repr__.

• В Python 2 вместо метода __str__ следует использовать метод __uni­code__.

4.3. Определение своих собственных классов-исключений

Когда я начал использовать Python, то не решался в своем программном коде писать собственные классы-исключения. Вместе с тем определение собственных типов ошибок может быть очень ценным. Вы четко выделите потенциальные случаи ошибок, и, как результат, ваши функции и модули станут более удобными в сопровождении. Вы также сможете использовать собственные типы ошибок, которые обеспечат дополнительную отладочную информацию.

Все это улучшит ваш программный код и облегчит его понимание. Он станет легче для отладки и удобнее в сопровождении. Задача определения ваших собственных классов-исключений не будет такой сложной, когда вы разобьете ее на несколько простых примеров. В этой главе я проведу вас по основным пунктам, которые необходимо помнить.

Допустим, что вы хотели бы выполнить валидацию входного строкового значения, которое в вашем приложении представляет имя человека. Игрушечный пример функции валидации имени может выглядеть следующим образом:

def validate(name):

     if len(name) < 10:

         raise ValueError

Если валидация терпит неудачу, она вызывает исключение ValueError. Это кажется вполне уместным и выглядит по-питоновски. Пока что все идет неплохо.

Вместе с тем в использовании универсального класса-исключения «высокого уровня» типа ValueError есть оборотная сторона. Предположим, что один из ваших коллег вызывает эту функцию как составную часть библиотеки и не очень разбирается в ее внутреннем устройстве. Когда не удается выполнить валидацию имени, отчет об обратной трассировке будет выглядеть примерно так:

>>> validate('джо')

Traceback (most recent call last):

   File "<input>", line 1, in <module>

    validate('джо')

   File "<input>", line 3, in validate

     raise ValueError

ValueError

Этот отчет не очень-то и полезен. Несомненно, мы знаем, что что-то пошло не так и что проблема имела отношение к «неправильному значению» или типа того, но чтобы быть в состоянии исправить эту проблему, ваш коллега почти наверняка должен свериться с реализацией функции validate(). Однако чтение исходного кода стоит времени. И это время будет быстро накапливаться.

К счастью, мы можем сделать и кое-что получше. Введем собственный тип исключений, который будет представлять неудавшуюся валидацию имени. Мы построим наш новый класс-исключение на основе встроенного в Python класса ValueError, но заставим его говорить за себя, дав ему более конкретное имя:

class NameTooShortError(ValueError):

     pass

 

def validate(name):

     if len(name) < 10:

         raise NameTooShortError(name)

Теперь у нас есть «самодокументирующий» тип исключений NameTooShortError («Имя слишком короткое»), который расширяет встроенный класс ValueError. Обычно вы будете делать свои собственные исключения производными от корневого класса Exception либо от других встроенных в Python исключений наподобие ValueError или TypeError — в зависимости от того, что кажется целесообразным.

Кроме того, обратите внимание на то, как мы теперь передаем переменную name в конструктор нашего собственного класса-исключения во время создания его экземпляра внутри validate. Новая реализация приводит к тому, что ваш коллега получит намного более приятный отчет об обрат­ной трассировке:

>>> validate('джейн')

Traceback (most recent call last):

   File "<input>", line 1, in <module>

     validate('джейн')

   File "<input>", line 3, in validate

     raise NameTooShortError(name)

NameTooShortError: джейн

Опять-таки, попытайтесь встать на место своего коллеги по команде. Собственные классы исключений существенно помогают понять, что именно происходит, когда дела идут не так, как надо (а рано или поздно это обязательно случится).

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

Потратив всего 30 секунд на определение простого класса-исключения, вы уже получили намного больший коммуникативный фрагмент кода. Но давайте пойдем дальше. Еще много чего нужно обследовать.

Всякий раз, когда вы выпускаете пакет Python в публичное пространство или создаете модуль многократного использования для своей компании, образцовая практика предусматривает создание для такого модуля собственного базового класса-исключения и затем создание производных от него всех других ваших исключений.

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

class BaseValidationError(ValueError):

     pass

Далее, все наши «реальные» классы ошибок могут быть сделаны производными от базового класса ошибок. В результате мы получаем хорошую и чистую иерархию исключений, приложив лишь незначительные дополнительные усилия:

class NameTooShortError(BaseValidationError):

     pass

class NameTooLongError(BaseValidationError):

     pass

 

class NameTooCuteError(BaseValidationError):

     pass

Например, это позволяет пользователям вашего пакета писать инструкции try-except, которые могут обработать все ошибки, возникающие в результате работы этого пакета, без необходимости отлавливать их вручную:

try:

     validate(name)

except BaseValidationError as err:

     handle_validation_error(err)

Люди по-прежнему могут отлавливать более конкретные виды исключений этим способом, но если они этого не хотят, то, по крайней мере им не придется прибегать к захватыванию всех исключений при помощи всеобъемлющей инструкции except. Обычно такой подход считается антишаблоном проектирования — он может негласно поглотить и скрыть разрозненные ошибки и сделать ваши программы намного труднее для отладки.

Разумеется, вы можете развить эту идею и логически сгруппировать исключения в подробнейшие субиерархии. Но будьте осторожны — можно очень легко внести ненужную сложность, переборщив с этой работой.

Подводя итоги, следует отметить, что определение собственных классов-исключений облегчает принятие вашими пользователями стиля программирования «Легче попросить прощения, чем разрешения» (EAFP), который считается более питоновским.

Ключевые выводы

• Определение ваших собственных типов исключений позволяет яснее сформулировать замысел вашего программного кода и облегчить его отладку.

• Следует делать свои собственные исключения производными от встроенного в Python класса Exception или от более конкретных классов-исключений, таких как ValueError или KeyError.

• Для определения логически сгруппированных иерархий исключений можно использовать наследование.

4.4. Клонирование объектов для дела и веселья

В Python инструкции присваивания не создают копии объектов, они лишь привязывают имена к объекту. Для неизменяемых объектов этот факт обычно не имеет значения.

Но для работы с изменяемыми объектами или коллекциями изменяемых объектов вам, возможно, стоит найти способ создания «реальных копий», или «клонов», этих объектов.

По существу, вам иногда будут требоваться копии, которые можно модифицировать без автоматической модификации оригинала. В этом разделе я кратко представлю то, как копировать, или «клонировать», объекты в Python, и покажу связанные с этим подводные камни.

Начнем с того, что обратимся к копированию встроенных в Python коллекций. Встроенные в Python изменяемые коллекции, такие как списки, словари и множества, могут быть скопированы путем вызова своих фабричных функций с существующей коллекцией в качестве аргумента:

new_list = list(original_list)

new_dict = dict(original_dict)

new_set = set(original_set)

Однако этот метод не будет работать с собственными объектами и, вдобавок ко всему, он создает только мелкие копии. Для составных объектов, таких как списки, словари и множества, между мелким и глубоким копированием имеется важное различие.

Мелкая копия (shallow copy) означает конструирование нового объекта-коллекции и затем его заполнение ссылками на дочерние объекты, найденные в оригинале. В сущности, мелкая копия имеет всего один уровень в глубину. Процесс копирования выполняется нерекурсивно и поэтому не создает копий самих дочерних объектов.

Глубокая копия (deep copy) выполняет процесс копирования рекурсивно. Это означает конструирование сначала нового объекта коллекции, а затем рекурсивное его заполнение копиями дочерних объектов, найденных в оригинале. При копировании объекта таким способом выполняется обход всего дерева объектов целиком, и создается полностью независимый клон исходного объекта и всех его потомков.

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

Создание мелких копий

В приведенном ниже примере мы создадим новый вложенный список и затем мелко его скопируем при помощи фабричной функции list():

>>> xs = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]

>>> ys = list(xs)  # Сделать мелкую копию

Это означает, что список ys теперь будет новым и независимым объектом с тем же самым содержимым, что и список xs. Это можно проверить, проинспектировав оба объекта:

>>> xs

[[1, 2, 3], [4, 5, 6], [7, 8, 9]]

>>> ys

[[1, 2, 3], [4, 5, 6], [7, 8, 9]]

Чтобы подтвердить, что список ys действительно независим от оригинала, давайте разработаем маленький эксперимент. Можно попробовать добавить новый подсписок в оригинал (xs) и затем убедиться, что эта модификация не затронула копию (ys):

>>> xs.append(['новый подсписок'])

>>> xs

[[1, 2, 3], [4, 5, 6], [7, 8, 9], ['новый подсписок']]

>>> ys

[[1, 2, 3], [4, 5, 6], [7, 8, 9]]

Как видите, эффект был ожидаем. С изменением скопированного списка на «поверхностном» уровне никаких проблем не возникло.

Однако поскольку мы создали лишь мелкую копию оригинального списка, список ys по-прежнему содержит ссылки на оригинальные дочерние объекты, хранящиеся в xs.

Эти дочерние элементы не были скопированы. Все свелось к тому, что в скопированном списке на них снова содержатся ссылки.

Поэтому, когда вы модифицируете один из дочерних объектов в списке xs, эта модификация также будет отражена в списке ys — таким образом, оба списка совместно используют одинаковые дочерние объекты. Эта копия представляет собой всего лишь мелкую копию с одним уровнем в глубину:

>>> xs[1][0] = 'X'

>>> xs

[[1, 2, 3], ['X', 5, 6], [7, 8, 9], ['новый подсписок']]

>>> ys

[[1, 2, 3], ['X', 5, 6], [7, 8, 9]]

В примере выше мы (казалось бы) изменили только список xs. Но оказывается, что в индексе 1 списков xs и ys были изменены оба подсписка. Опять-таки, это произошло, потому что мы создали всего-навсего мелкую копию оригинального списка.

Если бы на первом шаге мы создали глубокую копию списка xs, то оба объекта были бы полностью независимы. В этом и заключается практическая разница между мелкими и глубокими копиями объектов.

Теперь вы знаете, как создавать мелкие копии некоторых встроенных классов коллекций, и знаете разницу между мелким и глубоким копированием. Вопросы, ответы на которые мы по-прежнему хотим получить, следующие:

• Как создавать глубокие копии встроенных коллекций?

• Как создавать копии (мелкие и глубокие) произвольных объектов, включая собственные классы?

Ответы на эти вопросы лежат в модуле copy стандартной библиотеки Python. Этот модуль обеспечивает простой интерфейс для создания мелких и глубоких копий произвольных объектов Python.

Создание глубоких копий

Давайте повторим предыдущий пример с копированием списка, но с одним важным различием. В этот раз мы собираемся создать глубокую копию, используя вместо встроенной фабричной функции функцию deepcopy(), определенную в модуле copy:

>>> import copy

>>> xs = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]

>>> zs = copy.deepcopy(xs)

Когда вы проинспектируете список xs и его клон zs, созданный нами с помощью copy.deepcopy(), вы увидите, что они оба снова выглядят идентичными— точно так же, как и в предыдущем примере:

>>> xs

[[1, 2, 3], [4, 5, 6], [7, 8, 9]]

>>> zs

[[1, 2, 3], [4, 5, 6], [7, 8, 9]]

Однако если вы внесете модификацию в один из дочерних объектов в оригинальном объекте (xs), то вы увидите, что эта модификация не затронет глубокую копию (zs).

Оба объекта, оригинал и копия, на этот раз полностью независимы. Список xs был клонирован рекурсивно, включая все его дочерние объекты:

>>> xs[1][0] = 'X'

>>> xs

[[1, 2, 3], ['X', 5, 6], [7, 8, 9]]

>>> zs

[[1, 2, 3], [4, 5, 6], [7, 8, 9]]

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

Между прочим, при помощи функции в модуле copy вы также можете создавать мелкие копии. Функция copy.copy() создает мелкие копии объектов.

Это полезно, если вам нужно четко сообщить, что где-то в своем программном коде вы создаете мелкую копию. Использование copy.copy() позволяет указывать на этот факт. Однако что касается встроенных коллекций, то для создания их мелких копий более питоновским стилем будет считаться использование фабричных функций list, dict и set.

Копирование произвольных объектов

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

И снова на выручку приходит модуль copy. Его функции copy.copy() и copy.deepcopy() могут использоваться для создания дубликата любого объекта.

И снова наилучший способ понять, как их использовать, — поставить простой эксперимент. Я собираюсь взять за основу предыдущий пример с копированием списка. Давайте начнем с определения простого класса двумерной точки:

class Point:

     def __init__(self, x, y):

         self.x = x

         self.y = y

 

     def __repr__(self):

         return f'Point({self.x!r}, {self.y!r})'

Надеюсь, вы согласитесь, что это было довольно прямолинейно. Я добавил реализацию __repr__(), с тем чтобы мы могли легко проинспектировать создаваемые на основе этого класса объекты в интерпретаторе Python.

Далее мы создадим экземпляр Point, а затем его (мелко) скопируем, использовав модуль copy:

>>> a = Point(23, 42)

>>> b = copy.copy(a)

Если проинспектировать содержимое оригинального объекта Point и его (мелкого) клона, то мы увидим то, что и ожидали:

>>> a

Point(23, 42)

>>> b

Point(23, 42)

>>> a is b

False

Следует иметь в виду кое-что еще. Поскольку наш объект-точка для своих координат использует примитивные типы (целые числа), то в данном случае нет никакой разницы между мелкой и глубокой копией. Но я расширю пример секунду спустя.

Теперь перейдем к более сложному примеру. Я собираюсь определить еще один класс, который будет представлять двумерные прямоугольники. Я сделаю это таким образом, который позволяет создавать более сложную иерархию объектов, — мои прямоугольники будут использовать объекты Point, представляющие их координаты:

class Rectangle:

     def __init__(self, topleft, bottomright):

         self.topleft = topleft

         self.bottomright = bottomright

 

     def __repr__(self):

         return (f'Rectangle({self.topleft!r},'

                 f'{self.bottomright!r})')

Сначала мы попытаемся создать мелкую копию экземпляра Rectangle:

rect = Rectangle(Point(0, 1), Point(5, 6))

srect = copy.copy(rect)

Если вы проинспектируете оригинальный прямоугольник и его копию, то увидите, что переопределение метода __repr__() прекрасно сработало и процесс мелкого копирования был выполнен, как мы и ждали:

>>> rect

Rectangle(Point(0, 1), Point(5, 6))

>>> srect

Rectangle(Point(0, 1), Point(5, 6))

>>> rect is srect

False

Помните, как в предыдущем примере со списком иллюстрировалась разница между глубокими и мелкими копиями? Здесь я собираюсь применить тот же самый подход. Я изменю объект, находящийся глубоко в иерархии объектов, и затем вы вновь увидите, как это изменение будет отражено в (мелкой) копии:

>>> rect.topleft.x = 999

>>> rect

Rectangle(Point(999, 1), Point(5, 6))

>>> srect

Rectangle(Point(999, 1), Point(5, 6))

Надеюсь, что этот пример показал то, что вы ожидали. Далее, я создам глубокую копию оригинального прямоугольника. Затем внесу в нее одно изменение, и вы увидите, какие объекты были затронуты:

>>> drect = copy.deepcopy(srect)

>>> drect.topleft.x = 222

>>> drect

Rectangle(Point(222, 1), Point(5, 6))

>>> rect

Rectangle(Point(999, 1), Point(5, 6))

>>> srect

Rectangle(Point(999, 1), Point(5, 6))

Вуаля! На этот раз глубокая копия (drect) полностью независима от оригинала (rect) и мелкой копии (srect).

В этом разделе мы рассмотрели многие вопросы, и при этом остались еще некоторые тонкости, связанные с копированием объектов.

Эта тема стоит того, чтобы в нее углубиться (еще бы!), поэтому, возможно, вам стоит плотнее заняться документацией модуля copy. Например, объекты могут управлять тем, как они копируются, если в них определить специальные методы __copy__() и __deepcopy__(). Приятного времяпрепровождения!

Ключевые выводы

• В результате создания мелкой копии объекта дочерние объекты не клонируются. По этой причине результирующая копия не является полностью независимой от оригинала.

• В процессе глубокого копирования объекта дочерние объекты клонируются рекурсивно. Клон полностью независим от оригинала, но на создание глубокой копии уходит больше времени.

• При помощи модуля copy вы можете копировать произвольные объекты (включая собственные классы).

4.5. Абстрактные базовые классы держат наследование под контролем

Абстрактные классы (АК), иногда также называемые абстрактными базовыми классами, гарантируют, что производные классы реализуют те или иные методы базового класса. В этом разделе вы узнаете о преимуществах абстрактных классов и о том, как их определять при помощи встроенного в Python модуля abc.

Итак, в чем же прелесть абстрактных классов? Не так давно у меня на работе был спор о том, какой шаблон использовать для реализации удобной в сопровождении иерархии классов в Python. Точнее говоря, цель состояла в том, чтобы определить простую иерархию классов для сервисного бэкенда самым благоприятным для программиста и удобным в сопровож­дении способом.

У нас был класс BaseService, который определял общий интерфейс и несколько конкретных реализаций. Конкретные реализации делают разные вещи, но все они обеспечивают тот же самый интерфейс (MockService, RealService и т.д.). Чтобы более четко проявить взаимосвязи, все конкретные реализации были производными от класса BaseService.

Чтобы сделать этот программный код максимально удобным в обслуживании и благоприятным для программиста, мы хотели удостовериться, что

• создание экземпляров базового класса невозможно,

• упущение из виду реализации методов интерфейса в одном из подклассов вызывает ошибку на ранней стадии.

Итак, почему же может возникнуть потребность в использовании модуля Python abc для решения этой задачи? Названная выше конструкция довольно распространена в более сложных системах. Чтобы обеспечить реализацию ряда методов базового класса производным классом, как правило, используется примерно такая идиома Python:

class Base:

     def foo(self):

         raise NotImplementedError()

 

    def bar(self):

         raise NotImplementedError()

 

class Concrete(Base):

     def foo(self):

         return 'вызвана foo()'

     # О нет, мы забыли переопределить bar()...

     # def bar(self):

     #     return "вызвана bar()"

Итак, что же мы получаем из этой первой попытки решения задачи? Вызов методов экземпляра Base правильно вызывает исключения NotImplementedError:

>>> b = Base()

>>> b.foo()

NotImplementedError

Более того, и создание экземпляра, и использование Concrete работают так, как ожидалось. И если вызвать не реализованный в нем метод, такой как bar(), то в результате тоже будет вызвано исключение:

>>> c = Concrete()

>>> c.foo()

'вызвана foo()'

>>> c.bar()

NotImplementedError

Эта первая реализация выглядит неплохо, но пока не идеально. Ее оборотными сторонами является то, что мы по-прежнему можем

• легко создавать экземпляры Base, не получая ошибку, а также

• обеспечивать неполные подклассы — создание экземпляра Concrete не будет вызывать ошибку до тех пор, пока мы не вызовем отсутствующий метод bar().

При помощи модуля Python abc, который был добавлен в Python 2.6, мы можем добиться большего успеха и решить эти оставшиеся проблемы. Вот обновленная реализация с использованием абстрактного класса, определенного в модуле abc:

from abc import ABCMeta, abstractmethod

class Base(metaclass=ABCMeta):

     @abstractmethod

     def foo(self):

         pass

 

     @abstractmethod

     def bar(self):

         pass

 

class Concrete(Base):

     def foo(self):

         pass

     # Мы снова забыли объявить bar()...

Этот фрагмент кода по-прежнему ведет себя так, как нужно, и создает правильную иерархию классов:

assert issubclass(Concrete, Base)

С другой стороны, мы здесь получаем еще одно преимущество. Подклассы Base вызывают исключение TypeError во время создания экземпляра всякий раз, когда мы забываем реализовать какие-либо абстрактные методы. Вызванное исключение говорит о том, какой метод или методы отсутствуют:

>>> c = Concrete()

TypeError:

"Can't instantiate abstract class Concrete with abstract methods bar"

Без модуля abc мы получали бы только исключение NotImplementedError в случае фактического вызова отсутствующего метода. Возможность получать уведомления об отсутствующих методах во время создания экземпляра является большим преимуществом. В результате написание недопустимых подклассов в значительной степени блокируется. Возможно, этот факт не сыграет какой-то особой роли, если вы пишете новый код, но обещаю, что спустя несколько недель или месяцев он станет полезным.

Безусловно, этот шаблон не является полной заменой проверки типов во время компиляции. Однако я обнаружил, что он часто делает иерархии классов более прочными и более удобными в сопровождении. Использование абстрактных классов позволяет программисту четче формулировать свой замысел и таким образом делает код более коммуникативным. Я рекомендую вам почитать документацию по модулю abc и присмотреться к ситуациям, где применение этого шаблона имеет смысл.

Ключевые выводы

• Абстрактные классы (АК) следят за тем, чтобы производные классы реализовывали те или иные методы базового класса во время создания экземпляра.

• Применение АК помогает избежать ошибок и сделать иерархии классов более легкими в сопровождении.

4.6. Чем полезны именованные кортежи

Python поставляется со специализированным контейнерным типом namedtuple, то есть именованным кортежем. И этот тип, по всей видимости, не привлекает того внимания, которое он заслуживает. Именованный кортеж представляет собой одно из тех удивительных функциональных средств языка Python, которое спрятано у всех на виду.

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

Итак, что же такое именованный кортеж и в чем проявляется его исключительность? Именованные кортежи лучше всего представить как расширение встроенного типа данных tuple.

Кортежи Python — это простая структура данных, предназначенная для группирования произвольных объектов. Кроме того, кортежи не могут изменяться — после их создания их нельзя изменять. Ниже приведен короткий пример:

>>> tup = ('привет', object(), 42)

>>> tup

('привет', <object object at 0x105e76b70>, 42)

>>> tup[2]

42

>>> tup[2] = 23

TypeError:

"'tuple' object does not support item assignment"

Оборотной стороной простых кортежей является то, что данные, которые вы в них храните, могут быть извлечены только адресацией посредством целочисленных индексов. Вы не можете назначать имена отдельным свойствам, хранящимся в кортеже. А это может повлиять на удобочитаемость программного кода.

Кроме того, кортеж всегда является вспомогательной структурой. Трудно гарантировать, что у двух кортежей будет одно и то же количество полей и одинаковые хранящиеся в них свойства. В результате появляется возможность беспрепятственно вносить ошибки «по недоразумению», просто перепутав порядок следования полей.

Именованные кортежи спешат на помощь

Именованные кортежи призваны решать эти две проблемы.

В первую очередь именованные кортежи являются неизменяемыми контейнерами, точно так же, как обычные кортежи. Поместив данные в атрибут верхнего уровня в именованном кортеже, вы не сможете его модифицировать путем обновления этого атрибута. Все атрибуты объекта namedtuple подчиняются принципу «однократная запись, многократное чтение».

Помимо этого, контейнеры типа namedtuple являются, скажем так, именованными кортежами (named tuples). Доступ к каждому хранящемуся в них объекту можно получить через уникальный (человекочитаемый) идентификатор. Это свойство освобождает вас от необходимости запоминать целочисленные индексы или обращаться к искусственным приемам, таким как определение целочисленных констант в качестве мнемокодов ваших индексов.

Вот пример того, как выглядит именованный кортеж:

>>> from collections import namedtuple

>>> Car = namedtuple('Авто' , 'цвет пробег')

Именованные кортежи были добавлены в стандартную библиотеку Python версии 2.6. Чтобы ими воспользоваться, необходимо импортировать модуль collections. В приведенном выше примере я определил простой тип данных Car с двумя полями: цвет и пробег.

Вам, возможно, любопытно, почему в этом примере я передаю фабричной функции namedtuple строковое значение 'Авто' в качестве первого аргумента.

В документации Python этот параметр упоминается как «имя типа». Он является именем нового класса, который создается в результате вызова функции namedtuple.

Поскольку функция namedtuple не может знать о том, каким является имя переменной, которой мы назначаем результирующий класс, мы должны сообщить ему явным образом, какое имя класса мы хотим использовать. Имя класса используется в строке документации docstring и в реализации метода __repr__, которые функция namedtuple генерирует для нас автоматически.

В этом примере есть и другая синтаксическая диковинка — почему мы передаем поля в виде строки, в которой их имена закодированы как 'цвет пробег'?

Ответ заключается в том, что фабричная функция namedtuple вызывает функцию split() со строковым значением, содержащим имена полей, которая преобразовывает его в список имен полей. Так что в действительности это просто сокращенная запись для приведенных ниже двух шагов:

>>> 'цвет пробег'.split()

['цвет', 'пробег']

>>> Car = namedtuple('Авто', ['цвет', 'пробег'])

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

>>> Car = namedtuple('Авто', [

...     'цвет',

...     'пробег',

... ])

Что бы вы ни решили, теперь при помощи фабричной функции Car вы можете создавать новые объекты «car». Поведение будет таким же, как если бы вы создали класс Car вручную и определили в нем конструктор, принимающий значения «цвет» и «пробег»:

>>> my_car = Car('красный', 3812.4)

>>> my_car.цвет

'красный'

>>> my_car.пробег

3812.4

Помимо получения доступа к значениям, хранящимся в именованном кортеже, по их идентификаторам, вы по-прежнему можете получать к ним доступ по их индексу. Благодаря этому именованные кортежи могут использоваться в качестве прямой замены обычным кортежам:

>>> my_car[0]

'red'

>>> tuple(my_car)

('красный', 3812.4)

Распаковка кортежа и оператор * для распаковки аргументов функции по-прежнему работают как надо:

>>> color, mileage = my_car

>>> print(color, mileage)

красный 3812.4

>>> print(*my_car)

красный 3812.4

К тому же в качестве бесплатного приложения вы получите приличное строковое представление своего объекта namedtuple, что позволит набирать чуть меньше текста и сэкономит на многословности:

>>> my_car

Авто(цвет='красный' , пробег=3812.4)

Подобно кортежам, именованные кортежи не изменяются. Когда вы попытаетесь переписать одно из их полей, вы получите исключение AttributeError:

>>> my_car.цвет = 'синий'

AttributeError: "can't set attribute"

На внутреннем уровне объекты namedtuple реализованы как обычные классы Python. В том, что касается использования оперативной памяти, они тоже «лучше» обычных классов и так же эффективны с точки зрения потребления оперативной памяти, как и обычные кортежи.

В Python именованные кортежи неплохо рассматривать как эффективную с точки зрения потребления оперативной памяти краткую форму для определения неизменяющегося класса вручную.

Создание производных от Namedtuple подклассов

Поскольку объекты namedtuple строятся поверх обычных классов Python, вы даже можете добавлять в них методы. Например, подобно любому другому классу, вы можете расширить класс namedtuple и таким образом добавить в него методы и новые свойства. Приведем пример:

Car = namedtuple('Авто', 'цвет пробег')

class MyCarWithMethods(Car):

     def hexcolor(self):

         if self.цвет == 'красный':

             return '#ff0000'

         else:

             return '#000000'

Теперь можно создавать объекты MyCarWithMethods и, следовательно, вызывать их метод hexcolor():

>>> c = MyCarWithMethods('красный', 1234)

>>> c.hexcolor()

'#ff0000'

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

Например, в добавлении нового неизменяемого поля (immutable field) есть свои сложности из-за внутренней структуры именованных кортежей. Самый легкий способ создать иерархии именованных кортежей — использовать свойства _fields базового кортежа:

>>> Car = namedtuple('Авто', 'цвет пробег')

>>> ElectricCar = namedtuple(

...     'ЭлектрическоеАвто', Car._fields + ('заряд',))

Это дает желаемый результат:

>>> ElectricCar('красный', 1234, 45.0)

ЭлектрическоеАвто(цвет='красный', пробег=1234, заряд=45.0)

Встроенные вспомогательные методы

Помимо свойства _fields, каждый экземпляр именованного кортежа также предлагает еще несколько вспомогательных методов, которые могли бы быть вам полезны. Все их имена начинаются с одинарного символа подчеркивания (_), который обычно сигнализирует о том, что метод или свойство являются «приватными» и не являются частью стабильного публичного интерфейса класса или модуля.

Правда, в случае с именованными кортежами согласованное правило именования с символом подчеркивания несет в себе другой смысл. Эти вспомогательные методы и свойства являются составной частью публичного интерфейса класса namedtuple. Вспомогательные методы получают такие имена, чтобы избежать конфликтов имен с определяемыми пользователями полями кортежей. Так что можете спокойно ими пользоваться, если они вам нужны!

Хочу показать вам несколько сценариев, где могли бы пригодиться вспомогательные методы именованного кортежа. Давайте начнем со вспомогательного метода _asdict(). Он возвращает содержимое именованного кортежа в виде словаря:

>>> my_car._asdict()

OrderedDict([('цвет', 'красный'), ('пробег', 3812.4)])

Этот метод очень полезен для предотвращения опечаток в именах полей во время генерирования результата в формате JSON, например:

>>> json.dumps(my_car._asdict(), ensure_ascii=False)

# False для кириллицы

'{"цвет": "красный", "пробег": 3812.4}'

Метод _replace() — это еще один полезный вспомогательный метод. Он создает (мелкую) копию кортежа и позволяет вам выборочно заменять некоторые его поля:

>>> my_car._replace(цвет='синий')

Авто(цвет='синий', пробег=3812.4)

Наконец, метод класса _make() может использоваться для создания новых экземпляров класса namedtuple из (итерируемой) последовательности:

>>> Car._make(['красный', 999])

Авто(color='красный', пробег=999)

Когда использовать именованные кортежи

Именованные кортежи могут оказаться простым средством для приведения в порядок исходного кода, и они могут сделать код более удобочитаемым, обеспечив вашим данным наиболее совершенную структуру.

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

Использование именованных кортежей поверх неструктурированных, а также использование словарей может облегчить жизнь моих коллег, потому что именованные кортежи позволяют раздавать данные в «самодокументированном» виде (в известной степени).

С другой стороны, я стараюсь не использовать именованные кортежи ради них самих, если они не помогают мне писать «более чистый» и более удобный в сопровождении исходный код. Как и в отношении многих других методов, приводимых в настоящей книге, иногда может оказаться слишком много хорошего (что, как известно, тоже плохо).

Тем не менее если именованные кортежи использовать с осторожностью, они, несомненно, могут сделать ваш программный код Python лучше и выразительнее.

Ключевые выводы

• В языке Python collection.namedtuple является эффективной с точки зрения потребляемой оперативной памяти краткой формой для определения неизменяющегося класса вручную.

• Именованные кортежи помогут почистить ваш исходный код, обес­печив вашим данным более доступную для понимания структуру.

• Именованные кортежи обеспечивают несколько полезных вспомогательных методов, которые начинаются с одинарного символа подчеркивания, но при этом являются составной частью публичного интерфейса. Вполне нормально их использовать.

4.7. Переменные класса против переменных экземпляра: подводные камни

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

Это различие имеет большое значение. Мне, как начинающему разработчику на Python, оно также доставляло немало хлопот. В течение длительного времени я не мог найти время, чтобы разобраться в этих понятиях с самых азов. И поэтому мои первые эксперименты с ООП были пронизаны удивительными линиями поведения и странными ошибками. В этом разделе мы устраним путаницу относительно этой темы при помощи нескольких практических примеров.

Как я уже сказал, в объектах Python объявляются два вида атрибутов данных: переменные класса (class variables) и переменные экземпляра (instance variables).

Переменные класса объявляются внутри определения класса (но за ­пределами любых методов экземпляра). Они не привязаны ни к одному конкретному экземпляру класса. Вместо этого переменные класса хранят свое содержимое в самом классе, и все объекты, созданные на основе того или иного класса, предоставляют общий доступ к одинаковому набору переменных класса. Например, это означает, что модификация переменной класса одновременно затрагивает все экземпляры объекта.

Переменные экземпляра всегда привязаны к конкретному экземпляру объекта. Их содержимое хранится не в классе, а в каждом отдельном объекте, созданном на основе класса. По этой причине содержимое переменной экземпляра абсолютно независимо от одного экземпляра объекта к другому. И поэтому модификация переменной экземпляра одновременно затрагивает только один экземпляр объекта.

Ладно, все это было довольно абстрактно — самое время рассмотреть немного исходного кода! Давайте потренируемся на собачках… В обу­чающих пособиях, посвященных ООП, для иллюстрации этого тезиса всегда используются автомобили или домашние животные, и мне сложно отказаться от этой традиции.

Что собаке для счастья нужно? Правильно! Четыре лапы да имя:

class Dog:

     num_legs = 4  # <- Переменная класса

 

     def __init__(self, name):

         self.name = name  # <- Переменная экземпляра

О’кей. У нас есть изящное объектно-ориентированное представление ситуации с собакой, которую я только что описал. Создание новых экземпляров Dog работает, как и ожидалось, и каждый из них получает переменную экземпляра с именем name:

>>> jack = Dog('Джек')

>>> jill = Dog('Джилл')

>>> jack.name, jill.name

('Джек', 'Джилл')

Во всем, что касается переменных класса, всегда есть чуть больше гибкости. Доступ к переменной класса num_legs можно получить либо непосредственно в каждом экземпляре Dog, либо в самом классе:

>>> jack.num_legs, jill.num_legs

(4, 4)

>>> Dog.num_legs

4

Однако попытка получить доступ к переменной экземпляра через класс потерпит неудачу с исключением AttributeError. Переменные экземпляра характерны для каждого экземпляра объекта и создаются, когда выполняется конструктор __init__ — они даже не существуют в самом классе.

В этом заключается ключевое различие между переменными класса и переменными экземпляра:

>>> Dog.name

AttributeError:

"type object 'Dog' has no attribute 'name'"

Ладно, пока все идет неплохо.

Допустим, в один прекрасный день пес по кличке Джек поедал свой ужин, расположившись слишком близко от микроволновки, в результате у него выросла дополнительная пара лап. Как бы вы представили этот факт в небольшой «песочнице» с исходным кодом, которая у нас сейчас есть?

Первая идея — просто модифицировать переменную num_legs в классе Dog:

>>> Dog.num_legs = 6

Но помните, мы не хотим, чтобы все собаки стали носиться вокруг о шести лапах. Итак, сейчас мы только что превратили каждую собаку в нашей микровселенной в сверхсобаку, потому что мы модифицировали переменную класса. Это затрагивает всех собак, даже тех, которые были созданы ранее:

>>> jack.num_legs, jill.num_legs

(6, 6)

Этот вариант не сработал. А не сработал он потому, что модификация переменной класса в пространстве имен класса затрагивает все экземпляры класса. Давайте отыграем это изменение в переменной класса назад и вместо этого попробуем дать дополнительную пару лап только конкретному псу Джеку:

>>> Dog.num_legs = 4

>>> jack.num_legs = 6

Так, и что за чудовище мы получили? Сейчас выясним:

>>> jack.num_legs, jill.num_legs, Dog.num_legs

(6, 4, 4)

Ладно. Выглядит «довольно неплохо» (ну, кроме того, конечно, что мы прямо сейчас дали бедному псу несколько лишних лап). Но как это изменение на самом деле повлияло на наши объекты Dog?

А проблема, как выясняется, здесь в следующем: несмотря на то что мы получили желаемый результат (лишние лапы для Джека), мы внесли переменную экземпляра num_legs в экземпляр с псом по кличке Джек. И теперь новая переменная экземпляра num_legs «оттеняет» переменную класса с тем же самым именем, переопределяя и скрывая ее, когда мы обращаемся к области действия экземпляра:

>>> jack.num_legs, jack.__class__.num_legs

(6, 4)

Как вы видите, переменные класса, казалось бы, стали несогласованными. Это произошло потому, что внесение изменения в jack.num_legs создало переменную экземпляра с тем же самым именем, что и у переменной класса.

Это не всегда плохо, но важно понимать, что именно здесь произошло. Прежде чем я наконец-то разобрался в области действия уровня класса и уровня экземпляра в Python, это было широкими воротами, через которые в мои программы то и дело закрадывались ошибки.

Сказать по правде, попытка модифицировать переменную класса через экземпляр объекта, который затем непредумышленно создает переменную экземпляра с тем же именем, затеняя оригинальную переменную класса, является в Python чем-то вроде подводного камня ООП.

Пример без собак

Хотя в процессе написания этого раздела книги ни одна собака не пострадала (это все шутки и игры до тех пор, пока кто-то не вырастит себе лишнюю пару лап), я хочу дать вам еще один практический пример полезных штук, которые вы можете сделать с переменными класса. То, что будет немного ближе к реальным приложениям с переменными класса.

Итак, вот этот пример. Приведенный ниже класс CountedObject отслеживает, сколько раз он использовался для создания экземпляров на протяжении жизни программы (что на деле может обеспечить интересный метрический показатель производительности):

class CountedObject:

     num_instances = 0

 

     def __init__(self):

         self.__class__.num_instances += 1

Класс CountedObject содержит переменную класса num_instances, которая служит в качестве общего счетчика. Когда класс объявлен, он инициализирует счетчик нулем, а затем оставляет его в покое.

Всякий раз, когда вы создаете новый экземпляр этого класса, он увеличивает общий счетчик на единицу во время выполнения конструктора __init__:

>>> CountedObject.num_instances

0

>>> CountedObject().num_instances

1

>>> CountedObject().num_instances

2

>>> CountedObject().num_instances

3

>>> CountedObject.num_instances

3

Обратите внимание, как этот фрагмент кода должен проскакивать через небольшой обруч, чтобы обеспечить увеличение переменной счетчика в классе. Легко можно было бы сделать ошибку, если бы я написал конструктор следующим образом:

# ПРЕДУПРЕЖДЕНИЕ: Эта реализация содержит ошибку

 

class BuggyCountedObject:

     num_instances = 0

 

    def __init__(self):

         self.num_instances += 1  # !!!

Как вы увидите, эта (плохая) реализация никогда не будет увеличивать общую переменную счетчика:

>>> BuggyCountedObject.num_instances

0

>>> BuggyCountedObject().num_instances

1

>>> BuggyCountedObject().num_instances

1

>>> BuggyCountedObject().num_instances

1

>>> BuggyCountedObject.num_instances

0

Уверен, что вы увидели, где я допустил промах. Эта (ошибочная) реализация не увеличивает общий счетчик, потому что я сделал ошибку, которую объяснил в предыдущем примере с псом Джеком. Эта реализация не будет работать, потому что я непредумышленно «затенил» переменную класса num_instance, создав в конструкторе переменную экземпляра с тем же именем.

Она правильно вычисляет новое значение счетчика (перейдя от 0 к 1), но затем сохраняет результат в переменной экземпляра, а это означает, что другие экземпляры класса никогда не увидят обновленное значение счетчика.

Как вы видите, допустить эту ошибку очень легко. Во время работы с разделяемым состоянием в классе следует быть осторожным и перепроверять области действия. Автоматизированные тесты и контроль качества работы со стороны коллег существенно помогают в этом.

Однако надеюсь, что вы видите, почему и как переменные класса (несмотря на их подводные камни) могут оказаться полезными инструментами на практике. Удачи!

Ключевые выводы

• Переменные класса предназначены для данных, совместно используемых всеми экземплярами класса. Они принадлежат именно классу, а не конкретному экземпляру и являются общими для всех экземпляров класса.

• Переменные экземпляра предназначены для данных, которые уникальны для каждого экземпляра. Они принадлежат отдельным экземплярам объекта и не являются общими для других экземпляров класса. Каждая переменная экземпляра получает уникальное резервное хранилище, характерное для данного экземпляра.

• Поскольку переменные класса могут быть «затенены» переменными экземпляра, имеющими одинаковое имя, можно легко (непреднамеренно) переопределить переменные класса, в результате чего будут внесены ошибки и создано странное поведение.

4.8. Срыв покровов с методов экземпляра, методов класса и статических методов

В этой главе вы увидите, что именно в Python стоит за методами класса (class methods), статическими методами (static methods) и обычными методами экземпляра (instance methods).

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

Давайте начнем с написания класса (Python 3), который содержит простые примеры всех трех типов методов:

class MyClass:

    def method(self):

        return 'вызван метод экземпляра', self

 

    @classmethod

    def classmethod(cls):

         return 'вызван метод класса', cls

 

    @staticmethod

    def staticmethod():

         return 'вызван статический метод'

Примечание для пользователей Python 2: декораторы @staticmethod и @classmethod доступны, начиная с Python 2.4, и поэтому данный пример будет работать как есть. Вместо того чтобы использовать простое объявление class MyClass, вы можете объявить класс в новом стиле, с наследованием от object с помощью синтаксической конструкции MyClass(object). Но в остальном все в шоколаде!

Методы экземпляра

Первый метод в MyClass с именем method является обычным методом экземпляра. Это базовый, без наворотов, тип метода, который вы будете использовать большую часть времени. Вы видите, что этот метод принимает один параметр, self, который указывает на экземпляр класса MyClass во время вызова этого метода. Но, разумеется, методы экземпляра могут принимать более одного параметра.

Через параметр self методы экземпляра могут свободно получать доступ к атрибутам и другим методам в том же самом объекте. Это придает им большую мощь в том, что касается модификации состояния объекта.

Методы экземпляра могут не только модифицировать состояние объекта, но и получать доступ к самому классу через атрибут self.__class__. Это означает, что методы экземпляра также могут модифицировать состояние класса.

Методы класса

Давайте сравним это со вторым методом, MyClass.classmethod. Я пометил этот метод декоратором @classmethod, чтобы обозначить его как метод класса.

Вместо параметра self методы класса принимают параметр cls, который указывает на класс, а не на экземпляр объекта во время вызова этого метода.

Поскольку метод класса имеет доступ только к этому аргументу cls, он не может менять состояние экземпляра объекта. Для этого потребовался бы доступ к параметру self. Однако методы класса по-прежнему могут модифицировать состояние класса, которое применимо во всех экземплярах класса.

Статические методы

Третий метод, MyClass.staticmethod, был помечен декоратором @sta­ticmethod, чтобы обозначить его как статический метод.

Этот тип метода не принимает ни параметр self, ни параметр cls, хотя, конечно же, он может быть сделан так, чтобы принимать произвольное количество других параметров.

Как результат, статический метод не может модифицировать состояние объекта или состояние класса. Статические методы ограничены теми данными, к которым они могут получить доступ, — они, прежде всего, являются средством организации пространства имен ваших методов.

Посмотрим на них в действии!

Я знаю, что пояснения были весьма теоретизированными до этого места. Более того, полагаю, что важно на практике развить интуитивное понимание того, как эти типы методов различаются. Именно поэтому теперь мы пробежимся по нескольким примерам.

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

Класс MyClass был создан так, чтобы реализация каждого метода возвращала кортеж, содержащий информацию, которую мы можем использовать, чтобы проследить, что происходит и к каким частям класса или объекта этот метод может получить доступ.

Вот что происходит, когда мы вызываем метод экземпляра:

>>> obj = MyClass()

>>> obj.method()

('вызван метод экземпляра', <MyClass instance at 0x11a2>)

Этот результат подтверждает, что в данном случае метод экземпляра с именем method имеет доступ к экземпляру объекта (напечатанному как <MyClass instance>) через аргумент self.

Во время вызова этого метода Python заменяет аргумент self на объект экземпляра, obj. Чтобы получить тот же самый результат, мы можем проигнорировать синтаксический сахар, предоставляемый синтаксической конструкцией вызова с точкой, obj.method(), и передать объект экземпляра вручную:

>>> MyClass.method(obj)

('вызван метод экземпляра', <MyClass instance at 0x11a2>)

Между прочим, методы экземпляра могут также получать доступ непосредственно к самому классу через атрибут self.__class__. Это делает методы экземпляра мощными с точки зрения ограничений доступа — они могут свободно модифицировать состояние в экземпляре объекта и в самом классе.

Теперь давайте испытаем метод класса:

>>> obj.classmethod()

('вызван метод класса', <class MyClass at 0x11a2>)

Вызов метода classmethod() показал, что у него нет доступа к объекту <MyClass instance>, а есть только к объекту <class MyClass>, представляющему сам класс (в Python все является объектом, даже сами классы).

Обратите внимание на то, как Python автоматически передает класс в качестве первого аргумента в функцию, когда мы вызываем метод MyClass.classmethod(). В Python такое поведение запускается вызовом метода через точечный синтаксис (dot syntax). Параметр self в методах экземп­ляра работает таким же образом.

Также обратите внимание на то, что обозначение этих параметров как self и cls является всего-навсего согласованным правилом именования. С тем же успехом вы можете назвать их the_object и the_class и получить тот же самый результат. Важно лишь то, что в списке параметров для этого конкретного метода они располагаются первыми.

Теперь самое время вызвать статический метод:

>>> obj.staticmethod()

'вызван статический метод'

Заметили, как мы вызвали метод staticmethod() объекта и смогли сделать это успешно? Некоторые разработчики удивляются, когда узнают, что статический метод можно вызывать на экземпляре объекта.

За кадром, когда статический метод вызывается с использованием точечного синтаксиса, Python просто накладывает ограничения доступа, не передавая аргумент self или cls.

Этим подтверждается, что статические методы не могут получить доступ ни к состоянию экземпляра объекта, ни к состоянию класса. Они работают как обычные функции, но при этом они принадлежат пространству имен класса (и каждого экземпляра).

Теперь давайте посмотрим, что произойдет при попытке вызвать эти методы на самом классе, не создавая экземпляр объекта заранее:

>>> MyClass.classmethod()

('вызван метод класса', <class MyClass at 0x11a2>)

 

>>> MyClass.staticmethod()

'вызван статический метод'

 

>>> MyClass.method()

TypeError: """unbound method method() must

     be called with MyClass instance as first

     argument (got nothing instead)"""

Мы нормально смогли вызвать classmethod() и staticmethod(), а вот попытка вызвать метод экземпляра method() не удалась с исключением TypeError.

Такого результата следовало ожидать. На этот раз мы не создали экземп­ляр объекта и попытались вызвать функцию экземпляра непосредственно на самом шаблоне класса. Иными словами, в Python нет способа заполнить аргумент self, и поэтому данный вызов терпит неудачу с исключением TypeError.

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

В своих примерах я буду исходить из этого элементарного класса Pizza:

class Pizza:

     def __init__(self, ingredients):

         self.ingredients = ingredients

 

    def __repr__(self):

         return f'Pizza({self.ingredients!r})'

 

>>> Pizza(['сыр', 'помидоры'])

Pizza(['сыр', 'помидоры'])

Фабрики аппетитной пиццы с @classmethod

Если вы сталкивались с пиццей в реальном мире, то вы знаете, что существует много видов аппетитной пиццы:

Pizza(['моцарелла', 'помидоры'])

Pizza(['моцарелла', 'помидоры', 'ветчина', 'грибы'])

Pizza(['моцарелла'] * 4)

Итальянцы придумали свою классификацию пицц несколько веков назад, и поэтому все эти типы восхитительных пицц имеют свои собственные имена. Будет хорошо, если мы этим воспользуемся и дадим пользователям нашего класса Pizza более оптимальный интерфейс для создания объектов-пицц, которые они хотят.

Хороший и очевидный способ это сделать — использовать методы класса в качестве фабричных функций для различных видов пицц, которые мы можем создать:

class Pizza:

     def __init__(self, ingredients):

         self.ingredients = ingredients

    def __repr__(self):

         return f'Pizza({self.ingredients!r})'

 

    @classmethod

     def margherita(cls):

         return cls(['моцарелла', 'помидоры'])

 

    @classmethod

     def prosciutto(cls):

         return cls(['моцарелла', 'помидоры', 'ветчина'])

Обратите внимание на то, как я использую аргумент cls в фабричных методах margherita и prosciutto вместо вызова конструктора Pizza непосредственно.

Вы можете использовать эту идиому, чтобы следовать принципу «Не повторяйся» (DRY). Если в какой-то момент мы решим этот класс переименовать, нам не нужно будет помнить об обновлении имени конструктора во всех фабричных функциях.

Итак, что же мы можем сделать с этими фабричными методами? Давайте их испытаем:

>>> Pizza.margherita()

Pizza(['моцарелла', 'помидоры'])

 

>>> Pizza.prosciutto()

Pizza(['моцарелла', 'помидоры', 'ветчина'])

Как видите, фабричные функции можно использовать для создания новых объектов Pizza, которые сконфигурированы именно так, как мы хотим. Внутри они все используют одинаковый конструктор __init__ и просто обеспечивают краткую форму для запоминания самых разнообразных ингредиентов.

Еще один способ взглянуть на это использование методов класса — понять, что они позволяют определять для своих классов альтернативные конструкторы.

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

Когда использовать статические методы

Здесь уже сложнее найти хороший пример, и знаете что? Я просто продолжу растягивать аналогию пиццы, делая ее все тоньше и тоньше… (ам!)

И вот что я придумал:

import math

     class Pizza:

         def __init__(self, radius, ingredients):

             self.radius = radius

             self.ingredients = ingredients

 

        def __repr__(self):

             return (f'Pizza({self.radius!r},'

                     f'{self.ingredients!r})')

 

        def area(self):

             return self.circle_area(self.radius)

 

        @staticmethod

         def circle_area(r):

             return r ** 2 * math.pi

Итак, что же я тут поменял? Прежде всего, я изменил конструктор и метод __repr__, и теперь они принимают дополнительный аргумент radius.

Я также добавил метод экземпляра area(), который вычисляет и возвращает площадь пиццы. Это также будет подходящей кандидатурой для @property... но постойте, это же просто игрушечный пример.

Вместо того чтобы вычислять площадь непосредственно внутри метода area() при помощи общеизвестной формулы площади круга, я вынес это вычисление в отдельный статический метод circle_area().

Давайте его испытаем!

>>> p = Pizza(4, ['mozzarella', 'tomatoes'])

>>> p

Pizza(4, {self.ingredients})

>>> p.area()

50.26548245743669

>>> Pizza.circle_area(4)

50.26548245743669

Несомненно, этот пример по-прежнему довольно упрощенный, но он поможет объяснить некоторые преимущества, предоставляемые статическими методами.

Как мы узнали, статические методы не могут получать доступ к состоянию класса или экземпляра, потому что они не принимают аргумент cls или self. Этот факт является большим ограничением — но он также является замечательным сигналом, который обозначает, что тот или иной метод независим от всего остального вокруг него.

Из примера выше совершенно ясно, что circle_area() никак не может модифицировать класс или экземпляр класса. (Разумеется, это ограничение всегда можно обойти при помощи глобальной переменной, но это уже к делу не относится.)

Итак, почему же это полезно?

Обозначение метода как статического не просто подсказка, что этот метод не сможет модифицировать состояние экземпляра или класса. Но, как вы убедились, это ограничение также подкрепляется во время выполнения программы Python.

Такие приемы дают четкое представление о составных частях вашей архитектуры классов для того, чтобы процесс новой разработки естественным образом направлялся в пределах этих границ. Безусловно, эти ограничения достаточно легко нарушить. Но на практике они нередко помогают избежать непреднамеренных модификаций, которые идут вразрез с первоначальным проектом.

Другими словами, использование статических методов и методов класса способствует передаче замысла разработчика, при этом достаточно подкрепляя этот замысел, чтобы избежать большинства ошибок «по недора­зумению» и ошибок, которые разрушили бы проект.

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

Статические методы также обладают преимуществами в том, что касается написания тестового программного кода. Поскольку метод circle_area() абсолютно независим от остальной части класса, его намного легче протестировать.

Нам не придется переживать по поводу настройки полного экземпляра класса перед тем, как мы сможем протестировать этот метод в модульном тесте. Мы просто можем действовать подобно тому, как мы действовали бы при тестировании обычной функции. И опять-таки, это облегчает сопровождение кода в будущем и обеспечивает связь между объектно-ориентированным и процедурным стилями программиро­вания.

Ключевые выводы

• Методы экземпляра нуждаются в экземпляре класса и могут получать доступ к экземпляру через параметр self.

• Методы класса не нуждаются в экземпляре класса. Они не могут получать доступ к экземпляру (self), но у них есть доступ непосредственно к самому классу через cls.

• Статические методы не имеют доступа ни к cls, ни к self. Они работают как обычные функции, но принадлежат пространству имен класса.

• Статические методы и методы класса сообщают и (до известной степени) подкрепляют замысел разработчика в отношении конструкции класса. Это может обладать определенными преимуществами в сопровождении кода.

См. документацию Python «Модель данных Python»:

См. документацию Python 2 «Модель данных»:

См. документацию Python «Операции мелкого и глубокого копирования»:

См. документацию Python «Модуль abc»:

См. документацию Python «@classmethod»:

См. документацию Python «@staticmethod»:

См. Википедию: «Фабрика (объектно-ориентированное программирование)»: ) и )

Назад: 3. Эффективные функции
Дальше: 5. Общие структуры данных Python