Go Pointers: When I Don’t Use Interfaces (in Go)

Kent Rancourt
Mar 11, 2019 · 7 min read

Emphasis on I and in Go.

Photo by Clément H on Unsplash

In a previous post I took a deep dive into some idiosyncratic reasons that I use interfaces in Go where I might not necessarily do so if working with another language. In this post, I want to cover the opposite scenario — a case where I might use interfaces if working in another language but do not in Go.

Spoiler alert — this mainly relates to testing!

Level setting

I am duplicating the rest of this section verbatim from my previous post because it’s relevant context for the remainder of the post. Skip ahead if you’re confidant in your understanding of interfaces in general.

Using interfaces- — collections of methods or behaviors- — in any language, really, creates a thin layer of abstraction between bits of functionality and consumers of that functionality. By coding to interfaces, calling code requires no awareness of the underlying implementation details of functions it invokes. This is extremely important because it promotes a clean separation of concerns among components.

There are a lot of neat things that you can achieve using interfaces that you (often) could not otherwise. For instance, you can create multiple components that calling code can interact with in a uniform manner, even if the underlying implementations of those components vary wildly. This creates the possibility of swapping components that implement a common interface with one another at compile time or even dynamically at runtime.

A convenient real world example is that of Go’s io.Reader interface. All implementations of the io.Reader interface support a Read(p []byte) (n int, err error) function. Consumers coding to the io.Reader interface do not need to know where the bytes obtained by calling that function come from.

All of this is common sense for anyone who has been programming for a while.

Small interfaces

In the previous section, I defined interfaces as “collections of methods or behaviors.” I want to zero in on an edge case where the cardinality of such a collection is precisely one.

Let us consider a Greeter interface with one function-- SayGreeting():

Thanks to the existence of this interface, calling code that requires some implementation of the SayGreeting() behavior, but doesn't care how it's implemented can reasonably use any implementation of the Greeter interface, such as this one:

Though it’s relatively small, the problem with this is that there’s a wide cognitive gap between what we actually cared about and how we achieved it. If we only care about being able to swap out implementations of the SayGreeting() behavior (for whatever reason), it's unfortunate that we had to bring the concept of a Greeter into the equation as some kind of dumb agent that carries out that insipidly trivial function. This approach is a vestigial remnant of the object-oriented paradigm-- except Go isn't an object-oriented language!

If you’re not convinced that the above is an issue, consider what happens when you need to implement the SayGreeting() behavior in 100 different languages. You will have to define 100 new types that implement Greeter and they will exist for no reason other than to be the receiver for a SayGreeting() function.

First-class functions

In Go, functions are “first class citizens.” What this means is that function signatures are types, just as surely as an int or a string or any interface or struct you may define is also a type. With this being the case, it is possible to declare variables whose type is a specific function signature. The value of such a variable may be any function having the correct signature. Such variables, in that their values are functions, may be invoked like any other function. (It can also be nil, so be careful.)

Let’s look at how we can refactor our example to take advantage of this:

In case it isn’t obvious, this doesn’t work only for the most trivial function signature, func(). It works for arbitrarily complex function signatures as well. For example:

You can also do everything with such a variable that you would expect you can do with variables in general. You can change their values, pass them to other functions as arguments, or have other functions receive them as parameters.

To demonstrate, here we’ll pass an array of func(string) error implementations to another function, which will iterate over them all, invoking each in turn:

If we feel inconvenienced to type func(string) error everywhere we refer to some unspecified implementation of the greeting behavior, or if we simply wish to improve the clarity of our code, we can create a new type that is an alias for func(string) error:

Lastly, here is an example of passing an anonymous (unnamed, ad-hoc) implementation of GreetingFn to the SayGreetings() function:

Other languages

Especially since the premise of this blog post is a case where I might have used an interface in other languages, but not in Go, I need to be very fair in stating that first-class functions are not actually unique to Go, however, I find them easier to work with in Go than in other languages where I have personally encountered them.

