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 (6)

Collapse
 
pauljlucas profile image
Paul J. Lucas

The problem with Go's approach is detailed here.

Languages that use traditional inheritance give you the choice to use embedded if you want to; Go forces you to use embedding: you do not have the choice to use traditional inheritance. As shown in the article, that can lead to different problems.

Collapse
 
pietergroenendijk profile image
Pieter Groenendijk

Thank you for commenting.

At this time I don't see the problem you're trying to convey with "shallow" polymorphism. You say:

"Now a pointer, the only methods that can ever be called on b from within F() are those of B and never of any “derived” type — even when b points to a B object embedded in a D as is the case here."

What you portray as a problem, I portray as a solution in my article xD. This is a symptom of the idea that encapsulation of B can't be broken, which leads to higher coupling since the type specification may be affected and therefore can't be ensured, as I try to explain in my article.

Then again, my brain is tired right now :). I'll take the time tomorrow to read your article thoroughly.

Collapse
 
pauljlucas profile image
Paul J. Lucas

In software, higher coupling is generally considered a bad thing. That aside, in Go, it's very hard to implement traditional class hierarchies. It's not clear at all to me that not breaking encapsulation is a desirable goal. What problem does that solve?

In a language like C++, if you don't want a method's encapsulation not to be "broken," simply don't declare the method virtual. C++ gives you a choice; Go doesn't.

Thread Thread
 
pietergroenendijk profile image
Pieter Groenendijk • Edited

I am aware high coupling is considered bad. My wording in my previous comment was not formulated well to convey this correctly — sorry for that :).

Not breaking encapsulation, generally by enforcing encapsulation, protects the type specification, and therefore your expectations of the code.

If enforced, coupling remains at the interface you've defined; that being an explicit interface, or implicitly via access modifiers.

If not, the coupling remains at the whole surface of the two types, because there really only is one type specification left, and that's the "whole". Any change may affect the other, even with private methods.

That's the problem it solves.

I have no problem with "pure" inheritance, where nothing can be overridden. It's the virtual's and lack of final's that create the problem. The thing is composition then can do exactly what "pure" inheritance can do. Which is made easier in languages like Go since it adds ease of use with features like embedding.

Having said that, if overriding is the "bad", and "pure" inheritance can be functionally replaced with composition, then I see no use for traditional subclassing. Prompting me to ask the question: Why do you need to break encapsulation? Why do you need the option to do so?

Thread Thread
 
pauljlucas profile image
Paul J. Lucas

For another example:

package main

import "fmt"

type Employee interface {
    Print()
    PrintAdditionalInfo()
}

type Manager interface {
    Employee
}

type MyEmployee struct {
}

type MyManager struct {
    MyEmployee
}

func (e *MyEmployee) Print() {
    fmt.Println("MyEmployee.Print()")
    e.PrintAdditionalInfo()
}

func (e *MyEmployee) PrintAdditionalInfo() {
    fmt.Println("MyEmployee.PrintAdditionalInfo()")
}

func (m *MyManager) PrintAdditionalInfo() {
    fmt.Println("MyManager.PrintAdditionalInfo()")
}


func main() {
    var e Employee = &MyManager{}
    e.Print()
}
Enter fullscreen mode Exit fullscreen mode

When run, this will print:

MyEmployee.Print()
MyEmployee.PrintAdditionalInfo()
Enter fullscreen mode Exit fullscreen mode

even though the object is actually an instance of MyManager and MyManager has its own PrintAdditionalInfo() function.

This type of pattern happens frequently in applications that use traditional object hierarchies. People want to do that because it's often very useful. Not doing that is counterintuitive.

Thread Thread
 
pietergroenendijk profile image
Pieter Groenendijk

Thanks again for commenting, but I'll stop responding since I don't want us to start repeating ourselves. Have a good day :).