Простое наследование классов

На этом занятии мы начнем рассмотрение очень важной темы в ООП – наследование классов. На первом уроке данного курса я приводил пример с кофемолкой и говорил, что для создания электрической кофемолки можно взять уже готовые наработки механической кофемолки, добавить туда мотор, схему и получим новый прибор. Этот пример как раз описывает общий принцип наследования в ООП: мы можем взять некий класс и на его основе создать новый, дочерний, изменив и расширив функционал базового класса.

Причем, в Python 3 любой создаваемый класс, например:

class Point:
    def __init__(self, x = 0, y = 0):
        self.__x = x
        self.__y = y

автоматически является дочерним по отношению к базовому object:

И в этом легко убедиться, вызвав специальную функцию:

print( issubclass(Point, object) )

которая возвращает True, если класс является дочерним для класса, указанного вторым параметром.

Теперь давайте посмотрим как в Python реализуется механизм наследования. Для начала создадим вспомогательный класс Point для хранения координат на плоскости:

class Point:
    def __init__(self, x = 0, y = 0):
        self.x = x
        self.y = y

И после него объявим класс для работы с графическим примитивом линией:

class Line:
    def __init__(self, sp:Point, ep:Point, color:str = "red", width:int = 1):
        self._sp = sp
        self._ep = ep
        self._color = color
        self._width = width
 
    def drawLine(self):
        print(f"Рисование линии: {self._sp}, {self._ep}, {self._color}, {self._width}")

Обратите внимание вот на эту нотацию (:Point, :str, :int). Так можно в тексте программы указывать: какого типа параметры мы здесь ожидаем получать. Конечно, это не значит, что другие типы здесь не могут быть переданы (сюда по прежнему можно передать что угодно), это лишь подсказка программисту с чем мы тут собираемся иметь дело – с объектами класса Point.

Создадим экземпляр этого класса:

l = Line( Point(1,2), Point(10,20) )
l.drawLine()

И в консоли увидим не совсем читаемое сообщение. Дело в том, что при вызове self._sp возвращаются не координаты точки, а стандартное сообщение класса Point. Давайте его переопределим, чтобы выводились координаты:

def __str__(self):
    return f"({self.__x}, {self.__y})"

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

Далее, мы подумали и решили добавить класс еще одного примитива – прямоугольника. И мы пишем следующее:

class Rect:
    def __init__(self, sp:Point, ep:Point, color:str = "red", width:int = 1):
        self._sp = sp
        self._ep = ep
        self._color = color
        self._width = width
 
    def drawRect(self):
        print(f"Рисование прямоугольника: {self._sp}, {self._ep}, {self._color}, {self._width}")

И, затем, вызов:

r = Rect( Point(30,40), Point(70,80) )
r.drawRect()

Смотрите, мы здесь фактически, продублировали текст из класса Line. Отличие только вот этот метод drawRect, остальное абсолютно такое же. Как только что-то подобное встречается в тексте программы, это значит, что программист нарушает общеизвестный принцип DRY (Don’t Repeat Yourself) – не повторяйся! И как раз это можно поправить с помощью механизма наследования.

Вот этот дублирующий текст вынесем в отдельный класс:

class Prop:
    def __init__(self, sp:Point, ep:Point, color:str = "red", width:int = 1):
        self._sp = sp
        self._ep = ep
        self._color = color
        self._width = width

а, классы примитивов будем наследовать от него:

class Line(Prop):
    def drawLine(self):
        print(f"Рисование линии: {self._sp}, {self._ep}, {self._color}, {self._width}")
 
class Rect(Prop):
    def drawRect(self):
        print(f"Рисование прямоугольника: {self._sp}, {self._ep}, {self._color}, {self._width}")

То есть, мы в круглых скобках после имени класса указываем базовый класс. В результате, получаем такую иерархию:

И когда мы создаем объекты этих примитивов:

l = Line( Point(1,2), Point(10,20) )
r = Rect( Point(30,40), Point(70,80) )

