Emphasis on I and in Go.
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!
I'm 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.
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()
:
https://gist.github.com/4db8edaaad1e71828019d2af30ca3a79
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:
https://gist.github.com/b5eb0a4dd4c7bd6c30ef12217863954d
https://gist.github.com/ed9d05bc4ecbc31fc8ce063356d2353f
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.
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:
https://gist.github.com/caf1a6a222450bf9386ec4f2faa62a73
https://gist.github.com/eedd7767a700e827ca126d6ed89aaad0
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:
https://gist.github.com/39a712b1e8466d36b5e72d33cbafc696
https://gist.github.com/4a02d5f077e57347b4e2ca78942bf9b6
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:
https://gist.github.com/58c678092e8e64566320e1fcf2649152
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
:
https://gist.github.com/164275b10e122c7ec18831c02fcbee2e
Lastly, here is an example of passing an anonymous (unnamed, ad-hoc) implementation
of GreetingFn
to the SayGreetings()
function:
https://gist.github.com/7a570f751c33b54062544314de49bee2
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.
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?
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.
https://gist.github.com/02d81675b1e3ef001091abed7e76dba4
The Proxy
function does three distinct things that we probably want to test:
- It makes the choice to handle the request one way or another on the basis of the major protocol version.
- It may execute logic for proxying an HTTP/2 request.
- 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?
https://gist.github.com/518f0ee2cc19b4adb8e5b7cdcd1473f2
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:
https://gist.github.com/851ad4e3dbef0e09ebc1f92cc56e4c9a
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.
https://gist.github.com/d9db7348b264282e34faf7154546bbd5
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.
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.