Learning Go - Composition Over Inheritance

Intro

In traditional object-oriented languages like Java or C++, inheritance allows one class to derive from another, inheriting its behavior. For example:

1
2
3
4
5
6
7
class Animal {
void eat() { System.out.println("Eating"); }
}

class Dog extends Animal {
void bark() { System.out.println("Barking"); }
}

Go does not have classes or traditional inheritance. Instead, Go encourages composition, which means building structs by embedding other structs or interfaces embedding other interfaces. It favours the idea that objects should be built by what they have (structs) and what they can do (interfaces), rather than what they are (a place in a rigid hierarchy).

Problem with Inheritance

In traditional object oriented languages, inheritance is used when one class derives properties and methods from another. This creates an "is-a" relationship (e.g., a Dog is a Mammal). While inheritance is a powerful concept, it suffers from several drawbacks, often summarized as the Fragile Base Class Problem. Most prominent issues with inheritance are:

  • Tight Coupling: Changes to the parent class can unintentionally break functionality in numerous child classes, making maintenance difficult.
  • Inflexible Hierarchies: Once an object is placed in an inheritance tree, it is difficult to change its structure or reuse its features in different, unrelated hierarchies.
  • Gorilla/Banana Problem: As described by Joe Armstrong (creator of Erlang), “You wanted a banana, but what you got was a gorilla holding the banana and the entire jungle.” Inheriting a class often brings along methods and dependencies that are unnecessary or unwanted.

Struct Composition

Go facilitates composition through struct embedding (using anonymous fields). When we embed a struct within another struct, the outer struct automatically “promotes” the inner struct’s fields and methods, allowing the outer struct to access them directly, as if they belonged to it.

1
2
3
4
5
6
7
8
9
type Bicycle struct {
Tyres uint8
Bell string
}

// Common behavior: any bike can ring its bell
func (b Bicycle) RingBell() {
fmt.Printf("Ringing bell: %s!\n", b.Bell)
}

We create the specialized bikes by embedding Bicycle and adding new fields and methods. The MountainBike embeds Bicycle and adds Gears and a unique Shift() method.

1
2
3
4
5
6
7
8
type MountainBike struct {
Bicycle // embedded struct
Gears uint8
}

func (mb MountainBike) Shift() {
fmt.Printf("mountain bike using %d gears.\n", mb.Gears)
}

The RacingBike also embeds Bicycle but might not need extra state. We can add a specialized method like Draft().

1
2
3
4
5
6
7
type RacingBike struct {
Bicycle // Embedded struct
}

func (rb RacingBike) Draft() {
fmt.Println("racing bike is drafting")
}

Interface Composition

Composition handles code reuse (the “has-a” relationship), but Go uses interfaces to handle polymorphism and define behavior (the “can-do” relationship). This separation is crucial. Instead of inheriting from a single base class, components adhere to behavior contracts defined by interfaces.

1
2
3
4
5
6
7
type Ringer interface {
Ring()
}

type GearShifter interface {
Shift(gear uint8)
}

We combine these two simple capabilities into a single, comprehensive role called Cycler.

1
2
3
4
5
6
type Cycler interface {
Ringer
GearShifter
// just another method to allow bicycle locking
Lock()
}

Now, let’s make our MountainBike satisfy this full Cycler contract.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Concrete Type: MountainBike (Must satisfy ALL methods in the Commuter interface)
type MountainBike struct {
Gear uint8
Bell string
}

func (mb *MountainBike) Ring() {
fmt.Println("mountain bike rings: CLANG!")
}
func (mb *MountainBike) Shift(gear uint8) {
fmt.Printf("MTB shifting to gear %d.\n", gear)
}
func (mb *MountainBike) Lock() {
fmt.Println("MTB secured with chain.")
}

The RacingBike implements the exact same contract, but its underlying logic and behavior are different reflecting its lightweight design.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type RacingBike struct {
Gear uint8
}

func (rb *RacingBike) Ring() {
fmt.Println("Racing bike rings: Tring Tring!!")
}

func (rb *RacingBike) Shift(gear uint8) {
fmt.Printf("Racing bike quickly snaps into gear %d.\n", gear)
}

func (rb *RacingBike) Lock() {
fmt.Println("Racing bike secured with cable lock.")
}

Polymorphism

The function below accepts any type that satisfies the Cycler interface. It doesn’t care if it’s a MountainBike or a RacingBike.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import "fmt"

// The cycling function accepts the interface
func Cycling(c Cycler) {
c.Ring()
c.Shift(10)
c.Lock()

fmt.Println("cycling complete")
}

func main() {
mtb := &MountainBike{CurrentGear: 1}
roadBike := &RacingBike{CurrentGear: 5}

Commute(mtb)
Commute(roadBike)
}

The Cycling function is the key here. It is written only once, using the abstract Cycler interface.

  • When it receives the MountainBike, it executes the MountainBike’s specific logic.
  • When it receives the RacingBike, it executes the RacingBike’s specific logic.

This is the essence of polymorphism, treating different types (MountainBike and RacingBike) in a uniform manner while retaining their unique behaviors.

Benefits of Composition

The "Composition Over Inheritance" paradigm, enabled by struct embedding and interfaces, provides significant benefits:

  • Flexibility, avoiding the Fragile Base Class: If a part inside a structure changes, only the structure itself needs updating. Changes don’t break a whole chain of inherited classes.
  • Explicit Dependencies: It’s easy to see exactly what a structure relies on, without accidentally inheriting extra, unnecessary methods.
  • Encouraging Small, Single-Purpose Types: Developers are guided to make small, focused structures and interfaces. This makes the code easier to read, test, and understand.
  • Testability: Because interfaces are small and clear, creating test versions is simple. You only need to mimic the methods a component actually uses, not a whole parent class.

Outro

While I started writing this I was not very sure of how to convey my thoughts into words but now I have successfully written this, I have to agree that it helped me to clear some of the doubts and the pitfalls I had in my understandings. I hope this will also help you to clear your doubts as well and stay tuned for the next one.