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

Composition vs Inheritance: Building Better Software Architecture

By scribe 9 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 create reusable components. One of the most prominent discussions revolves around the use of composition versus inheritance. Many experienced developers advocate for favoring composition over inheritance, but what does this really mean in practice?

Understanding Inheritance

Inheritance is a fundamental concept in object-oriented programming (OOP) that allows a class to inherit properties and methods from another class. This creates a parent-child relationship between classes, where the child class (subclass) extends the functionality of the parent class (superclass).

In theory, inheritance seems like an elegant solution for code reuse and creating hierarchies of related objects. However, as we'll explore, it can lead to several challenges in real-world applications.

The Pitfalls of Inheritance

While inheritance can be useful in certain scenarios, it often introduces several problems:

  1. Tight coupling: Subclasses are tightly bound to their parent classes, making it difficult to modify the parent without affecting all its children.

  2. Inflexibility: As your codebase grows, you may find that your inheritance hierarchy becomes rigid and hard to adapt to new requirements.

  3. Complexity: Deep inheritance hierarchies can become difficult to understand and maintain, especially for new team members.

  4. The "is-a" relationship: Inheritance enforces an "is-a" relationship between classes, which may not always accurately represent the real-world relationships between objects.

A Practical Example: Drawing Shapes

Let's consider a practical example to illustrate the challenges of inheritance. Imagine we're building an application that allows users to draw various shapes on a canvas. We might start with a base Shape class and create subclasses for different types of shapes.

open class Shape(var x: Int, var y: Int) {
    open fun draw() {
        // Draw the shape
    }
    
    open fun area(): Double {
        return 0.0
    }
}

class Rectangle(x: Int, y: Int, var width: Int, var height: Int) : Shape(x, y) {
    override fun draw() {
        // Draw rectangle
    }
    
    override fun area(): Double {
        return width * height.toDouble()
    }
}

class Circle(x: Int, y: Int, var radius: Int) : Shape(x, y) {
    override fun draw() {
        // Draw circle
    }
    
    override fun area(): Double {
        return Math.PI * radius * radius
    }
}

At first glance, this structure seems logical. However, as we add more shapes and requirements, we start to encounter issues:

  1. Irrelevant properties: A Circle doesn't need width and height properties, but it inherits them from the Shape class.

  2. Violated contracts: If we add a Line shape, it doesn't have an area, violating the contract imposed by the Shape class.

  3. Shared but not universal properties: Some shapes might be drawn at an angle, but this property doesn't apply to all shapes.

  4. Behavioral differences: We might want some shapes to be clickable, but not all. This leads to questions about where to implement such behavior.

The Composition Alternative

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

Let's look at how we might restructure our shapes example using composition in Go, a language that encourages this approach:

type Position struct {
    X, Y int
}

type Dimensions struct {
    Width, Height int
}

type Circle struct {
    Position
    Radius int
}

type Rectangle struct {
    Position
    Dimensions
}

type Line struct {
    Start, End Position
}

type Drawable interface {
    Draw()
}

func (c Circle) Draw() {
    // Draw circle
}

func (r Rectangle) Draw() {
    // Draw rectangle
}

func (l Line) Draw() {
    // Draw line
}

type Clickable interface {
    OnClick()
}

func (r Rectangle) OnClick() {
    // Handle click on rectangle
}

In this approach, we've broken down our shapes into smaller, more focused components. We can now compose these components to create our shapes without enforcing unnecessary relationships or properties.

Benefits of Composition

  1. Flexibility: It's easier to add new types of shapes without affecting existing code.

  2. Modularity: Each component (like Position or Dimensions) can be reused across different types of objects.

  3. Clearer semantics: The relationships between components are more explicit and easier to understand.

  4. Easier testing: Smaller, focused components are typically easier to test in isolation.

  5. Better encapsulation: Components can hide their internal details, reducing coupling between different parts of the system.

Diving Deeper: Composition in Practice

To further illustrate the power of composition, let's expand our example to include more complex shapes and behaviors.

Adding Complex Shapes

Suppose we want to add a Polygon shape to our drawing application. With inheritance, we might be tempted to create a new subclass of Shape. However, this could lead to complications if Polygon shares some properties with Rectangle but not others.

Using composition, we can create a more flexible structure:

type Point struct {
    X, Y int
}

type Polygon struct {
    Points []Point
}

func (p Polygon) Draw() {
    // Draw polygon
}

func (p Polygon) Area() float64 {
    // Calculate area of polygon
}

This approach allows us to represent any polygon without being constrained by a rigid class hierarchy.

Implementing Behaviors

One of the strengths of composition is the ability to add behaviors to objects without modifying their core structure. Let's implement a few more behaviors:

type Rotatable interface {
    Rotate(angle float64)
}

type Scalable interface {
    Scale(factor float64)
}

type RotatableRectangle struct {
    Rectangle
    Angle float64
}

func (r *RotatableRectangle) Rotate(angle float64) {
    r.Angle += angle
}

type ScalableCircle struct {
    Circle
}

func (c *ScalableCircle) Scale(factor float64) {
    c.Radius = int(float64(c.Radius) * factor)
}

With this approach, we can create shapes that have specific behaviors without forcing these behaviors on all shapes. This is much more flexible than trying to implement these behaviors through inheritance.

The Role of Interfaces in Composition

Interfaces play a crucial role in making composition powerful and flexible. In Go, interfaces are implemented implicitly, which means any type that implements the methods of an interface automatically satisfies that interface.

This implicit implementation allows for great flexibility in how we structure our code. We can define interfaces for common behaviors and then implement these behaviors for our composed types as needed.

