Go for Object-Oriented Developers

Software design is about representation: how do we represent the solution to a problem in code that can be executed on the machine of our choice? How do we represent the problem domain to the user? The software design problem is not inherently different from the problem of expression in any language, formal or informal.
Representation is in the form of verbs, nouns, and states or contexts, which in programming languages are functions, local data, and global or environment data. Creating a software representation that can be usefully viewed as isomorphic with some aspect of the real world is a common software design task.

Representation in Programming Languages

Early languages like Fortran were focused on representing mathematical formulas as code. Cobol was focused on representing business or accounting processes. C was focused on representing system-level tasks, and C++ was focused on representing everything that exists and a good deal that doesn’t.
Languages generally take one of two approaches to the problem of representation, which we can call “simplicity” and “complexity”. An extremely simple language is one like Lisp or Mathematica, where everything is a list and a small number of predefined operations on lists allow anything to be represented, albeit in ways that are sometimes verbose, awkward, elegant, or obscure. A complex language is one like C++ or Perl, so full of special cases for particular representations that they become difficult to read, write, and parse.
Modern languages are typically characterized by a relatively simple core that is extensible in useful and interesting ways, particularly with regard to types.

Representation in Go

Golang makes a number of design choices that distinguish it from other languages and make it a flexible, powerful, and relatively safe tool for representing a wide range of things. This note captures some of my thinking around this problem, with the caveat that while I am an expert in certain areas of software design, I am not an expert in Go. So consider this an Object-Oriented (OO) designer’s meditation on Go, not a tutorial on how to design in Go.
In any language, representation happens at all levels, from lowest choice of base types (an int or a float, signed or unsigned?) to object hierarchies, functional design, or structured design in procedural programs.
The past twenty years has seen the rise of OO design to the point that it is comfortably the dominant paradigm, but Go is not (quite) an OO language. Developers coming from Python or C++, therefore, should take some time to think about Go’s more interesting representational features, particularly:

  1. ability to add methods to any type except primitives
  2. interfaces
  3. structs

Methods in Go

Go is a very strongly typed language, to the extent that even ints and floats won’t interoperate without explicit casting. Types have a number of different aspects, however, that make them very flexible.
In most OO languages object types consist of a combination of data and methods, and we can’t change either without changing the type. In Go, however, it is possible to add a method to a type without changing the type. By declaring new types for primitives we can even come close to adding new methods to integers, floats, etc:

type Int int
// a function that takes our derived integer type
func someIntFunction(i Int) Int {
        return i+5
}
// a method on Ints, which does not modify the type
func (i Int) negate() Int {
	return -i
}
// a legal function call
fmt.println(someIntFunction(4))

In C++ or Python, this isn’t even meaningful: there is no way to simply add a method to a type without creating a new (sub-)type. In C++ we can’t even add a friend function without explicitly modifying the declaration of the class. In Go, adding a method doesn’t change the type at all. This creates a weaker syntactic binding between operators and the data to be operated on than in more fully OO languages. Whether that is a good thing or not is a matter of perspective and discipline.
From a design perspective, this creates a situation where we are going to be tempted to “just add a method” when many problems come up. This is a bad idea unless it is handled in a very disciplined way, because in large systems with large packages it will lead to large numbers of unrelated methods on the same types scattered all over the code. Discovering all these methods and finding the ones you want will become the kind of problem we have with deep OO hierarchies, where one has to wade through a dozen layers of inheritance and composition before finding the five lines of code that do the actual work.
To be discoverable and maintainable, methods should be reasonably well-localized in code. OO languages enforce this by their class declaration syntax and making methods part of the type. In Go, because methods are not part of the type and can be defined anywhere, a separate discipline must be practiced to ensure that the methods (verbs) we define on types (nouns) add up to a more-or-less coherent representation of whatever aspect of the world it is that is being represented by the type.

Interfaces

The next interesting bit of the language is interfaces, which have two roles in Go: enforcing duck-like typing at compile time and allowing dynamic dispatch.
Duck typing is the Pythonic concept that if it walks and talks like a duck, it is a duck: if an object has an interface that conforms to something, it is a thing of that type. In Python this isn’t really about the type of an object at all. It just says that if we call a method on an object and the object has a method that matches the signature of the call, then that method will be called. This might also be called “syntactical typing”, as all that matters is the syntax of the call. In C++ the following is a compiler error:

