Go-tcha: Shallow Polymorphism

Paul J. Lucas - Jun 25 '22 - - Dev Community

Introduction

Polymorphism (or, more specifically, subtype polymorphism) is one of the characteristics of an object-orient programming language (OOP) and is the ability of functions written to operate on objects of a particular type to operate on objects of subtypes also, sometimes doing different things.

Conventional Inheritance & Polymorphism

Consider the following example in C++:

struct B {
    void F() {
        G();
    }

    virtual void G() {
        std::cout << "B::G()\n";
    }
};

struct D : B {
    void G() override {
        std::cout << "D::G()\n";
    }
};

int main() {
    B b;
    D d;
    B *p;

    p = &b;
    p->F();
    p = &d;
    p->F();
}
Enter fullscreen mode Exit fullscreen mode

In C++ parlance, B is a base type and D is derived type (from B). (Or in conventional OOP parlance, B is a super-type and D is a subtype.)

B defines two member functions (methods): F() and G().

In C++, the presence of virtual allows derived types to override what a base type’s function does.

D inherits F() from B, but overrides G() to do something different from B’s G().

Compiling and running the C++ program produces the output:

B::G()
D::G()
Enter fullscreen mode Exit fullscreen mode

that surprises nobody familiar with C++. Specifically, even though F() (implemented only in B) calls G() (also implemented in B), for the d object, it’s D’s G() that actually gets called since D overrode B’s G(). The particular G() called by F() is determined dynamically (at run-time) based on the type of the object even though the function was called via a base type pointer B.

Equivalent programs can be easily written in C# and Java that produce the same output.

Embedding in Go

Go doesn’t have the concept of inheritance. Instead, it uses embedding, that is instead of D inheriting from B, D embeds a B inside it. The equivalent types in Go would be declared as:

type B struct {
}

type D struct {
    B            // a B is embedded inside D
}

func (b *B) F() {
    b.G()
}

func (b *B) G() {
    fmt.Println("B::G()")
}

func (d *D) G() {
    fmt.Println("D::G()")
}
Enter fullscreen mode Exit fullscreen mode

Despite being embedded, you can still call methods implemented only for B on an object of D:

var d D
d.F()    // works
Enter fullscreen mode Exit fullscreen mode

So it looks like D inherits F() from B; but the Go compiler adds a bit of syntactic sugar by automatically synthesizing a forwarding method for you:

func (d *D) F() {  // synthesized by the Go compiler
    d.B.F()        // calls F() on the B object embedded in D
}
Enter fullscreen mode Exit fullscreen mode

That is, the compiler automatically forwards unimplemented methods to embedded objects on your behalf (because having to do so manually would be tedious).

So if it looks (sort of) like inheritance and works like inheritance, is that good enough? Is it merely semantic quibbling not to call it inheritance?

Let’s try to convert the last bit of the C++ program to Go:

func main() {
    var b B
    var d D
    var p *B

    p = &b;
    p.F()
    p = &d;  // line 34
    p.F()
}
Enter fullscreen mode Exit fullscreen mode

Compiling this produces:

example.go:34:7 cannot use &d (type *D) as type *B in assignment
Enter fullscreen mode Exit fullscreen mode

In OOP parlance, just because D “has-a” B embedded inside it doesn’t mean D “is-a” B. Specifically in Go, you can’t use a pointer to an embedded type (here, B) to point at a type into which an embedded type is embedded (here, D). In Go, pointers can only ever point to objects of their declared static (compile-time) type.

Interfaces

To get polymorphism in Go, you need an interface:

type I interface {
    F()
    G()
}

func main() {
    var b B
    var d D
    var i I  // interface variable now, not *B

    i = &b;
    i.F()
    i = &d;
    i.F()
}
Enter fullscreen mode Exit fullscreen mode

Here, we’ve added an interface that has the two methods F() and G() we’ve been using. A variable of an interface (here, i) is like a pointer, but also can do polymorphism. It can point to any object that implements it, so the code now compiles.

In Go, any type that just so happens to implement F() and G() (with matching signatures) automatically implements the interface: no explicit implements keyword is used (as it is in Java).

Astute readers will realize that, unlike C++ where inheritance is a prerequisite for polymorphism, embedding and polymorphism are orthogonal in Go. This has some advantages (often espoused by Go proponents), but it comes at a price.

However, running the Go program produces the output:

B::G()
B::G()
Enter fullscreen mode Exit fullscreen mode

that surprises anybody familiar with C++, C#, or Java, because D’s G() is not called. Why not? Because as mentioned, in Go, polymorphism can happen only for interfaces, not types. But we are using an interface, so why doesn’t it produce the same output as the C++ program?

Even though F() was called via an interface:

    i = &d;
    i.F()
Enter fullscreen mode Exit fullscreen mode

the declaration of F():

func (b *B) F() {
    b.G()
}
Enter fullscreen mode Exit fullscreen mode

has the receiver declared as *B (pointer to type B) and not an interface because a function’s receiver must be a type in Go. So even though F() was called on an object via an interface, when the object is received by F(), it’s as if the interface “decayed” into a pointer.

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.

Once an interface “decays” into a pointer, there’s no way back: there’s no way to recover the original interface. Hence, polymorphism in Go is “shallow” in that it goes one call level down from an interface to a pointer and no further.

Conventional Polymorphism in Go

Is there any way to get conventional polymorphism in Go? One way to do it is if we clone B’s F(), but for D:

func (d *D) F() {
    d.G()          // not d.B.G() as in synthesized F()
}
Enter fullscreen mode Exit fullscreen mode

(which will suppress the compiler from synthesizing a forwarding method) and re-run the program, it produces the output:

B::G()
D::G()
Enter fullscreen mode Exit fullscreen mode

matching the C++ program because the receiver is *D now so D’s G() is called. So if you want more conventional polymorphism in Go, one way to do it is by overriding every method even if does the exact same thing as the “base type.” That’s really tedious. There’s at least one other way, but that’s just as tedious if not more so.

Conclusion

Shallow polymorphism in Go is a consequence of the deliberate language design choices made, in particular the orthogonality of inheritance and polymorphism and the use of an interface as the mechanism for polymorphism rather than a vptr (virtual table pointer) as used in C++. As mentioned, it has some advantages, but it makes writing programs that use non-trivial object hierarchies and polymorphism a lot harder to write.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .