Python - о множественном наследовании и функции super() простыми словами

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

Python позволяет указать для класса несколько родителей. Это называется множественным наследованием.

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

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

Загадка

class Vertebrate:
    def lay_eggs(self):
        return None

class Bird(Vertebrate):
    def lay_eggs(self):
        return True

class Mammal(Vertebrate):
    pass

class PlatypusMammalFirst(Mammal, Bird):
    pass

class PlatypusBirdFirst(Bird, Mammal):
    pass

print(PlatypusMammalFirst().lay_eggs())
print(PlatypusBirdFirst().lay_eggs())

Для PlatypusMammalFirst первый родитель, Mammal, не переопределяет искомый метод, а второй родитель, Bird - переопределяет. Что вернет lay_eggs()?

А для PlatypusBirdFirst? Разгадка ниже.

Кстати, если быть занудой, то утконос это млекопитающее, обладающее также свойствами рептилиии. Но рептилии скользкие и холодные, и мне показалось более уютно заменить их на птиц ;)

Проблема ромбов при множественном наследовании

Описанная выше загадка относится к “проблеме ромбов” (diamond problem), она всегда возникает при множественном наследовании, не только в Python.

Если у класса есть несколько родителей, а у родителей есть общий предок, получаем ромб в дереве наследования, как видно на диаграмме.

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

Алгоритм C3 поиска в дереве наследования классов Python 3 (MRO)

Для поиска методов и полей в дереве родителей (MRO), Python использует C3 алгоритм.

MRO расшифровывается как method resolution order - не слишком удачное название, учитывая, что так ищутся не только методы, но и поля.

Совсем упрощенно C3 алгоритм MRO можно представить так:

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

Например, для класса Platypus, MRO будет [Mammal, Bird, Dinosaur, Vertebrate]. Вначале ищем в классах Mammal, Bird, Dinosaur, и, только если не найдет там, в Vertebrate.

Алгоритм обеспечивает поиск переопределенного метода класса-предка, если этот метод переопределен хотя бы в одном потомке этого класса-предка.

Ответ на загадку выше для Python 3 - мы в обоих случаях получим True, и никогда не “провалимся” до возвращающего None класса Vertebrate.

Python 2 использовал другой алгоритм (deep first), MRO для Python 2 [Mammal, Vertebrate, Bird, Dinosaur] - если бы он не нашел метод в Mammal, далее стал бы искать выше по иерархии, в Vertebrate, а не в следующем по списку множественного наследования предке, Bird.

Поэтому для Python 2 ответ на загадку будет None и True. Но только если мы используем классы старого типа (не указываем, что Vertebrate наследуется от object). А если используем классы нового типа (наследуемые от object)), то поведение будет таким же, как для Python 3.

С алгоритмом deep first, использовать множественное наследование в Python 3 практически было бы невозможно, поскольку, как сказано выше, любое множественное наследование в Python 3 является ромбовидным - у всех есть общий предок, object. И наши mixin смогли бы переопределить его методы, только если ставить их на первое место в списке родителей. Но тогда они стали бы уже просто “основным предком” а не mixin.

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

Функция super()

Функция super() обеспечивает так называемое “кооперативное” наследование методов. Если во всех переопределенных методах использовать эту функцию, то она обеспечит вызов методов всех классов по алгоритму MRO.

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

Название super() вводит в заблуждение - как показано ниже, super() вполне может найти метод не в родителе, а в “брате”, если тот следует далее по алгоритму MRO.

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

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

Иллюстрация кооперативного множественного наследования с помощью super()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Vertebrate:
    def __init__(self):
        print('Vertebrate.__init__()')

class Bird(Vertebrate):
    def __init__(self):
        print('Bird.__init__()')
        super().__init__()

class Mammal(Vertebrate):
    def __init__(self):
        print('Mammal.__init__()')
        super().__init__()

class Platypus(Mammal, Bird):
    def __init__(self):
        print('Platypus.__init__()')
        super().__init__()

duckbill = Platypus()

… Выполнить код …

Platypus.__init__()
Mammal.__init__()
Bird.__init__()
Vertebrate.__init__()

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

Например, давайте закомментарим вызов super() в классе Bird (строка 8). Вывод изменится следующим образом:

Platypus.__init__()
Mammal.__init__()
Bird.__init__()

Причина в том, что из Mammal.__init__ вызывается следующий по MRO класс (Bird), а вовсе не родитель Mammal (Vertebrate). Родителя ранее вызывал Bird, но мы убрали этот вызов.

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Vertebrate:
    def __init__(self):
        print('Vertebrate.__init__()')

class Bird(Vertebrate):
    def __init__(self, beak_length):
        print('Bird.__init__()')
        super().__init__()

class Mammal(Vertebrate):
    def __init__(self, hair_length):
        print('Mammal.__init__()')
        super().__init__()

class Platypus(Mammal, Bird):
    def __init__(self):
        print('Platypus.__init__()')
        super().__init__(1)

duckbill = Platypus()
Platypus.__init__()
Mammal.__init__()
...
File "animal_class_tree_arguments.py", line 13, in __init__
    super().__init__()
TypeError: __init__() missing 1 required positional argument: 'beak_length'

Ошибка показывает, что в Mammal код super().__init__() пытается вызвать Bird.__init__.

Примечание - особенности работы super()

Одним из ограничений super() является то, что не получится выполнить операции (binary operations, subscriptions и т.д.) над возвращенным объектом, даже если эти операции реализованы в родителе вызывающего класса с помощью “магических методов”.

Если выполнить операцию над экземпляром класса, то Python найдет нужный для выполнения операции “магический метод” в родителе (в примере ниже - __getitem__ для индексирования с помощью оператора []).

Но если попытаться выполнить операцию над объектом, возвращаемым super(), получим ошибку:

class Parent:
    def __getitem__(self, idx):
        return 0

class Child(Parent):
    def index_super(self, idx):
        return super()[idx]

kid = Child()
print(f'kid[0]: {kid[0]}')
print(f'kid.index_super(0): {kid.index_super(0)}')
kid[0]: 0
...
TypeError: 'super' object is not subscriptable

Презентация