В данной главе мы продолжим разбирать ООП в Pythоn, подробно рассмотрим механизм наследования. Также затронем тему исключений, как одного из способов управления поведением программы.
Быстрый поиск
Наследование
Наследование — одна из самых естественных и понятных частей ООП, без которой моделирование прикладной области практически не имеет смысла.
Наследование объясняется очень просто: на основе структуры одного класса строится другой, имеющий те же поля и методы, что и базовый, за исключением явно модифицированных или добавленных атрибутов.
>>> class A:
... a = 100500
...
>>> class B(A):
... b = 42
...
>>> dir(B)
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__firstlineno__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__static_attributes__', '__str__', '__subclasshook__', '__weakref__', 'a', 'b']
>>> dir(A)
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__firstlineno__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__','__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__static_attributes__', '__str__', '__subclasshook__', '__weakref__', 'a']
Поскольку классы в Python это, в некотором смысле, иерархическое множество пространств имён, при наследовании работает та же идея с атрибутами базового и производного классов, что и с атрибутами класса и его экземпляра: если поле или метод явно не указываются в производном классе, то при обращении к ним обращения перенаправляются в базовый класс. При явной модификации атрибутов они становятся чисто классовыми:
>>> id(A.a)
140281905109488
>>> id(B.a)
140281905109488
>>>
>>> class C(B):
... b = 12345
...
>>> B.b, id(B.b)
(42, 140281922918480)
>>> C.b, id(C.b)
(12345, 140281905109552)
>>>
Проблема типизации, поле __class__
В связи с таким «пробрасыванием» атрибутов могут возникать проблемы с типами объектов при выполнении методов из производных классов. Рассмотрим пример базового класса с определённой операцией сложения:
>>> class A:
... def __init__(self, val=0):
... self.val = val
... def __add__(self, other):
... return A(self.val + other.val)
... def __str__(self):
... return f'<{self.val}>'
...
>>> a, b = A(2), A(3)
>>> print(a + b)
<5>
Определим производный класс, который будет иметь другое строковое представление. Благодаря Duck Typing метод, описанный в базовом классе, корректно сработает для объектов производного, однако поскольку в возвращаемом значении мы явно указали создание объекта базового класса, он и вернётся после операции:
>>> class B(A):
... def __str__(self):
... return f"<<{self.val}>>"
...
>>> a, b = B(2), B(3)
>>> print(a + b)
<5>
>>>
Для того, чтобы сделать метод автоматически определяющим класс текущего объекта, необходимо указывать не класс явно, а некоторый параметр, меняющийся в зависимости от экземпляра, от которого метод вызывается. Таким параметром выступает поле __class__, хранящее информацию о классе-родителе текущего экземпляра
>>> class A:
... def __init__(self, val=0):
... self.val = val
... def __add__(self, other):
... return self.__class__(self.val + other.val)
... def __str__(self):
... return f'<{self.val}>'
...
>>> class B(A):
... def __str__(self):
... return f"<<{self.val}>>"
...
>>> a, b = B(2), B(3)
>>> print(a + b)
<<5>>
>>>
Заметим, что несмотря на недостижимость прошлого класса А он не удалился, и класс B всё ещё зависит от него. Поэтому необходимо пересоздать и его, хоть и без изменений.
Создание классов с помощью type()
Кроме задания классов привычным образом через полное описание класса существует способ создания при помощи функции type. В будущих главах мы обсудим, что на самом деле представляет из себя type (такой объект называется метаклассом — классом, порождающим классы), а сейчас просто заметим, что для создания класса ему необходимо указать три параметра — имя класса (некоторая строка, записывающаяся в поле __name__), кортеж из последовательности родительских классов и словарь полей и методов класса:
>>> QWE = type("C_class", (), {"val": 100500, 'x': 42})
>>> dir(QWE)
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'val', 'x']
>>> QWE.val
100500
>>>
>>> ASD = type("!Привет!", (QWE,), {'x': -1, 'y': -2})
>>> ASD.val
100500
>>> ASD.__name__
'!Привет!'
>>> ASD.x
-1
>>> QWE
<class '__main__.C_class'>
>>> ASD
<class '__main__.!Привет!'>
>>>
«Защита» от коллизий имён
При работе в больших проектах существует вероятность случайного перекрытия каких-то полей родительских классов. в языках программирования aka C++ для защиты полей и методов используются явные механизмы ограничения доступа — public / protected / private. В Python нет никаких ограничений по обращению к атрибутам всей сети родителей (не только ближайших, но и вообще всех вплоть до самого базового класса object, от которого построено всё).
Для избежания случайной перегрузки полей используется специальный формат именования через __
>>> class A:
... __f = 1
...
>>> dir(A)
['_A__f', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__firstlineno__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__static_attributes__', '__str__', '__subclasshook__', '__weakref__']
Поле __name класса cls хранится в памяти как _cls__name. Только в рамках описания класса доступен формат короткого обращения к нему.
>>> class A:
... __f = 1
... def __str__(self):
... return str(self.__f)
...
>>> a = A()
>>> str(a)
'1'
>>>
Любое другое обращение к полю будет требовать явного указания полного имени поля:
>>> a.__f
Traceback (most recent call last):
File "<python-input-55>", line 1, in <module>
a.__f
AttributeError: 'A' object has no attribute '__f'
>>> a._A__f
1
>>>
>>> A.__f
Traceback (most recent call last):
File "<python-input-59>", line 1, in <module>
A.__f
AttributeError: type object 'A' has no attribute '__f'
>>> A._A__f
1
>>>
При наследовании аналогичное именование поля приведёт к созданию отдельного поля, поскольку полные имена будут различны.
>>> class B(A):
... __f = 42
...
>>> dir(B)
['_A__f', '_B__f', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__firstlineno__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__static_attributes__', '__str__', '__subclasshook__', '__weakref__']
>>>
Данный способ всё ещё не защищает данные от модификации, однако исключает случайность. Поэтому если вы перегрузили такое поле, вы либо очень этого хотели, либо специально прицельно стреляете себе в колено.
Модификация методов, прокси-объект super()
Вернёмся к рассмотрению взаимодействия базового и производного классов.
>>> class A:
... def __init__(self, val=0):
... self.val = val
... def __add__(self, other):
... return self.__class__(self.val + other.val)
... def __str__(self):
... return f'<{self.val}>'
...
>>> class B(A):
... def __str__(self):
... return f"<<{self.val}>>"
...
Поставим задачу не переписать метод __str__ в классе В, а модифицировать результат метода класса А. Для этого необходимо каким-то образом обратиться к базовому методу из производного, получить его результат, а затем поработать с ним. Явное обращение с указанием класса-родителя — не лучшая идея, более того, совершенно нерабочая в случае множественного наследования: какой из классов-родителей «по правилам» должен срабатывать при обращении?
Для решения необходимо использовать специальный виртуальный объект super, который может обращаться ко всем имеющимся полям и методам родительских классов согласно «правилам достижимости» этих атрибутов.
>>> class B(A):
... def __str__(self):
... res = super().__str__()
... return '#' + res + '$$'
...
>>> b = B(3)
>>> print(b)
#<3>$$
>>>
Множественное наследование
Для понимания того, что же за «правила», которым следует super, и как вообще возможно упорядочить родительские классы, необходимо разобрать структуру множественного наследования.
Граф наследования представляет из себя некоторый ориентированный граф без циклов. В классах могут случаться повторения имён полей и, особенно, методов (как минимум, названия всех встроенных методов абсолютно одинаковы). Алгоритм, который определяет, какие атрибуты приходят в класс от каких родительских классов, называется MRO — Method Resolution Order
Классические алгоритмы обхода графа в ширину или глубину не дают результатов: существуют графы наследования, обход которых будет неправильным (ожидается, вроде, ближайший, а получается наоборот):
![]() |
![]() |
|---|---|
| Обход в глубину добирается до A.v раньше, чем до C.v | Обход в ширину добирается до A.v раньше, чем до B.v |
Более сложный алгоритм должен как-то линеаризовать сеть, при этом должны соблюдаться некоторые правила:
- Монотонность — соблюдение порядка наследования: для
C → B → Amro(C): [C … B … A …] - Соблюдение порядка объявления: для
C(D, E)mro(C): [C … D … E …]
Алгоритм обхода в ширину с удовлетворением двум порядкам называется MRO C3.
Алгоритм MRO C3
Если коротко: MRO C3 линеаризация — это обычный алгоритм слияния очередей, применённый к N+1 списку:
- 0. Сам класс + N родительских классов в порядке, взятом из объявления этого класса
- 1. Линеаризация первого родительского класса
- …
- N. Линеаризация N-го родительского класса
Слияние очередей происходит так:
- Рассматривается набор (всех линеаризаций + список родительских классов) слева направо
- Рассматривается очередной элемент очередного списка, начиная с нулевого элемента
- Если он входит только в начала некоторых списков (или не входит никуда) (то есть:
1. не является ничьим предком;
2. не следует после кого-то оставшихся элементов в объявлениях классов):
- добавляем его в линеаризацию
- удаляем его из всех списков
- переходим к п. 1.
- В противном случае возобновляем п. 2
- Если он входит только в начала некоторых списков (или не входит никуда) (то есть:
1. не является ничьим предком;
2. не следует после кого-то оставшихся элементов в объявлениях классов):
- Если в п.2 хороших кандидатов не нашлось, линеаризация невозможна
Рассмотрим простейший вариант наследования — от базового и его же производного класса:

Для класса Y алгоритм корректно сработает:
L[A] = A
————————————————————————————
L[B] = (BA, L[A]) = (BA, A)
B ✓
L[B] = B (A, A)
A ✓
L[B] = BA
————————————————————————————
L[Y] = (YBA, L[B], L[A]) = (YBA, BA, A)
Y ✓
L[Y] = Y (BA, BA, A)
B ✓
L[Y] = YB (A, A, A)
A ✓
L[Y] = YBA
А для X произойдёт ошибка:
L[X] = (XAB, L[A], L[B]) = (XAB, A, BA)
X ✓
L[X] = X (AB, A, BA)
A (∃ BA) ×
B (∃ AB) ×
L[X] = ∄
>>> class A: pass
... class B(A): pass
...
>>> class X(A, B): pass
...
Traceback (most recent call last):
File "<python-input-1>", line 1, in <module>
class X(A, B): pass
TypeError: Cannot create a consistent method resolution order (MRO) for bases A, B
>>> class Y(B, A): pass
...
>>>
Аналогичным образом можно построить линеаризации и для более сложных графов:

>>> O = object
... class F(O): pass
... class E(O): pass
... class D(O): pass
... class C(D,F): pass
... class B(D,E): pass
... class A(B,C): pass
...
>>> A.mro()
[<class '__main__.A'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.D'>, <class '__main__.E'>, <class '__main__.F'>, <class 'object'>]
>>>

>>> O = object
... class F(O): pass
... class E(O): pass
... class D(O): pass
... class C(D,F): pass
... class B(E,D): pass
... class A(B,C): pass
...
>>> A.mro()
[<class '__main__.A'>, <class '__main__.B'>, <class '__main__.E'>, <class '__main__.C'>, <class '__main__.D'>, <class '__main__.F'>, <class 'object'>]
>>>
Возвращаясь к вопросу о «правилах достижимости» атрибутов, можно точно утверждать, что super использует именно mro() для их поиска.
Особенности множественного наследования
На практике именно путём обхода графа наследования и поиска атрибутов по нему можно осуществлять работу с как бы абстрактными классами. На практике из коробки абстрактных классов в Python не существует (есть отдельное задание таких классов декораторами, но об этом — в будущем). А так потенциальная возможность поиска атрибутов у родительских классов позволяет играть с несуществующими полями:
>>> class A:
... def __str__(self):
... return f"<{self.val}>"
...
>>> print(A())
Traceback (most recent call last):
File "<python-input-10>", line 1, in <module>
print(A())
~~~~~^^^^^
File "<python-input-9>", line 3, in __str__
return f"<{self.val}>"
^^^^^^^^
AttributeError: 'A' object has no attribute 'val'
>>> class B:
... def __init__(self, val):
... self.val = val
...
>>> print(B(12))
<__main__.B object at 0x7f78802a1010>
>>> class C(A, B):
... pass
...
>>> print(C(123))
<123>
>>>
Говоря о полиморфизме в Python, следует заметить, что по умолчанию он обеспечен Duck Typing-ом. Для проверки наследования объекта от какого-то класса используется функция isinstance
>>> c = C(123)
>>> isinstance(c, C)
True
>>> isinstance(c, B)
True
>>> isinstance(c, A)
True
>>> dir()
['A', 'B', 'C', 'D', 'E', 'F', 'O', 'Y', '__annotations__', '__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', 'c']
>>> isinstance(c, Y)
False
>>>
Однако, при работе с базовыми объектами Python и наследовании от них возникают некоторые трудности типизации. Рассмотрим данный пример: добавим в унаследованный от str класс унарный минус, автоматически разворачивающий строку.
>>> class UStr(str):
... def __neg__(self):
... return "".join(reversed(self))
...
>>> s = UStr("QWER")
>>> print(s)
QWER
>>> print(-s)
REWQ
Поскольку join возвращает строку, ожидаемо получаем тип str для преобразованного объекта. Добавим явное указание нашего класса UStr:
>>> print(type(-s))
<class 'str'>
>>>
>>> class UStr(str):
... def __neg__(self):
... return self.__class__("".join(reversed(self)))
...
>>> s = UStr("QWER")
>>> print(type(-s), -s)
<class '__main__.UStr'> REWQ
Однако для любых других операций с объектом также необходимо явно указывать класс:
>>> type(s[1:-1])
<class 'str'>
>>>
Для преобразования при наследовании от базовых классов используются специальные классы, например collections.UserString и аналогичные, там вручную прописаны все явные указания типов, что на деле, естественно, совершенно непрактично.
Исключения
На практике протокол исключения представляет из себя реализацию метода для работы с ключевым словом raise. Собственные классы-исключения могут быть реализованы, как наследники дерева классов-исключений:
>>> class E_A(Exception):
... pass
...
>>> class E_B(E_A):
... pass
...
>>> class E_C(E_B):
... pass
...
>>> E_C.mro()
[<class '__main__.E_C'>, <class '__main__.E_B'>, <class '__main__.E_A'>, <class 'Exception'>, <class 'BaseException'>, <class 'object'>]
>>>
Для работы с исключениями используется конструкция try-except (минимум один except обязан быть в описании конструкции). Как и для других классов-наследников в случае сопоставления шаблона, производный класс перехватывается except-шаблоном базового класса, но не наоборот:
>>> for ext in (E_A, E_B, E_C):
... try:
... raise ext
... except E_C:
... print("C")
... except E_B:
... print("B")
... except E_A:
... print("A")
...
A
B
C
>>> for ext in (E_A, E_B, E_C):
... try:
... raise ext
... except Exception:
... print("Exception")
... except E_C:
... print("C")
... except E_B:
... print("B")
... except E_A:
... print("A")
...
Exception
Exception
Exception
>>>
Важно понимать, что исключения это управление вычислениями, а не затирание ошибок. Бездумное использование «заглушек» вида except Exception: pass стремительно приводит ваш код в абсолютную отлаживаемую непригодность. Используйте исключения для контроля работы системы, а не для замазывания дыр.
>>> from math import inf
>>> def divisor(a, b):
... return a/b
...
>>> def divv(a, b, div=divisor):
... try:
... return div(a, b)
... except ZeroDivisionError:
... return inf
...
>>> divv(123, 23)
5.3478260869565215
>>> divv(123, 0)
inf
>>>
Специфичные параметры объектов исключений
Для объектов-исключений существует возможность задавать параметры, которые хранятся в специальном поле args объекта. Также объекту-исключению можно задавать заметки для специфической обработки.
>>> def genex(arg):
... raise RuntimeError(arg)
...
...
>>> try:
... genex("100500")
... except Exception as E:
... print(dir(F := E), E, sep = '\n')
...
['__cause__', '__class__', '__context__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__setstate__', '__sizeof__', '__str__', '__subclasshook__', '__suppress_context__', '__traceback__', 'add_note', 'args', 'with_traceback']
100500
>>> F
RuntimeError('100500')
>>> F.args
('100500',)
>>> F.add_note('NOOOTE')
>>> F.__notes__
['NOOOTE']
>>>
Особенности обработки исключений
Как и для операторов цикла, у блока исключений есть специальные дополнительные ключевые слова для обработки:
- else — срабатывает, если исключения не было;
- finally — срабатывает всегда после блока исключения вне зависимости от срабатывания except
>>> for b in 1, 0:
... try:
... a = 1 / b
... except:
... print("smth is not working")
... else:
... print("All good")
... finally:
... print("-----")
...
All good
-----
smth is not working
-----
>>>
Кстати, этим удачным примером показывается также, что при отсутствии явного указания класса исключения по умолчанию используется шаблон Exception, отлавливающий все его исключения-наследники.
Особенность оператора raise
Оператор raise позволяет вбросить исключение в любом месте программы (не обязательно внутри try-блока). Вне зависимости от указания класса или экземпляра для оператора, он всегда порождает из него экземпляр и выбрасывает именно его. Как следует из протокола ислючения, только объекты классов-исключений могут быть выброшены в качестве исключения (что, например, расходится с логикой исключений в C++, где объектом-исключением может выступать абсолютно любой объект)
Группы исключений
В некоторых случаях при работе с исключениями необходимо выбрасывать сразу несколько исключений для отлова их в разных местах программы. Решением могут выступить множественное наследование от необходимых исключений и сложная логика повторного выбрасывания при первом отлове, однако данный метод достаточно неудобный. В связи с этим, начиная с python3.11, появилась возможность создания группы исключений и её обработка специальным блоком try-except. Обращаем внимание, что это отдельная пара операторов, которая обрабатывает исключительно группы исключений. Сочетание except и except невозможно.
>>> def fun():
... raise ExceptionGroup("Oops!", [ValueError("Ping"), TypeError("Bang")])
...
... def catch_value():
... try:
... fun()
... except* ValueError as EGroup:
... print("Cath_value:", EGroup.exceptions)
...
... try:
... catch_value()
... except* TypeError as EGroup:
... print("main:", EGroup.exceptions)
...
Cath_value: (ValueError('Ping'),)
main: (TypeError('Bang'),)
>>>