то вот этот self ссылается на общий класс Line, поэтому все публичные свойства и методы создаются для дочерних классов Line и Rect.

Вот это нижнее подчеркивание сигнализирует программисту о режиме доступа protected к этим атрибутам. То есть, мы оговариваем, что они должны использоваться только внутри объекта Prop и во всех его дочерних классах. И здесь ключевое слово оговариваем. Python при этом никак не ограничивает область их использования. Фактически, это такие же публичные атрибуты, только с одним нижним подчеркиванием. Если выполнить вот такую операцию:

print( l._width )

то это свойство будем выведено в консоль без каких-либо ошибок. Но тогда зачем нам писать это нижнее подчеркивание, если оно не играет никакой роли. Одна роль у этого префикса все-таки есть: нижнее подчеркивание должно предостерегать программиста от использования этого свойства вне класса. Впоследствии это может стать причиной непредвиденных ошибок. Например, изменится версия модуля и такое свойство может перестать существовать, т.к. никто не предполагал доступа к нему извне. А вот сеттеры и геттеры будут по-прежнему работать и давать тот же результат. Так что, к таким атрибутам лучше не обращаться напрямую – это внутренние, служебные переменные.

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

Давайте переопределим конструктор в дочернем классе Line и убедимся в этом:

def __init__(self, *args):
    print("Переопределенный конструктор Line")

Если теперь запустить программу, то мы увидим сообщение «Переопределенный конструктор» и, затем ошибку, связанную с отсутствием выводимых свойств, так как конструктор базового класса не был вызван и локальные свойства не были созданы.

Конструктор суперкласса можно вызвать, просто обратившись к нему:

Prop.__init__(self, *args)

И в конструкторе Prop добавим:

print("Конструктор базового класса Prop")

Но делать так – порочная практика. В случае множественного наследования – это источник потенциальных ошибок. Почему? Мы об этом позже еще поговорим. А сейчас отметим, что вместо явного указания имени базового класса, следует вызвать специальную функцию super, которая в правильном порядке будет перебирать вышестоящие классы:

super().__init__(*args)

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

Вот так в самом простом варианте работает механизм наследования. Давайте, теперь немного усложним программу и посмотрим как наследуются приватные (частные) свойства. Укажем в суперклассе Prop свойство _width как приватное:

self.__width = width

добавим в него метод

def getWidth(self):
    return self.__width

При запуске видим ожидаемую ошибку, т.к. частное свойство __width базового класса Prop стало недоступно в дочернем классе Line. Но почему? Ведь self в базовом классе Prop ссылается на Line. Так и есть, но создавая приватную переменную в каком-либо определенном классе, она создается с префиксом этого класса и среди локальных свойств Line мы увидим свойство

_Prop__width

Выведем их в косоль:

print(l.__dict__)

и вот в списке локальных атрибутов объекта Line появилось _Prop__width со значением 1. Причем, обратиться к нему мы можем только из метода getWidth класса Prop:

def drawLine(self):
    print(f"Рисование линии: {self._sp}, {self._ep}, {self._color}, {self.getWidth()}")

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

Если в конструкторе класса Line после вызова конструктора базового класса создать такое же частное свойство:

self.__width = 5

то в экземпляре класса Line появится запись:

_Line__width

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

def drawLine(self):
    print(f"Рисование линии: {self._sp}, {self._ep}, {self._color}, {self.__width}")

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

Задания для самоподготовки

1. Создайте суперкласс «Персональные компьютеры» и на его основе подклассы: «Настольные ПК» и «Ноутбуки». В базовом классе определите общие свойства: размер памяти, диска, модель, CPU. А в производных классах уникальные свойства:

  • для настольных: монитор, клавиатура, мышь, их габариты; и метод для вывода этой информации в консоль;
  • для ноутбуков: габариты, диагональ экрана; и метод для вывода этой информации в консоль.

2. Повторите это задания для суперкласса «Человек» и подклассов «Мужчина» и «Женщина». Подумайте, какие общие характеристики можно выделить в суперкласс и какие частные свойства указать в подклассах.