DEV Community

Cover image for Composition over inheritance, and Go's approach
Pieter Groenendijk
Pieter Groenendijk

Posted on

Composition over inheritance, and Go's approach

Preface

I estimate that you only need a basic understanding of Go to be able to read almost all Go code. The compiler is highly opinionated, and holds you accountable for any shenanigans. Forcing you to write consistent and simple code; almost no style deviations, and almost no syntax sugar. Short term convenience is traded in for long-term clarity and reliability, as seen here:

package main

import "container/list" // ERROR: Import not used — we don't do retours...

func main() {
    err := wrongStuff() // ERROR: You gotta face your errors man...
}

func crazyFunc() string
{ // ERROR: That curly brace should not be there...
    if true {

    }
    // ERROR: Does not seem like that "else" is connected well...
    else {

    }

    return 5 + "helloWorld" // ERROR: No implicit type conversions, show me that you mean it...
}

func wrongStuff() error {
    return errors.New("I did some wrong stuff, whoopsie")
}
Enter fullscreen mode Exit fullscreen mode

"An idiot admires complexity; a genius admires simplicity"
~ Terry A. Davis

For me, above example is a symptom of the principled programming Go wants you to do, and shows superficially why I love Go. Yet, I speculate that some principles may be more difficult to derive from just writing code, not knowing their deeper design motivations. I likely write code unfit for Go — code in Go, just not Go code.

As a consequence, these pitfalls emerge, things I repeatedly try to solve a certain way, even though it's hurting me more than it's benefiting me. I want to research these pitfalls one by one, adapting
to the go mindset where sensible. My hope is that this analysis can also be used by other software engineers to learn alongside me. It's time to challenge the underlying assumptions and break open those learned [N]-oriented, -driven, or -based development methodologies.

What's inheritance?

Tougher to answer than you might think. For the same reason why the Java keyword likely is "extends", and not "inherits". At the same time, if you look up "Java extends keyword", you'll find: "The extends keyword in Java is used to indicate that a class inherits from a superclass".

Let's try — there are two concepts to differentiate, namely subtyping, and inheritance.

Subtyping refers to compatibility of interfaces. Type B is a subtype of A if every function that can be invoked on an object of type A can also be invoked on an object of type B. Therefore in practice, allowing substitution.

Inheritance refers to reuse of implementations. Type B inherits from another type A if some functions for B are written in terms of functions of A. Therefore in practice, allowing code reuse.

Noteworthy, A being a subtype of B does in fact not have to mean A also inherits from B, or the other way around; they're independent relationships. Yet in practice, many languages couple
these concepts anyways, forming subclassing.

Take the following example:

Animal {
    sleep() { ... }

    poop() { ... }
}

Dog subclasses Animal {
    happy360() {
        // spinning around
        poop()
    }
}

singToSleep(Animal animal) {
    // singing oh so graciously
    animal.sleep()
}

Dog dog = new Dog();
dog.happy360();
singToSleep(dog);
Enter fullscreen mode Exit fullscreen mode

In this example, the Dog class subclasses the Animal class. The Dog inherits the implementation from Animal, but also subtypes Animal. Respectively allowing me to reuse the poop() implementation in Dog, and to substitute an Animal with a
Dog in singToSleep().

Rough terminology of subclassing

What's composition?

Composition refers to the combining of types into more complex ones. Practically, meaning placing variables of their own type inside a type definition. A simple example:

Pet {
    play() { ... }
}

Person {
    Pet pet

    playWithPet() {
        ...
        pet.play()
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, Person is composited of a Pet. Allowing pet.play() to be invoked inside Person.

Rough terminology, including composition

Composition over inheritance

The argument generally falls back onto the maintainability of the code.

The specification of an object is not limited to it's signatures, but also includes the pre- and -post conditions. If a type's specification is consistent, then users of that type can have reliable expectations, if not, functionality may be unpredictable.
The purpose of encapsulation is to create abstractions that keep a consistent specification.

Yet in practice, subclassing does allow meddling with internal
state, and I'm not just talking about Javascript (I propose we call "programming in Javascript" "meddling"). Moreso, many wide-spread design patterns are reliant on it. Let me show you with the following example.

Worker {
    tasks string[]
    coffee Coffee

    doWorkDay() {
        prepare()
        prepareMore()
        work()
    }

    prepare() {
        // plan tasks
        tasks = {
            "meeting",
            "bug A",
            "bug B",
            "feature X",
        }
    }

    prepareMore() {
        // get coffee
        coffee = new Coffee()
    }

    work() {
        // work
        foreach task in tasks {
            // do task
        }
    }
}

ChaosWorker subclasses Worker {
    // time to meddle
    prepare() {
        // Not doing anything
    }
}

doWorkDay(workers Worker[]) {
    foreach worker in workers {
        worker.doWorkDay()
    }
}
Enter fullscreen mode Exit fullscreen mode

By ChaosWorker overriding the prepare() method the Worker's specification has been sabotaged. The function doWorkDay() asking for Worker's will fail for any given ChaosWorker even though they can be freely substituted. From doWorkDay()'s perspective the Worker's interface has remained consistent, but the specification has not.

Using inheritance, one has to fully grasp the inner workings of Worker to reuse code correctly. That is because each specialization of Worker is not only coupled to the interface, but also the internal implementation. Worker is not enforced to be used as a cohesive type. Any Worker usage such as goWorkDay() needs to worry about each given Worker's concrete implementation.

Another example, showing how seemingly safe mutations to Worker may cause the amalgamation to stop functioning. Imagine the following addition.

DoubleWorker subclasses Worker {
    // Seems perfectly fine
    prepareMore() {
        prepare()
    }
}
Enter fullscreen mode Exit fullscreen mode

This worker will now plan double the tasks, without any real benefit — sure. A dumb but innocent change.

Now imagine we refactor our Worker's method prepare() and doWorkDay() as follows:

doWorkDay() {
    prepare()
    work()
}

prepare() {
    // plan tasks
    tasks = {
        "meeting",
        "bug A",
        "bug B",
        "feature X",
    }

    prepareMore()
}
Enter fullscreen mode Exit fullscreen mode

Another innocent change? From Worker's perspective the meaningful instructions doWorkDay() should produce hasn't changed. Yet, you'll find the following call stack:

doWorkDay()
    prepare()
        prepareMore()
            prepare()
                prepareMore()
                    prepare()
                        ...
Enter fullscreen mode Exit fullscreen mode

Infinite recursion due to the fact that Worker doesn't specifically call Worker's version of prepareMore(). The amalgamation of each implementation of Worker and Worker itself is treated as one cohesive whole; it's a package deal. You deal with a Worker, you deal with every specialization of it.

Practically when^[i.e. only basic access modifiers such as "public", "protected" and "private" are used.]:

  • A subclasses B, you get the type specifications: AB. only the amalgamation is enforced to be consistent; the coupling is implementation-wide.

  • When A is composed of B, you get the type specifications: A and B. both specifications are enforced to be consistent independently; the coupling remains at the interface.

All the while object-oriented programming advertises encapsulation as a core feature. The core design of many languages don't really
stimulate using it correctly however. Of course, in Java the keyword final can be freely used on classes and methods, disallowing subclassing and overriding respectively. But it needs to be explicitly declared — and I haven't seen any codebase doing this consistently. Reportedly, the Java inventor James Gosling himself has spoken against implementation inheritance, stating that he would not include it if he were to redesign Java.

On the other side composition generally has better encapsulation enforcement out of the box, needing only the explicit access modifiers such as public and private. This leads to stronger encapsulation, and therefore lower coupling and higher cohesion can be more easily enforced. Abuse is still possible, just a lot harder.

Code reuse will work simply as follows:

Animal {
    public poop() { ... }
}

Dog {
    Animal animal

    public poop() { 
        animal.poop() 
    }
}
Enter fullscreen mode Exit fullscreen mode

With composition we don't propagate the interface hoever, losing it's
substitution capabilities. By introducing an interface, and a forwarding method we can achieve this explicitly.

interface Pooper {
    poop()
}

Animal implements Pooper {
    public poop() { ... }
}

Dog implements Pooper {
    Animal animal

    public poop() {
        animal.poop()
    }
}
Enter fullscreen mode Exit fullscreen mode

Rough terminology, including interface subtyping (made up the term myself)

Go's approach

Go does not provide subclassing, just composition.

It does provide embedding. Embedding builds on interface-inheritance
and method-forwarding. It's functionally the same as previous example, just without the boilerplate.

By inheriting the interface of the outer type, it can be substituted for that interface. Shown in the updated example below:

Animal {
    public poop() { ... }
}

Dog {
    Animal // Embedding of Animal since only the type is provided (no name)
}
Enter fullscreen mode Exit fullscreen mode

Dog embeds Animal, thereby inheriting it's interface. Therefore poop() can be directly called onto a Dog instance. The implementation of poop(), opposed to with subclassing, remains encapsulated in Animal, limiting our coupling to the interface.

We hereby have the writing convenience of subclassing, but the lower coupling and higher cohesion composition provides.

Rough terminology, including embedding

Conclusion

Many languages provide subclassing, a heavily flawed coupling of inheriting both the interface as the implementation, creating amalgamations of high coupling and low cohesion, making maintainability that much harder.

Composition provides the wanted low coupling and high cohesion, at the cost of verbosity. A tradeoff many consider worthy, fueling the "Composition over inheritance" perspective.

Go is an example of a language which strictly enforces object code reuse through composition. With embedding the endeavor is not only possible but also highly pragmatic, solving the verbosity otherwise associated with composition.

Sources

Top comments (0)