Единственная ответственность в ООП и композиция против наследования

Цели урока
  • Распознавать класс, у которого больше одной ответственности, и разбивать его
  • Объяснить, что такое композиция, и строить объект, составленный из других объектов
  • Различать has-a и is-a и выбирать между композицией и наследованием
  • Понимать, почему «композиция важнее наследования» — принятое эмпирическое правило

Что мы изучим?

  • Принцип единственной ответственности (Single Responsibility Principle, SRP) на уровне класса.
  • Композицию (Composition): объект, составленный из других объектов.
  • Композицию против наследования: как выбирать.

Зачем это нужно?

В теме чистого кода мы изучили SRP для функций: каждая функция делает одно дело. Ровно тот же принцип действует для классов, и там он ещё важнее, потому что большой класс — это «куча» данных и поведения. Когда каждое изменение (формат чека, способ оплаты, расчёт НДС) трогает один и тот же класс, он становится хрупким, и его страшно менять.

А в уроке о наследовании мы видели, что машина не должна наследовать двигатель. Сегодня увидим, что делать вместо этого.

Объяснение с основ

SRP на уровне класса

У каждого класса должна быть одна причина для изменения (ответственность = «причина для изменения»).

Как распознать класс со слишком большой ответственностью?

  1. Его трудно описать одним предложением без «и ещё».
  2. У него есть группы атрибутов, каждую из которых использует только часть методов.
  3. Он меняется по разным причинам: изменение дизайна чека, и изменение налоговых правил, и изменение отправки email.
  4. Его имя слишком общее: Manager, Handler, Processor, Utils, System.

Композиция — «имеет» (has-a)

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

class Car:
    def __init__(self):
        self.engine = Engine()       # the car HAS an engine
        self.gps = GPS()             # the car HAS a GPS

    def start(self):
        return self.engine.start()   # delegate the work to the part that knows how
Аналогия: компьютер

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

Композиция против наследования

НаследованиеКомпозиция
Отношениеis-a («является разновидностью»)has-a («имеет»)
ПримерManager(Employee)Car хранит Engine
СвязанностьСильная. Потомок зависит от всех деталей родителяСлабая. Общение только через интерфейс части
ГибкостьРешается при написании классаЧасть можно заменить во время выполнения
Когда что-то меняется в родителе/частиМожет сломать всех потомковВлияет только на того, кто использует интерфейс

Принятое в индустрии эмпирическое правило: «Favor Composition over Inheritance» — предпочитайте композицию. Используйте наследование, только когда есть настоящее отношение is-a и когда нужен полиморфизм.

Простой тест:

  • «X является разновидностью Y»? → наследование.
  • «У X есть Y»? / «X использует Y»? → композиция.

Синтаксис

class Part:
    def do_work(self):
        return "part did its job"

class Whole:
    def __init__(self, part):
        self.part = part                 # composition: hold a part object

    def work(self):
        return self.part.do_work()       # delegation: let the part do it

whole = Whole(Part())                    # the part is passed in (can be swapped)

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

Первый пример кода

God Class до разбиения:

class ShopSystem:
    def __init__(self):
        self.items = []
        self.vat = 0.17
        self.customer_email = ""

    def add_item(self, name, price, quantity):
        self.items.append((name, price, quantity))

    def subtotal(self):
        return sum(price * quantity for name, price, quantity in self.items)

    def total_with_vat(self):
        return round(self.subtotal() * (1 + self.vat), 2)

    def receipt_text(self):
        lines = []
        for name, price, quantity in self.items:
            lines.append(name + " x" + str(quantity) + " = " + str(price * quantity))
        lines.append("TOTAL: " + str(self.total_with_vat()))
        return "\n".join(lines)

    def send_receipt(self):
        print("Sending email to", self.customer_email)
        print(self.receipt_text())

Сколько здесь причин для изменения? (1) Как управляются позиции в корзине. (2) Как считается налог. (3) Как выглядит чек. (4) Как он отправляется. Четыре ответственности в одном классе.

После разбиения с помощью композиции. week-3/shop_composition.py:

class Cart:
    def __init__(self):
        self._items = []

    def add(self, name, price, quantity=1):
        if price < 0 or quantity <= 0:
            raise ValueError("invalid price or quantity")
        self._items.append((name, price, quantity))

    def items(self):
        return list(self._items)

    def subtotal(self):
        return sum(price * quantity for name, price, quantity in self._items)

class TaxCalculator:
    def __init__(self, rate):
        self.rate = rate

    def total(self, amount):
        return round(amount * (1 + self.rate), 2)

class ReceiptFormatter:
    def format(self, cart, total):
        lines = []
        for name, price, quantity in cart.items():
            lines.append(name + " x" + str(quantity) + " = " + str(price * quantity))
        lines.append("TOTAL: " + str(total))
        return "\n".join(lines)

class Checkout:
    def __init__(self, cart, tax, formatter):
        self.cart = cart
        self.tax = tax
        self.formatter = formatter

    def receipt(self):
        total = self.tax.total(self.cart.subtotal())
        return self.formatter.format(self.cart, total)

cart = Cart()
cart.add("Bread", 8, 2)
cart.add("Cheese", 32)

checkout = Checkout(cart, TaxCalculator(0.17), ReceiptFormatter())
print(checkout.receipt())

Вывод:

Bread x2 = 16
Cheese x1 = 32
TOTAL: 56.16

Разбор кода построчно

КлассОдна ответственностьОдна причина для изменения
CartУправление позициямиПравила корзины (количества, ограничения)
TaxCalculatorРасчёт налогаИзменение налогового закона
ReceiptFormatterФорматирование чека в текстИзменение дизайна чека
CheckoutСоединение частейИзменение процесса оформления
  • Checkout состоит из корзины, калькулятора налога и форматировщика (композиция) и делегирует им работу: self.tax.total(...), self.formatter.format(...). Сам он почти ничего не вычисляет.
  • Каждый класс маленький, понятный, и его можно протестировать отдельно: TaxCalculator(0.17).total(100) → 117.0.

Ещё один пример

Сила композиции: замена частей (композиция + полиморфизм). Добавляем другой форматировщик чека и калькулятор налога для освобождённого от налога бизнеса. Checkout вообще не меняется.

class HtmlReceiptFormatter:
    def format(self, cart, total):
        rows = ""
        for name, price, quantity in cart.items():
            rows = rows + "<li>" + name + " x" + str(quantity) + "</li>"
        return "<ul>" + rows + "</ul><b>Total: " + str(total) + "</b>"

class TaxExempt:
    def total(self, amount):
        return amount

html_checkout = Checkout(cart, TaxCalculator(0.17), HtmlReceiptFormatter())
print(html_checkout.receipt())

exempt_checkout = Checkout(cart, TaxExempt(), ReceiptFormatter())
print(exempt_checkout.receipt())

Вывод:

<ul><li>Bread x2</li><li>Cheese x1</li></ul><b>Total: 56.16</b>
Bread x2 = 16
Cheese x1 = 32
TOTAL: 48

Сравните с версией God Class: чтобы поддержать HTML-чек и освобождённый от налога бизнес, пришлось бы добавить if внутрь receipt_text и total_with_vat. Здесь каждый вариант — это часть, которую вы заменяете.

Композиция вместо наследования — классический пример

class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower
        self.running = False

    def start(self):
        self.running = True
        return "Engine started (" + str(self.horsepower) + " hp)"

class Car:
    def __init__(self, model, engine):
        self.model = model
        self.engine = engine

    def start(self):
        return self.model + ": " + self.engine.start()

    def upgrade_engine(self, new_engine):
        self.engine = new_engine

car = Car("Corolla", Engine(130))
print(car.start())
car.upgrade_engine(Engine(180))
print(car.start())
Corolla: Engine started (130 hp)
Corolla: Engine started (180 hp)

С наследованием (class Car(Engine)) «заменить двигатель» нельзя. Машина является двигателем. С композицией часть заменяется во время выполнения.

Типичная ошибка

Ошибка 1: наследование ради «полезных методов».

class Order(list):
    pass

order = Order()
order.append("pizza")
order.sort()
order.clear()        # an order that can be "cleared", "reversed", "popped"...?

Order получил все 40+ методов list, включая те, у которых нет смысла для заказа, и правила контролировать нельзя. Правильный способ: композиция, self._items = [] и открытие только того, что нужно заказу (add, items).

Ошибка 2: «открытая» композиция — обход сложного объекта.

checkout.cart._items.append(("Free TV", -500, 1))

Глубокая цепочка, которая трогает внутренности части, обходит её правила. Общайтесь с частью только через её публичные методы (checkout.cart.add(...)).

Ошибка 3: чрезмерное дробление. 15 классов по 3 строки для задачи, которая решается 2 понятными классами. SRP говорит «одна причина для изменения», а не «один метод на класс». Если два действия всегда меняются вместе, это, вероятно, одна ответственность.

Ошибка 4: «композиция», где часть создаётся внутри, всегда одна и та же. self.tax = TaxCalculator(0.17) внутри __init__ работает, но тогда её нельзя заменить. Передача части как параметра (Checkout(cart, tax, formatter)) даёт гибкость.

Практика

Упражнения опираются только на то, что пройдено в этом уроке и в предыдущих. Сначала попробуйте сами, и только потом открывайте решение.

Что вы отрабатываете на этой странице: одна ответственность на класс (SRP), композиция — класс, который хранит другие объекты, замена части без изменения остального и когда не использовать наследование.

К концу страницы вы будете уметь:

  • Распознавать класс, который делает слишком много, и разбивать его
  • Строить класс, составленный из других объектов (has-a)
  • Заменять компонент в объекте, не меняя использующий его код
  • Различать «является разновидностью» (наследование) и «имеет» (композиция)

Упражнение 1: Класс, который делает всё

Что вы делаете: только проектирование — распознаёте класс, который делает слишком много, и предлагаете разбиение.

Код:

class ShopSystem:
    def add_item(self, name, price, quantity): ...
    def subtotal(self): ...
    def add_vat(self, amount): ...
    def format_receipt(self): ...
    def send_email(self, address): ...

Задание:

  1. Сколько здесь разных ответственностей? Опишите каждую словами.
  2. Предложите разбиение на несколько классов: имя для каждого класса и какие методы в него переходят.

Что сдать: список ответственностей и таблицу классов и методов.

Показать решение
  1. Четыре ответственности: управление корзиной (позиции и сумма), расчёт налога, форматирование чека, отправка email. Каждая — отдельная «причина для изменения»: это God Class.
  2. Разбиение:
КлассОтветственностьМетоды
CartПозицииadd_item, subtotal
VatCalculatorНалогadd_vat
ReceiptFormatterФорматированиеformat_receipt
EmailSenderОтправкаsend_email

Упражнение 2: Композиция — корзина и касса

Что вы строите: корзину покупок и кассу — где касса составлена из корзины и калькулятора налога.

Что написать:

  1. Cart — с add, который получает название, цену и количество (по умолчанию 1), и subtotal, который возвращает сумму корзины.
  2. IsraeliVat — с apply, который получает сумму и возвращает её с НДС 17%.
  3. Checkout — получает корзину и калькулятор налога в __init__ и хранит их (has-a).
  4. В Checkout: total — сумма корзины после калькулятора налога.

Проверочный код:

cart = Cart()
cart.add("Pen", 5, 4)
cart.add("Bag", 60)
checkout = Checkout(cart, IsraeliVat())
print(checkout.total())

Желаемый вывод:

93.6
Показать решение
class Cart:
    def __init__(self):
        self._items = []

    def add(self, name, price, quantity=1):
        self._items.append((name, price, quantity))

    def subtotal(self):
        total = 0
        for name, price, quantity in self._items:
            total = total + price * quantity
        return total


class IsraeliVat:
    RATE = 0.17

    def apply(self, amount):
        return round(amount * (1 + IsraeliVat.RATE), 2)


class Checkout:
    def __init__(self, cart, tax):
        self.cart = cart
        self.tax = tax

    def total(self):
        return self.tax.apply(self.cart.subtotal())


cart = Cart()
cart.add("Pen", 5, 4)
cart.add("Bag", 60)
checkout = Checkout(cart, IsraeliVat())
print(checkout.total())
93.6

Checkout сам не вычисляет — он делегирует (Delegation) корзине и калькулятору налога, каждый из которых эксперт в одном деле.

Упражнение 3: Замена части

Что вы строите: продолжение кассы — заменяете калькулятор налога, не меняя Checkout.

Что написать:

  1. Новый класс с именем TaxExempt с apply, который возвращает сумму без изменений.
  2. Создайте одну кассу с IsraeliVat и вторую кассу с TaxExempt для одной и той же корзины и напечатайте сумму обеих.
  3. Проверьте: вы не изменили ни одной строки в Checkout.

Желаемый вывод:

93.6
80
Показать решение
class Cart:
    def __init__(self):
        self._items = []

    def add(self, name, price, quantity=1):
        self._items.append((name, price, quantity))

    def subtotal(self):
        total = 0
        for name, price, quantity in self._items:
            total = total + price * quantity
        return total