A word of caution

We’ve zeroed in on an edge case where the interface we’ve forgone would have defined only one behavior. Before diving into this approach, you may wish to be certain that the interface you’re forgoing is unlikely to grow over time. The example we’ve worked with thus far is actually quite contrived, because it’s easily conceivable that over time, we might like to implement a farewell or an expression of thanks in different languages as well. All of the sudden, grouping multiple related behaviors together in agents that implement some common Greeter interface makes all the sense in the world.

So when is it advisable to actually use this strategy of forgoing interfaces and favoring first-class functions?

Where I tend to use this

TESTING!

One guiding principle I follow when testing code — Go code especially — is that if something is difficult to test, you wrote it wrong. Put another way, testability is an attribute of well-structured code.

A second principle I follow, which is a corollary of the first, is that shorter, more discrete functions are easier to test. To make the case for this, let’s consider a simplified version of some real code I wrote recently. I was creating an HTTP reverse proxy which needed to accommodate both HTTP/1.x and HTTP/2 requests, but (for reasons too nuanced to explain here) needed to handle those two cases differently.

The Proxy function does three distinct things that we probably want to test:

  1. It makes the choice to handle the request one way or another on the basis of the major protocol version.
  2. It may execute logic for proxying an HTTP/2 request.
  3. It may execute logic for proxying an HTTP/1.x request.

Conceivably, testing any one of these shouldn’t require us to test the other two, but the way we’ve written the function makes it an impossibility to test any of these in isolation.

What if we were to refactor like this?

This is an improvement because we can now test both the proxyHTTP2Request() and proxyHTTP1xRequest() functions in isolation. Unfortunately, we still cannot test the logic that invokes one of these or the other in isolation. i.e. We cannot test that logic without also invoking one of proxyHTTP2Request() or proxyHTTP1xRequest(), which may, in fact, be only a minor nuisance, but what remains more problematic is that we have no way to directly assert which of the two was invoked.

We could lean on first class functions here and refactor like so:

In the code above, an httpReverseProxy has two attributes proxyHTTP2RequestFn and proxyHTTP1xRequestFn, which are both of type func(http.ResponseWriter, *http.Request). Our constructor-like function (see previous post) NewHTTPReverseProxy() assigns the defaultProxyHTTP2Request() and defaultProxyHTTP1xRequest() functions, respectively, as the values of these two attributes.

We can still test defaultProxyHTTP2Request() and defaultProxyHTTP1xRequest() in isolation, no differently than we would otherwise, but something we can do now that we couldn't do before is override these functions during testing of the Proxy() function, and even use those alternative function implementations to assert correct behavior of the logic that selects one proxy function or the other on the basis of the major protocol version.

In the test code above, we use anonymous functions as the values of the proxyHTTP2RequestFn and proxyHTTP1xRequestFn attributes. The function assigned to proxyHTTP2RequestFn, which should be invoked if the logic in the function under test is correct, closes over a boolean variable to record whether it was invoked or not. In the final line of the test case, we make an assertion that it was. By contrast, the function assigned to proxyHTTP1xRequestFn, which should not be invoked if the logic in the function under test is correct, will explicitly fail the test case if it is erroneously invoked. We can write a similar test case to assert correct behavior with respect to an HTTP/1.x request.

While the code under test is admittedly a bit more complex for having done so, the choice to use first class functions to allow certain behaviors to be overridden ensured our code was tested more easily and more thoroughly than would have been possible otherwise. This is a trade-off I am, personally, willing to make. Your own tolerance for this may vary.

Conclusion

In this post, I’ve demonstrated Go’s first-class treatment of functions and dived deep into how this is enormously useful when teasing apart complex functions to improve their testability.

Kent Rancourt

Written by

Kent is a senior engineer on the Azure Cloud Native Computing team at Microsoft, working primarily with Kubernetes and other open source projects.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade