View on GitHub

Methodics of Python Intro

Practical materials for learning Python

В данной главе мы продолжим разбирать ООП в 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

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

Алгоритм обхода в ширину с удовлетворением двум порядкам называется MRO C3.

Алгоритм MRO C3

Если коротко: MRO C3 линеаризация — это обычный алгоритм слияния очередей, применённый к N+1 списку:

Слияние очередей происходит так:

  1. Рассматривается набор (всех линеаризаций + список родительских классов) слева направо
  2. Рассматривается очередной элемент очередного списка, начиная с нулевого элемента
    • Если он входит только в начала некоторых списков (или не входит никуда) (то есть: 1. не является ничьим предком; 2. не следует после кого-то оставшихся элементов в объявлениях классов):
      • добавляем его в линеаризацию
      • удаляем его из всех списков
      • переходим к п. 1.
    • В противном случае возобновляем п. 2
  3. Если в п.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']  
>>>

Особенности обработки исключений

Как и для операторов цикла, у блока исключений есть специальные дополнительные ключевые слова для обработки:

>>> 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'),)  
>>>