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

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

class Point:
    def __init__(self, x = 0, y = 0):
        self.__x = x
        self.__y = y
 
    def __str__(self):
        return f"({self.__x}, {self.__y})"
 
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}")

Для изменения координат графических примитивов добавим в класс Prop метод:

def setCoords(self, sp:Point, ep:Point):
    if sp.isDigit() and ep.isDigit():
        self._sp = sp
        self._ep = ep
    else:
        print("Координаты должны быть числами")

Который вначале проверяет: являются ли переданные значения числами или нет. Соответственно, в классе Point мы определим метод:

def isDigit(self):
    if (isinstance(self.__x, int) or isinstance(self.__x, float)) and \
       (isinstance(self.__y, int) or isinstance(self.__y, float)):
        return True
    return False

Теперь, мы можем создать экземпляр класса Line и менять его координаты:

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

Далее, нам приходит указание свыше устанавливать координаты для объекта Line только целочисленные. А все остальные примитивы могут принимать как целочисленные, так и вещественные координаты. Как нам это реализовать? Очевидно, нужно переопределить метод setCoords в дочернем классе Line. Сделаем это, запишем его в виде:

def setCoords(self, sp:Point, ep:Point):
    if sp.isInt() and ep.isInt():
        self._sp = sp
        self._ep = ep
    else:
        print("Координаты должны быть целочисленными")

А в класс Point добавим метод:

def isInt(self):
    if isinstance(self.__x, int) and isinstance(self.__y, int):
        return True
    return False

Теперь, при запуске программы, мы увидим сообщение, что координаты не целочисленные. То есть, это сработал переопределенный метод класса Line. А что же метод базового класса? Существует ли он? Да, и в данном случае, чтобы не дублировать вот этот код, мы можем его вызвать напрямую из этого класса:

Prop.setCoords(self, sp, ep)

В результате, у нас получается такая картина использования методов:

То есть, метод сначала ищется в дочернем классе и если не находится, то поиск продолжается в базовых. И, так как мы создали аналогичный в дочернем, то он и был взят, но при необходимости, мы всегда можем обратиться и к методу базового класса. Здесь всегда следует помнить, что методы в Python ведут себя как статические и обращаясь к ним из экземпляров, мы берем их непосредственно из классов. И только благодаря параметру self можем «понимать» с каким экземпляром класса работаем.

Но так переопределять можно только публичные методы, с частными (private) это не работает: они просто будут определяться независимо в своих классах. Я думаю это вполне очевидно и понятно.

Далее, в Python методы можно перегружать, то есть, выполнять разный функционал в зависимости от переданных данных. Например, можно перегрузить метод setCoords так, что при передаче только одного аргумента данные будут записываться в свойство _sp, а при двух он бы работал так как и сейчас. В Python это делается следующим образом. Второй аргумент устанавливается по умолчанию в None:

def setCoords(self, sp:Point, ep:Point = None):
    if ep is None:
        if sp.isInt():
            self._sp = sp
        else:
            print("Координата должна быть целочисленной")
    else:
        if sp.isInt() and ep.isInt():
            Prop.setCoords(self, sp, ep)
        else:
            print("Координаты должны быть целочисленными")

И, теперь можно вызывать этот метод с одним или двумя аргументами:

l.setCoords( Point(10,10), Point(30,30) )
l.drawLine()
l.setCoords( Point(-10,-10) )
l.drawLine()

Метод setCoords в данном случае можно улучшить и реализовать через два вспомогательных метода:

def __setOneCoord(self, sp):
    if sp.isInt():
        self._sp = sp
    else:
        print("Координата должна быть целочисленной")
 
def __setTwoCoords(self, sp, ep):
    if sp.isInt() and ep.isInt():
        Prop.setCoords(self, sp, ep)
    else:
        print("Координаты должны быть целочисленными")
 
def setCoords(self, sp:Point, ep:Point = None):
    if ep is None:
        self.__setOneCoord(sp)
    else:
        self.__setTwoCoords(sp, ep)

Так программа выглядит более читабельной.

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

Например, так:

class Line(Prop):
    def draw(self):
        print(f"Рисование линии: {self._sp}, {self._ep}, {self._color}, {self._width}")
 
class Rect(Prop):
    def draw(self):
        print(f"Рисование прямоугольника: {self._sp}, {self._ep}, {self._color}, {self._width}")
 
class Ellipse(Prop):
    def draw(self):
        print(f"Рисование эллипса: {self._sp}, {self._ep}, {self._color}, {self._width}")
 
figs = []
figs.append( Line(Point(0,0), Point(10,10)) )
figs.append( Line(Point(10,10), Point(20,10)) )
figs.append( Rect(Point(50,50), Point(100,100)) )
figs.append( Ellipse(Point(-10,-10), Point(10,10)) )
 
for f in figs:
    f.draw()

В других языках программирования, таких как С++ или Java это реализуется через виртуальные методы. Здесь же весь этот механизм скрыт от нас и представляется естественным образом. Дополнительно здесь можно поставить «защиту» на случай, если метод draw не будет определен в дочернем классе. Для этого в базовом можно прописать такой же метод, но генерирующий специальное исключение:

def draw(self):
    raise NotImplementedError("В дочернем классе должен быть определен метод draw()")

Теперь, если убрать в каком-либо дочернем классе метод draw, то он будет вызван из базового класса и возникнет это исключение. Такие методы в Python иногда называют абстрактными, т.к. для корректной работы они требуют своего обязательного переопределения в дочерних классах. Хотя, на мой взгляд, это не совсем точное название, здесь лишь имитация абстракции в реализации метода.

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

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

2. Создайте класс Animal (животное) и разные производные от него подклассы: Fox, Bird, Cat, Dog и т.п. Реализуйте у них общий метод say(), который бы возвращал звук, издаваемый этим животным. Создайте кортеж из нескольких этих экземпляров классов, переберите их в цикле и выведите в консоль их звуки (вызовите метод say()).