What can composition solve that inheritance cannot?
Aggregation and composition are two alternatives to inheritance that can be used in object-oriented programming to achieve code reuse and flexibility.
Aggregation is a type of association that allows one object to contain another object as a member, but the contained object can still exist independently of the containing object. Composition is a stronger form of aggregation that allows one object to contain another object as a member, but the contained object cannot exist independently of the containing object.
Here are some examples of problems that can be solved using aggregation and composition instead of inheritance:
- A Car class can contain an Engine object as a member using aggregation. The Engine object can be created and used independently of the Car object, but the Car object can use the Engine object to perform certain operations, such as starting the engine or accelerating.
- A GUI (Graphical User Interface) framework can use composition to build complex user interfaces by combining smaller UI components. For example, a button can be composed of a label and an image, and a window can be composed of a title bar, a menu bar, and a content area.
- A Logging class can use aggregation to delegate logging functionality to a Logger object. The Logging class can call methods on the Logger object to log messages, but the Logger object can also be used independently to log messages from other parts of the application.
- A ShoppingCart class can use composition to contain multiple Product objects. The ShoppingCart class can provide methods to add and remove products from the cart, and to calculate the total price of all products in the cart.
- A MusicPlayer class can use aggregation to contain a Playlist object. The Playlist object can be created and modified independently of the MusicPlayer object, but the MusicPlayer object can use the Playlist object to play songs in a specific order.
Overall, aggregation and composition can provide more flexibility and modularity than inheritance, as they allow objects to be combined and reused in different ways, and can make it easier to modify and extend code over time.
Composition Example in Go
In this example, we have a Car
struct that is composed of an Engine
struct. The Engine
struct is not tied to the Car
struct, and can be used independently.
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)
}
In this example, the Car
struct has an Engine
struct as a member using composition. The Engine
struct is not tied to the Car
struct, and can be used independently. The Start
method of the Car
struct can access the Power
field of the Engine
struct to start the car.
Inheritance Example in Java
In this example, we have a Vehicle
class that is inherited by a Car
class. The Car
class inherits the start
method from the Vehicle
class and overrides it to provide specific behavior for starting a car.
public class Vehicle {
public void start() {
System.out.println("Starting vehicle");
}
}
public class Car extends Vehicle {
public void start() {
System.out.println("Starting car");
}
}
In this example, the Car
class inherits the start
method from the Vehicle
class using inheritance. The start
method of the Car
class overrides the start
method of the Vehicle
class to provide specific behavior for starting a car. The Vehicle
class cannot be used independently of the Car
class, as it is only meant to be used as a base class for inheritance.
Overall, composition in Go and inheritance in Java achieve similar goals, but use different mechanisms to achieve them. Composition provides more flexibility and modularity, as objects can be combined and reused in different ways. Inheritance provides more structure and can help reduce code duplication, but can be less flexible over time.
Something more real?
Here’s a real-world example of a problem that can be solved using composition and aggregation, but cannot be solved using inheritance:
Let’s say we are building a system to model a banking application, and we need to represent various types of accounts, such as savings accounts, checking accounts, and investment accounts. Each account type has its own behavior and data, but all accounts share some common functionality, such as the ability to deposit and withdraw funds.
We can use composition and aggregation to solve this problem. We can define a generic Account
interface that defines the common functionality for all accounts, and then define specific account types that implement the Account
interface using composition or aggregation to encapsulate their behavior and data. For example:
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
}
In this example, we define the Account
interface, which defines the common functionality for all accounts, such as depositing and withdrawing funds, and checking the balance. We then define specific account types, such as SavingsAccount
, CheckingAccount
, and, InvestmentAccount
, which implement the Account
interface using composition or aggregation.
The SavingsAccount
uses composition to contain an underlying account object, and computes the balance based on the interest rate. The CheckingAccount
also uses composition to contain an underlying account object and adds an overdraft limit to the balance check. The InvestmentAccount
uses aggregation to contain a list of investment funds and computes the balance based on the value of the investment funds.
In this case, inheritance is not a good fit, because the SavingsAccount
, CheckingAccount
, and InvestmentAccount
types do not have a clear "is-a" relationship with each other. They all have different behavior and data but share some common functionality. Composition and aggregation allow us to encapsulate this common functionality in the Account
interface and implement the specific account types using different techniques.
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;
}
}
In Java, we use interfaces to define the common functionality for all accounts, and then define specific account types that implement the Account
interface using composition to encapsulate their behavior and data.
The SavingsAccount
, CheckingAccount
, and InvestmentAccount
classes implement the Account
interface and contain an instance of another Account
object (the account that they are composed of). They implement the common deposit
, withdraw
, and getBalance
methods by calling the corresponding methods of the composed account object and adding or modifying behavior as necessary.
Note that the Java implementation is very similar to the Go implementation I provided earlier. The main differences are the syntax and some minor details, such as the use of @Override
annotations in Java to indicate that a method is overriding a superclass method.
Nope, did not get the difference 😅
In object-oriented programming, an “is-a” relationship refers to a relationship between a superclass and a subclass, where the subclass is a type of the superclass. For example, a SavingsAccount
can be considered a type of Account
, so we could say that SavingsAccount
"is-a" Account
.
However, in the example I provided, the SavingsAccount
, CheckingAccount
, and InvestmentAccount
types do not have a clear "is-a" relationship with each other. They have different behavior and data, and it's not immediately obvious how they would fit into a class hierarchy.
If we were to try to represent these account types using inheritance, we might end up with a complicated class hierarchy that doesn’t accurately reflect the real-world relationships between the types. For example, we might have a base Account
class with a lot of methods and properties, and then define multiple levels of subclasses to capture the different behavior and data of each account type. This could result in a lot of redundant code and make the class hierarchy difficult to maintain.
Instead, we can use composition and aggregation to encapsulate the behavior and data of each account type and define a common interface (Account
) to represent the shared functionality of all accounts. This approach allows us to represent the relationships between the different account types more accurately and with less redundancy.