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

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

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

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

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

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

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

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

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

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

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

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

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

```python
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
```

{{< box "analogy" "Аналогия: компьютер" >}}
Компьютер **состоит из** процессора, памяти, диска и видеокарты (вспомните тему о компьютере и операционной системе!). Компьютер не «наследует» процессор. У него **есть** процессор. Каждый компонент отвечает за одну задачу, и компонент можно **заменить** (диск побольше), не меняя остальное. Это и есть композиция.
{{< /box >}}

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

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

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

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

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

## Синтаксис

```python
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 до разбиения:**

```python
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`:

```python
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())
```

**Вывод:**

```text
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` вообще не меняется.**

```python
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())
```

**Вывод:**

```text
<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`. Здесь **каждый вариант — это часть, которую вы заменяете**.

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

```python
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())
```

```text
Corolla: Engine started (130 hp)
Corolla: Engine started (180 hp)
```

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

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

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

```python
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: «открытая» композиция — обход сложного объекта.**

```python
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)`) даёт гибкость.

<!-- exercises:start — generated by scripts/intro6/sync_site_exercises.py; edit scripts/intro6/exercises/ -->
## Практика

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

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

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

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

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

**Код:**

```text
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. Предложите разбиение на несколько классов: имя для каждого класса и какие методы в него переходят.

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

{{< solution >}}
1. Четыре ответственности: **управление корзиной** (позиции и сумма), **расчёт налога**, **форматирование чека**, **отправка email**. Каждая — отдельная «причина для изменения»: это **God Class**.
2. Разбиение:

| Класс | Ответственность | Методы |
|---|---|---|
| `Cart` | Позиции | `add_item`, `subtotal` |
| `VatCalculator` | Налог | `add_vat` |
| `ReceiptFormatter` | Форматирование | `format_receipt` |
| `EmailSender` | Отправка | `send_email` |
{{< /solution >}}

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

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

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

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

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

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

```text
93.6
```

{{< solution >}}
```python
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())
```

```text
93.6
```

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

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

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

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

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

```text
93.6
80
```

{{< solution >}}
```python
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())
```

```text
93.6
80
```

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

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

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

**Код:**

```text
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`, который использует двигатель.

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

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

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

```text
Mazda: engine 150hp started
```

{{< solution >}}
1. Машина **не является разновидностью** двигателя — у машины **есть** двигатель. Наследование сделали только чтобы «получить» `start`, и теперь `Car` включает всё, что есть у двигателя, а двигатель нельзя заменить.
2. **has-a**:

```python
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())
```

```text
Mazda: engine 150hp started
```
{{< /solution >}}

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

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

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

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

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

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

```text
8-core 3.2GHz CPU, 8GB RAM, 512GB SSD
Can run the game? False
```

{{< solution >}}
```python
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))
```

```text
8-core 3.2GHz CPU, 8GB RAM, 512GB SSD
Can run the game? False
```

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

<!-- exercises:end -->

## Итоги

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

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

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

- [Python Tutorial — Classes](https://docs.python.org/3/tutorial/classes.html)
- [Python Tutorial — Inheritance](https://docs.python.org/3/tutorial/classes.html#inheritance)

<!-- docs:end -->
