Единственная ответственность в ООП и композиция против наследования
- Распознавать класс, у которого больше одной ответственности, и разбивать его
- Объяснить, что такое композиция, и строить объект, составленный из других объектов
- Различать has-a и is-a и выбирать между композицией и наследованием
- Понимать, почему «композиция важнее наследования» — принятое эмпирическое правило
Что мы изучим?
- Принцип единственной ответственности (Single Responsibility Principle, SRP) на уровне класса.
- Композицию (Composition): объект, составленный из других объектов.
- Композицию против наследования: как выбирать.
Зачем это нужно?
В теме чистого кода мы изучили SRP для функций: каждая функция делает одно дело. Ровно тот же принцип действует для классов, и там он ещё важнее, потому что большой класс — это «куча» данных и поведения. Когда каждое изменение (формат чека, способ оплаты, расчёт НДС) трогает один и тот же класс, он становится хрупким, и его страшно менять.
А в уроке о наследовании мы видели, что машина не должна наследовать двигатель. Сегодня увидим, что делать вместо этого.
Объяснение с основ
SRP на уровне класса
У каждого класса должна быть одна причина для изменения (ответственность = «причина для изменения»).
Как распознать класс со слишком большой ответственностью?
- Его трудно описать одним предложением без «и ещё».
- У него есть группы атрибутов, каждую из которых использует только часть методов.
- Он меняется по разным причинам: изменение дизайна чека, и изменение налоговых правил, и изменение отправки email.
- Его имя слишком общее:
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): ...Задание:
- Сколько здесь разных ответственностей? Опишите каждую словами.
- Предложите разбиение на несколько классов: имя для каждого класса и какие методы в него переходят.
Что сдать: список ответственностей и таблицу классов и методов.
Показать решение
- Четыре ответственности: управление корзиной (позиции и сумма), расчёт налога, форматирование чека, отправка email. Каждая — отдельная «причина для изменения»: это God Class.
- Разбиение:
| Класс | Ответственность | Методы |
|---|---|---|
Cart | Позиции | add_item, subtotal |
VatCalculator | Налог | add_vat |
ReceiptFormatter | Форматирование | format_receipt |
EmailSender | Отправка | send_email |
Упражнение 2: Композиция — корзина и касса
Что вы строите: корзину покупок и кассу — где касса составлена из корзины и калькулятора налога.
Что написать:
Cart— сadd, который получает название, цену и количество (по умолчанию 1), иsubtotal, который возвращает сумму корзины.IsraeliVat— сapply, который получает сумму и возвращает её с НДС 17%.Checkout— получает корзину и калькулятор налога в__init__и хранит их (has-a).- В
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.6Checkout сам не вычисляет — он делегирует (Delegation) корзине и калькулятору налога, каждый из которых эксперт в одном деле.
Упражнение 3: Замена части
Что вы строите: продолжение кассы — заменяете калькулятор налога, не меняя Checkout.
Что написать:
- Новый класс с именем
TaxExemptсapply, который возвращает сумму без изменений. - Создайте одну кассу с
IsraeliVatи вторую кассу сTaxExemptдля одной и той же корзины и напечатайте сумму обеих. - Проверьте: вы не изменили ни одной строки в
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Задание:
- Почему наследование здесь неправильное, хотя код работает? Используйте тест «является разновидностью».
- Перепишите:
Engineполучает мощность в лошадиных силах, аCarполучает модель и двигатель и хранит его (has-a). УCarестьstart, который использует двигатель.
Проверочный код:
car = Car("Mazda", Engine(150))
print(car.start())Желаемый вывод:
Mazda: engine 150hp startedПоказать решение
- Машина не является разновидностью двигателя — у машины есть двигатель. Наследование сделали только чтобы «получить»
start, и теперьCarвключает всё, что есть у двигателя, а двигатель нельзя заменить. - 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: Компьютер из частей
Что вы строите: компьютер, составленный из процессора, памяти и диска.
Что написать:
CPU— с числом ядер и частотой в ГГц.RAM— с объёмом в ГБ.Disk— с объёмом в ГБ.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__, чтобы их можно было заменить (композиция + полиморфизм). - Общайтесь с частью через её интерфейс, а не через внутренности.