type Measurable interface {
    Area() float64
    Perimeter() float64
}

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

func (r Rectangle) Perimeter() float64 {
    return float64(2 * (r.Width + r.Height))
}

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

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

Now, any function that expects a Measurable can work with both Rectangle and Circle without needing to know the specific type.

Composition vs Inheritance: When to Use Each

While composition offers many advantages, it's important to note that inheritance isn't always bad. There are scenarios where inheritance can be the right choice:

  1. When there's a clear "is-a" relationship: If you have a genuine hierarchical relationship between objects (e.g., a Car is a Vehicle), inheritance might be appropriate.

  2. For framework design: Many frameworks use inheritance as a way to provide extensibility points for users.

  3. When dealing with established patterns: Some design patterns, like the Template Method pattern, rely on inheritance.

However, in most cases, composition provides a more flexible and maintainable approach:

  1. When relationships between objects are complex: Composition allows for more nuanced relationships than the rigid parent-child structure of inheritance.

  2. When you need to combine multiple behaviors: Composition makes it easier to mix and match different behaviors without creating deep inheritance hierarchies.

  3. When requirements are likely to change: Composed objects are typically easier to modify and extend than inherited ones.

  4. When you want to favor delegation over inheritance: Composition allows you to delegate behavior to composed objects, which is often more flexible than inheriting behavior.

Best Practices for Using Composition

To make the most of composition in your software design:

  1. Start with small, focused components: Design your components to do one thing well. This makes them more reusable and easier to understand.

  2. Use interfaces to define behavior: Interfaces allow you to work with different implementations interchangeably.

  3. Favor shallow hierarchies: If you do use inheritance, try to keep the hierarchies shallow. Deep inheritance trees are often a sign that composition might be a better fit.

  4. Consider the "Has-a" relationship: If you can describe the relationship between two objects as "has-a" rather than "is-a", composition is likely the better choice.

  5. Be mindful of coupling: Even with composition, be careful not to create tightly coupled components. Each component should be as independent as possible.

  6. Use embedding judiciously: In Go, embedding can be a powerful tool, but overuse can lead to confusion about where methods and fields are coming from.

Refactoring from Inheritance to Composition

If you're working with an existing codebase that heavily uses inheritance, you might wonder how to refactor towards a more composition-based approach. Here are some steps you can follow:

  1. Identify problematic hierarchies: Look for deep inheritance trees or classes that have a lot of overridden methods.

  2. Break down classes into components: Identify the core responsibilities of each class and consider how they could be separated into smaller, more focused components.

  3. Create interfaces for behaviors: Define interfaces for the behaviors that are currently implemented through inheritance.

  4. Compose new structures: Create new types that compose the smaller components and implement the necessary interfaces.

  5. Update client code: Modify code that uses the old inheritance-based classes to work with the new composed structures.

  6. Test thoroughly: Refactoring from inheritance to composition can be a significant change. Ensure you have good test coverage to catch any regressions.

Here's a simple example of refactoring from inheritance to composition:

Before (inheritance):

open class Animal {
    open fun makeSound() {
        println("Some generic animal sound")
    }
}

class Dog : Animal() {
    override fun makeSound() {
        println("Woof!")
    }
    
    fun fetch() {
        println("Fetching the ball")
    }
}

class Cat : Animal() {
    override fun makeSound() {
        println("Meow!")
    }
    
    fun purr() {
        println("Purring")
    }
}

After (composition):

type SoundMaker interface {
    MakeSound()
}

type DogSound struct{}
func (d DogSound) MakeSound() {
    fmt.Println("Woof!")
}

type CatSound struct{}
func (c CatSound) MakeSound() {
    fmt.Println("Meow!")
}

type Fetcher interface {
    Fetch()
}

type DogFetcher struct{}
func (d DogFetcher) Fetch() {
    fmt.Println("Fetching the ball")
}

type Purrer interface {
    Purr()
}

type CatPurrer struct{}
func (c CatPurrer) Purr() {
    fmt.Println("Purring")
}

type Dog struct {
    SoundMaker
    Fetcher
}

type Cat struct {
    SoundMaker
    Purrer
}

func NewDog() Dog {
    return Dog{
        SoundMaker: DogSound{},
        Fetcher:    DogFetcher{},
    }
}

func NewCat() Cat {
    return Cat{
        SoundMaker: CatSound{},
        Purrer:     CatPurrer{},
    }
}

This refactored version allows for more flexibility. For example, we could easily create a RobotDog that uses a different SoundMaker implementation without changing the core Dog structure.

Conclusion

While inheritance has its place in object-oriented programming, composition often provides a more flexible and maintainable approach to building software systems. By focusing on small, reusable components that can be combined in various ways, we can create code that's easier to understand, test, and modify.

The key takeaways are:

  1. Composition allows for more flexible and modular code structures.
  2. Interfaces play a crucial role in making composition powerful.
  3. Inheritance isn't always bad, but it should be used judiciously.
  4. When refactoring, look for opportunities to break down complex inheritance hierarchies into composable components.
  5. Always consider the future maintainability and extensibility of your code when choosing between composition and inheritance.

By understanding these principles and applying them thoughtfully, you can create software architectures that are more resilient to change and easier to work with over time. Remember, the goal is not to completely avoid inheritance, but to use the right tool for the job and to be aware of the trade-offs involved in each approach.

As you continue to develop your skills, pay attention to how you structure your code. Look for opportunities to use composition to create more flexible and maintainable systems. With practice, you'll develop an intuition for when to use composition and when inheritance might be more appropriate.

Keep exploring, keep learning, and most importantly, keep coding!

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

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

Start for free