One such topic that has challenged my own conceptions is how errors are handled in Go. To recap:
An error in Go is any type implementing the
errorinterface with an
errors just like any other value. Multiple return values distinguish errors from normal return values.
- Errors are handled by checking the value(s) returned from a function and propagated to higher layers of abstraction through simple returns (perhaps adding details to the error message).
For example, consider a function that resolves a host address and starts listening for TCP connections. There are two things that can go wrong, so there are two error checks:
This topic has its own entry on the Go FAQ, and the community has offered a wide range of opinions on the matter. Depending upon your past experiences, you may be inclined to think:
Go should implement some form of exception handling that allows you to write
try/catchblocks to group error-generating code and distinguish it from error-handling code.
- Go should implement some form of pattern matching that offers a concise way to wrap errors and apply distinct transformations to values and errors, alike.
While those are useful features in other languages, they are not likely to be implemented anytime soon in Go. Instead, let's take a look at a few ways that we can write idiomatic Go code today, using the features that are already available.
A longer example
There may not be much to change in the prior example to make it shorter or simpler, but writing an
if statement after each function call can start to feel like it's getting out of hand:
There are a few things left to be desired in this method:
- The total number of lines in this method suggests that some helper functions could be extracted.
- Returning a successful value from the middle of two error cases makes it hard to read.
It's not clear if the second
erris a new variable at a new address or a re-assignment of the first one. It's not entirely consistent with the single-variable form of
:=, which forbids re-assignment.
First option: Keep it and move on
While this version of
parse does bug me, using an
if guard after each possible error is the idiomatic way of handling errors in Go. We will explore some other ways to refactor this code, but keep in mind that this code does what it needs to do without abusing features of the language. There is an advantage to Go's sometimes Spartan philosophy: When there is one, clear way to do something, you can accept it and move on (even if you're not thrilled with the standard).
Take Go's official code format and its stance on forbidding unused variables and imports, for example. I may disagree with the way some code is formatted and think that there are reasonable times to allow unused variables, but tools like
goimports make it easy to follow standards, and the compiler doesn't leave you with much of a choice. The time that would otherwise be spent deliberating alternatives can now be re-focused on other code that might be more important.
Back to the code in question—we can explore different structures that may clarify its control flow, but the lack of general-purpose, higher-order functions in Go limits our options.
You may be familiar with other approaches to control flow that are used in other languages, and it can be tempting to try to apply your favorite technique to Go. Let us briefly consider a few tempting options that are likely to raise eyebrows in the Go community, in all but the rarest of circumstances.
Defer, Panic, Recover
The first is called Defer, Panic, and Recover, where
recover are used sort of like
catch in other languages. There are a few points worth noting here:
The Go authors do cite a case where it's used in Go standard libraries, but they have also been careful to keep panics from escaping to the outside world. In most cases,
panicis reserved for truly catastrophic errors (much like the
Errorclass in Java is used for non-recoverable errors).
The theoretical argument that exceptions break Referential Transparency: Functional Programming in Scala describes this well. To sum it up: code that can throw an exception can evaluate to different values depending on whether or not it is surrounded in a
try/catchblock, so the programmer has to be aware of global context in order to avoid bugs. There's a nice example of this on GitHub and a concise explanation in this answer.
- The practical argument: It's difficult to distinguish correct and incorrect exception-based code.
The Go authors tend to make distinctions between expected branches in control flow (valid and invalid input, for example) and large-scale events that threaten the process as a whole. If you are to remain in the good graces of the Go community, you should make an effort to reserve
panic for the latter.
Higher-order functions and wrapper types
Go authors like Rob Pike say over and over again to just write a "for" loop, but it's hard to resist thinking about the example problem as a series of transformations:
bufio.Reader -> string -> RequestLine -> Request
Shouldn't we be able to write a
map function for this?
Go is a statically typed language without generics, so your options are to declare types and functions with the types specific to your domain, or abandon type safety altogether.
Imagine what it would look like if you tried to write
There are options that abandon type safety, like this Go Promise library. However, take a close look at how the example code carefully sidesteps the issue of type safety by using the output value in a function that accepts any type (
fmt.Println does ultimately use reflection to type its argument).
Making a wrapper like Scala's
Either type doesn't really solve the problem either, because it would need to have type-safe functions to transform happy- and sad-path values. It would be nice to be able to write the example function a bit more like this:
But look how much single-use code you would have to write to support that:
So the various forms of writing higher-order functions wind up being non-idiomatic, impractical, or both. Go simply is not a functional language.
Back to basics
Now that we have seen how functional styles are not likely to be helpful, let's remind ourselves:
The key lesson, however, is that errors are values and the full power of the Go programming language is available for processing them.
The other bit of good news is that you can't unknowingly ignore a returned error, like you can with an unchecked exception. The compiler will force you at a minimum to declare the error as
_, and tools like
errcheck do a good job of keeping you honest.
Looking back at the example code, there are two definite errors (input not ending in CRLF and not-well-formed HTTP request lines), one clearly successful response, and one default response.
Why don't we just group those cases together?
Here we get the benefit of a smaller
parse at the cost of a couple of extra functions. You get to decide whether you prefer more functions, or larger functions.
Happy and Sad paths in parallel
One could also refactor the existing functions to handle happy and sad paths at the same time.
Instead of four
if guards in the original example, we're down to three without adding any more functions. The trade-off is having to read the top-level
Closure for errors
You can also create a closure over the first error. The example code from the article starts like so:
The author creates a
func that applies the next step as long as no error has been encountered yet.
This approach works well when you can pass each step in your workflow to the closure, which suggests that each step should operate on the same types. In some cases, this can work great.
In the example in this blog, you would have to create a
struct to close over the error and write separate
applyRoute functions for each step in the workflow, which is probably more trouble than it's worth.
Although Go's design choices in error handling may seem foreign at first, it's clear from the various blogs and talks its authors have given that these choices were not arbitrary. Personally, I try to remember that the burden of inexperience lies with me—not with the Go authors—and that I can learn to think of an old problem in new ways.
When I started writing this article, I thought it might be possible to apply experiences from other, more functional languages to make my Go code simpler. This experience has been a good reminder to me of what Go authors have been emphasizing all along: sometimes it's helpful to write a few functions specific to your own purposes and move on.