Агрегация и композиция — две альтернативы наследованию, которые можно использовать в объектно-ориентированном программировании для достижения гибкости и повторного использования кода.
Агрегация — это тип ассоциации, который позволяет одному объекту содержать другой объект в качестве члена, но содержащийся объект может по-прежнему существовать независимо от содержащего объекта. Композиция — это более сильная форма агрегации, которая позволяет одному объекту содержать другой объект в качестве члена, но содержащийся объект не может существовать независимо от содержащего объекта.
Вот несколько примеров проблем, которые можно решить, используя агрегацию и композицию вместо наследования:
- Класс Car может содержать объект Engine в качестве члена, использующего агрегацию. Объект Engine можно создать и использовать независимо от объекта Car, но объект Car может использовать объект Engine для выполнения определенных операций, таких как запуск двигателя или ускорение.
- Платформа GUI (графический пользовательский интерфейс) может использовать композицию для создания сложных пользовательских интерфейсов путем объединения более мелких компонентов пользовательского интерфейса. Например, кнопка может состоять из метки и изображения, а окно может состоять из строки заголовка, строки меню и области содержимого.
- Класс Logging может использовать агрегацию для делегирования функций ведения журнала объекту Logger. Класс Logging может вызывать методы объекта Logger для регистрации сообщений, но объект Logger также можно использовать независимо для регистрации сообщений из других частей приложения.
- Класс ShoppingCart может использовать композицию, чтобы содержать несколько объектов Product. Класс ShoppingCart может предоставлять методы для добавления и удаления товаров из корзины, а также для расчета общей стоимости всех товаров в корзине.
- Класс MusicPlayer может использовать агрегацию для содержания объекта Playlist. Объект Playlist можно создавать и изменять независимо от объекта MusicPlayer, но объект MusicPlayer может использовать объект Playlist для воспроизведения песен в определенном порядке.
В целом, агрегация и композиция могут обеспечить большую гибкость и модульность, чем наследование, поскольку они позволяют комбинировать и повторно использовать объекты различными способами, а также упрощают изменение и расширение кода с течением времени.
Пример композиции в Go
В этом примере у нас есть структура Car
, состоящая из структуры Engine
. Структура Engine
не привязана к структуре Car
и может использоваться независимо.
type Engine struct { Power int } type Car struct { Engine Engine Color string } func (c Car) Start() { fmt.Printf("Starting car with %d horsepower engine\n", c.Engine.Power) }
В этом примере структура Car
имеет структуру Engine
в качестве члена, использующего композицию. Структура Engine
не привязана к структуре Car
и может использоваться независимо. Метод Start
структуры Car
может получить доступ к полю Power
структуры Engine
, чтобы завести машину.
Пример наследования в Java
В этом примере у нас есть класс Vehicle
, который наследуется классом Car
. Класс Car
наследует метод start
от класса Vehicle
и переопределяет его, чтобы обеспечить определенное поведение при запуске автомобиля.
public class Vehicle { public void start() { System.out.println("Starting vehicle"); } } public class Car extends Vehicle { public void start() { System.out.println("Starting car"); } }
В этом примере класс Car
наследует метод start
от класса Vehicle
, используя наследование. Метод start
класса Car
переопределяет метод start
класса Vehicle
, чтобы обеспечить определенное поведение при запуске автомобиля. Класс Vehicle
нельзя использовать независимо от класса Car
, так как он предназначен только для использования в качестве базового класса для наследования.
В целом, композиция в Go и наследование в Java достигают схожих целей, но для их достижения используются разные механизмы. Композиция обеспечивает большую гибкость и модульность, поскольку объекты можно комбинировать и повторно использовать по-разному. Наследование обеспечивает большую структуру и может помочь уменьшить дублирование кода, но со временем становится менее гибким.
Что-то более настоящее?
Вот реальный пример проблемы, которую можно решить с помощью композиции и агрегации, но нельзя решить с помощью наследования:
Допустим, мы создаем систему для моделирования банковского приложения, и нам нужно представить различные типы счетов, такие как сберегательные счета, расчетные счета и инвестиционные счета. Каждый тип учетной записи имеет собственное поведение и данные, но все учетные записи имеют некоторые общие функции, такие как возможность вносить и снимать средства.
Мы можем использовать композицию и агрегацию для решения этой проблемы. Мы можем определить общий интерфейс Account
, который определяет общую функциональность для всех учетных записей, а затем определить конкретные типы учетных записей, которые реализуют интерфейс Account
, используя композицию или агрегацию для инкапсуляции их поведения и данных. Например:
type Account interface { Deposit(amount float64) Withdraw(amount float64) error Balance() float64 } type SavingsAccount struct { account Account interestRate float64 } func (s *SavingsAccount) Deposit(amount float64) { s.account.Deposit(amount) } func (s *SavingsAccount) Withdraw(amount float64) error { return s.account.Withdraw(amount) } func (s *SavingsAccount) Balance() float64 { return s.account.Balance() * (1.0 + s.interestRate) } type CheckingAccount struct { account Account overdraftLimit float64 } func (c *CheckingAccount) Deposit(amount float64) { c.account.Deposit(amount) } func (c *CheckingAccount) Withdraw(amount float64) error { balance := c.account.Balance() if balance - amount < -c.overdraftLimit { return errors.New("insufficient funds") } return c.account.Withdraw(amount) } func (c *CheckingAccount) Balance() float64 { return c.account.Balance() } type InvestmentAccount struct { account Account investmentFunds []string } func (i *InvestmentAccount) Deposit(amount float64) { i.account.Deposit(amount) } func (i *InvestmentAccount) Withdraw(amount float64) error { return i.account.Withdraw(amount) } func (i *InvestmentAccount) Balance() float64 { // compute balance based on value of investment funds return 0.0 }
В этом примере мы определяем интерфейс Account
, который определяет общие функции для всех учетных записей, такие как ввод и вывод средств и проверка баланса. Затем мы определяем определенные типы учетных записей, такие как SavingsAccount
, CheckingAccount
и InvestmentAccount
, которые реализуют интерфейс Account
с использованием композиции или агрегации.
SavingsAccount
использует композицию для содержания базового объекта счета и вычисляет баланс на основе процентной ставки. CheckingAccount
также использует композицию для содержания базового объекта счета и добавляет лимит овердрафта к проверке баланса. InvestmentAccount
использует агрегацию, чтобы содержать список инвестиционных фондов и вычисляет баланс на основе стоимости инвестиционных фондов.
В этом случае наследование не подходит, потому что типы SavingsAccount
, CheckingAccount
и InvestmentAccount
не имеют четкой связи друг с другом. Все они имеют разное поведение и данные, но имеют некоторые общие функции. Композиция и агрегация позволяют нам инкапсулировать эту общую функциональность в интерфейсе Account
и реализовывать определенные типы учетных записей, используя различные методы.
interface Account { void deposit(double amount); boolean withdraw(double amount); double getBalance(); } class SavingsAccount implements Account { private Account account; private double interestRate; public SavingsAccount(Account account, double interestRate) { this.account = account; this.interestRate = interestRate; } @Override public void deposit(double amount) { account.deposit(amount); } @Override public boolean withdraw(double amount) { return account.withdraw(amount); } @Override public double getBalance() { return account.getBalance() * (1.0 + interestRate); } } class CheckingAccount implements Account { private Account account; private double overdraftLimit; public CheckingAccount(Account account, double overdraftLimit) { this.account = account; this.overdraftLimit = overdraftLimit; } @Override public void deposit(double amount) { account.deposit(amount); } @Override public boolean withdraw(double amount) { double balance = account.getBalance(); if (balance - amount < -overdraftLimit) { return false; } return account.withdraw(amount); } @Override public double getBalance() { return account.getBalance(); } } class InvestmentAccount implements Account { private Account account; private List<String> investmentFunds; public InvestmentAccount(Account account, List<String> investmentFunds) { this.account = account; this.investmentFunds = investmentFunds; } @Override public void deposit(double amount) { account.deposit(amount); } @Override public boolean withdraw(double amount) { return account.withdraw(amount); } @Override public double getBalance() { // compute balance based on value of investment funds return 0.0; } }
В Java мы используем интерфейсы для определения общей функциональности для всех учетных записей, а затем определяем конкретные типы учетных записей, которые реализуют интерфейс Account
, используя композицию для инкапсуляции их поведения и данных.
Классы SavingsAccount
, CheckingAccount
и InvestmentAccount
реализуют интерфейс Account
и содержат экземпляр другого объекта Account
(учетной записи, из которой они состоят). Они реализуют общие методы deposit
, withdraw
и getBalance
, вызывая соответствующие методы составного объекта учетной записи и добавляя или изменяя поведение по мере необходимости.
Обратите внимание, что реализация Java очень похожа на реализацию Go, которую я представил ранее. Основными отличиями являются синтаксис и некоторые второстепенные детали, такие как использование аннотаций @Override
в Java для указания того, что метод переопределяет метод суперкласса.
Нет, не уловил разницы 😅
В объектно-ориентированном программировании отношение «является» относится к отношению между суперклассом и подклассом, где подкласс является типом суперкласса. Например, SavingsAccount
можно считать типом Account
, так что мы могли бы сказать, что SavingsAccount
«является» Account
.
Однако в приведенном мной примере типы SavingsAccount
, CheckingAccount
и InvestmentAccount
не имеют четкой связи друг с другом. У них разное поведение и данные, и не сразу понятно, как они впишутся в иерархию классов.
Если бы мы попытались представить эти типы учетных записей с помощью наследования, мы могли бы получить сложную иерархию классов, которая не точно отражает реальные отношения между типами. Например, у нас может быть базовый класс Account
с множеством методов и свойств, а затем определить несколько уровней подклассов, чтобы фиксировать различное поведение и данные каждого типа учетной записи. Это может привести к большому количеству избыточного кода и затруднить поддержку иерархии классов.
Вместо этого мы можем использовать композицию и агрегацию для инкапсуляции поведения и данных каждого типа учетной записи и определить общий интерфейс (Account
) для представления общей функциональности всех учетных записей. Этот подход позволяет нам более точно и с меньшей избыточностью представлять отношения между различными типами учетных записей.