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();
}
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 tooverride
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()
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()")
}
Despite being embedded, you can still call methods implemented only for B
on an object of D
:
var d D
d.F() // works
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
}
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()
}
Compiling this produces:
example.go:34:7 cannot use &d (type *D) as type *B in assignment
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()
}
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()
andG()
(with matching signatures) automatically implements the interface: no explicitimplements
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()
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()
the declaration of F()
:
func (b *B) F() {
b.G()
}
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()
}
(which will suppress the compiler from synthesizing a forwarding method) and re-run the program, it produces the output:
B::G()
D::G()
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.