Golang "Inheritance"
For those coming from an Object Oriented language background to Go, it can be rather jarring to hear that there are no classes and thus no inheritance in Go. While this is strictly true, there are ways to "inherit" the same behavior and promote more code reuse in Go.
Before we get started, it's important to understand why Go doesn't have inheritance - by design - and the problems that classical inheritance can create. We'll first approach a situation with inheritance and gradually move toward composition.
Basic Inheritance
The benefits of inheritance are mainly code reuse, polymorphism, and dynamic dispatch. While these are valid objectives, there's more than one way to achieve them.
One of the most common examples of inheritance actually illustrates some of the problems with the approach.
In Java this relationship can be expressed clearly with the implements
keyword.
public interface Animal {
Sound makeSound()
}
public class Dog implements Animal {
/* implementation */
}
public class Cat implements Animal {
/* implementation */
}
As we can see in the the diagram above, a Dog IS-A Animal, and the same goes for Cat. There are some problems here though.
- The abstraction - and the basis for our mental model - is faulty, because not all animals make a sound. This is something we may not consider up front and will lead to segregating the interface later or reworking the inheritance chain in the worst case.
- Similarly, each of our implementing classes has a
wagTail()
method that is inaccessible through the interface, so we would have to break the abstraction to call it. This makes the abstraction rather leaky because we're not cleanly separating implementation from use.
While both of our Animal
implementations have a wagTail()
method, not all Animal
s have tails, so we can't just hoist up the method declaration into the Animal
interface.
Interface Segregation
One way to address these problems is to separate the interface up front. It allows for more flexibility in how we represent functionality, and one class can implement multiple interfaces.
public interface SoundMaker {
Sound makeSound()
}
public interface TailWagger {
void wagTail()
}
public class Dog implements SoundMaker, TailWagger {
@Override
public Sound makeSound() {
return new Bark();
}
@Override
public void wagTail() {
}
}
This can be done in Go too. These two snippets are pretty much equivalent. The method names are capitalized in the Go version to expose them for use to code outside the current package.
type SoundMaker interface {
MakeSound() Sound
}
type TailWagger interface {
WagTail()
}
type Dog struct {
}
func (d Dog) MakeSound() Sound {
return "Ruff!"
}
func (d Dog) WagTail() {
}
You might be wondering how we know that Dog
implements the interface. Go uses "duck typing" to determine whether a type implements a given interface. So there's really no need for an implements
keyword.
Looks like a duck, quacks like a duck, must be a duck.
This means that the compiler can't enforce whether a type implements an interface until we try to assign it to one. This is a common pattern to enforce this up front.
var _ SoundMaker = (*Dog)(nil)
var _ TailWagger = (*Dog)(nil)
This will simulate the assignment, but ignore the resulting variable declaration since the go compiler throws errors for unused variables.
Note:
Go interfaces do not encapsulate data, only operations. This is a key difference between the two languages.
A Quick Aside
There's a small convention clash between how Java interfaces are named vs. Go. In Java these would be names something like Soundable
or Waggable
. In Go, the common way to name interfaces is as shown. An example of this is the Stringer
interface (see the docs).
Code Reuse
This is where we are so far.
In the case of Dog and Cat, they don't sound alike, nor do they wag their tail in exactly the same way. With many different types of Animals, it could get really tedious to have to write the same code over and over again for similar animals with slight differences in implementation. Since Go doesn't allow locked in class hierarchies, we can extend and reuse this functionality by embedding a type in another.
Here we can reuse the common aspects of Dog
and Cat
to create specific variants of them. Again, this is not inheritance, but embedding. What's happening under the covers is that Corgi
and Persian
are given matching methods of Dog
and Cat
that delegate to the underlying implementation.
var _ SoundMaker = (*Corgi)(nil) // No error here
var _ TailWagger = (*Corgi)(nil) // Still good
type Corgi struct {
Dog
}
func (c Corgi) BeSuperCute() {
}
This is effectively the code that is added onto the type by embedding Dog
in Corgi
. Note that this does not have to be written by us, we get it by embedding the existing struct. This makes the embedding and delegation mechanism pretty convenient.
func (c Corgi) MakeSound() {
return c.Dog.MakeSound()
}
func (c Corgi) WagTail() {
c.Dog.WagTail()
}
Since the MakeSound
and WagTail
methods exist for the Corgi
type, it satisfies the requirement to implement the respective interfaces.
Composition
The example above doesn't get us away from the fragile base class problem because we're still forcing the abstract base class model on the language. This also means we haven't really solved the abstraction problems. There is still a tightly coupled base implementation that requires more of the lower level functionality than what might make sense.
This is where we start moving more toward composition of functionality versus inheritance-like coupling, all while providing different implementations behind abstract interfaces. Let's start with some examples of components which can be embedded to satisfy these interfaces.
type HissSoundMaker struct {}
func (h HissSoundMaker) MakeSound() Sound {
return "Hisss!"
}
type BarkSoundMaker struct {}
func (b BarkSoundMaker) MakeSound() Sound {
return "Arf!"
}
type MeowSoundMaker struct {}
func (m MeowSoundMaker) MakeSound() Sound {
return "Meow!"
}
type InsubordinateTailWagger struct {}
func (i InsubordinateTailWagger) WagTail() {
fmt.Println("...No.")
}
type HappyTailWagger struct {}
func (h HappyTailWagger) WagTail() {
fmt.Println("I'm wagging my tail right now!")
}
Now we can construct many different animals all from different building blocks. Ones with or without tails, animals that may or may not make noise, and functions can make assertions about what it requires without ever knowing about or relying upon the details of how tails and sounds work under the covers.
// We can use composition with interfaces too!
type NoisyTailWagger interface {
SoundMaker
TailWagger
}
var _ NoisyTailWagger = (*Corgi)(nil)
type Corgi struct {
BarkSoundMaker
HappyTailWagger
}
var _ NoisyTailWagger = (*Persian)(nil)
type Persian struct {
HissSoundMaker
InsubordinateTailWagger
}
var _ NoisyTailWagger = (*Rattlesnake)(nil)
type Rattlesnake struct {
HissSoundMaker
HappyTailWagger
}
// Lizard doesn't make noise!
// var _ NoisyTailWagger = (*Gecko)(nil)
var _ TailWagger = (*Lizard)(nil)
type Lizard struct {
HappyTailWagger
}
// Completely valid slice of NoisyTailWaggers
var animals = []NoisyTailWagger{Corgi{}, Persian{}, Rattlesnake{}}
func PrintAnimals() {
for _, a := range animals {
fmt.Printf("Animal says %s. Wag your tail, please.", a.MakeSound())
a.WagTail()
}
}
Notice that we have Lizard
which has a tail but doesn't make noise, so it doesn't make sense to force a Lizard
to implement SoundMaker
. The compiler helps protect us from ourselves and prevents us from accidentally adding it in the list of animals
at the bottom of the snippet.
Conclusion
Hopefully this has provided some food for thought about how to structure complex structures in more maintainable and testable ways (you are testing, right). This also accomplishes the same goals of object oriented class hierarchies without getting locked into that rigid view. Composition can make building up complex families of structures easier by creating building blocks first, then putting them all together to expose more complex implementations. This allows a system to be more flexible and maintainable by reducing the impact of change and providing better abstractions.
Here are some guidelines for moving toward composition.
- Interfaces should be small. If many interfaces are needed to make it clear what is required, embed them together into a larger interface.
- Resist creating complex "inheritance" chains, as this can makes the system more rigid and harder to change without breaking things.
- Think of composition as types "opting in" to functionality as opposed to being bound to a complex contractual obligation. With small interfaces, actual implementation requirements are lighter and easier to change as necessary.