Fun With Go Method Routing
Introduction
Many of us here at Farsight Security are fans of the (relatively) new Go programming language, and are using it in several projects. In addition to its powerful standard library, Go is notable for a clean C-like syntax, integrated support for concurrency, and a simple yet powerful type system. This article focuses on a subtlety of the type system.
Embedding, Inheritance
All Go types, struct or not, can have methods. Struct types, however, can “embed” other types. Embedding is similar to having a field with the embedded type, but this field is unnamed and, most significantly, all methods of the embedded type are “promoted” to the embedding struct. For example, in the following:
type T struct { A, B int }
func (t *T) String() string {
return fmt.Sprintf("T: %d, %d", t.A, t.B)
}
type T2 struct { T }
type T2
will also have a String()
method, returning the string
representation of the embedded type T.
Although the promotion of methods (and, since T
is a struct, fields)
to T2
may superfically resemble inheritance (“is a”), it is composition
(“has a”) under the hood. This affects method routing significantly relative
to other object-oriented languages.
Going Virtual
A common object-oriented library structure is the “abstract base class”, where a library provides a type with some high level methods defined on it, defines some methods with no (or stub) implementations, and relies on the user to provide implementations of these methods in derived classes. A python example would be:
class FileProcessor:
def __init__(self, in, out):
self.in = in
self.out = out
# default process is to do nothing
def Process(self, line):
return line
def run(self):
while True:
line = self.in.readline()
if line == "":
return
self.out.write(self.Process(line))
class capitalizer(FileProcessor):
def Process(self, line):
return line.upper()
An instance of capitalizer
will have a run
method which, although
defined in FileProcessor
, calls the capitalizer
implementation of
Process
.
The Process
method here is said to be “virtual”. All python methods
are virtual, it is the default behavior of methods in Java, and can
be requested with the “virtual” keyword in C++. In contrast, Go has no virtual
methods. Consider the following code:
type FileProcessor struct {
in *bufio.Reader
out io.Writer
}
func (f *FileProcessor) Process(line string) string { return line }
func (f *FileProcessor) Run() {
for {
line, err := f.in.ReadString('\n')
if err != nil {
break
}
io.WriteString(f.out, f.Process(line))
}
}
type capitalizer struct {
*FileProcessor
}
func (c *capitalizer) Process(line string) string {
return strings.ToUpper(line)
}
The capitalizer
implementation of Process
will never be called.
The capitalizer Run method
calls the FileProcessor Run
method,
which will always call the FileProcessor
implementation of
Process
, because its method receiver *f
is of type *FileProcessor.
Inside Out
The above case would work, sort of, if we inverted our approach to
the FileProcessor
abstraction. Instead of making it the base type,
FileProcessor
becomes the outer type, and users fill in behavior
with composition. So, the Go version becomes:
type capitalizer struct {}
func (c *capitalizer) Process(line string) string {
return strings.ToUpper(line)
}
type FileProcessor struct {
in *bufio.Reader
out io.Writer
*capitalizer
}
This, along with removing the FileProcessor
implementation of Process
,
will do what we wanted capitalizer
to do. However, this does
not allow multiple FileProcessor instances with different processing:
all FileProcessor
s are capitalizer
s in this example.
To overcome this last hurdle, we use Go’s
interface
types. An
interface
is merely a collection of methods, and a variable
or field with an interface type can carry any value whose underlying type
supports those methods. In this case:
type StringProcessor interface {
Process(string) string
}
type FileProcessor struct {
in *bufio.Reader
out io.Writer
StringProcessor
}
the StringProcessor
element can take a value of any type which has a method
Process
with a single string argument, returning a single string.
The initialization of this new FileProcessor
is slightly clunky:
fp := &FileProcessor{in, out, &capitalizer{}}
but in practice, this complexity will be hidden in a
constructor for a capitalizing FileProcessor
, and will
mirror the complexity of the equivalent inheritance hierarchy.
Conclusion
While abstract base classes with virtual methods are common in object-oriented libraries, the Go programming language does not support (and is arguably actively hostile to) this design approach. Even with this non-support, it is possible to realize much of the flexibility of this approach by using object composition, which Go’s embedding mechanism makes almost as convenient as typical object-oriented class inheritance.
Chris Mikkelson is a Senior Distributed Systems Engineer for Farsight Security, Inc.