Contents
- Introduction
- Base class
- Subclass
- Main and test
- Overriding
- Downcall
- Method promotion
- Solution
- Conclusion
Introduction
In lieu of inheritance, the Go language encourages composition by allowing one struct to be embedded in another struct in a way that allows calling methods defined on the embedded struct as if they are defined on the containing struct.Note: In this post I occasionally use object-oriented terminology such as base class, subclass, and override. Please remember that Go does not support these concepts; I am using those terms here to show how thinking that way with Go can lead to problems.
For the examples that follow, I assume we are building a graphical editor that allows manipulating visual objects on the screen. We want to be able to draw those objects, and we want to be able to transform them with operations such as rotate, so we define an interface with those methods:
Note: For convenience, the final collected code used in this post is available on play.golang.org.
We write a function that will draw all our shapes:type shape interface { draw() rotate(radians float64) // translate and scale omitted for simplicity }
func drawShapes(shapes []shape) { for _, s := range shapes { s.draw() } }
Base class
We define our "base class", calledpolygon
, where we implement a draw
method
that we can invoke from our "subclasses":
type polygon struct { sides int angle float64 } func (p *polygon) draw() { fmt.Printf("draw polygon with sides=%d\n", p.sides) vertexDelta := 2*math.Pi / float64(p.sides) vertexAngle := p.angle x0 := math.Cos(vertexAngle) y0 := math.Sin(vertexAngle) for i := 0; i < p.sides; i++ { // Draw one side within unit circle, offset by p.angle. vertexAngle += vertexDelta x1 := math.Cos(vertexAngle) y1 := math.Sin(vertexAngle) fmt.Printf("draw from (%v, %v) to (%v, %v)\n", x0, y0, x1, y1) x0 = x1 y0 = y1 } } func (p* polygon) rotate(radians float64) { p.angle += radians }
Subclass
We define a couple of "subclasses",triangle
and square
,
that "extend" our "base class",
along with functions to create instances of those types:
type triangle struct { polygon } type square struct { polygon } func createTriangle() *triangle { return &triangle{ polygon { sides: 3, }, } } func createSquare() *square { return &square{ polygon { sides: 4, }, } }
Main and test
Finally, we write a couple of test functions to create a list of shapes and draw them, and a one-linemain
function that calls our test function.
When we run this program, it produces the expected output:package main import ( "fmt" "math" ) func createTestShapes() []shape { shapes := make([]shape, 0) shapes = append(shapes, createTriangle()) shapes = append(shapes, createSquare()) return shapes } func testDrawShapes() { drawShapes(createTestShapes()) } func main() { testDrawShapes() }
Note that we have not defined any methods on thedraw polygon with sides=3 draw from (1.000, 0.000) to (-0.500, 0.866) draw from (-0.500, 0.866) to (-0.500, -0.866) draw from (-0.500, -0.866) to (1.000, -0.000) draw polygon with sides=4 draw from (1.000, 0.000) to (0.000, 1.000) draw from (0.000, 1.000) to (-1.000, 0.000) draw from (-1.000, 0.000) to (-0.000, -1.000) draw from (-0.000, -1.000) to (1.000, -0.000)
triangle
and square
types,
yet the compiler accepts them as implementing shape
, as seen by the fact that
we can store them in a slice of shape
and we can invoke draw
on them.
Because we embedded polygon
in triangle
and square
, without giving them field
names, Go has promoted all of the methods in polygon
into the namespaces of
triangle
and square
, allowing draw
to be called directly on an
instance of type triangle
or square
.
So far, relying on an object-oriented mental model has not caused us problems. Let's keep going and see when it does.
Overriding
We add atypeName
method to our shape
interface
and our "base class", polygon
,
and we "override" that method in our "subclasses", triangle
and square
:
We can test ourtype shape interface { draw() rotate(radians float64) // translate and scale omitted for simplicity typeName() string } func (p *polygon) typeName() string { return "polygon" } func (p *triangle) typeName() string { return "triangle" } func (p *square) typeName() string { return "square" }
typeName
methods by pointing our main
to
a different test function:
This outputs:func printShapeNames(shapes []shape) { for _, s := range shapes { fmt.Println(s.typeName()) } } func testShapeNames() { printShapeNames(createTestShapes()) } func main() { testShapeNames() }
No problems yet.triangle square
Downcall
Let's add a method to our interface and "base class" that invokes the method that we are overriding, and a new test function to call it. This is sometimes referred to as a downcall, in that a superclass calls into the overriding method of a subclass that is below it in the class hierarchy.This outputs:type shape interface { draw() rotate(radians float64) // translate and scale omitted for simplicity typeName() string nameAndSides() string } func (p *polygon) nameAndSides() string { return fmt.Sprintf("%s (%d)", p.typeName(), p.sides) } func printShapeNamesAndSides(shapes []shape) { for _, s := range shapes { fmt.Println(s.nameAndSides()) } } func testShapeNamesAndSides() { printShapeNamesAndSides(createTestShapes()) } func main() { testShapeNamesAndSides() }
Well, that doesn't look right. We wanted it to print triangle and square instead of polygon both times. Thinking of this as inheritance has led us astray.polygon (3) polygon (4)
Method promotion
So, what happened here? Why didprintShapeNames
work, but printShapeNamesAndSides
did not?
Let's dig into that.
The return value of
createShapes
is []shape
, which is a slice of objects that implement
the shape
interface. Since the triangle
and square
types implement that interface, we can store
instances of those types
in that slice. But how is it that those types implement that interface when we didn't write those
methods for those types?
The answer is method promotion.
When we embed one type inside another without giving the internal type a field name, Go automatically promotes all unambiguous names from the embedded type to the containing type. Effectively, for each method in the embedded type whose name does not conflict with a method in the containing type or in any other embedded type within that container, Go creates a method on the containing type that turns around and calls that method on the embedded type. For example, when we embed
polygon
in triangle
the compiler effectively creates this code:
If the embedded type satisfies an interface, and there are no ambiguous method names, this promotion of all the methods of the embedded type makes the containing type also satisfy that interface. Let's explore this method promotion behavior. We create another struct type calledfunc (t *triangle) typeName() string { return t.polygon.typeName() }
thing
that
has a typeName
method,
embed it along with our previously defined polygon
,
which also has a typeName
method, in a new type polygonThing
,
then try to assign an instance of that to a variable of type shape
.
When we compile this, we get these errors:type thing struct{} func (t *thing) typeName() string { return "thing" } type polygonThing struct { polygon thing } func testPolygonThing() { p := &polygonThing{} p.draw() fmt.Println(p.typeName()) var s shape = p fmt.Println(s.typeName()) } func main() { testPolygonThing() }
where line 131 is the line where we are assigning to./comp.go:130:16: ambiguous selector p.typeName ./comp.go:131:7: polygonThing.typeName is ambiguous ./comp.go:131:7: cannot use p (type *polygonThing) as type shape in assignment: *polygonThing does not implement shape (missing typeName method)
s
.
From this error we can see that Go did not promote the
typeName
method from either
of the embedded structs into polygonThing
. But there was no error message about the
call to draw
, so it did promote that method from polygon
, since it is
not ambiguous.
If we comment out the embedded
thing
line from the definition of polygonThing
,
the code compiles.
If, instead, we comment out the embedded polygon
line, we get different errors:
If we want to keep both embedded structs in our composite struct, there are a couple of ways we can resolve the ambiguity of./comp.go:129:4: p.draw undefined (type *polygonThing has no field or method draw) ./comp.go:131:7: cannot use p (type *polygonThing) as type shape in assignment: *polygonThing does not implement shape (missing draw method)
typeName
appearing in both embedded structs.
The simplest is to assign a name to one
of the embedded structs, converting it to a regular field. Instead of writing
thing
in the definition of polygonThing
, we can write t thing
.
Go then does not attempt to promote the methods from thing
into polygonThing
,
and the promotion of typeName
from polygon
into
polygonThing
is no longer ambiguous, so it succeeds.
Another possibility is to resolve the ambiguity by defining a
typeName
method
directly on polygonThing
. In this case, Go does not attempt to promote typeName
from either of the embedded structs. We can call a method in an embedded struct
by referring to that embedded struct as if it were a named field.
With this definition, the program compiles and runs, outputtingfunc (t *polygonThing) typeName() string { return t.polygon.typeName()+"Thing" }
draw polygon with sides=0 polygonThing polygonThing
Solution
Now that we understand how embedded structs work in Go, let's go back and reconsider what happened with ourprintShapeNamesAndSides
function.
Assume one of the elements in our slice of
shape
is an instance of triangle
.
We call nameAndSides
with that triangle
as the receiver. Since we did not define nameAndSides
on triangle
, that calls the promoted version of that method. That promoted method turns around and calls
nameAndSides
on the embedded polygon
, passing the embedded polygon
as the receiver.
In polygon.nameAndSides
, it calls p.typeName
, but p
here is the receiver of the
nameAndSides
method, which is the polygon
, not the triangle
. So the call from nameAndSides
to typeName
call's the typeName
method on polygon
rather than on triangle
.
With this understanding, let's update our code to make "overriding" work. The difference between the behavior we are seeing and what we would expect from a system with inheritance and overriding is that here our "base class" does not, by default, make calls to methods of the "subclass". It can't because the method in the "base class" has no reference to the type of the containing object. In order to implement a call to method in an instance of a "subclass" from
polygon.nameAndSides
, we need a reference to
that instance, such as a triangle
.
We will do this by explicitly passing our shape
as an argument, then calling the typeName
method on
that shape
rather than on the receiver.
By calling a method on a passed-in argument rather than the receiver,
it is clear, when looking at that method in the "base class", that the call may
be going to a different type of object than polygon
.
With these changes, we get the expected output:type shape interface { ... nameAndSides(s shape) string } func (p *polygon) nameAndSides(s shape) string { return fmt.Sprintf("%s (%d)", s.typeName(), p.sides) } func printShapeNamesAndSides(shapes []shape) { for _, s := range shapes { fmt.Println(s.nameAndSides(s)) } }
triangle (3) square (4)
Conclusion
The way Go promotes methods of embedded structs makes it have some of the characteristics of inheritance as defined in object-oriented programming. In particular, it allows for methods to be automatically promoted to the containing struct, and thus for interfaces to be automatically promoted to the containing struct. One key difference is that, when you override one of those promoted methods in the containing struct, the code in the embedded class does not automatically call the overridden method in the containing class, as happens in some object-oriented languages such as Java.You may have heard of the fragile base class problem. A related issue, that can arise when there are downcalls from a superclass to an overridden method in a subclass, similar to the example here where I "overrode" the
typeName
method,
might be termed the fragile subclass problem.
If you are interested into digging into that, you can read
Safely Creating Correct Subclasses without Seeing Superclass Code,
a paper from OOPSLA 2000 that examines that issue. See section 4.
The designers of Go chose not to implement inheritance, but instead to
favor composition.
Although some Go constructs can look a little like inheritance, it's better to
start thinking about designing in Go using composition rather than trying to bend
Go to do something like inheritance.
No comments:
Post a Comment