Menu

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.

Embedding is Not Inheritance

There is one thing about struct embedding that trips up almost everyone arriving from Java or C++, and it is worth spelling out because the compiler will never warn us about it.

A promoted method still runs with the inner type as its receiver. The outer type is invisible to it. There is no virtual dispatch, no method overriding and no super.

Let’s make that concrete. Suppose Bicycle gains a Describe() method that calls its own RingBell():

1
2
3
4
func (b Bicycle) Describe() {
	fmt.Print("Announcing arrival: ")
	b.RingBell()
}

Now we give MountainBike a louder bell of its own, shadowing the promoted one:

1
2
3
4
5
6
7
8
9
10
11
12
13
func (m MountainBike) RingBell() {
	fmt.Println("BRAAAP! (trail horn)")
}

func main() {
	mtb := MountainBike{
		Bicycle: Bicycle{Tyres: 2, Bell: "ting-ting"},
		Gears:   21,
	}

	mtb.RingBell() // BRAAAP! (trail horn)
	mtb.Describe() // Announcing arrival: Ringing bell: ting-ting!
}

mtb.RingBell() resolves to the mountain bike’s method, exactly as we would expect. But mtb.Describe() still rings the original bell, because Describe() was compiled against a Bicycle receiver and knows nothing about whatever type embedded it. The same code in Java would print the trail horn for both calls.

This is not a limitation to work around, it is the whole point. Embedding hands us the inner type’s behavior exactly as written, and when we want polymorphism we ask for it explicitly through an interface, which is precisely what Cycler is doing above. The behavior we get is the behavior we can see at the call site.

Outro

When I started writing this I was not very sure how to convey my thoughts into words, but now that it is written, I have to agree that it helped me clear up some of the doubts and pitfalls in my own understanding. I hope it clears up a few of yours as well. Stay tuned for the next one.

Comments