Повышение эффективности классов Python с помощью super()

Оглавление

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

В этом уроке вы узнаете о следующем:

  • Концепция наследования в Python
  • Множественное наследование в Python
  • Как работает функция super()
  • Как работает функция super() при одиночном наследовании
  • Как работает функция super() при множественном наследовании

Бесплатный бонус: 5 Thoughts On Python Mastery - бесплатный курс для разработчиков на Python, который покажет вам дорожную карту и образ мышления, необходимые для того, чтобы поднять ваши навыки работы на Python на новый уровень.

Обзор функций Python super()

Если у вас есть опыт работы с объектно-ориентированными языками, вы, возможно, уже знакомы с функциональностью super().

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

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

Зачем вам все это нужно? Хотя возможности ограничены только вашим воображением, распространенным вариантом использования является создание классов, расширяющих функциональность ранее созданных классов.

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

super() в одиночном наследовании

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

По крайней мере, мне легче понять эти концепции, глядя на код, поэтому давайте напишем классы, описывающие некоторые фигуры:

class Rectangle:
    def __init__(self, length, width):
        self.length = length
        self.width = width

    def area(self):
        return self.length * self.width

    def perimeter(self):
        return 2 * self.length + 2 * self.width

class Square:
    def __init__(self, length):
        self.length = length

    def area(self):
        return self.length * self.length

    def perimeter(self):
        return 4 * self.length

Здесь есть два похожих класса: Rectangle и Square.

Вы можете использовать их следующим образом:

>>> square = Square(4)
>>> square.area()
16
>>> rectangle = Rectangle(2,4)
>>> rectangle.area()
8

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

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

class Rectangle:
    def __init__(self, length, width):
        self.length = length
        self.width = width

    def area(self):
        return self.length * self.width

    def perimeter(self):
        return 2 * self.length + 2 * self.width

# Here we declare that the Square class inherits from the Rectangle class
class Square(Rectangle):
    def __init__(self, length):
        super().__init__(length, length)

Здесь вы использовали super() для вызова __init__() класса Rectangle, что позволило вам использовать его в классе Square без повторения кода. Ниже показана основная функциональность, сохранившаяся после внесения изменений:

>>> square = Square(4)
>>> square.area()
16

В данном примере Rectangle - это суперкласс, а Square - подкласс.

Поскольку методы Square и Rectangle .__init__() настолько похожи, вы можете просто вызвать метод .__init__() суперкласса (Rectangle.__init__()) из метода Square с помощью super(). Это устанавливает атрибуты .length и .width, несмотря на то что в конструкторе Square нужно было указать всего один параметр length.

При выполнении этой функции, даже если ваш класс Square не реализует ее явно, вызов .area() будет использовать метод .area() в суперклассе и выведет 16. Класс Square наследует .area() от класса Rectangle.

Примечание: Чтобы узнать больше о наследовании и объектно-ориентированных концепциях в Python, обязательно ознакомьтесь с Inheritance and Composition: A Python OOP Guide и Object-Oriented Programming (OOP) in Python 3.

Что super() может сделать для вас?

Итак, что же может сделать для вас super() в едином наследовании?

Как и в других объектно-ориентированных языках, он позволяет вызывать методы суперкласса в своем подклассе. В основном это делается для расширения функциональности унаследованного метода.

В приведенном ниже примере вы создадите класс Cube, который наследуется от Square и расширяет функциональность .area() (наследуется от класса Rectangle через Square) для вычисления площади поверхности и объема экземпляра Cube:

class Square(Rectangle):
    def __init__(self, length):
        super().__init__(length, length)

class Cube(Square):
    def surface_area(self):
        face_area = super().area()
        return face_area * 6

    def volume(self):
        face_area = super().area()
        return face_area * self.length

Теперь, когда вы создали классы, давайте рассмотрим площадь поверхности и объем куба с длиной стороны 3:

>>> cube = Cube(3)
>>> cube.surface_area()
54
>>> cube.volume()
27

Внимание: Обратите внимание, что в нашем примере сам по себе super() не выполнит вызов метода за вас: вы должны вызвать метод на самом прокси-объекте.

Здесь вы реализовали два метода для класса Cube: .surface_area() и .volume(). Оба эти вычисления основаны на вычислении площади одной грани, поэтому вместо того, чтобы заново реализовывать вычисление площади, вы используете super() для расширения вычисления площади.

