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

Наследование как инструмент создания новых объектов

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

Предположим, у нас есть класс Animal, на основе которого мы хотим создать объект Cat.

import random

class Animal:
    hungry = random.randint(0, 10)

    def eat(self):
        self.hungry -= 1

Классу Animal присуще поведение всех животных. Например: «все животные едят» — это утверждение не противоречит и в отношении кошек. Но кошки ещё и мяукают, чего нельзя сказать обо всех остальных животных. Поэтому в классе Cat мне нужно добавить способность мяукать, при этом, сохранив способность питаться. Для этого существует механизм наследования:

class Cat(Animal):
    def meow(self):
        print("meow!")

Несмотря на то, что в коде класса Cat не присутствует метод eat, при создании объекта он появится в качестве ссылки на класс Animal:

barsik = Cat()
barsik.hungry
barsik.eat()
barsik.hungry
barsik.meow()
7
6
meow!

Благодаря механизму наследования я могу создать класс Dog, который будет, к примеру, гавкать и не потеряет способности кушать.

Выводы

  • Наследование расширяет родительский класс дочерним.

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

  • Дочерний класс всегда зависит от родительского.

  • Методы и атрибуты родительского класса становятся собственными методами дочернего.

Композиция как средство расширения существующих объектов

В отличие от наследования, композиция не связана с иерархией классов. Она не предназначена для создания новых объектов, а скорей для расширения существующих.

Предположим, что у нашей кошки есть хозяин — довольно сложный объект. Сложный потому, что это не просто атрибут, который можно добавить в класс Cat. Это полноценный объект со своим поведением.

Ключевые слова в принятии решения в пользу композиции — это: «довольно сложный объект». Иначе говоря, это класс, который не наследуется явно, а встраивается в существующий в качестве атрибута или сам содержит таковой.

class CatOwner:
    age = random.randint(0,99)
    name = ""
    surname = ""
    profession = ""
    cat = Cat() #  композиция

    def feed_cat(self):
        self.cat.eat()

Ещё один способ понять, что вам нужна композиция — это неуместность наследования. Наследование предполагает однородность. Владелец кошки и кошка не одного рода или ещё: владелец кошки — это не животное (не наследует класс Animal). Вместо этого: у владельца есть кошка; или конкретней: владелец состоит из кошки (а также из своих атрибутов).

Выводы

  • Композиция связывает два разнородных объекта.

  • Композиция подходит для «сборки» сложного объекта из более простых.

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

  • При композиции объекты могут сохранять независимую инициализацию (не зависят друг от друга).

Реальный пример

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

Итак, у меня есть класс MyBot, в котором я ошибочно использовал композицию:

import telegram

class MyBot(NetworkExceptionHandler):
    """
    RSS/Atom feed bot for Telegram. Processes images for the RSS entries,
    Attaches the text message and buttons for the Telegram message.
    Sends it to the channel.
    """
    def __init__(self) -> None:
        super().__init__()
        self._bot = telegram.Bot(token='1367659674:Wb0q07PCbuZDu9LR5BF01i66Fow0UXjv')

В последней строке я делаю объект класса Bot модуля telegram скрытым атрибутом собственного класса! Не знаю что на меня нашло, но когда я это увидел, я в спешке бросился клонировать репозиторий на локальную машину с целью исправить это недоразумение.

Собственно, недоразумение состоит в том, что класс MyBot в качестве композиции использует почти одноименный класс Bot, то есть класс, равный ему по своему качеству. Гипотетически «бот может состоять из (другого) бота» в случае композиции, но, согласитесь, куда логичней утверждение, что «мой бот — это бот Telegram». А значит, речь идёт об однородных объектах как это бывает в случае наследования. Более того, при наследовании, MyBot расширял бы класс Bot модуля telegram и сам бы он являлся просто ботом, а не «ботом в боте» как в случае с композицией.

Здесь я могу только процитировать:

Простое лучше, чем сложное.

Плоское лучше, чем вложенное.

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

Рефакторинг

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

class MyBot(NetworkExceptionHandler, telegram.Bot):
    def __init__(self) -> None:
        super().__init__(token='1367659674:Wb0q07PCbuZDu9LR5BF01i66Fow0UXjv')

В итоге я выиграл лишнюю строчку кода!

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