After taking an operating systems class
last year and taking a
data systems class this semester, I’ve picked up a few patterns
to make it easier to handle error conditions in C.
Consider the following example from my data systems class, where I initialize
a directory to act as persistent storage for my database.
Here’s a first pass at initializing a storage struct. In this example,
I ignore all errors and will throw an assertion error if an error
occurs.
Obviously, this is not robust. If any of the operations return
an error, we would get an assertion failure and our server
process would exit unexpectedly. Thus, we need to check for errors
and cleanup all the calls that occurred before the error.
As you can see, performing error handling naively like this
results in quadratic growth in cleanup operations: each
error checking needs to cleanup every operation before it,
and as a result, the free(storage) line gets repeated
multiple times. Can we do better?
Yes! The key to this is use goto statements. Many
introductory computer science courses discourage use
of goto statements, and rightfully so: goto statements,
if used inappropriately, can lead to spaghetti code
and can make code very difficult to reason about. However,
error handling is a perfect use for goto statements
to avoid quadratic code growth.
By laying out
the error handling code labels in reverse order in which
the operations were invoked, we can quickly jump
to the appropriate position to start cleaning up
all the operations that occurred before it. This eliminates
the quadratic code growth in error handling! Furthermore,
there is only one exit point of this function (at the very
bottom), and reasoning about exit points for this version
is much easier than the previous version, especially
when we throw in concurrencry primitives and needing to remember
to release locks.
To eliminate the boiler plate of checking the return value
and then jumping to the appropriate label on error, I wrote
a couple of useful macros. It relies on design decision
to make all functions that may have an error:
Return NULL (e.g., if the function allocates a data structure)
Return int, where the int is an error code specific to your application, and 0 is success.
In this code, I define an enumeration enum dberror to represent
the different kinds of error codes for my database application.
I also provide a DBLOG(result) macro which, when given an error
code, prints out the human readable string for that code, as well
as the file, line number, and function where DBLOG was invoked.
By designing your internal API using the two points above and invoking
DBLOG every time an error occurs, we effectively get a stack
trace for every error!
Now let’s combine this error logging facility to reduce the
boiler plate for the error handling code above.
The TRY macro allows us to execute expr, and if
that returns a nonzero error code, we jump to the provided
cleanup label.
The TRYNULL macro is similar–it assigns var to be the
result of expr, checks if var is NULL, and if it is,
assigns the appropriate error code to result and jumps to
the cleanup label.
Using this, let’s write our final version of storage:
Nice and simple! Here’s all the things that this pattern addressed:
Allow error handling using only linear, and not quadratic, code growth.
Our TRY and TRYNULL macros eliminate the boiler plate, and automatically performs logging to give us a stack trace of errors.