class Type1
{
public:
    void bar() {std::cout << "Hello" << std::endl;}
};
class Type2
{
public:
    void bar() {std::cout << "Hello" << std::endl;}
};
void foo(Type1& x)
{
    x.bar();
}
Type1 y;
Type2 z;
foo(y); // works
foo(z); // fails: there is no foo that takes Type2 as an argument

In Python, the equivalent code would be perfectly legal, because all that matters is that Type1 conforms to the appropriate interface, which in this case is to have a bar() method.
The problem with the Pythonic way of doing things is that there is no way of telling if a type conforms to an interface until it doesn’t. Python’s type system doesn’t have any way of expressing “must have this method with that signature”…it just waits for you to call a method on an object that doesn’t have it and then throws an exception. Type hinting improves this situation considerably, enabling static analyzers to ensure types are correct throughout, but breaks duck typing in the process. Interfaces find a middle ground.
Interfaces allow Go programs to be checked at compile time to see if an object has all the methods required for a given interface, without further constraining the type:

// bar is a method on Ints
func (i Int) bar(j Int) Int {
	return i+j // admittedly a not very interesting one
}
// barface specifies that a type must have a bar method to conform to the interface
type barface interface {
	bar(k Int) Int
}
// foo will take any type that conforms to the barface interface
func foo(thing barface, x Int) Int {
	return thing.bar(x)
}
var Int i = 5
var Int k = 2
foo(i, k) // WORKS: Int’s have a bar method now
type Float float64
var Float f := 5.0
foo(f, k)	// FAILS: Floats do not have a bar method

Notice that there is nothing that declares that Ints support the barface interface: it is simply a matter of fact that they do (or not) and the compiler is clever enough to figure this out. This can be somewhat problematic if we have method definitions scattered all over the place, as while the compiler is clever enough to figure it out, the maintenance developer may not be.
Finally, if we do define a bar method on Floats, we can see that interfaces enable a dynamic dispatch mechanism that allows the method called to depend on the concrete type of the value passed in:

func (f Float) bar(Int j) Int {
	return Int(f)+j
}
foo(f, k)	// calls bar on Float, not Int

So interfaces allow us to implement some traditional OO design techniques in Go.

Structs

Finally, there are structs. As the name suggests, a struct is more like a C struct than a C++ class. Structs allow data to be grouped together in convenient representational form, and permit a limited form of inheritance.
Structs take subtypes by declaring anonymous members rather than an explicit inheritance syntax. This is surprisingly reminiscent of the old-style C trick of making the first member of a struct another struct and counting on the underlying memory layout to handle the rest. In Go the equivalent is:

type Base struct {
    thing1 int
    thing2 float64
}
type Derived struct {
    Base
    value string
}

Go simply recognizes anonymous members at the start of a struct declaration as base types for the struct, and allows the fields of the base types to be accessed directly.
There are those who insist that this is not inheritance, but to a designer, inheritance is a design mechanism that can be implemented in a variety of ways. Go implements in one way (via delegation) that differs from some other implementations. The way inheritance is implemented in Go means that when a variable is in a base type context, the base type method is always called. This is still inheritance, just as C++ has inheritance despite the slicing problem.

Go for OO Developers

Go provides designers coming from an OO background a wealth of interesting representational tools that allow us to express our designs clearly and cleanly. Like any tools, they require a certain degree of discipline and care to use well, but they strike an interesting balance between OO and procedural features that promise to be useful and powerful.


Gopher Image by Renee French

Recent Posts

Tech Debt Best Practices: Minimizing Opportunity Cost & Security Risk

Tech debt is an unavoidable consequence of modern application development, leading to security and performance concerns as older open-source codebases become more vulnerable and outdated. Unfortunately, the opportunity cost of an upgrade often means organizations are left to manage growing risk the best they can. But it doesn’t have to be this way.

Read More
Scroll to Top