Skip to content

21

Содержание статьи

В этом году анонсирован последний выпуск Python 2.7, после чего Python Software Foundation перестанет поддерживать ветку 2.7. Множество популярных библиотек и фреймворков тоже прекращают официальную поддержку Python 2, а целый ряд дистрибутивов Linux уже не включают его в набор пакетов по умолчанию.

Конечно, Python 2 не исчез из реальности, как только питоночасы пробили 00:00, но он уже стал темным прошлым, а не суровым настоящим. Начинающие могут смело знакомиться с Python 3 без оглядки на 2.7, что сильно упрощает жизнь.

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

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

Иерархия классов и object

Начнем с самого простого случая — с классов, у которых нет явно указанного предка.

В Python 3 у любого пользовательского класса есть как минимум один базовый класс. В корне иерархии классов находится встроенный класс object — предок всех классов.

В учебных материалах и коде часто можно видеть такую конструкцию:

class MyClass(object):
    pass

В Python 3 она избыточна, поскольку object — базовый класс по умолчанию. Можно смело писать так:

class MyClass:
    pass

Популярность явного синтаксиса в коде на Python 3 связана с существовавшей долгое время необходимостью поддерживать обе ветки.

В Python 2.7 синтаксис MyClass(object) был нужен, чтобы отличать «новые» классы от режима совместимости с доисторическими версиями. В Python 3 никакого режима совместимости с наследием старых версий просто не существует, поэтому наконец можно вернуться к более короткому старому синтаксису.

Что особенного в классе object?

  1. У него самого нет базового класса.
  2. У объектов этого класса не только нет атрибутов и методов — нет даже возможности их присвоить.
>>> o = object()
>>> o.my_attribute = None
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'object' object has no attribute 'my_attribute'

«Техническая» причина этому — отсутствие у object поля __dict__, в котором хранятся все поля и методы класса.

Инкапсуляция и «частные» атрибуты

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

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

>>> class MyClass:
...   def my_method(self):
...     print("I'm a method")
... 
>>> o = MyClass()
>>> o.my_method = None
>>> o.my_method()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'NoneType' object is not callable

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

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

Для этого в Python есть один встроенный механизм. Те поля и методы, которые не входят в публичный интерфейс, называют с подчеркиванием перед именем: _foo, _bar. Никакого влияния на работу кода это не оказывает, это просто просьба не использовать такие поля бездумно.

Для создания частных (private) атрибутов применяются два подчеркивания (__foo, __bar). Такие поля будут видны изнутри объекта под своими исходными именами, но вне объекта к ним применяется name mangling — переименования в стиле _MyClass__my_attribute:

class MyClass:
    x = 0
    _x = 1
    __x = 2
    def print_x(self):
        print(self.__x)

>>> o = MyClass()
>>> o.print_x()
2
>>> o.x
0
>>> o._x
1
>>> o.__x
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'MyClass' object has no attribute '__x'
>>> o._MyClass__x
2

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

INFO

Переименование не применяется к полям и методам с подчеркиванием с двух сторон вроде __init__. По соглашению такие имена дают «магическим методам», на которых построены все внутренние интерфейсы стандартной библиотеки Python: к примеру, o = MyClass() — это эквивалент o = MyClass.__new__(MyClass). Такие методы, очевидно, должны быть доступны извне объекта под исходными именами.

Сэкономить время на создание публичного интерфейса к частным полям можно с помощью встроенного декоратора @property. Он создает поле, которое выглядит как переменная только для чтения — попытка присвоить значение вызовет исключение AttributeError. Для примера создадим класс с логическим значением _boolean_property, которое можно поменять только методом set_property, отклоняющим значения всех типов, кроме bool.

class MyClass:
    def __init__(self):
        self._boolean_property = True
    @property
    def my_property(self):
        return self._boolean_property
    def set_property(self, value):
        if type(value) == bool:
            self._boolean_property = value
        else:
            raise ValueError("Property value must be a bool")

>>> o = MyClass()
>>> o.my_property
True
>>> o.my_property = False
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: cant set attribute

>>> o.set_property(9)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 11, in set_property
ValueError: Property value must be a bool

>>> o.set_property(False)
>>> o.my_property
False

Функцию set_property можно было завернуть в декоратор @my_property.setter, в этом случае присваивание o.my_property = False работало бы, а o.my_property = 9 вызывало ValueError.

Проверка соответствия типов

В предыдущем примере мы явно проверяем, что тип значения — bool, с помощью type(value) == bool. В случае с bool это оправданно, поскольку никакой другой тип не может его заменить в логических выражениях, а наследоваться от него Python нам не даст.

В общем случае проверять type() — плохая идея, поскольку не учитывает ни возможность наследования, ни возможность создать свои классы с совместимым интерфейсом.

Для проверки совместимости с неким базовым классом нужно использовать isinstance(object, class). Эта проверка истинна для класса class и всех его потомков.

