SOLID Go: Liskov Substitution Principle

8 minute read

Introduction

Many software development professionals find the original definition of the Liskov Substitution Principle somewhat short or hard to grasp. To understand what the Liskov Substitution Principle really is about, you need to extract the two core statements from the original LSP definition:

  1. A statement about datatype compatibility before the program runtime: in Golang, this means compile time, carried out by a compiler.
  2. A statement about the behaviour of already type-checked code during runtime: surprisingly, violations of this rule sneak in during code implementation because humans implement behaviour as code based on other variable names, function names and other identifiers.

In more practical everyday programmer’s terms:

  1. LSP refers to typed class hierarchies.
  2. LSP refers to code identifiers (names of structs, classes, functions etc.) that must always reflect their actual runtime behaviour, or you will end up in trouble.

Both conditions must apply at the same time for programs to be LSP compliant.

Whereas the first statement is based on hard technical facts, the second relates to how humans process identifiers while reading source code. So, yes, the second LSP statement is the one that feels hard to grasp.

The specifics of these two statements might be different with statically and dynamically typed languages. However, since Golang is a statically typed programming language, this article only refers to the static POV.

Liskov Substitution Principle and Type Hierarchies in Golang

The classic negative example from Barbara Liskov’s work is using a Queue class to extend the Stack class. This example, unfortunately, cannot be directly translated into the Golang language space because classical object-oriented programming allows the child class to be used instead of the parent class.

Instead, in Golang, you will probably work around this “shortcoming” by using interfaces for a type abstraction. A well-designed interface usually communicates few concrete promises. Commonly, a well-designed interface has a clear but mostly generic identifier. The same rule applies to the function names of interfaces. Choosing the most suitable identifiers can already save computer programmers from a lot of trouble.

There is a relationship between type naming and type behaviour. Trouble starts when the identifier is disconnected from the actual code behaviour. For example, if a Queue class behaves like a Stack, this is a potential problem for the consuming function: Because Queue and Stack behave fundamentally differently. The compiler does not mind because their function interface looks the same, and purely technical speaking, the inheritance hierarchy is also valid. But when a third party consumes the child’s class, this third party expects the class to act like the promised parent.

To come up with an extreme example to highlight the subtle contrast: It is like being promised a Sheep but handed a Wolf in a sheep’s coat. You can tell the difference only when the actual behaviour is very different from what you expected.

Practical Example

Let’s try to make sense of the Liskov Substitution Principle by starting with the classic example of the Queue and the Stack data structures. First, you will learn why this object-oriented style is not directly applicable to Golang. Afterwards, we will integrate the code one step at a time into a version that still makes sense with the LSP in Golang terms.\

 1package main
 2
 3import (
 4	"fmt"
 5	"reflect"
 6)
 7
 8type Queue struct {
 9}
