Наследование

Наследование является ключевым аспектом ООП и, конечно, доступно в python. Как уже многократно подмечалось ранее, в python всё является объектом. Можно даже сказать больше, любой тип объекта (встроенный или пользовательский) или напрямую или через один из своих базовых классов расширяет тип object.

В частности, следующее объявление класса неявно расширяет object.

class MyClass:
    pass

print(MyClass.__bases__)
issubclass(MyClass, object)
(<class 'object'>,)
True

Специальный атрибут __bases__ позволяет узнать базовый класс (или классы, python поддерживает множественное наследование). Встроенная функция issubclass возвращает True, отвечает на вопрос, является ли класс указанный в первом аргументе производным от класса указанного во втором аргументе.

Базовый синтаксис

Допустим, у нас есть базовый класс BaseClass и мы хотим объявить класс DerivedClass, который будет расширять его. Тогда необходимо указать базовый класс в заголовочной строке производного класса в круглых скобках после имени базового класса.

class BaseClass:
    pass

class DerivedClass(BaseClass):
    pass

DerivedClass.__bases__
(__main__.BaseClass,)

В данном примере заголовок class DerivedClass(BaseClass): сигнализирует, что DerivedClass наследует от BaseClass.

../_images/single_0.png

При проверке принадлежности экземпляра производного класса к базовому классу метод isinstance вернет True.

d = DerivedClass()
print(isinstance(d, BaseClass))
print(type(d) == BaseClass)
True
False

Из-за этой особенности, принято проверять принадлежность к классу именно методом isinstance(obj, cls), а не выражением вида type(obj) == cls. Это позволяет писать код, который не будет замечать разницы между экземплярами базового и производного классов. В ряде ситуаций область применения такого кода можно будет расширять, не редактируя его.

Наследование атрибутов класса

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

Определим базовый класс с атрибутом attr и со статическим (для удобства вызова) методом method.

class BaseClass:
    attr = "Атрибут класса BaseClass"

    @staticmethod
    def method():
        print("Метод класса BaseClass")

class DerivedClass(BaseClass):
    pass

DerivedClass.method()
print(DerivedClass.attr)
Метод класса BaseClass
Атрибут класса BaseClass
../_images/single_0.png

Видим, что через объект объявления производного класса DerivedClass удаётся получить доступ к атрибутам и методам базового класса BaseClass.

Под капотом.

Механизм наследования атрибутов не совсем очевиден. У объекта объявления производного класса не появляются атрибуты базового класса, но получить доступ к атрибуту базового класса через объект объявления производного класса получается из-за механизма поиска атрибутов класса.

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

  1. Атрибут x ищется у класса C. Если он обнаруживается, то он и возвращается;

  2. Если атрибут x у класса C найти не удаётся, то атрибут x ищется у базового класса B.

Второй шаг выполняется рекурсивно, т.е. если B расширяет класс A и в B тоже не удаётся найти атрибут x, то поиск продолжится в классе A (и далее по цепочке наследования).

Доступ к атрибутам базового класса через экземпляр производного класса тоже возможен, т.к. процедура поиска атрибута у экземпляра делегирует этот поиск классу этого экземпляра (см. процедуру выше), если в самом экземпляре нет такого атрибута.

Перекрытие атрибутов

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

Расширим определение производного класса DerivedClass из предыдущего примера его собственными атрибутами attr и method.

class BaseClass:
    attr = "Атрибут класса BaseClass"

    @staticmethod
    def method():
        print("Метод класса BaseClass")

class DerivedClass(BaseClass):
    attr = "Атрибут класса DerivedClass"

    @staticmethod
    def method():
        print("Метод класса DerivedClass")

DerivedClass.method()
print(DerivedClass.attr)
Метод класса DerivedClass
Атрибут класса DerivedClass
../_images/single_0.png

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

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

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

class LeftBase:
    a = "a left"
    b = "b left"

class MiddleBase:
    b = "b middle"
    c = "c middle"

class RightBase:
    c = "c right"
    d = "d right"


class DerivedClass(LeftBase, MiddleBase, RightBase):
    pass

В примере DerivedClass наследует сразу от трех классов.

../_images/multi.png

Атрибуты в базовых классах пересекаются: атрибут b есть и у LeftBase и у MiddleBase, атрибут c есть и у MiddleBase и у RightBase. Возникает вопрос, если обратиться от производного класса к этим атрибутам, то значение атрибута какого из базовых классов вернется в качестве результата? Распечатаем атрибуты a, b, c и d класса DerivedClass.

