1. YouTube Summaries
  2. Composition vs Inheritance: Building Better Software

Composition vs Inheritance: Building Better Software

By scribe 8 minute read

Create articles from any YouTube video or use our API to get YouTube transcriptions

Start for free
or, create a free article to see how easy it is.

The Great Debate: Composition vs Inheritance

In the world of software development, there's an ongoing debate about the best way to structure code and promote reusability. Two main approaches stand at the forefront of this discussion: composition and inheritance. While both have their merits, many experienced developers advocate for favoring composition over inheritance. But what does this really mean, and why is it considered a better approach?

Understanding Inheritance

Inheritance is a fundamental concept in object-oriented programming (OOP). It allows a class to inherit properties and methods from another class, creating a parent-child relationship between classes. This approach seems intuitive at first glance, as it mirrors real-world hierarchies and relationships.

The Promise of Inheritance

Inheritance promises code reuse and a clear hierarchical structure. In theory, it allows developers to create specialized classes based on more general ones, reducing code duplication and creating a logical organization of code.

The Pitfalls of Inheritance

However, inheritance comes with several drawbacks:

  1. Tight Coupling: Child classes are tightly coupled to their parent classes. Changes in the parent class can have unexpected effects on all its descendants.

  2. Complicated Nested Hierarchies: As applications grow, inheritance hierarchies can become deep and complex, making the codebase difficult to understand and maintain.

  3. Inflexibility: The rigid structure of inheritance can make it challenging to adapt to changing requirements without significant refactoring.

  4. The Fragile Base Class Problem: Modifications to a base class can potentially break functionality in its subclasses.

A Practical Example: Drawing Shapes

Let's consider a practical scenario to illustrate the challenges of inheritance. Imagine we're building an application that allows users to draw various shapes on a canvas.

The Inheritance Approach

Initially, we might create a base Shape class and then derive specific shapes from it:

open class Shape(var x: Int, var y: Int, var color: String) {
    open fun draw() {
        // Basic drawing logic
    }
    open fun calculateArea(): Double {
        return 0.0
    }
}

class Rectangle(x: Int, y: Int, color: String, var width: Int, var height: Int) : Shape(x, y, color) {
    override fun draw() {
        // Rectangle-specific drawing logic
    }
    override fun calculateArea(): Double {
        return width * height.toDouble()
    }
}

class Circle(x: Int, y: Int, color: String, var radius: Int) : Shape(x, y, color) {
    override fun draw() {
        // Circle-specific drawing logic
    }
    override fun calculateArea(): Double {
        return Math.PI * radius * radius
    }
}

This structure seems logical at first. However, problems arise as we introduce more shapes:

The Circle Dilemma

When we add a Circle class, we realize that concepts like width and height don't apply. We're forced to either:

  1. Move width and height back to subclasses, losing the benefit of inheritance for these properties.
  2. Keep them in the base class, creating a "god class" anti-pattern where the base class accumulates properties that aren't universally applicable.

The Line Conundrum

Adding a Line class introduces even more issues:

  1. Lines don't have an area, breaking the contract imposed by the Shape class.
  2. Lines can be drawn at an angle, a property that might apply to some shapes (like rectangles) but not others (like circles).

The Clickable Requirement

If we need to make some shapes clickable, we face another dilemma:

  1. Should we implement a Clickable interface in the base Shape class?
  2. Or should each clickable shape implement it individually?

Both approaches have drawbacks, either forcing non-clickable shapes to implement unnecessary methods or duplicating code across multiple subclasses.

The Composition Alternative

Composition offers a more flexible approach to building software structures. Instead of creating rigid hierarchies, composition allows us to build objects by combining simpler, reusable components.

Composition in Go

Let's look at how we might approach our shape-drawing application using composition in Go:

type Position struct {
    X, Y int
}

type Color string

type Drawable interface {
    Draw()
}

type AreaCalculator interface {
    CalculateArea() float64
}

type Clickable interface {
    OnClick()
}

type Rectangle struct {
    Position
    Color
    Width, Height int
}

func (r Rectangle) Draw() {
    // Rectangle-specific drawing logic
}

func (r Rectangle) CalculateArea() float64 {
    return float64(r.Width * r.Height)
}

type Circle struct {
    Position
    Color
    Radius int
}

func (c Circle) Draw() {
    // Circle-specific drawing logic
}

func (c Circle) CalculateArea() float64 {
    return math.Pi * float64(c.Radius * c.Radius)
}

type Line struct {
    Position
    Color
    EndX, EndY int
}

func (l Line) Draw() {
    // Line-specific drawing logic
}

type ClickableRectangle struct {
    Rectangle
}

func (cr ClickableRectangle) OnClick() {
    // Handle click event
}

In this approach:

  1. We define small, focused structs (Position, Color) that can be embedded in other types.
  2. We use interfaces (Drawable, AreaCalculator, Clickable) to define behavior contracts.
  3. Each shape type embeds the necessary components and implements only the relevant interfaces.
  4. We can easily create new types (like ClickableRectangle) by composing existing types and adding new behavior.

Benefits of Composition

  1. Flexibility: It's easy to create new types by combining existing components.
  2. Modularity: Each component is independent and can be tested and maintained separately.
  3. Avoiding Unnecessary Relationships: We don't force relationships between concepts that aren't truly related.
  4. Easier Refactoring: Changes to one component are less likely to affect others.

When to Use Inheritance