10
11type Stack struct {
12	*Queue
13}
14
15func someFn(aQueue Queue) {
16	fmt.Printf("Received a %s\n", reflect.TypeOf(aQueue))
17}
18
19func main() {
20	aStack := Stack{}
21	aQueue := Queue{}
22
23	fmt.Printf("This is a %v \n", reflect.TypeOf(aQueue))
24	fmt.Printf("This is a %v \n", reflect.TypeOf(aStack))
25
26	// will work fine
27	someFn(aQueue)
28
29	// Error: cannot use aStack (variable of type Stack) as type Queue in argument
30	// someFn(aStack)
31
32	// Error: invalid operation: aQueue (variable of type Queue) is not an interface
33	// aConvertedStack, _ := aQueue.(Stack)

The above code listing defines two empty structs, Queue and Stack. Stack inherits the traits of Queue. Since both structs don’t implement any details, you can consider them behavioural empty. This way, you can focus on the type hierarchies exclusively. The last two commands at the bottom are intentionally out-commented because Golang won’t compile them. If you want to see the compiler failing live in front of you, copy and paste the code into the Golang playground of a local editor of your choice. Then run the compiler on it.

The code contains a function “someFn(aQueue Queue)”, which accepts a parameter of type Queue. In classic object-oriented terms, you are usually allowed to pass a child of a class. That’s not valid with Golang. For example, the line “someFn(aStack)” will result in the Golang compiler error “cannot use aStack (variable of type Stack) as type Queue in argument”. So Golang forbids this behaviour. They are different types but not inherited the way object orientation inherits.

It would help if you introduced an interface type to use different concrete struct implementations with the same type identifiers. Because interfaces in That’s also how you can substitute derived structs. You will not convert the structs from type A to type B, but with an interface, you define a contract all the implementing parties agree on. Introducing an interface enables any interface consumer to access the passed structs without knowing anything about their concrete types.

package main

import (
	"fmt"
	"reflect"
)

// introduce the empty "Nonsense" interface
// just to prove the point of casting "type hierarchies" in Golang
type Nonsense interface {
	GetType() string
}

// Queue struct is implicitly implementing
// the Nonesense Interface.
//behaviour is returning the type-name
// of struct the function is bound to
type Queue struct {
}

func (q Queue) GetType() string {
	return reflect.TypeOf(q).String()
}

// Queue is implicitly implementing
// the Nonesense interface because
// Struct is embedding Queue.
type Stack struct {
	Queue
}

// This function is now accepting a parameter
// named aQueue of type Nonesense.
// The function does not care about Nonsense
// being a struct or an interface.
func someFn(aQueue Nonsense) {
	fmt.Printf("Received a %s\n", aQueue.GetType())
}

func main() {
	aQueue := Queue{}
	aStack := Stack{}

	// casting
	someFn(aQueue)
	someFn(aStack)

	// casting aQueue to be of type Nonsense
	aNonsense := Nonsense(aQueue)
	someFn(aNonsense)

	// casting aStack to be of type Nonsense
	aNonsense = Nonsense(aStack)
	someFn(aNonsense)
}

That’s it for the hard technical parts. You might remember that this article mentioned two statements which can be extracted from the LSP. Since the second statement is heavily based on language semantics, this is something also valid for programming in Golang. Let’s enhance the last example by introducing LSP-compliant type names.

We are introducing the “Bag” interface as a generic supertype. Type “Bag” communicates a very basic statement about the data types behaviour:

  1. Put something into this container/bag.
  2. Get something out of this container/bag.

Notice that there is no statement about a particular sequence of stored or restored elements. This behavioural gap is absolutely intended because this is just the common denominator of more concrete derivations like Stack and Queue. By finding a common denominator of two fundamentally different behaviours/types, you can find a potential candidate for a well-designed interface type. Below is the complete code listing. A Struct and a Queue, implementing the common Bag interface. Also, the LSP valid testBagBehaviour() and the LSP violating testQueueBehaviour() functions.

ackage main

import (
	"fmt"
	"reflect"
)

// Introduce the "Bag" interface as generic supertype.
// Bag communicates a very basic promise of this data types behaviour:
// 1. Put something into this container/bag
// 2. Get something out of this container/bag.
// You cannot tell what comes out of the bag first.
type Bag interface {
	Put(item string)
	Get() (string, error)
}

// Queue struct is implicitly implementing the Bag Interface.
// You can tell that a Queue dequeues first what went in first.
type Queue struct {
	items []string
}

func (q *Queue) Put(item string) {
	q.items = append(q.items, item)
}

func (q *Queue) Get() (string, error) {
	count := len(q.items)
	if count < 1 {
		return "", fmt.Errorf("No more elements")
	}

	item := q.items[0]
	q.items = q.items[1:]

	return item, nil
}

// Stack struct is implicitly implementing the Bag Interface.
// You can tell that a Stack dequeues first what went in last.
// Stack is not embedding Queue at this point.
type Stack struct {
	items []string
}

func (s *Stack) Put(item string) {
	s.items = append(s.items, item)
}

func (s *Stack) Get() (string, error) {
	count := len(s.items)
	if count < 1 {
		return "", fmt.Errorf("No more elements")
	}

	item := s.items[count-1]
	s.items = s.items[:count-1]

	return item, nil
}

// This function is now accepting a parameter
// named aBag of type Bag.
// Since Bag does not promise any particular special behaviour,
// also the function does not rely on Bag behaving opinionated.
// A Stack's opinionated behaviour is FiFo.
// A Queue's opinionated behaviour is LiFo.
// This functions code can only rely on that what comes out of the bag
// must at some point being put into the bag.
func testBagBehaviour(aBag Bag) {
	fmt.Println("testBagBehaviour using a", conceptName(aBag))

	items := []string{
		"Foo", "Bar", "Baz",
	}

	aBag.Put(items[0])
	aBag.Put(items[1])
	aBag.Put(items[2])

	for range items {
		item, err := aBag.Get()
		fmt.Printf("Item: %s // Error: %v\n", item, err)
		if err != nil || contains(items, item) == false {
			fmt.Printf("Error: Unexpected behaviour!\n\n")
			return
		}
	}

	fmt.Println("")
}

// This function is now accepting a parameter
// named aBag of type Bag.
//
// ONLY for demonstration purposes, this function
// will violate the Liskov Substitution Principle.
// The function will receive a Bag, but awaits Queue behaviour.
func testQueueBehaviour(aBag Bag) {
	fmt.Println("testQueueBehaviour using a", conceptName(aBag))

	items := []string{
		"Foo", "Bar", "Baz",
	}

	aBag.Put(items[0])
	aBag.Put(items[1])
	aBag.Put(items[2])

	for idx := range items {
		item, err := aBag.Get()
		fmt.Printf("Item: %s // Error: %v\n", item, err)
		if err != nil || items[idx] != item {
			fmt.Printf("Error: Unexpected behaviour!\n\n")
			return
		}
	}

	fmt.Println("")
}

// This is a helping function for telling if an item is contained in an array
func contains(items []string, item string) bool {
	for idx := range items {
		if items[idx] == item {
			return true
		}
	}
	return false
}

// This is a helper for getting the name of the concrete implementation
func conceptName(concept interface{}) string {
	t := reflect.TypeOf(concept)
	if t.Kind() == reflect.Ptr {
		t = t.Elem()
	}

	return t.Name()
}

func main() {
	aQueue := Queue{}
	aStack := Stack{}

	testBagBehaviour(&aQueue)
	testBagBehaviour(&aStack)

	testQueueBehaviour(&aQueue)
	testQueueBehaviour(&aStack)
}

Conclusion

The Liskov Substitution Principle also applies to Golang structs and interfaces. The difference here is that Golang does not allow direct substitution of structs. You will need to work around this intended shortcoming by making strong use of the interface building block. However, the build limitation of struct substitution indirectly supports you in adhering to the Liskov Substitution Principle since you don’t accidentally mutate the behaviour of parent structs without a consuming code to be able to notice. Implement code always towards the abstract interface, nearly never to the concrete struct implementation.