One of the tiresome aspects of Go is its standard idiom for error-handling. In that idiom, functions return errors, callers check those errors, and then take action accordingly. Typically, that action is for the caller to return the error to its own caller, gradually passing it up the call-stack for something else to handle. In practice, this results in a lot of boilerplate code that looks like this:
func MyFunc() (int, error) {
n, err := someOtherFunc()
if err != nil {
return nil, err
}
if err := anotherFunc(n); if err != nil {
return nil, err
}
return n, nil
}
The code above illustrates a couple of variations on the technique, but the pattern should be evident. Call after call, you check the err to see if it’s nil.
This has two implications. First, there’s a lot of redundancy; it can quickly become tedious typing the same code again and again. Second, it gets hard to see what’s going on with the code because it’s hidden behind all of this error handling code. I’ve found there are a couple of things I can do.
If I strive for functional cohesion, I tend to end up with functions that have fewer lines of code because they focus on specific tasks. As a result, I end up having to write less of this boilerplate code per function simply because there’s less code per function generally.
For more complex functions, such as those with procedural cohesion, I can often use some sort of centralized error to make the procedure in the function more apparent by hiding the error-handling. Let me provide a couple of examples of this.
Central Error Handling in a Builder
In a current project, I have a Builder type similar to the GoF builder pattern. Something like this:
type Builder struct{
...
}
func (b Builder) AddObjA(a ObjA) error {
...
}
func (b Builder) AddObjB(b ObjB) error {
...
}
func (b Builder) constructPartA() (Product, error) {
...
}
func (b Builder) constructPartB(p Product) (Product, error) {
...
}
func (b Builder) finishConstruction(p Product) (Product, error) {
...
}
func (b Builder) GetFinishedProduct() (Product, error) {
product, err := b.constructPartA()
if err != nil {
return nil, err
}
product, err = b.constructPartB(product)
if err != nil {
return nil, err
}
return b.finishConstruction(product)
}
In the actual code, the GetFinishedProduct function does a lot more, but the idea is the same: GetFinishedProduct calls several functions in a specific sequence to build the final product from parts that were added to the builder earlier on. The sequence of GetFinishedProduct was obscured by all the error-handling code.
To make it clearer, I added a central error field to the Builder, and then adjusted my “construction” methods to check that error field before they did anything. If the error field was non-nil, the methods simply did nothing. As a result, my GetFinishedProduct could simply call the construction methods in sequence without worrying about their errors. It looks like this:
type Builder struct{
...
err error
}
...
func (b Builder) constructPartA() Product {
...
product, err := doSomeWork()
if err != nil {
b.err = err
return nil
}
return product
}
func (b Builder) constructPartB(p Product) Product {
if b.err != nil {
return nil
}
...
}
func (b Builder) finishConstruction(p Product) Product {
if b.err != nil {
return nil
}
...
}
func (b Builder) GetFinishedProduct() (Product, error) {
product := constructPartA()
product = constructPartB(product)
product = finishConstruction(product)
return product, b.err
}
Error-handling is still going on, but it happens in construction functions that tend to be very focused, so they have few lines of code and thus less error-handling to do. Additionally, every construction function stores any of its errors in b.err; this puts the Builder into an “error” state so that future construction functions become no-ops. Each construction function (except the first) checks to see if the Builder is in an error state and simply skips any work if it is.
As a result of these changes, the GetFinishedProduct method becomes far simpler and its construction procedure is more apparent. It doesn’t need to do any error-handling itself since the construction functions are taking care of that. It simply returns whatever the construction functions produced along with the Builder’s error state.
Central Error Handling in a Function
I arrived at a similar approach recently using anonymous functions. I had a scenario where I was creating an implementation of the io.WriterTo interface, i.e. a function with the signature
func (o MyObject) WriteTo(w io.Writer) (int64, error) {...}
One of the challenges with this function was the need to count how many bytes I’d written when I was using other objects to do a lot of the writing and, depending on the methods called, I was getting back an int count of lines written in some cases and int64 in others. This, in addition to error handling, was making the code unwieldy.
What I arrived at was the use of some anonymous methods to help with the accumulation of total bytes written and managing the error-handling in a fashion similar to what I used in the Builder. The final product looked something like this:
func (o MyObject) WriteTo(w io.Writer) (int64, error) {
total, err := int64(0), error(nil)
print := func(s string) {
if err != nil { return }
var n int
n, err = fmt.Fprint(w, s)
total += int64(n)
}
write := func(wt io.WriterTo) {
if err != nil { return }
var n int64
n, err = wt.WriteTo(w)
total += n
}
// Actual procedure
print(headerText)
for _, child := range o.children { // children are io.WriterTo
write(child)
}
print(footerText)
return total, err
}
Here, the central error is a variable, err, that is accessible to the anonymous functions (assigned to the print and write variables) via closure. As in the Builder’s construction methods, these assign any errors to the err variable and they check to see if that variable is nil before doing any work, becoming “no-ops” if the error is non-nil.
This approach, again, allows the function to express its central procedure more clearly since it doesn’t have to handle errors after every function call.
The driver in this case wasn’t really the central error handling. The real driver was wanting a way to deal with the fact that fmt.Fprint returns the count of written bytes as an int while io.WriteTo returns it as an int64. This required having multiple variables to capture the intermediate counts before adding them to the total. That drove me toward the use of anonymous functions to wrap that work, and once they were in place, it was a simple step to centralizing the error handling.
Conclusion
While I wish that Go gave us the option to use structured exception handling (i.e. try…catch… blocks), there are ways to deal with its native approach without having to write a ton of redundant code in each function. Keep your functions functionally cohesive (i.e. focused on doing one thing) and they’ll have fewer lines of code, and hence, fewer calls that can result in errors. For functions with sequential or procedural cohesion, where the function’s work is more about calling other functions in sequence, look for opportunities to “centralize” your errors so that you can reduce the error-handling and make the procedure clearer.