What can composition solve that inheritance cannot?

Sreesha S Kuruvadi
7 min readFeb 18, 2023
https://www.theneweuropean.co.uk/everyday-philosophy-why-we-pay-attention-to-autumn-leaves

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

--

--