Также обратите внимание, что в определении класса Cube отсутствует .__init__(). Поскольку Cube наследуется от Square и .__init__() на самом деле не делает ничего другого для Cube, чем для Square, вы можете пропустить его определение, и .__init__() суперкласса (Square) будет вызван автоматически.

super() возвращает объект делегата родительского класса, и вы вызываете нужный метод непосредственно на нем: super().area().

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

А super() Глубокое погружение

Прежде чем перейти к рассмотрению множественного наследования, давайте сделаем небольшой экскурс в механику super().

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

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

class Rectangle:
    def __init__(self, length, width):
        self.length = length
        self.width = width

    def area(self):
        return self.length * self.width

    def perimeter(self):
        return 2 * self.length + 2 * self.width

class Square(Rectangle):
    def __init__(self, length):
        super(Square, self).__init__(length, length)

В Python 3 вызов super(Square, self) эквивалентен вызову super() без параметров. Первый параметр ссылается на подкласс Square, а второй - на объект Square, которым в данном случае является self. Вы можете вызывать super() и с другими классами:

class Cube(Square):
    def surface_area(self):
        face_area = super(Square, self).area()
        return face_area * 6

    def volume(self):
        face_area = super(Square, self).area()
        return face_area * self.length

В этом примере вы задаете Square в качестве аргумента подкласса для super(), а не Cube. Это заставляет super() начать поиск подходящего метода (в данном случае .area()) на один уровень выше Square в иерархии экземпляров, в данном случае Rectangle.

В этом конкретном примере поведение не меняется. Но представьте, что Square также реализует функцию .area(), которую вы хотите сделать так, чтобы Cube не использовал. Вызов super() таким образом позволяет вам это сделать.

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

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

А как насчет второго параметра? Помните, что это объект, который является экземпляром класса, используемого в качестве первого параметра. Для примера, isinstance(Cube, Square) должен возвращать True.

Включая инстанцированный объект, super() возвращает связанный метод: метод, привязанный к объекту, который передает методу контекст объекта, например, любые атрибуты экземпляра. Если этот параметр не включен, возвращаемый метод является просто функцией, не связанной с контекстом объекта.

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

Примечание: Технически, super() не возвращает метод. Он возвращает прокси-объект. Это объект, который делегирует вызовы методов нужного класса, не создавая для этого дополнительный объект.

super() в множественном наследовании

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

Обзор множественного наследования

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

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

A diagrammed example of multiple inheritance

Пример множественного наследования в виде диаграммы (Изображение: Кайл Стратис)

Чтобы лучше проиллюстрировать множественное наследование в действии, вот код, который вы можете опробовать, показывающий, как можно построить правильную пирамиду (пирамиду с квадратным основанием) из Triangle и Square:

class Triangle:
    def __init__(self, base, height):
        self.base = base
        self.height = height

    def area(self):
        return 0.5 * self.base * self.height

class RightPyramid(Triangle, Square):
    def __init__(self, base, slant_height):
        self.base = base
        self.slant_height = slant_height

    def area(self):
        base_area = super().area()
        perimeter = super().perimeter()
        return 0.5 * perimeter * self.slant_height + base_area

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

Наклонная высота - это высота от центра основания объекта (например, пирамиды) вверх по его грани до вершины этого объекта. Подробнее о наклонных высотах вы можете прочитать на сайте WolframMathWorld.

В этом примере объявлены класс Triangle и класс RightPyramid, которые наследуются от классов Square и Triangle.

Вы увидите еще один метод .area(), который использует super(), как и при одиночном наследовании, с целью достижения методов .perimeter() и .area(), определенных в классе Rectangle.

Примечание: Вы можете заметить, что приведенный выше код пока не использует никаких унаследованных свойств от класса Triangle. В последующих примерах будет полностью использовано наследование как от Triangle, так и от Square.

Однако проблема в том, что оба суперкласса (Triangle и Square) определяют .area(). Потратьте секунду и подумайте, что может произойти, когда вы вызовете .area() на RightPyramid, а затем попробуйте вызвать его, как показано ниже:

>> pyramid = RightPyramid(2, 4)
>> pyramid.area()
Traceback (most recent call last):
  File "shapes.py", line 63, in <module>
    print(pyramid.area())
  File "shapes.py", line 47, in area
    base_area = super().area()
  File "shapes.py", line 38, in area
    return 0.5 * self.base * self.height
AttributeError: 'RightPyramid' object has no attribute 'height'

Догадались ли вы, что Python попытается вызвать Triangle.area()? Это происходит из-за того, что называется порядком разрешения методов.

Примечание: Как мы заметили, что был вызван Triangle.area(), а не Square.area(), как мы надеялись? Если вы посмотрите на последнюю строку трассировки (перед AttributeError), то увидите ссылку на определенную строку кода:

return 0.5 * self.base * self.height

Возможно, вы узнаете это из курса геометрии как формулу для площади треугольника. В противном случае, если вы похожи на меня, вы могли пролистать вверх до определений классов Triangle и Rectangle и увидеть этот же код в Triangle.area().

Порядок разрешения метода

Порядок разрешения методов (или MRO) указывает Python, как искать унаследованные методы. Это очень удобно при использовании super(), потому что MRO указывает, где именно Python будет искать метод, который вы вызываете с помощью super(), и в каком порядке.

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

>>> RightPyramid.__mro__
(<class '__main__.RightPyramid'>, <class '__main__.Triangle'>, 
 <class '__main__.Square'>, <class '__main__.Rectangle'>, 
 <class 'object'>)

Это говорит нам о том, что методы будут искаться сначала в Rightpyramid, затем в Triangle, затем в Square, затем Rectangle, а затем, если ничего не будет найдено, в object, из которого происходят все классы.

Проблема здесь в том, что интерпретатор ищет .area() в Triangle перед Square и Rectangle, а найдя .area() в Triangle, Python вызывает его вместо того, который вам нужен. Поскольку Triangle.area() ожидает, что будут присутствовать атрибуты .height и .base, Python выбрасывает ошибку AttributeError.

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

class RightPyramid(Square, Triangle):
    def __init__(self, base, slant_height):
        self.base = base
        self.slant_height = slant_height
        super().__init__(self.base)

    def area(self):
        base_area = super().area()
        perimeter = super().perimeter()
        return 0.5 * perimeter * self.slant_height + base_area

Обратите внимание, что RightPyramid частично инициализируется .__init__() из класса Square. Это позволяет .area() использовать .length на объекте, как и задумано.

Теперь вы можете построить пирамиду, осмотреть ее MRO и вычислить площадь поверхности:

>>> pyramid = RightPyramid(2, 4)
>>> RightPyramid.__mro__
(<class '__main__.RightPyramid'>, <class '__main__.Square'>, 
<class '__main__.Rectangle'>, <class '__main__.Triangle'>, 
<class 'object'>)
>>> pyramid.area()
20.0

Вы видите, что MRO теперь то, что вы ожидали, и вы можете осмотреть область пирамиды также, благодаря .area() и .perimeter().

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

Это вызывает проблемы с разрешением методов, поскольку будет вызван первый экземпляр .area(), который встретится в списке MRO.

Когда вы используете super() с множественным наследованием, крайне важно спроектировать классы так, чтобы они взаимодействовали. Частью этого является обеспечение уникальности ваших методов, чтобы они разрешались в MRO, путем обеспечения уникальности сигнатур методов, будь то с помощью имен методов или параметров методов.

В этом случае, чтобы не переделывать весь код, можно переименовать метод Triangle класса .area() в .tri_area(). Таким образом, методы области смогут продолжать использовать свойства класса, а не принимать внешние параметры:

class Triangle:
    def __init__(self, base, height):
        self.base = base
        self.height = height
        super().__init__()

    def tri_area(self):
        return 0.5 * self.base * self.height

Давайте также используем это в классе RightPyramid:

class RightPyramid(Square, Triangle):
    def __init__(self, base, slant_height):
        self.base = base
        self.slant_height = slant_height
        super().__init__(self.base)

    def area(self):
        base_area = super().area()
        perimeter = super().perimeter()
        return 0.5 * perimeter * self.slant_height + base_area

    def area_2(self):
        base_area = super().area()
        triangle_area = super().tri_area()
        return triangle_area * 4 + base_area

Следующая проблема заключается в том, что в коде нет делегированного объекта Triangle, как в случае с объектом Square, поэтому вызов .area_2() даст нам AttributeError, поскольку .base и .height не имеют значений.

Чтобы исправить это, вам нужно сделать две вещи:

  1. Все методы, вызываемые с помощью super(), должны содержать вызов версии этого метода для своего суперкласса. Это означает, что вам нужно добавить super().__init__() к .__init__() методам Triangle и Rectangle.

  2. Переделайте все вызовы .__init__() так, чтобы они принимали словарь ключевых слов. Полный код смотрите ниже.

class Rectangle:
    def __init__(self, length, width, **kwargs):
        self.length = length
        self.width = width
        super().__init__(**kwargs)

    def area(self):
        return self.length * self.width

    def perimeter(self):
        return 2 * self.length + 2 * self.width

# Here we declare that the Square class inherits from 
# the Rectangle class
class Square(Rectangle):
    def __init__(self, length, **kwargs):
        super().__init__(length=length, width=length, **kwargs)

class Cube(Square):
    def surface_area(self):
        face_area = super().area()
        return face_area * 6

    def volume(self):
        face_area = super().area()
        return face_area * self.length

class Triangle:
    def __init__(self, base, height, **kwargs):
        self.base = base
        self.height = height
        super().__init__(**kwargs)

    def tri_area(self):
        return 0.5 * self.base * self.height

class RightPyramid(Square, Triangle):
    def __init__(self, base, slant_height, **kwargs):
        self.base = base
        self.slant_height = slant_height
        kwargs["height"] = slant_height
        kwargs["length"] = base
        super().__init__(base=base, **kwargs)

    def area(self):
        base_area = super().area()
        perimeter = super().perimeter()
        return 0.5 * perimeter * self.slant_height + base_area

    def area_2(self):
        base_area = super().area()
        triangle_area = super().tri_area()
        return triangle_area * 4 + base_area

В этом коде есть несколько важных отличий:

  • **kwargs изменен в некоторых местах (например, RightPyramid.__init__()):** Это позволит пользователям этих объектов инстанцировать их только с теми аргументами, которые имеют смысл для данного конкретного объекта.

  • Установка именованных аргументов перед **kwargs: Вы можете увидеть это в RightPyramid.__init__(). Это дает замечательный эффект - ключ вытаскивается прямо из словаря **kwargs, так что к тому времени, когда он окажется в конце MRO в классе object, **kwargs будет пуст.

Примечание: Следить за состоянием kwargs здесь может быть непросто, поэтому вот таблица вызовов .__init__() по порядку, показывающая класс, которому принадлежит этот вызов, и содержимое kwargs во время этого вызова:

Class Named Arguments kwargs
RightPyramid base, slant_height  
Square length base, height
Rectangle length, width base, height
Triangle base, height  

Теперь, когда вы используете эти обновленные классы, у вас есть следующее:

>>> pyramid = RightPyramid(base=2, slant_height=4)
>>> pyramid.area()
20.0
>>> pyramid.area_2()
20.0

Это работает! Вы использовали super() для успешной навигации по сложной иерархии классов, используя наследование и композицию для создания новых классов с минимальной повторной реализацией.

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

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

Если вы видите, что начинаете использовать множественное наследование и сложную иерархию классов, стоит спросить себя, можно ли добиться более чистого и понятного кода, используя композицию вместо наследования. Поскольку эта статья посвящена наследованию, я не буду слишком подробно рассказывать о композиции и о том, как использовать ее в Python. К счастью, Real Python опубликовал глубокое руководство по наследованию и композиции в Python, которое сделает вас профессионалом ООП в кратчайшие сроки.

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

Миксин работает как разновидность наследования, но вместо определения отношений "есть - есть" правильнее будет сказать, что он определяет отношения "включает - включает". С помощью mix-in вы можете написать поведение, которое может быть непосредственно включено в любое количество других классов.

Ниже приведен небольшой пример использования VolumeMixin для придания определенной функциональности нашим 3D-объектам, в данном случае для вычисления объема:

class Rectangle:
    def __init__(self, length, width):
        self.length = length
        self.width = width

    def area(self):
        return self.length * self.width

class Square(Rectangle):
    def __init__(self, length):
        super().__init__(length, length)

class VolumeMixin:
    def volume(self):
        return self.area() * self.height

class Cube(VolumeMixin, Square):
    def __init__(self, length):
        super().__init__(length)
        self.height = length

    def face_area(self):
        return super().area()

    def surface_area(self):
        return super().area() * 6

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

>>> cube = Cube(2)
>>> cube.surface_area()
24
>>> cube.volume()
8

Этот миксин можно использовать в любом другом классе, для которого определена площадь и для которого формула area * height возвращает правильный объем.

A super() Recap

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

Затем вы узнали, как работает множественное наследование в Python, и методы сочетания super() с множественным наследованием. Вы также узнали о том, как Python разрешает вызовы методов с помощью порядка разрешения методов (MRO), а также о том, как проверять и изменять MRO, чтобы обеспечить вызов соответствующих методов в нужное время.

Для получения дополнительной информации об объектно-ориентированном программировании на Python и использовании super(), ознакомьтесь с этими ресурсами:

Вернуться на верх