Error handling is the one thing that puts me off learning Go
Posted at 08:00 on 07 December 2017
It seems that all the cool kids are learning Go these days. It's certainly appealing to be able to get the performance of C without all the headaches, and to be able to package your program up into a single, tight binary without masses and masses of bloated dependencies. Furthermore, since it's the language of choice for a lot of important software, such as Kubernetes and Terraform, I'm probably going to have to get my head round it one way or another sooner or later.
But there's one thing about Go that I really, really do not like one little bit: its approach to error handling. Rather than having exceptions, it reports errors as return codes.
Go's justification for not having exceptions is as follows:
We believe that coupling exceptions to a control structure, as in the
try-catch-finally
idiom, results in convoluted code. It also tends to encourage programmers to label too many ordinary errors, such as failing to open a file, as exceptional.Go takes a different approach. For plain error handling, Go's multi-value returns make it easy to report an error without overloading the return value. A canonical error type, coupled with Go's other features, makes error handling pleasant but quite different from that in other languages.
This is an unjustified assertion. They give no explanation as to what is supposed to be convoluted about exception handling code, nor do they give any examples. Consequently, I have no idea what on earth they are talking about, and neither do you. Sure, I've seen code that gets exception handling wrong, but that was more due to the code itself being bad rather than any problem with the concept of exceptions itself. I've also worked with codebases that get it right, and all I can say is that exceptions done right are much easier to work with and reason about than error codes.
Wrong reasons for objecting to exceptions
There are various reasons why people don't like exceptions. Some people react against them because they were popularised by Java, and a whole lot of other Bad Things were popularised by Java as well. And yes, maybe Java did make some mistakes by implementing checked exceptions, but please, don't throw out the baby with the bathwater.
Others complain about exceptions because people get them wrong, doing stupid things like this:
try: do_something() except: pass
Please, people. The correct response to misuse is not disuse, but proper use. People will do stupid things with any programming language construct. It doesn't mean that the constructs themselves are bad.
Others consider not having to write extra error handling code as laziness. But so what? Work is not about favouring busyness over laziness; work is about delivering value to your customers. If you can deliver the same value in half the time with half the bugs and half as many lines of code, you're not being lazy; you're being efficient.
Others complain about exceptions crashing their Python or C# code with an ugly looking stack trace. But this is easy to fix, simply by implementing a global exception handler at the top of your code, sending the stack trace to a logging service such as ElasticSearch, and just showing an appropriate message. In any case, which is worse — a stack trace, or a program that silently corrupts your data?
Others complain that they mean that you don't know which functions might throw errors and which might not. But the only safe assumption that you can make is that any line of code might throw an error — and often for reasons that you are not expecting and did not anticipate.
What are you supposed to do with errors anyway?
The most important thing you need to realise is that both exceptions and error codes should have a very specific meaning — namely, that your method was unable to do what its specification says that it does. It could have failed for any number of reasons: bad user input, missing dependencies, external services having gone offline, timeouts, foreign key violations, null references, division by zero, out of memory, stack overflow, array bounds errors, or even literal bugs. But the important information that they convey is that you asked some other code to do X, and for whatever reason, it did not do X.
It is completely unhelpful to try to categorise them as "ordinary errors" or "exceptional errors." This distinction is so vague and ambiguous as to be effectively meaningless, and in any case will depend more on context and your own use cases than on any intrinsic properties of the exceptions themselves. The main distinction that you need to make with errors is between those that you have anticipated and can meaningfully correct, and everything else.
For errors that you are able to handle, the correct action will usually be specified in your user stories, and as such, they will need to be handled on a case by case basis. But for errors that you have not yet anticipated, 95% of the time the correct action will be to assume that your own code is also unable to do what it is supposed to, stop what it is doing, and report a failure to the caller.
It is almost never appropriate for your code to carry on regardless after an error. If it does so, it will be running under assumptions that are incorrect. At best, it will result in further errors. At worst, it will silently corrupt your data.
Exceptions are about convention over configuration and safe defaults
Convention over configuration is a principle of language and framework design that says that if one specific course of action predominates, it should be made an implicit convention, and extra code should only be needed to override it.
With error codes, the default behaviour is to do precisely what you are not supposed to do — carry on regardless. Consequently, every single function call needs to be followed by a test for the return value. And the code to do so will be mindlessly, frustratingly repetitive:
if err := datastore.Get(c, key, record); err != nil { return &appError{err, "Record not found", 404} } if err := viewTemplate.Execute(w, record); err != nil { return &appError{err, "Can't display record", 500} }
But what this is doing is exactly what exceptions do anyway! The whole point of exceptions is to take this repetitive boilerplate code and make it implicit. Other mechanisms, such as try
/catch
/finally
blocks, exist to provide clear and specific ways to override this convention. The result is code that is clearer and easier to understand, with a significantly improved signal-to-noise ratio.
Yet the language designers of Go consider this boilerplate a virtue!
In Go, error handling is important. The language's design and conventions encourage you to explicitly check for errors where they occur (as distinct from the convention in other languages of throwing exceptions and sometimes catching them). In some cases this makes Go code verbose, but fortunately there are some techniques you can use to minimize repetitive error handling.
Whatever happened to DRY? Whatever happened to convention over configuration?
Cleaning up
When your exception handling code demands more complex scenarios than just propagating the error up the call stack, 90% of the time it will simply be to clean up: close file handles, release locks, roll back transactions, and then propagate the error condition up the call stack. Furthermore, the cleanup code will usually be common to a whole block of other method calls, any of which could raise an error. This Python code is an example:
f = open('data.json') try: transaction = repository.start_transaction() data = json.load(f) repository.update(data, transaction) transaction.commit() except: transaction.rollback() raise finally: f.close()
I shall leave it as an exercise for the reader to translate this block of code into Go. Now Go gives you the defer
instruction to allow you to queue up functions to run at the end of your method, but if you have to run separate code for success (transaction.commit()
) and failure (transaction.rollback()
), as is the case here, your code will be significantly more complex. Additionally, many exception-based languages give you syntactic sugar for these most common exception handling cases — in particular, using
in C#, or with
in Python.
Stick to the conventions of your language
Of course, if you're using Go, handling error codes is what you have to do. The Go designers decided to do without exceptions, and to introduce them at this stage would just cause confusion. You would end up with some functions returning error codes while others throw exceptions under exactly the same failure conditions.
Now Go does have a panic
/recover
construct that is similar to exceptions in some respects. But it is rarely — and inconsistently — used. We are told that it is supposed to be reserved for "truly exceptional conditions." But what, exactly, makes one condition "truly exceptional" and another not? Why, for example, are array bounds errors exceptional, but Println errors, bad format strings, and broken connections are not? There is neither rhyme nor reason to the distinction.
The Go community seems to be avoiding problems with error handling for now, but that is mainly because Go programmers tend to be experienced, high-end developers who are used to the discipline of meticulously writing all the extra error-handling code. But with the rise in Go's popularity, sooner or later it is going to experience an eternal September, with newbies piling in and forgetting the all-important error-handling boilerplate code left, right and centre, and when that happens, they will discover that return codes instead of exceptions are no panacea.