>>> class Foo:
...   pass
... 
>>> class Bar(Foo):
...   pass
... 
>>> x = Foo()
>>> y = Bar()
>>> isinstance(x, Foo)
True
>>> isinstance(y, Foo)
True
>>> type(y) == Foo
False

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

А вот в библиотеке для широкого круга пользователей даже isinstance() может быть слишком жестким ограничением. Почти все встроенные возможности Python основаны на соглашениях о «магических методах», включая арифметические операторы, итерацию, контексты (with ... as ...) и многое другое. Сторонний разработчик вполне может создать свой контейнер, который можно использовать наравне со встроенными list и dict, или, к примеру, запускать и завершать работу демонов через интерфейс контекста.

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

О регистре букв и слове self

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

В стандартной библиотеке Python и сообществе его пользователей повелось, что названия классов начинаются с заглавной буквы, а ссылку на сам объект именуют self. Однако ни то, ни другое не является элементом синтаксиса языка, в отличие от this в Java.

Эти два определения класса эквивалентны:

class MyClass:
    def __init__(self, x):
        self.x = x
class myclass:
    def __init__(this, x):
        this.x = x

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

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

Этим метод объекта и отличается от просто функции — при вызове ему неявно передается ссылка на какой-то объект в качестве первого аргумента. На какой именно объект? По умолчанию — на экземпляр, которому принадлежит метод. С помощью декораторов @classmethod и @staticmethod это поведение можно изменить.

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

Посмотрим на пример. Обрати внимание: x — поле класса, а не объектов-экземпляров.

class MyClass:
    x = 10
    @staticmethod
    def foo():
        print(x)

    @classmethod
    def bar(self):
        print(self.x)

    def quux(self):
        print(self.x)

>>> o = MyClass()
>>> o.x = 30
>>> o.foo()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 5, in foo
NameError: name 'x' is not defined
>>> o.bar()
10
>>> o.quux()
30

С помощью o.x = 30 мы меняем значение поля x в объекте o. Как видишь, метод класса bar выводит исходное значение, потому что self для него — это сам класс MyClass. Для quux переменная self содержит ссылку именно на экземпляр o, поэтому метод выводит поле самого объекта.

Метод foo вовсе не работает, если во внешней области видимости нет переменной x. Если есть, то выводит ее. С помощью @staticmethod можно создать только такие методы, которым доступ к полям класса не нужен вообще, хотя зачем это может понадобиться в языке с модулями и пространствами имен — вопрос сложный.

Изменяемость полей класса

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

class MyClass:
    def __init__(self):
        self.x = 10
        self.hello = "hello world"

Почему так? Дело в том, что классы сами относятся к объектам класса type и их поля так же изменяемы, как поля всех остальных объектов.

INFO

Теперь ты знаешь, как проверить, что пользователь передал именно класс в твою абстрактную фабрику абстрактных фабрик или еще какую-то функцию для создания объектов произвольного класса. Совершенно верно, isinstance(c, type).

В момент создания объекты содержат ссылки на все поля и методы своего класса. Если кто-то случайно модифицирует поле самого класса (в стиле MyClass.x = y), это автоматически изменит поле x для всех уже созданных объектов этого класса.

class MyClass:
    x = 10

o = MyClass()

>>> o.x
10
>>> MyClass.x = 70
>>> o.x
70

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

>>> o.x = 80
>>> MyClass.x = 100
>>> o.x
80

Если инициализировать переменные в __init__, это будет происходить каждый раз, когда кто-то создает новый объект, и тогда эти переменные будут действительно локальными для конкретного объекта (instance variables).

Множественное наследование

Получить прямой доступ к информации о предках класса можно с помощью поля __bases__. В рабочем коде лучше так не делать, а обратиться к isinstance(), но ради интереса можно попробовать.

>>> import collections
>>> collections.OrderedDict.__bases__
(<class 'dict'>,)

>>> dict.__bases__
(<class 'object'>,)

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

Тем не менее возможность есть и порядок поиска методов (method lookup) достаточно логичный: от первого указанного предка к последнему.

Для примера создадим два базовых класса и двух потомков, которые наследуются от них в разном порядке:

class Foo:
    def hello(self):
        print("hello world")

class Bar:
    def hello(self):
        print("hi world")

class Baz(Foo, Bar):
    pass

class Quux(Bar, Foo):
    pass

x = Baz()
y = Quux()

>>> x.hello()
hello world
>>> y.hello()
hi world

>> Baz.__bases__
(<class '__main__.Foo'>, <class '__main__.Bar'>)
>>> Quux.__bases__
(<class '__main__.Bar'>, <class '__main__.Foo'>)

То же самое относится к вызовам методов предка через super() — вызов отправится к первому предку в списке:

class Xyzzy(Foo, Bar):
    def hello(self):
        super().hello()
print("This is what my parent does")

x = Xyzzy()

>>> x.hello()
hello world
This is what my parent does

Заключение

Даже небольшие приложения будет проще поддерживать и расширять, если уделить внимание взаимоотношениям между кодом и данными. Теперь ты знаешь детали поведения объектов — надеюсь, это тебе поможет.

Back to top