$ sudo teach IT
МОДУЛЬ 6 · УРОК 3

Агрегация и её отличие от композиции

В прошлом уроке мы освоили композицию — объект создаёт свои части внутри себя. Сегодня разберём агрегацию: когда части могут жить отдельно от целого. Разница кажется мелкой — но именно она определяет, как грамотно проектировать базы данных, ORM-модели и целые системы.

⏱ ~40 минут 🎓 Для новичков 🐍 Python 3

🔑 Ключевая идея: жизненный цикл объектов

И композиция, и агрегация описывают отношение «целое — часть». У дома есть комнаты, у команды есть игроки, у отдела есть сотрудники. Технически оба подхода похожи. Но между ними есть одна фундаментальная разница:

Могут ли «части» существовать без «целого»?
Если нет — это композиция. Если да — это агрегация.

🏠

Композиция

Части принадлежат целому

Дом уничтожен → комнат тоже нет.
Комнаты были созданы внутри дома и без него не существуют.

Объект СОЗДАЁТСЯ внутри __init__

⚽

Агрегация

Части живут самостоятельно

Команда расформирована → игроки живы.
Они существовали до команды и продолжат жить после.

Объект ПЕРЕДАЁТСЯ снаружи

Именно жизненный цикл частей отличает эти два паттерна. Давайте посмотрим на оба — сначала в жизни, потом в коде.

🏠 Композиция: дом создаёт свои комнаты

Комнаты — это неотъемлемая часть дома. Они создаются вместе с домом, и без дома они существовать не могут. В коде это выражается просто: объекты-части создаются внутри метода __init__.

class Room:
    """Комната — часть дома."""
    def __init__(self, name, area):
        self.name = name    # название: "Спальня", "Кухня" и т.д.
        self.area = area    # площадь в кв.м

    def __repr__(self):
        return f"Room('{self.name}', {self.area} м²)"


class House:
    """Дом — владелец своих комнат."""
    def __init__(self, address):
        self.address = address
        # ✅ КОМПОЗИЦИЯ: комнаты СОЗДАЮТСЯ внутри __init__
        # Они принадлежат этому конкретному дому и больше никому.
        self.rooms = [
            Room("Гостиная", 25),
            Room("Спальня", 18),
            Room("Кухня", 12),
        ]

    def total_area(self):
        return sum(r.area for r in self.rooms)

    def describe(self):
        print(f"Дом по адресу: {self.address}")
        print(f"Комнаты ({len(self.rooms)}):")
        for r in self.rooms:
            print(f"  - {r.name}: {r.area} м²")
        print(f"Общая площадь: {self.total_area()} м²")


house = House("ул. Пушкина, 10")
house.describe()
# Дом по адресу: ул. Пушкина, 10
# Комнаты (3):
#   - Гостиная: 25 м²
#   - Спальня: 18 м²
#   - Кухня: 12 м²
# Общая площадь: 55 м²

# Когда дом «уничтожается» (выходит из области видимости),
# комнаты тоже исчезают — у них нет жизни вне этого дома.
del house
# Теперь ни house, ни его комнаты недоступны.

🔍 Обратите внимание: никто снаружи не передаёт комнаты в дом. Дом сам создаёт Room(...) внутри __init__. Это и есть признак композиции — объект сам порождает свои части.

⚽ Агрегация: команда принимает готовых игроков

Игрок существует сам по себе: у него есть имя, возраст, история. Он может войти в команду, потом перейти в другую, потом оказаться в третьей. Команда не создаёт игроков — она их принимает извне. Это агрегация.

class Player:
    """Игрок — самостоятельный объект. Живёт независимо от команды."""
    def __init__(self, name, position):
        self.name = name
        self.position = position    # позиция: "нападающий", "вратарь" и т.д.

    def __repr__(self):
        return f"Player('{self.name}', '{self.position}')"


class Team:
    """Команда — агрегирует игроков, но не владеет ими."""
    def __init__(self, name):
        self.name = name
        # ✅ АГРЕГАЦИЯ: список пустой — игроки добавляются снаружи
        self.players = []

    def add_player(self, player):
        """Принимаем готового игрока извне."""
        self.players.append(player)
        print(f"{player.name} вступил в команду '{self.name}'")

    def remove_player(self, player):
        """Игрок уходит из команды, но сам объект player НЕ удаляется."""
        self.players.remove(player)
        print(f"{player.name} покинул команду '{self.name}'")

    def describe(self):
        print(f"\nКоманда: {self.name} ({len(self.players)} игроков)")
        for p in self.players:
            print(f"  - {p.name} ({p.position})")


# Создаём игроков ОТДЕЛЬНО — они существуют сами по себе
player1 = Player("Иванов", "нападающий")
player2 = Player("Петров", "защитник")
player3 = Player("Сидоров", "вратарь")

# Создаём команду
team = Team("Динамо")

# Передаём игроков в команду — АГРЕГАЦИЯ
team.add_player(player1)   # Иванов вступил в команду 'Динамо'
team.add_player(player2)   # Петров вступил в команду 'Динамо'
team.add_player(player3)   # Сидоров вступил в команду 'Динамо'
team.describe()

# Команда расформирована — объект удалён
del team
# НО игроки живы! Они по-прежнему доступны:
print(player1)   # Player('Иванов', 'нападающий')
print(player2)   # Player('Петров', 'защитник')

# Один игрок может быть в нескольких командах одновременно
team_a = Team("Спартак")
team_b = Team("ЦСКА")

team_a.add_player(player1)   # Иванов вступил в команду 'Спартак'
team_b.add_player(player1)   # Иванов вступил в команду 'ЦСКА'
# player1 теперь числится в обеих командах —
# при композиции такое невозможно!

✅ Два признака агрегации в этом коде:
1. Игроки созданы до команды и за пределами класса Team.
2. После del team объекты player1, player2, player3 живы — они не были «собственностью» команды.

🔬 Сравнение бок о бок: Department и Employee

Посмотрим на ещё один пример — отдел компании и сотрудников. Сотрудники — самостоятельные люди: они существовали до прихода в отдел и продолжат жить после увольнения. Отдел их агрегирует, а не порождает.

class Employee:
    """Сотрудник — полностью самостоятельный объект."""
    def __init__(self, name, role):
        self.name = name
        self.role = role

    def work(self):
        print(f"{self.name} ({self.role}) работает")

    def __repr__(self):
        return f"Employee('{self.name}', '{self.role}')"


class Department:
    """Отдел агрегирует сотрудников.

    Принципиально: сотрудники ПЕРЕДАЮТСЯ снаружи,
    не создаются внутри __init__.
    """
    def __init__(self, name):
        self.name = name
        self.employees = []    # изначально пусто

    def hire(self, employee):
        """Принять сотрудника на работу в отдел."""
        self.employees.append(employee)

    def fire(self, employee):
        """Уволить сотрудника — он уходит, но как объект продолжает жить."""
        self.employees.remove(employee)

    def daily_standup(self):
        print(f"\n📋 Стендап отдела '{self.name}':")
        for emp in self.employees:
            emp.work()


# --- Создаём сотрудников независимо от отделов ---
alice = Employee("Алиса", "бэкенд-разработчик")
bob   = Employee("Боб",   "дизайнер")
carol = Employee("Кэрол", "тестировщик")
dave  = Employee("Дэйв",  "аналитик")

# --- Создаём отделы ---
dev_dept = Department("Разработка")
qa_dept  = Department("QA")

# --- Наполняем отделы через агрегацию ---
dev_dept.hire(alice)
dev_dept.hire(bob)
dev_dept.hire(carol)
qa_dept.hire(carol)    # ← Кэрол работает сразу в двух отделах!
qa_dept.hire(dave)

dev_dept.daily_standup()
# 📋 Стендап отдела 'Разработка':
# Алиса (бэкенд-разработчик) работает
# Боб (дизайнер) работает
# Кэрол (тестировщик) работает

qa_dept.daily_standup()
# 📋 Стендап отдела 'QA':
# Кэрол (тестировщик) работает
# Дэйв (аналитик) работает

# Отдел ликвидирован — сотрудники никуда не делись
del dev_dept
print(alice)    # Employee('Алиса', 'бэкенд-разработчик') — жива!
print(carol)    # Employee('Кэрол', 'тестировщик') — жива!

💡 Ключевые наблюдения:
1. carol числится одновременно в двух отделах — это возможно только при агрегации.
2. После del dev_dept объекты alice, bob, carol живы.
3. В __init__ класса Department нет ни одного вызова Employee(...) — это и есть маркер агрегации.

А теперь сравним оба подхода в единой таблице, чтобы разница отложилась в памяти навсегда.

📊 Сравнительная таблица

Критерий
🔵 Композиция
🟢 Агрегация
Жизненный цикл части
Зависит от целого
Независим от целого
Создание частей
Внутри __init__
Передаются снаружи
Часть в нескольких целых
❌ Нет
✅ Да
Пример
Дом → Комнаты
Команда → Игроки
Удалили «целое»
Части тоже уничтожены
Части продолжают жить
UML-ромб
◆ Закрашенный
◇ Незакрашенный

📐 Как это выглядит на UML-диаграммах

UML (Unified Modeling Language) — стандартный язык для рисования схем объектных систем. Когда вы рисуете схему классов на доске или в документации, для связей «целое — часть» используют ромбики.

◆ Закрашенный ромб

Рисуется у целого. Означает композицию: части не могут жить без владельца.

House ◆── Room

◇ Незакрашенный ромб

Рисуется у целого. Означает агрегацию: части живут независимо.

Team ◇── Player

⚠️ Честное предупреждение для новичков: на реальных проектах UML-диаграммы рисуют не всегда — многие команды обходятся без них. Но понимать эти обозначения важно, потому что в книгах, документации и обсуждениях архитектуры вы будете их встречать.

🐍 Важное уточнение: в Python разница концептуальная

Вот правда о Python, которую важно знать: язык сам по себе не enforces разницу между композицией и агрегацией. Python не выдаст ошибку, если вы напутали. Оба подхода технически реализуются одинаково — через атрибуты и ссылки.

🤔 Тогда зачем вообще различать?
Потому что разница важна при проектировании, а не при написании кода. Правильный выбор влияет на:
— структуру базы данных (внешние ключи, CASCADE DELETE)
— ORM-модели (Django: ForeignKey vs вложенный объект)
— управление памятью в больших системах
— понимание кода другими разработчиками

# Разница в коде — одна строка, но она говорит о многом

# --- КОМПОЗИЦИЯ: объект создаётся ВНУТРИ ---
class House:
    def __init__(self, address):
        self.address = address
        self.rooms = [Room("Гостиная", 25), Room("Спальня", 18)]
        #                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        #                  Room создаётся здесь — внутри House


# --- АГРЕГАЦИЯ: объект ПЕРЕДАЁТСЯ снаружи ---
class Team:
    def __init__(self, name):
        self.name = name
        self.players = []   # пусто — заполняется снаружи

    def add_player(self, player):
        self.players.append(player)
        #                   ^^^^^^
        #                   player уже существовал до этого вызова


# Наглядное сравнение при создании объектов:

# Композиция — создаём только «целое»:
house = House("ул. Лесная, 5")
# Комнаты создаются автоматически внутри

# Агрегация — создаём части отдельно, потом собираем:
p1 = Player("Марков", "нападающий")
p2 = Player("Нилов", "вратарь")
team = Team("Локомотив")
team.add_player(p1)
team.add_player(p2)

💡 Простой способ запомнить:
Видите self.parts = [SomeClass(...)] внутри __init__? → Это композиция.
Видите self.parts = [] и метод add(item)? → Это агрегация.

🏗 Зачем это нужно в реальной разработке

Разберём три конкретные области, где понимание этой разницы помогает принимать правильные решения.

🗄 Проектирование БД

Композиция → CASCADE DELETE: удалил заказ — удалились все строки заказа автоматически.

Агрегация → SET NULL или RESTRICT: удалил команду — игроки остаются в базе.

🔧 ORM (Django)

Композиция → ForeignKey(on_delete=CASCADE).

Агрегация → ManyToManyField или ForeignKey(on_delete=SET_NULL): объект живёт после удаления родителя.

🧠 Архитектурные решения

Неправильный выбор → баги при удалении данных, утечки памяти, нарушения бизнес-логики. Правильный выбор → предсказуемая, надёжная система.

📖 Пример из жизни Django-разработчика:
Вы проектируете интернет-магазин. Order (заказ) содержит OrderItem (строки заказа) — это композиция: строки заказа существуют только в рамках заказа. Логично использовать CASCADE.

Но Product (товар) входит во многие заказы — это агрегация. Нельзя удалить товар и потерять все исторические заказы! Здесь нужен PROTECT или SET_NULL.

✅ Итоги урока

  • Главный вопрос: могут ли «части» существовать без «целого»? Нет → композиция. Да → агрегация.
  • Композиция в коде: части создаются внутри __init__ — self.rooms = [Room(...)].
  • Агрегация в коде: части передаются снаружи — список пуст в __init__, заполняется через метод add().
  • Агрегация позволяет: части жить после удаления целого; одна часть — в нескольких «целых» одновременно.
  • В Python: язык не enforces разницу технически — она остаётся концептуальной, но от этого не менее важной.
  • UML: ◆ закрашенный ромб — композиция; ◇ незакрашенный — агрегация.
  • В реальных проектах: правильный выбор влияет на структуру БД, ORM-политики удаления и архитектуру систем.

👉 В следующем уроке мы завершим модуль о связях между классами и сведём воедино все изученные паттерны: наследование, ассоциацию, композицию и агрегацию. Вы научитесь смотреть на задачу и сразу выбирать правильный тип связи.