Error codes - A simple way to signal errors in code
Besides hardware errors, there are many situations where things can go wrong in our code. Basically any time our code interfaces with some external system:
- Reading/writing files
- Network connections
- Using the graphics processing unit (GPU)
- Getting user input
A fundamental insight in software development is that errors are not to be treated as some unfortunate event that we try to ignore, but instead to plan for errors and handle them correctly in code. Essentially, we accept that errors are a natural part of any program that is as important as the regular control flow of the program. In this and the next chapters, we will look at how we as programmers can deal with errors in our code and make it explicit that a piece of code might encounter an error. In fact, this is already the fundamental principle of error handling in code:
Whenever something might go wrong in our code, we have to signal this fact to the calling code!
It is important to realize that with errors, we do not know upfront if a piece of code might succeed or not! This is different from e.g. the usage of Option<T>
that we saw in the last chapter, i.e. the log
function which only produces an output if a number > 0 is passed in. When downloading something through the network, we can't ask beforehand 'will this download succeed?'. Accordingly, when requiring user input, we don't know beforehand what our users will input (after all, that's kind of the reason for writing software for users: That they can do arbitrary things with it). So while Option<T>
was nice, it does not help us all that much with error handling.
Three ways of signaling errors to calling code
In programming languages today, we mainly encounter three different ways of signaling errors to calling code:
- Error codes (covered in this chapter)
- Exceptions (covered in chapter 5.3)
- Result types (covered in chapter 5.4)
Not all languages support all three types of errors, neither C nor Rust have exceptions for example, and not all are equally useful in every situation. Let's start with the simplest way of signaling errors: Error codes.
Using the function return value to signal an error
Perhaps the simplest way to signal a potential error in a function is to the return value of the function. We could return a number that indicates either success or the reason for failure. We call this approach error codes and this is what C does. It is very simple, can be realized with every language that supports functions (which is like, 99% of all languages in use today) and has little performance overhead. Here are some examples for error codes in functions from the C standard library:
fclose
for closing a file (int fclose( std::FILE* stream );
): Returns0
on success and an error codeEOF
on failurefopen
, which has the signaturestd::FILE* fopen( const char* filename, const char* mode )
: It returns a pointer, which will benull
if the file could not be opened.poll
from the Linux API, which can be used to check if a network connection is ready for I/O. It has the signatureint poll(struct pollfd *fds, nfds_t nfds, int timeout);
and returns-1
if an error has occurred.
As you can see, many functions use the return value to indicate whether an error occurred or not, but often, you are left wondering what kind of error occured exactly. For this, Linux has a global variable called errno
, which contains the error code of the last failed operation. Error codes include things such as:
ENOENT 2 No such file or directory
EACCES 13 Permission denied
By checking the value of errno
, we can get more information about the cause of an error after a failed operation. This approach has traditionally been used in other libraries as well. For example, the OpenGL library for accessing the GPU also uses error codes excessively. Just like Linux, it has a global error variable that can be queried with a call to glGetError
.
The problem with error codes
Error codes are used a lot in low-level code, because they are so simple. There are a lot of problems with error codes though:
- They take up the return value of a function. Since you often want to return something else besides an error, many systems instead keep a global error variable somewhere and write error codes into this variable (such as
errno
). You has programmer have to remember to keep looking into this variable to ensure that no error occurred. - If you use the return value of the function for an error code, you get weird-looking code that mixes regular behaviour with error checking (e.g.
if(!do_something(...)) { /* Success */ }
orif(do_something(...) == FAILED) { }
)- Also this becomes quite annoying if you combine functions with error handling because you then have to pipe through the error to calling functions. If you have multiple different error codes, how do you combine them?
- Error codes don't permit exceptional control flow easily. If you have three functions
a()
,b()
andc()
, wherea()
callsb()
callsc()
,c()
can return an error and you want to handle it ina()
, you have to add error handling code also tob()
- Error codes are not obvious and the compiler does not force you to handle them in any way. It is perfectly fine to ignore the error code:
do_something(); do_something_else();
Since usually integer numbers are used for error codes, it can be hard to tell whether the return value of a function is a regular integer value, or an error code. Especially in C-code, you might see something liketypedef int error_t
to make the return value more explicit, there is still zero compiler support for checking how the return value is used. Recall thattypedef
in C (and C++) does not introduce a new type, it is just an alias for an existing type.
So really, error codes are not a great tool for error handling. They are fine mostly due to legacy reasons, some people might even like them because they are so simple, but they leave a lot to be expected. So in the next chapter, we will look at a more powerful alternative: Exceptions.