Что такое композиция и зачем она нужна
Мы уже умеем строить иерархии через наследование — когда один класс является разновидностью другого. Но в жизни объекты часто не наследуют друг друга, а собираются из частей. Автомобиль не «является» двигателем — он его содержит. Именно эту идею воплощает композиция — один из мощнейших приёмов объектно-ориентированного проектирования.
🔑 Центральная идея: is-a vs has-a
Прежде чем писать код, нужно понять два фундаментальных вопроса о связях между объектами. Каждый раз, когда вы проектируете классы, задавайте себе один из них:
is-a
«является разновидностью»
Dog is a Animal
Cat is a Animal
ElectricCar is a Car
→ Используй наследование
has-a
«содержит как часть»
Car has a Engine
Person has a Address
Order has a Product
→ Используй композицию
💡 Как запомнить? Подставьте слова «является» и «содержит» в предложение про ваши объекты. Автомобиль является двигателем? Звучит нелепо. Автомобиль содержит двигатель? Да, именно так! Если предложение со словом «является» звучит естественно — наследование. Если естественнее «содержит» — композиция.
Это кажется простым, но именно неправильный ответ на этот вопрос приводит к самым запутанным и хрупким архитектурам в программировании. Разберём, как правильно реализовать оба подхода.
🧩 Что такое композиция
Композиция — это когда один объект содержит другой объект как свою часть. Объект-«контейнер» создаёт или получает объект-«деталь» и хранит его в своём атрибуте.
Слово «композиция» не случайное — как музыкальное произведение составляется из нот и тем, так и сложный объект составляется из более простых объектов. Это принцип сборки: из маленьких хорошо определённых частей строим сложное целое.
🚗 Аналогия с автомобилем. Настоящий автомобиль собирается на конвейере из отдельных узлов: двигатель, коробка передач, кузов, колёса. Каждый узел — самостоятельная деталь, которую можно заменить отдельно. Так же и в коде: класс Car не наследует Engine — он содержит объект Engine внутри себя.
В Python это реализуется просто: в методе __init__ класса-контейнера создаём экземпляр класса-части и сохраняем его в атрибуте. Давайте посмотрим на код.
💻 Пример 1: Car содержит Engine
Начнём с простого примера. Сначала определим класс Engine — двигатель. Затем класс Car, который будет создавать объект двигателя внутри своего __init__.
class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower # мощность в лошадиных силах
def start(self):
print(f"Двигатель запущен! Мощность: {self.horsepower} л.с.")
def stop(self):
print("Двигатель остановлен.")
class Car:
def __init__(self, brand, horsepower):
self.brand = brand
# Композиция: Car СОДЕРЖИТ Engine — создаём объект внутри __init__
self.engine = Engine(horsepower)
def drive(self):
print(f"{self.brand} едет!")
self.engine.start() # Делегируем вызов объекту-части
def park(self):
self.engine.stop()
print(f"{self.brand} припаркован.")
# Создаём автомобиль — двигатель создаётся автоматически внутри
my_car = Car("Toyota", 150)
my_car.drive()
# Toyota едет!
# Двигатель запущен! Мощность: 150 л.с.
my_car.park()
# Двигатель остановлен.
# Toyota припаркован.
# Можно обратиться к двигателю напрямую через атрибут
print(my_car.engine.horsepower) # 150
🎯 Ключевая строка: self.engine = Engine(horsepower) — вот она, композиция. Объект Car создаёт объект Engine и хранит его в атрибуте engine. Теперь Car может делегировать работу своей части: вызывать self.engine.start() и self.engine.stop().
Делегирование — важное слово. Вместо того чтобы дублировать логику двигателя внутри Car, мы просто передаём задачу нужной части. Разделение ответственности: Engine знает, как запускаться и останавливаться, а Car знает, как ехать и парковаться.
🔧 Пример 2: гибкость через инъекцию зависимости
В первом примере Car сам создавал двигатель. Но что, если мы хотим подставить разные двигатели? Например, обычный бензиновый или электрический? Для этого вместо создания двигателя внутри мы передаём его снаружи как параметр.
Это называется инъекция зависимости (Dependency Injection) — длинное название для простой идеи: «не создавай зависимость внутри себя, получи её снаружи».
class PetrolEngine:
def __init__(self, horsepower):
self.horsepower = horsepower
def start(self):
print(f"Бензиновый двигатель запущен! {self.horsepower} л.с.")
class ElectricEngine:
def __init__(self, kilowatts):
self.kilowatts = kilowatts
def start(self):
print(f"Электромотор запущен! Мощность: {self.kilowatts} кВт. Тихо и плавно.")
class Car:
def __init__(self, brand, engine):
# Теперь engine ПЕРЕДАЁТСЯ снаружи, а не создаётся внутри
self.brand = brand
self.engine = engine # Принимаем любой объект с методом start()
def drive(self):
print(f"{self.brand} трогается...")
self.engine.start()
# Создаём разные двигатели отдельно
petrol = PetrolEngine(200)
electric = ElectricEngine(150)
# Подставляем нужный двигатель в автомобиль
bmw = Car("BMW", petrol)
tesla = Car("Tesla", electric)
bmw.drive()
# BMW трогается...
# Бензиновый двигатель запущен! 200 л.с.
tesla.drive()
# Tesla трогается...
# Электромотор запущен! Мощность: 150 кВт. Тихо и плавно.
💡 Главное преимущество — гибкость. Класс Car не знает и не заботится о том, какой именно двигатель ему дали — бензиновый, электрический или водородный. Он просто вызывает self.engine.start(). Пока у двигателя есть метод start() — всё работает. Хотите заменить двигатель? Создайте новый объект и передайте его в Car.
Вы только что столкнулись с идеей полиморфизма через композицию — разные объекты с одинаковым интерфейсом (оба имеют start()) взаимозаменяемо используются внутри Car. Это очень мощная техника.
🚫 Пример 3: почему наследование здесь абсурдно
Посмотрим, что произойдёт, если мы попытаемся выразить отношение «Car содержит Engine» через наследование. Это распространённая ошибка новичков — и она наглядно показывает, почему важно правильно различать is-a и has-a.
# ❌ НЕПРАВИЛЬНО: наследование вместо композиции
class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower
def start(self):
print(f"Двигатель запущен! {self.horsepower} л.с.")
class Car(Engine): # Car наследует Engine — звучит нелепо!
def __init__(self, brand, horsepower):
super().__init__(horsepower)
self.brand = brand
def drive(self):
print(f"{self.brand} едет!")
self.start() # Метод Engine теперь у Car — странно!
my_car = Car("Toyota", 150)
my_car.drive()
# Toyota едет!
# Двигатель запущен! 150 л.с.
# Вроде бы работает... Но посмотрите на абсурд:
print(my_car.horsepower) # 150 — автомобиль имеет лошадиные силы как атрибут?
my_car.start() # Car сам является двигателем?!
# А ещё: Car теперь ограничен ОДНИМ двигателем навсегда.
# Нельзя легко заменить двигатель — он встроен в саму структуру класса.
# И Car нельзя наследовать от другого класса — Python поддерживает
# множественное наследование, но это быстро становится хаосом.
🚨 Проблемы неправильного наследования:
1. Объект Car становится «двигателем» — нарушена логика предметной области
2. Невозможно заменить двигатель — он зашит в ДНК класса через наследование
3. Car наследует ВСЕ атрибуты и методы Engine, в том числе ненужные
4. Легко сломать: изменение Engine ломает Car, хотя они концептуально независимы
✅ Правило: если вы можете сказать «A является B» — наследование уместно. Если правда «A содержит B» — используйте композицию. Звучит просто, но именно это разграничение лежит в основе хорошей архитектуры.
👤 Пример 4: Person и Address
Рассмотрим ещё один классический пример. У нас есть Person (человек) и Address (адрес). Как их связать?
Проверяем: «Человек является адресом»? Нет, абсурд. «Человек содержит адрес»? Да, у каждого человека есть адрес проживания. Значит — композиция.
class Address:
def __init__(self, city, street, building):
self.city = city
self.street = street
self.building = building
def full_address(self):
return f"{self.city}, ул. {self.street}, д. {self.building}"
class Person:
def __init__(self, name, age, city, street, building):
self.name = name
self.age = age
# Композиция: Person HAS-A Address
self.address = Address(city, street, building)
def introduce(self):
print(f"Привет! Я {self.name}, {self.age} лет.")
print(f"Живу по адресу: {self.address.full_address()}")
alice = Person("Алиса", 28, "Москва", "Ленина", 15)
alice.introduce()
# Привет! Я Алиса, 28 лет.
# Живу по адресу: Москва, ул. Ленина, д. 15
# Доступ к объекту-части через атрибут
print(alice.address.city) # Москва
print(alice.address.full_address()) # Москва, ул. Ленина, д. 15
# Можно изменить адрес — просто присвоить новый объект Address
alice.address = Address("Санкт-Петербург", "Невский", 1)
alice.introduce()
# Привет! Я Алиса, 28 лет.
# Живу по адресу: Санкт-Петербург, ул. Невский, д. 1
💡 Обратите внимание на гибкость: чтобы поменять адрес человека, мы просто создаём новый объект Address и присваиваем его атрибуту alice.address. Если бы мы попытались вместить все поля адреса прямо в Person (city, street, building как отдельные атрибуты), то: 1) класс распух бы, 2) нельзя было бы переиспользовать Address в других классах (например, в Company).
Посмотрим для сравнения на сценарий с наследованием — чисто чтобы понять, почему это плохая идея:
# ❌ НЕПРАВИЛЬНО: Person наследует Address
class Person(Address):
def __init__(self, name, age, city, street, building):
super().__init__(city, street, building)
self.name = name
self.age = age
alice = Person("Алиса", 28, "Москва", "Ленина", 15)
# Person "является" Address? — Абсурд!
# Alice теперь "является" адресом, а не "имеет" адрес.
# full_address() у самой Alice — странно!
alice.full_address() # Москва, ул. Ленина, д. 15 — но это метод Адреса!
# Хуже того: у Alice есть атрибуты city, street, building прямо на ней:
print(alice.city) # Москва
# Алиса является городом? Нет, это бессмыслица.
Разница принципиальная. Наследование говорит: «Алиса — это адрес». Композиция говорит: «У Алисы есть адрес». Второе соответствует реальности — используйте композицию.
📐 Когда применять композицию
Вот практическая шпаргалка: в каких ситуациях выбирать композицию.
🧩 Отношение часть-целое
Один объект логически является частью другого: колесо — часть машины, страница — часть книги, строка — часть таблицы.
🔄 Нужна замена части
Хотите легко подменять «детали»: разные движки, разные алгоритмы сортировки, разные форматы вывода — используйте композицию.
♻️ Переиспользование
Класс Address нужен и в Person, и в Company, и в Order — создайте один раз и используйте везде через композицию.
⚠️ Популярный совет в ООП: «Предпочитай композицию наследованию» (Favor composition over inheritance). Это не значит «никогда не используй наследование» — это значит, что при сомнении лучше выбрать композицию. Она гибче, проще тестировать и реже приводит к запутанным иерархиям.
Наследование стоит выбирать, когда действительно есть иерархия типов: Dog — это Animal, Square — это Shape. Но если связь «содержит» — всегда предпочитайте композицию.
✅ Итоги урока
- is-a («является») — сигнал для наследования: Dog is an Animal.
- has-a («содержит») — сигнал для композиции: Car has an Engine.
- Композиция — один объект хранит другой объект в своём атрибуте:
self.engine = Engine(...). - Метод делегирования: вместо дублирования логики — вызываем метод части (
self.engine.start()). - Инъекция зависимости — передавать объект-часть снаружи через параметр, а не создавать его внутри. Это делает замену деталей гибкой.
- Наследование вместо композиции — антипаттерн: объект «становится» деталью, теряется гибкость замены, код становится хрупким.
- Золотое правило: Предпочитай композицию наследованию.
👉 В следующем уроке мы разберём агрегацию — вариант композиции, при котором объект-часть существует независимо от контейнера и может принадлежать нескольким объектам одновременно. Поймём, чем агрегация отличается от «жёсткой» композиции — и когда применять каждую из этих форм связи.