To err is Human; to forgive, divine.
- Alexander Pope
Computers, being made by humans, are prone to errors. All the software on those
computers, written by humans, are also fallible. Error handling is an
important topic in software development, and being able to properly use and
interact with your language’s error systems, is an important step in becoming a
competent software engineer.
Most languages provide means for dealing with errors, in one way or another.
However there are two main qualities that I think are important to any system:
Transparency, and Decisiveness. In order to illustrate these
qualities, I find it useful to compare how different languages (in this case
Python, Go and Zig) approach (or fail to approach) them.
Transparency is the quality of being able to look at code and understand what
kind of errors, or failure modes are possible. If function
foo may fail with
NoBarFoundError, it should be easily and transparently delcared.
Python deals with errors by throwing exceptions. Unfortunately it is impossible to
declare anywhere in code that a function can fail, let alone how. Short of
auditing every function call and subroutine, it is impossible to know all
the failure modes of a function. Often
possible errors and exceptions are declared in documentation strings, however
the lack of transparency means that it is almost impossible to know all the ways
a function can fail, due to the unkown nature of all the subroutines. This lack
of transparency has two detrimental effects. First it lulls developers into a
false comfort regarding stability of their code. Second it can lead developers
to treat errors as an unimportant afterthought.
Go errors are values. All fallible functions will return (at least) one value
error type. If no error occurred the value will be
nil. This setup is
very transparent. If a function does not have a return value of the
then it can be considered “infallible”, all possible errors are handled at or
below that function. Otherwise we know that it could fail. Go also let’s you
create custom error types, allowing you to specifically indicate the failure mode
in the function declaration.
Zig also returns errors as values. However, in zig errors are always returned in
union types, so that only one value is returned from a function. If you are unfamiliar
with union types, think of it as an either/or type. The result can be either an
expected value, or an error. Like in Go this means that any function that can fail
can be seen by looking just at the return type.
Errors should be decisive. Decisive means that an error should force a
branching decision when they appear. Either no error has occured and we can
continue on the “happy path”, or the error has occured and must be dealt with
or crash the system. This is desirable because errors indicate that something
has gone wrong and that our system (software) is now in a state that is
unexpected. Being in an unexpected state should force a resolution or the
system should fail, otherwise we risk the system behaving unexpectedly.
Exceptions alter the flow of execution. As soon as an exception is thrown the system
is on a direct route to crash, unless the exception is caught and handled. This is
great. The one draw back is that because exceptions lack transparency, all of this
occurs at runtime, meaning that crashing can be a frequent occurrence, if excpetions
are not being caught and handled properly.
Go errors are values, just like any other. The only language mechanism to force
recognition of an error, is that you cannot assign two values to one. So, for
example the following code would fail to compile.
// Fails to compile
val := FallibleFunc();
Instead the value must be assigned to it’s own variable.
val, err := FallibleFunc();
Beyond that however, there is nothing that stops a developer from doing nothing with the error.
And in fact the default line of code would be that the error is ignored. It requires the
discipline of the programmer to do an
if (err != nil) check every time an error could occur.
If that is forgotten, then the code will plod along silently ignoring the error.
Since Zig errors are values, one might expect it to have the same issues with
decisiveness. However in zig, errors are special values. First they are unions,
so in order to access the expected value of a function, you must provide a
way for the code to act if there is an error there instead. Secondly, in the
case that you are calling a function just for it’s side effects, it is a
compiler error to just throw away a value of the error union type. This means
you always have to provide a line of execution that deals with the error. Even
if your catch just ignores the error, at least then it was an explicit choice on
the part of the developer.
So It’s probably pretty obvious that I think zig has done errors well. It meets
both of my transparency and decisiveness requirments. Unfortunately, Zig is
not stable, and so I don’t get to program in it in my day to day. Which then
of the other two languages do I prefer?
On one hand, I really like Go because of the transparency. After using a
transparent language, going back to one like python feels like you are flying
blind. The peace of mind that comes with knowing that a function cannot fail
is nice, and once you get used to it, it’s hard to go back to playing exception roulette.
That being said, Go’s system depends on both my discipline, and my trust in the
discipline of other developers to always be checking the errors. I like
to think that I’m good enough to always will do that, but I do make mistakes.
I’m only human after all.