Наследование классов в Python: руководство по повторно используемому коду

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

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

Первые два понятия, которые следует изучить в наследовании в Python, — это класс Parent и класс Child.

Что такое родительский класс?

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

Что такое дочерний класс?

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

Мы поработаем над простой футбольной игрой и покажем, как работает наследование в Python.

Но сначала давайте начнем с некоторых очень важных основ наследования!

Разница между родительским и дочерним классом

Мы говорили о занятиях для родителей и детей…

В чем разница между этими двумя понятиями с точки зрения того, как мы их определяем?

Давайте начнем с определения класса с именем A так, как мы определяем любой класс в Python. Чтобы упростить этот пример, мы просто будем использовать оператор pass в реализации каждого класса.

Что такое оператор pass в классе Python?

Оператор pass используется в классах Python для определения класса без реализации в нем какого-либо кода (например, атрибутов и методов). Использование оператора pass является распространенным приемом для создания структуры вашей программы и избежания ошибок, возникающих в интерпретаторе из-за отсутствия реализации в классе.

Я использую оператор pass, потому что не хочу, чтобы вы сейчас сосредотачивались на коде классов, а сосредоточились только на самой концепции наследования.

class A:
    pass

Класс А — это просто обычный класс.

Какую ошибку мы увидим, если не включим оператор pass в наш класс?

$ python inheritance.py 
  File "inheritance.py", line 2
    
            ^
SyntaxError: unexpected EOF while parsing

Интерпретатор Python не любит код, содержащий только первую строку определения класса A без pass.

Итак, возвращаясь к нашему рабочему примеру. Что делает класс A родительским классом?

Тот факт, что в нашей программе мы создаем класс с именем B, который наследует (или выводит) из него:

class B(A):
    pass

Обратите внимание, что после имени класса BI также включил в скобки класс A. Это означает, что B наследуется от A. Другими словами, B является дочерним классом, а A — его родительским классом.

Но это еще не все…

Мы можем определить класс с именем C, который наследует от B:

class C(B):
    pass

Вы видите, что роль класса в вопросах наследования не является раз и навсегда определенной… что я имею в виду?

Класс может быть как родительским, так и дочерним классом, как мы видели на примере класса B в нашем примере.

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

А теперь давайте рассмотрим практический пример наследования.

Первый пример наследования классов в Python

Во-первых, мы создаем базовый класс с именем Player. Его конструктор принимает имя и вид спорта:

class Player:

    def __init__(self, name, sport):
        self.name = name
        self.sport = sport

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

Я определю четыре детских класса для четырех футбольных амплуа: вратарь, защитник, полузащитник и нападающий.

Как мы уже видели, вот как создать класс в Python, который наследует от другого класса. Мы используем оператор class и дополнительно указываем имя класса, от которого хотим наследовать, после имени нашего класса в скобках:

class ChildClass(ParentClass):
   ...
   ...

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

Мы определим наши дочерние классы таким образом, что атрибут спорта будет установлен автоматически. Вот пример для класса Goalkeeper:

class Goalkeeper(Player):

    def __init__(self, name):
        super().__init__(name, 'football')

Как видите, родительский класс Player находится в скобках.

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

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

В конструкторе мы используем метод super(), который ссылается на родительский класс. Мы используем его для вызова конструктора родительского класса и передаем ему:

  • Имя игрока, указанное при создании объекта типа «Вратарь».
  • Вид спорта «футбол».

То же самое относится ко всем ролям:

class Player:

    def __init__(self, name, sport):
        self.name = name
        self.sport = sport

class Goalkeeper(Player):

    def __init__(self, name):
        super().__init__(name, 'football')

class Defender(Player):

    def __init__(self, name):
        super().__init__(name, 'football')

class Midfielder(Player):

    def __init__(self, name):
        super().__init__(name, 'football')

class Striker(Player):

    def __init__(self, name):
        super().__init__(name, 'football')

Теперь давайте создадим объект типа Striker:

striker1 = Striker('James Striker')
print(striker1.__dict__)

Как видите, пространство имен нового объекта содержит атрибуты name и role:

{'name': 'James Striker', 'sport': 'football'}

Следующим шагом будет добавление нового атрибута к нашим классам.

Добавление атрибута к дочернему классу

Пришло время добавить атрибут к нашим дочерним классам. Атрибут, который применяется только к футболистам, а не обязательно ко всем игрокам в спорте.

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

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

Например, добавим роль в наш класс Striker:

class Striker(Player):

    def __init__(self, name):
        super().__init__(name, 'football')
        self.role = 'striker'

Теперь мы можем увидеть атрибут роли в пространстве имен экземпляра дочернего класса:

>>> striker1 = Striker('James Striker')
>>> print(striker1.__dict__)
{'name': 'James Striker', 'sport': 'football', 'role': 'striker'}

Этот код работает, но он не универсален…

Что, если мы хотим создать объект типа Вратарь, Защитник или Полузащитник?

Чтобы сделать его универсальным, нам нужно добавить новый атрибут в конструктор каждого дочернего класса.

Так, например, класс Striker становится:

class Striker(Player):

    def __init__(self, name, role):
        super().__init__(name, 'football')
        self.role = role

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

$ python football.py 
Traceback (most recent call last):
  File "football.py", line 28, in <module>
    striker1 = Striker('James Striker')
TypeError: __init__() missing 1 required positional argument: 'role'

Итак, вот как мы теперь создаем объект-нападающего:

striker1 = Striker('James Striker', 'striker')

Круто! Наши занятия потихоньку становятся лучше.

Добавление метода в родительский класс

А теперь…

…давайте добавим метод с именем play в наш родительский класс:

class Player:
  
    def __init__(self, name, sport):
        self.name = name
        self.sport = sport

    def play(self):
        pass

Определенный мной метод включает только оператор pass, который, как мы видели ранее, в Python ничего не делает.

Так почему же мы добавляем это в метод?

Давайте создадим объект типа Player и запустим метод play:

player1 = Player('Player1', 'football')
player1.play()

Вы увидите, что при запуске этого кода вы не получите никаких выходных данных от метода play.

Давайте попробуем удалить оператор pass из метода и посмотрим, что произойдет, если мы выполним тот же код, что и выше:

$ python football.py 
  File "football.py", line 9
    class Goalkeeper(Player):
    ^
IndentationError: expected an indented block

