Skip to main content

Command Palette

Search for a command to run...

The Hidden Costs of Inheritance: Weakened Encapsulation and the Diamond Problem

Updated
6 min read
The Hidden Costs of Inheritance: Weakened Encapsulation and the Diamond Problem

In a previous article of this series, we examined how interface inheritance provides a flexible alternative to rigid class hierarchies. But why exactly is class inheritance considered "rigid", or even potentially dangerous?

Influential software engineering texts, such as Joshua Bloch's Effective Java, often warn developers to "Prefer interfaces to abstract classes" (Item 20), or "Favour composition over inheritance" (Item 18). This article delves into the reasons behind this advice, specifically exploring two of the main drawbacks of class inheritance: weakened encapsulation and the complexity they introduce if used for multiple inheritance—the infamous "Deadly Diamond of Death."

1. Weakened Encapsulation: The Ripple Effect

Encapsulation is one of the core pillars of Object-Oriented Programming. It suggests that a class should hide its internal workings and protect its state. However, inheritance can paradoxically weaken this encapsulation by creating a tight coupling between a superclass and its subclasses.

Consider the simple hierarchy of mammals represented in the diagram below.

  • Mammal: The parent class with basic common traits like eyeColour and hairColour.

  • Dog, Cat, Bat: Subclasses that inherit from Mammal and add their specific features.

The Problem of "Shared" Behaviour

Now, as the diagram shows, let’s imagine we decide that all mammals eat. We add a eat() method to the Mammal superclass to save time and reuse code. This works perfectly with the subclasses: all Dog, Cat and Bat instances inherit this behaviour that also pertains to them.

In the following stage of our software development, suppose we decide that all mammals should be able to walk. We add a walk() method to the Mammal superclass to avoid code duplication:

  • Dog inherits walk() => Works perfectly.

  • Cat inherits walk() => Works perfectly.

  • Bat inherits walk() => Problem. Bats primarily fly; walking is not their main mode of movement.

Finally, suppose we add fly() to Mammal because we are thinking about bats. This results in a possible Mammal code implementation like the one shown below:

public class Mammal {
    private String eyeColour;
    private String hairColour;
    // additional required attributes [...]

    public Mammal(string ec, string hc) {
        eyeColour = ec;
        hairColour = hc;
    }

    public String getEyeColour() {
        return eyeColour;
    }

    public String getHairColour() {
        return hairColour;
    }

    public void eat() { ... } // All Mammals eat: great for everybody!

    public void walk() { ... } // Great for Dog and Cat, bad for Bat

    public void fly() { ... } // Great for Bat, terrible for Dog and Cat
}

However, by modifying the superclass, we have now inadvertently forced incorrect behaviours onto our subclasses. Suddenly, our dogs (and cats) can fly! This is known as the Ripple Effect: a change in the parent class ripples down the hierarchy, potentially breaking the logic of descendant classes and forcing us to re-test the entire tree.

The Solution: Interfaces and Aggregation (Has-A)

A better approach is to use Interfaces along with Aggregation (or Composition). Instead of saying "A Dog is-a Walker," we say "A Dog has-a WalkBehaviour." This involves two steps:

  1. Define the Capability: Create an interface that defines what the action is.

  2. Delegate the Logic: Use a helper class to handle how the action is performed.

First, we define a Walkable interface:

public interface Walkable {
    void walk();
}

Next, we create a helper class solely responsible for walking logic. For consistency, this helper can also implement the Walkable interface, though it's not strictly required:

public class WalkBehaviour implements Walkable {
    @Override
    public void walk() {
        System.out.println("Walking on four legs...");
    }
}

Finally, we update our Dog class. Instead of inheriting walking logic from Mammal, the Dog implements Walkable and "has-a" WalkBehaviour object instance:

public class Dog extends Mammal implements Walkable {
    // Composition: The Dog "has-a" WalkBehaviour object (walker)
    // The walker object and 'this' Dog object have the same lifetime 
    private WalkBehaviour walker = new WalkBehaviour(); 

    @Override
    public void walk() {
        // Delegate the implementation to the helper object
        walker.walk(); 
    }

    // ... Dog constructor and other Dog methods ...
}

This design is far more flexible. If we add a Bat class, it simply won't implement Walkable and will implement a Flyable interface instead. A class diagram based on this new system design is illustrated below:

As we assumed in the previous Dog code example that the Walkbehaviour walker instance is created within the Dog class, the diagram shows this connection as a "strong has-a" (i.e., composition) relationship. Nonetheless, the difference between using composition and aggregation is not paramount in this specific design. Using interface inheritance and either aggregation or composition enables us to decouple the walk() and fly() behaviours from the parent Mammal class, effectively preventing the ripple effect.

2. The Complexity of Multiple Inheritance

If inheritance allows code reuse, why can't a class inherit from two parents? For example, further extending one of the branches of the previous class hierarchy, why can't we create a GoldenApso dog that inherits from both GoldenRetriever and LhasaApso, as represented in the class diagram below? (For simplicity, we are omitting the Walkable capability discussed in the previous section.)

In theory, we should define the GoldenApso class as represented below, though this is not allowed in Java:

// Note: This is NOT allowed in Java
public class GoldenApso extends GoldenRetriever, LhasaApso { 
    ... 
}

In theory, this sounds useful. The GoldenApso could inherit the retrieve() method from one parent and the guard() method from the other. However, this leads to a logical paradox known as the Deadly Diamond of Death (or simply the Diamond Problem).

The Deadly Diamond of Death (DDD)

Imagine both GoldenRetriever and LhasaApso inherit from a common ancestor, Dog, as the previous diagram shows. The Dog class defines a method bark().

public class Dog {
    public void bark() { 
        System.out.println("Generic bark"); 
    }
}

Now, let’s assume the following happens:

  1. GoldenRetriever overrides bark() to define its specific barking behaviour (e.g., friendly).

  2. LhasaApso overrides bark() to define a different barking behaviour (e.g., alert).

  3. GoldenApso tries to inherit from both those parents.

The Paradox: When we call myGoldenApso.bark(), which version of bark() should it be executed?

  • The GoldenRetriever version?

  • The LhasaApso version?

  • A mixture of both?

The compiler cannot decide. This ambiguity creates complex conflicts that the programmer must manually resolve. As it may be noticed, this issue has been named the Deadly Diamond of Death due to the diamond shape that those classes assume when the above paradox occurs.

Java's Solution: Interfaces

To avoid this complexity, unlike C++, languages such as Java (and C#) prohibit multiple class inheritance entirely. You can extend only one class. However, they do allow you to implement multiple interfaces.

Why is this safe? Because interfaces (traditionally) do not hold state or method implementation. If a class implements two interfaces that both define a bark() method signature, the class simply provides one implementation that satisfies both contracts. There is no conflicting code to inherit, eliminating the ambiguity of the Diamond Problem.

Conclusion

While class inheritance is a powerful tool for code reuse, it comes with hidden costs. It creates tight coupling that can weaken encapsulation and make systems rigid and fragile. Furthermore, the complexities of multiple inheritance (the Diamond Problem) have led modern languages like Java to enforce a single-inheritance model for classes.

By understanding these limitations, developers can make better design choices—favouring interfaces, composition, and loose coupling to build software that is robust, flexible, and easy to maintain.