for attr in "abcd":
    print(f"{attr}: {getattr(DerivedClass, attr)}")
a: a left
b: b left
c: c middle
d: d right

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

Вызов методов базового класса. Функция super

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

Неопытному программисту на python может показаться, что этого можно добиться следующим образом:

class A:
    def __init__(self, x):
        self.x = x

class B(A):
    def __init__(self, x, y):
        self.__init__(x)
        self.y = y

Но это приведет к рекурсии: метод B.__init__ создаётся на этапе объявления класса, а значит при поиске атрибута self.__init__ найдется именно B.__init__ (у экземпляра self такого атрибута нет, а значит поиск идёт в его классе), а не A.__init__.

Выход из этой ситуации — вызвать метод A.__init__ явно.

class A:
    def __init__(self, x):
        self.x = x

class B(A):
    def __init__(self, x, y):
        A.__init__(self, x)
        self.y = y

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

A.__init__(self, x)

заменяется на

super().__init__(x)

Note

Параметр self при вызове через super передавать не надо!

class A:
    def __init__(self, x):
        print("Инициализация в A")
        self.x = x

class B(A):
    def __init__(self, x, y):
        print("Инициализация в B")
        super().__init__(x)
        self.y = y


b = B(42, 3.14)
print(b.x, b.y)
Инициализация в B
Инициализация в A
42 3.14

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

../_images/super.png

Рассмотрим следующую иерархию наследования: South наследует от West и East, каждый из которых в свою очередь расширяют класс North. Хочется, чтобы при инициализации экземпляра класса South вызывались и методы инициализации всех базовых классов. Попробуем реализовать эту схему, указывая все базовые классы явно.

class North:
    def __init__(self):
        print("North")

class West(North):
    def __init__(self):
        print("West")
        North.__init__(self)
        
class East(North):
    def __init__(self):
        print("East")
        North.__init__(self)
        
class South(West, East):
    def __init__(self):
        print("South")
        West.__init__(self)
        East.__init__(self)
        
s = South()
South
West
North
East
North

Видим, что метод инициализации класса North вызвался дважды. Первый раз это произошло через класс West, а второй раз через класс East. Теперь заменим все явные упоминания классов через функцию super.

class North:
    def __init__(self):
        print("Up")

class West(North):
    def __init__(self):
        print("West")
        super().__init__()
        
class East(North):
    def __init__(self):
        print("East")
        super().__init__()
        
class South(West, East):
    def __init__(self):
        print("South")
        super().__init__()
        

s = South()
South
West
East
Up

Проблема с тем, что метод инициализации North вызывался дважды, решена! Функция super использует С3-линеаризацию (method resolution order) для определения порядка, в котором вызывать методы классов в иерархии наследования. Но чтобы это работало, необходимо, чтобы везде вызов происходил именно через super.

Абстрактный базовый класс. Абстрактный метод.

Модуль abc (сокращение от Abstract Base Class) предоставляет инструменты для реализации абстрактных базовых классов, т.е. классов, которые лишь задают интерфейс и не предназначены для создания экземпляров напрямую. Обычно, абстрактный базовый класс наследует от abc.ABC, а абстрактные методы помечаются декоратором abc.abstractmethod. Производные от такого абстрактного базового класса классы смогут создавать экземпляры, только если они переопределят все абстрактные методы. Если не переопределен хоть один из абстрактных методов, то python возбудит ошибку при попытке создать экземпляр. Так как тело абстрактной функции не играет никакой роли, то в нем часто возбуждают исключение NotImplementedError.

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

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def perimeter(self):
        raise NotImplementedError
    
    @abstractmethod
    def area(self):
        raise NotImplementedError
    

try:
    Shape()
except TypeError as msg:
    print(msg)
Can't instantiate abstract class Shape with abstract methods area, perimeter

Видим, что экземпляр класса Shape создать не удаётся — у него есть абстрактные методы.

from math import pi

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def perimeter(self):
        return 2 * pi * self.radius

try:
    Circle()
except TypeError as msg:
    print(msg)
Can't instantiate abstract class Circle with abstract methods area

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

from math import pi

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def perimeter(self):
        return 2 * pi * self.radius

    def area(self):
        return pi * self.radius * self.radius

c = Circle(1)
print(c.area(), c.perimeter())
3.141592653589793 6.283185307179586