While composition is often preferred, inheritance isn't always bad. It can be appropriate in certain scenarios:

  1. True "is-a" Relationships: When there's a genuine hierarchical relationship between concepts (e.g., a Car is a Vehicle).
  2. Framework Design: Many frameworks use inheritance to allow users to extend functionality.
  3. Simple, Stable Domains: In domains where the relationships between concepts are well-understood and unlikely to change.

Best Practices for Code Reusability

Regardless of whether you're using composition or inheritance, here are some best practices for promoting code reusability:

  1. Keep It Simple: Avoid creating deep hierarchies or complex structures.
  2. Favor Composition: Start with composition and only use inheritance when it truly makes sense.
  3. Use Interfaces: Define behavior through interfaces rather than concrete implementations.
  4. Single Responsibility Principle: Each class or component should have a single, well-defined purpose.
  5. Open/Closed Principle: Design your code to be open for extension but closed for modification.

Composition in Different Programming Languages

While we've looked at examples in Kotlin and Go, the principle of favoring composition over inheritance applies across many programming languages. Let's briefly explore how this concept manifests in other popular languages:

Java

Java, like Kotlin, supports both inheritance and composition. However, Java's single inheritance model (a class can only inherit from one superclass) often pushes developers towards composition:

public class Engine {
    public void start() { /* ... */ }
}

public class Wheels {
    public void rotate() { /* ... */ }
}

public class Car {
    private Engine engine;
    private Wheels wheels;

    public Car() {
        this.engine = new Engine();
        this.wheels = new Wheels();
    }

    public void drive() {
        engine.start();
        wheels.rotate();
    }
}

Python

Python supports multiple inheritance, which can lead to complex hierarchies. However, Python's "duck typing" also makes composition and interface-like patterns easy to implement:

class Engine:
    def start(self):
        pass

class Wheels:
    def rotate(self):
        pass

class Car:
    def __init__(self):
        self.engine = Engine()
        self.wheels = Wheels()

    def drive(self):
        self.engine.start()
        self.wheels.rotate()

JavaScript

JavaScript uses prototype-based inheritance, which is more flexible than class-based inheritance. However, composition is still often preferred:

const createEngine = () => ({
    start: () => console.log('Engine started')
});

const createWheels = () => ({
    rotate: () => console.log('Wheels rotating')
});

const createCar = () => {
    const engine = createEngine();
    const wheels = createWheels();

    return {
        drive: () => {
            engine.start();
            wheels.rotate();
        }
    };
};

Rust

Rust doesn't have inheritance at all, pushing developers towards composition and trait-based polymorphism:

struct Engine;
struct Wheels;

impl Engine {
    fn start(&self) { /* ... */ }
}

impl Wheels {
    fn rotate(&self) { /* ... */ }
}

struct Car {
    engine: Engine,
    wheels: Wheels,
}

impl Car {
    fn drive(&self) {
        self.engine.start();
        self.wheels.rotate();
    }
}

The Impact on Software Design

Favoring composition over inheritance has a profound impact on how we design software:

  1. Modular Design: It encourages breaking down complex systems into smaller, reusable components.
  2. Improved Testability: Smaller, focused components are easier to test in isolation.
  3. Enhanced Flexibility: It's easier to adapt to changing requirements by recombining existing components.
  4. Reduced Coupling: Components are less dependent on each other, making the system more robust.
  5. Clearer Abstractions: Composition often leads to clearer, more intuitive abstractions of real-world concepts.

Challenges in Adopting Composition

While composition offers many benefits, it's not without its challenges:

  1. Initial Complexity: Designing a good compositional structure can be more challenging initially.
  2. Performance Considerations: In some cases, composition might introduce slight performance overhead compared to direct inheritance.
  3. Learning Curve: Developers accustomed to inheritance-heavy designs might need time to adjust their thinking.
  4. Potential for Overengineering: It's possible to go too far with composition, creating unnecessarily complex structures.

Tools and Techniques for Effective Composition

To make the most of composition in your software design:

  1. Use Design Patterns: Patterns like Strategy, Decorator, and Composite can help you effectively use composition.
  2. Leverage Dependency Injection: This technique can make your composed structures more flexible and testable.
  3. Create Small, Focused Interfaces: Define clear contracts for your components to interact.
  4. Utilize Functional Programming Concepts: Techniques like higher-order functions can enhance the power of composition.
  5. Employ Static Analysis Tools: These can help identify areas where composition might be beneficial.

The Future of Software Design

As software systems become increasingly complex, the trend towards composition and other flexible design approaches is likely to continue. We're seeing this in:

  1. Microservices Architecture: Breaking down large applications into smaller, composable services.
  2. Functional Programming: The rise of functional programming languages and techniques that naturally align with compositional thinking.
  3. Component-Based Frameworks: Front-end frameworks like React that emphasize composing UIs from smaller components.
  4. Serverless Computing: Building applications by composing cloud functions and services.

Conclusion

While the debate between composition and inheritance continues, the software development community is increasingly recognizing the benefits of favoring composition. It offers greater flexibility, modularity, and maintainability, aligning well with the complex and ever-changing nature of modern software systems.

However, it's crucial to remember that software design is not about dogmatically following any single principle. The key is to understand the strengths and weaknesses of different approaches and choose the right tool for the job. By mastering both composition and inheritance, and knowing when to apply each, you'll be well-equipped to create robust, flexible, and maintainable software systems.

As you continue your journey in software development, keep exploring these concepts, experiment with different approaches, and always strive to write code that not only works but is also easy to understand, maintain, and evolve over time. Happy coding!

Article created from: https://youtu.be/_hb_oRhASuw

Ready to automate your
LinkedIn, Twitter and blog posts with AI?

Start for free