На этот раз Python выдает ошибку отступа, вызванную отсутствием кода внутри метода play (который непосредственно предшествует определению класса Goalkeeper.

Итак, мы добавим сообщение print в метод play родительского класса и перейдем к реализации того же метода для некоторых дочерних классов.

Вот как выглядят все наши занятия на данный момент:

class Player:

    def __init__(self, name, sport):
        self.name = name
        self.sport = sport

    def play(self):
        print("Player {} starts running".format(self.name))

class Goalkeeper(Player):

    def __init__(self, name, role):
        super().__init__(name, 'football')
        self.role = role

class Defender(Player):

    def __init__(self, name, role):
        super().__init__(name, 'football')
        self.role = role

class Midfielder(Player):

    def __init__(self, name, role):
        super().__init__(name, 'football')
        self.role = role

class Striker(Player):

    def __init__(self, name, role):
        super().__init__(name, 'football')
        self.role = role

Теперь мы можем увидеть, как метод play наследуется дочерним классом. Давайте создадим объект типа Midfielder и выполним на нем метод play:

midfielder1 = Midfielder('James Midfielder', 'midfielder')
midfielder1.play()

Вывод:

$ python football.py 
Player James Midfielder starts running

Когда мы вызываем метод play на объекте Midfielder, вызывается метод play класса Player. Это связано с порядком разрешения методов.

Порядок разрешения методов (MRO) — это порядок, в котором Python ищет метод в иерархии классов.

Вы можете использовать метод mro() класса, чтобы увидеть порядок разрешения:

print(Midfielder.mro())
[<class '__main__.Midfielder'>, <class '__main__.Player'>, <class 'object'>]

Вывод показывает, что в этом случае Python использует следующий порядок разрешения методов:

  • Класс полузащитника.
  • Класс игрока.
  • класс объекта, от которого наследуется большинство классов в Python.

Итак, в нашем сценарии Python не находит метод play в классе Midfielder и использует тот же метод из родительского класса Player.

Переопределить метод в классе Python

Переопределение метода означает определение метода в дочернем классе с тем же именем, что и у одного из методов в родительском классе.

В этом случае мы можем определить метод игры в классе Полузащитник следующим образом:

class Midfielder(Player):

    def __init__(self, name, role):
        super().__init__(name, 'football')
        self.role = role

    def play(self):
        print("Player {} passes the ball to a striker".format(self.name))

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

Давайте выполним этот метод на объекте типа Полузащитник таким же образом, как мы это делали в предыдущем разделе:

midfielder1 = Midfielder('James Midfielder', 'midfielder')
midfielder1.play()

Вывод:

$ python football.py 
Player James Midfielder passes the ball to a striker

На этот раз Python выполняет метод дочернего класса Midfielder, поскольку он реализован в нем, и не выполняет тот же метод родительского класса (следуя порядку разрешения методов).

Вызов родительского метода из дочернего класса

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

Но существуют ли сценарии, в которых нам может потребоваться явно вызвать родительский метод из дочернего класса, даже если тот же метод существует в дочернем классе?

Давайте узнаем!

Я хочу изменить код так, чтобы при выполнении метода play в одном из дочерних классов выводились два сообщения:

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

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

Например, давайте обновим метод play класса Midfielder:

class Midfielder(Player):

    def __init__(self, name, role):
        super().__init__(name, 'football')
        self.role = role

    def play(self):
        super().play()
        print("Player {} passes the ball to a striker".format(self.name))

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

И вот что мы видим, когда запускаем метод play на объекте типа Midfielder:

$ python football.py 
Player James Midfielder starts running
Player James Midfielder passes the ball to a striker

В этом примере я использую Python 3.

$ python --version
Python 3.7.4

Мне интересно, работает ли это также с Python 2…

$ python2 --version
Python 2.7.14
$ python2 football.py 
Traceback (most recent call last):
  File "football.py", line 39, in <module>
    midfielder1 = Midfielder('James Midfielder', 'midfielder')
  File "football.py", line 25, in __init__
    super().__init__(name, 'football')
TypeError: super() takes at least 1 argument (0 given)

Мы можем увидеть ошибку при вызове super() без аргументов, если используем Python 2.

Это потому что…

В Python 2 метод super() требует дополнительных аргументов по сравнению с Python 3. Нам также необходимо явно наследовать родительский класс от object, как показано ниже.

class Player(object):
  
    def __init__(self, name, sport):
        self.name = name
        self.sport = sport

    def play(self):
        print("Player {} starts running".format(self.name))

...
...
...

class Midfielder(Player):

    def __init__(self, name, role):
        super(Midfielder, self).__init__(name, 'football')
        self.role = role

    def play(self):
        super(Midfielder, self).play()
        print("Player {} passes the ball to a striker".format(self.name))

Я объясню точное обоснование этого в другой статье о разнице между классами старого и нового стилей в Python.

На данный момент обратите внимание на следующие изменения…

Определение родительского класса теперь начинается со слов:

class Player(object):

А два вызова super принимают два аргумента: подкласс, в котором вызывается super(), и экземпляр подкласса:

super(Midfielder, self).__init__(name, 'football')

super(Midfielder, self).play()

В следующих разделах этого руководства мы продолжим использовать синтаксис Python 3 для вызова метода super.

Разница между isinstance и issubclass с классами Python

Давайте углубим наши знания о классах Python в связи с наследованием.

В этом последнем разделе мы рассмотрим разницу между встроенными функциями Python isinstance и issubclass.

Разница между этими двумя функциями объясняется в их названии:

  • isinstance применяется к экземплярам. Позволяет проверить тип экземпляра класса (или объекта).
  • i ssubclass применяется к классам. Он предоставляет подробную информацию о наследовании между классами.

Начнем с isinstance…

Функция isinstance принимает два аргумента в следующем порядке: object и classinfo. Она возвращает True, если объект является экземпляром classinfo или его подклассом. В противном случае она возвращает False.

Вот что он возвращает, когда мы применяем его к нашему объекту midfielder1, определенному в предыдущем разделе:

>>> print(isinstance(midfielder1, Midfielder))
True
>>> print(isinstance(midfielder1, Player))
True

Как видите, функция возвращает True в обоих случаях, поскольку midfielder1 является экземпляром типа Midfielder, но также и типа Player из-за наследования.

А теперь давайте посмотрим на issubclass…

Функция issubclass принимает два аргумента: class и classinfo. Она возвращает True, если класс является подклассом classinfo. В противном случае она возвращает False.

Применим его к классам «Полузащитник» и «Игрок»:

>>> print(issubclass(Midfielder, Midfielder))
True
>>> print(issubclass(Midfielder, Player))
True

Мы уже знали, что Midfielder является подклассом Player. Но с кодом выше мы также узнали, что Midfielder является подклассом Midfielder.

Класс является подклассом самого себя.

Все ясно?

Заключение

В этой статье мы рассмотрели довольно многое…

Вы узнали:

  • Основы наследования в Python.
  • Разница между родительскими и дочерними классами.
  • Способ определения методов в дочерних классах, которые переопределяют те же методы из родительских классов.
  • Метод вызова родительских методов из дочерних классов.
  • Разница между встроенными методами Python isinstance и issubclass.

А вы? Как вы используете наследование в своих программах на Python?