class IsraeliVat:
    RATE = 0.17

    def apply(self, amount):
        return round(amount * (1 + IsraeliVat.RATE), 2)


class TaxExempt:
    def apply(self, amount):
        return amount


class Checkout:
    def __init__(self, cart, tax):
        self.cart = cart
        self.tax = tax

    def total(self):
        return self.tax.apply(self.cart.subtotal())


cart = Cart()
cart.add("Pen", 5, 4)
cart.add("Bag", 60)
print(Checkout(cart, IsraeliVat()).total())
print(Checkout(cart, TaxExempt()).total())
93.6
80

В этом сила композиции + полиморфизма: каждый калькулятор налога реализует apply, и касса работает с любым из них.

Упражнение 4: Наследовать, чтобы сэкономить код?

Что вы делаете: распознаёте неправильное наследование и исправляете его композицией.

Код:

class Engine:
    def start(self):
        return "engine started"

class Car(Engine):
    def __init__(self, model):
        self.model = model

Задание:

  1. Почему наследование здесь неправильное, хотя код работает? Используйте тест «является разновидностью».
  2. Перепишите: Engine получает мощность в лошадиных силах, а Car получает модель и двигатель и хранит его (has-a). У Car есть start, который использует двигатель.

Проверочный код:

car = Car("Mazda", Engine(150))
print(car.start())

Желаемый вывод:

Mazda: engine 150hp started
Показать решение
  1. Машина не является разновидностью двигателя — у машины есть двигатель. Наследование сделали только чтобы «получить» start, и теперь Car включает всё, что есть у двигателя, а двигатель нельзя заменить.
  2. has-a:
class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower

    def start(self):
        return "engine " + str(self.horsepower) + "hp started"


class Car:
    def __init__(self, model, engine):
        self.model = model
        self.engine = engine

    def start(self):
        return self.model + ": " + self.engine.start()


car = Car("Mazda", Engine(150))
print(car.start())
Mazda: engine 150hp started

Упражнение 5: Компьютер из частей

Что вы строите: компьютер, составленный из процессора, памяти и диска.

Что написать:

  1. CPU — с числом ядер и частотой в ГГц.
  2. RAM — с объёмом в ГБ.
  3. Disk — с объёмом в ГБ.
  4. Computer — получает три части и хранит их. У него есть __str__, который описывает компьютер, как в выводе, и can_run, который получает минимум ядер и минимум памяти и возвращает True или False.

Проверочный код:

pc = Computer(CPU(8, 3.2), RAM(8), Disk(512))
print(pc)
print("Can run the game?", pc.can_run(6, 16))

Желаемый вывод:

8-core 3.2GHz CPU, 8GB RAM, 512GB SSD
Can run the game? False
Показать решение
class CPU:
    def __init__(self, cores, ghz):
        self.cores = cores
        self.ghz = ghz

    def __str__(self):
        return str(self.cores) + "-core " + str(self.ghz) + "GHz CPU"


class RAM:
    def __init__(self, gb):
        self.gb = gb

    def __str__(self):
        return str(self.gb) + "GB RAM"


class Disk:
    def __init__(self, gb):
        self.gb = gb

    def __str__(self):
        return str(self.gb) + "GB SSD"


class Computer:
    def __init__(self, cpu, ram, disk):
        self.cpu = cpu
        self.ram = ram
        self.disk = disk

    def can_run(self, min_cores, min_ram):
        return self.cpu.cores >= min_cores and self.ram.gb >= min_ram

    def __str__(self):
        return str(self.cpu) + ", " + str(self.ram) + ", " + str(self.disk)


pc = Computer(CPU(8, 3.2), RAM(8), Disk(512))
print(pc)
print("Can run the game?", pc.can_run(6, 16))
8-core 3.2GHz CPU, 8GB RAM, 512GB SSD
Can run the game? False

Ядер достаточно (8 ≥ 6), но RAM только 8 ГБ из требуемых 16. Каждая часть умеет представлять себя, а Computer только соединяет их.

Итоги

  • SRP в классе: одна причина для изменения. God Class (...Manager, ...System) → разбейте.
  • Композиция = объект, составленный из других объектов (has-a), который делегирует им работу.
  • Наследование — только для настоящего is-a. Для has-a или «использует» → композиция.
  • Favor Composition over Inheritance: слабая связанность и заменяемые части.
  • Передавайте части как параметры в __init__, чтобы их можно было заменить (композиция + полиморфизм).
  • Общайтесь с частью через её интерфейс, а не через внутренности.

Официальная документация