EmbeddedRelated.com
Blogs

Lightweight C++ Error-Codes Handling

Massimiliano PaganiNovember 16, 20232 comments

Let's face it, handling errors is boring and hard. It is not a case that most introductory programming classes just glimpse over the matter and focus on other topics.  There are various approaches, but when you need to distinguish between the happy and the unhappy paths, no one is a clear winner. In this article, I will focus on Error Codes (a quite common way of reporting errors in C-based APIs), and present a lightweight approach that reduces the boilerplate code needed to handle failures.

Error codes are usually defined as integer or enumeration types. One value of the error code is used to mark the successful completion of the function, while the other values report a reason or condition for the failure of the called function.

As a side note, if the function needs to return a value in addition to the error code, then an extra argument is needed to pass the reference to where the value has to be stored.

This article is available in PDF format for easy printing

To exemplify the case, let's consider an I2C library composed of the following functions:

ErrorCode prepareI2cTx( I2cChannel, I2cSpeed );
ErrorCode sendI2c( I2cChannel, I2cAddress, uint8_t reg, uint8_t data );

Now let's sketch out the code for initializing an I2C peripheral using this API. First, we need to configure and open the bus, and then we write a couple of registers to set up the device:

prepareI2cTx( AccelChannel, I2cSpeed::khz400 ):
sendI2c( AccelChannel, AccelAddress, ModeRegister, ModeIdle );
sendI2c( AccelChannel, AccelAddress, OdrRegister, Odr1khz );

I just used some made-up constant names, to avoid meaningless magic numbers and to make the example more readable.

In the code above there is no error handling - should an error occur it would go unnoticed by the software (regretfully, it happens to see code like this in production software).

Let's try to write some error handling using a traditional approach -

ErrorCode error = prepareI2cTx(  AccelChannel, I2cSpeed::khz400 ) );
if( error == SUCCESS ) {
  error = sendI2c( AccelChannel, AccelAddress, ModeRegister, ModeIdle );
  if( error == SUCCESS ) {
    error = sendI2c( AccelChannel, AccelAddress, OdrRegister, Odr1khz );
  }
}
return error;

As you can see this approach doesn't scale too well, and this is an example, a real-life code could have some tens of device register initialization making the indentation going out every extra wide monitor. You can indeed keep the indentation low if you can do early returns:

ErrorCode error = prepareI2cTx( AccelChannel, I2cSpeed::khz400 ) );
if( error != SUCCESS ) {
  return error;
}
error = sendI2c( AccelChannel, AccelAddress, ModeRegister, ModeIdle );
if( error != SUCCESS ) {
  return error;
}
error = sendI2c( AccelChannel, AccelAddress, OdrRegister, Odr1khz );
return error;

Still, you have a verbose and repetitive code, where the error handling part is quite invasive:

To simplify the error code handling I developed a dedicated lightweight component that allows you to chain together the API invocations and short-circuit with the error code at the first of them that fails.

auto result = prepareI2cTx( AccelChannel, I2cSpeed::khz400 )
  .andThen( [](){ return sendI2c( AccelChannel, AccelAddress, ModeRegister, ModeIdle ); } )
  .andThen( [](){ return sendI2c( AccelChannel, AccelAddress, OdrRegister, Odr1khz ); } );

Now, once your eyes stop bleeding for the C++ lambda syntax, you can appreciate how terser and more focused this version is. The control flow is hidden in the andThen function and mostly kept away from your attention which can focus on the actual functions you are invoking.

This approach also scales fine - you can add dozens of lines and still keep good readability and low overhead.

How to get this result? The idea is to wrap the result code into a lightweight wrapper so that the run-time overhead is negligible if any at all.

Being a wrapper, it can be wrapped around different kinds of codes. Some codes may use 0 as the success indicator, some may use some form of bit encoding, or define a different value for success. Also, the error type may be an integer or an enum. For these reasons, it makes sense to use a type trait, to define the characteristics of the error code:

template<typename E>
class ErrorTraits
{
    public:
        static constexpr bool isError( E error ) noexcept;
        static constexpr E getSuccess() noexcept;
};

The trait is parametrized on the implementation error code type. You should define this class against your type providing the implementation for the two methods. Let's say that you have an enumeration as the error code type:

enum class LibErrorCode {
  FailedToAcquireBus,
  CommunicationTimeOut,
  DeviceNack,
  Success
};

I put the Success as last in the enum, just to show that although it is common practice to have the value '0' represent the success condition, this approach allows for different conventions.

The specific error traits will be defined as:

template<>
class ErrorTraits
{
    public:
        static constexpr bool isError( LibErrorCode error ) noexcept
        {
            return error != LibErrorCode::Success;
        }
        static constexpr LibErrorCode getSuccess() noexcept
        {
            return LibErrorCode::Success;
        }
};

The wrapper class, I named, with some lack of imagination, Error, has an explicit constructor from the underlying error type. All Error methods are constexpr since we want them to be resolved at compile time whenever possible.

template<typename E, typename T=ErrorTraits<E>>
class Error
{
    private:
        E m_errorCode;
    public:
        constexpr explicit Error( ErrorType errorCode ) noexcept
        : m_errorCode{errorCode}
        {}
        [[nodiscard]] bool isError() const noexcept
        {
            return T::isError(m_errorCode);
        }

Although a good rule of thumb is to provide a single way to use your class, I find that having an additional method to check for non-error, improves the readability:

        [[nodsicard]] bool isOk() const noexcept
        {
            return !T::isError(m_errorCode);
        }

But it is time to get to the more interesting part - the andThen method. This method has a single parameter accepting a function that returns an Error object. Now, although C++ claims to support the functional paradigm, the type for a function is a somewhat lacking concept. There are at least three ways to pass a function (and none of them employs a function type):

  • the function pointer type: andThen( Error (*f)() );
  • the std::function template class: andThen( std::function<Error()> const& f );
  • a template type: template<typename F> andThen( F&& f );

Function pointers are the least flexible - for example, you cannot use a lambda with a capture list as a function pointer.

The std::function template is a convenient way to store a function, but it is not a lightweight object and, according to the size of the captured values, it may require dynamic allocation.

The template argument is very flexible and lightweight, but the function type is sort of undefined. I mean, you will get an error if you try to pass the wrong type of object, but the error will be one of those endless gibberish about something wrong somewhere involving a template.

So I chose the template only for the lack of better options:

    template<typename F>
    Error andThen( F f ) const noexcept( std::is_nothrow_invocable_v<F> )
    {
        return isOk() ?
            f() :
            *this;
    }

The logic is pretty simple, basically the if/else logic of the unscalable solution has been moved into this function. If no error has been detected then call the function and pass the result to the return statement. If this error code represents an error, then the function is not called and the error code is returned.

I find it useful to add a recovery function so that a function can be invoked to recover from an error. Since the recovery function may fail, I designed it to return an Error:

    template<typename F>
    Error orElse( F f ) const noexcept( std::is_nothrow_invocable_v<F> )
    {
        static_assert( std::is_invocable_r_v<Error<E,T>, F, Error<E,T>>);
        return isOk() ?
            *this :
            f( *this );
    }

The logic is similar to the andThen method, but the function is called only if the current error code indicates a failure. The recovery function is fed with the error code, so that the called code may use this information for better recovery.

In the Error class, you find the usual comparisons and getter, I won't bother you with, and a ready-made success object. This can be used to return success value without building one each you need it. This is provided as a static field:

static Error<E,T> const Ok;

This is a bit of a shortcut that avoids writing wordy Error<E>( E::Success ) in place of Error<E>::Ok. It is worth noting that being a static object, initialized before the main method is called, may cause some headaches if used for initializing other static objects, as the initialization order is undefined.

The use of the Error class may give the impression of adding overhead or bloat, to the binary code, but when optimizations are enabled, the template inline magic happens and the resulting binary is the same (or very close) to what would result from traditional error handling.

You can find this complete and ready-to-use class in my chef-fun library. The library is headers only and requires no additional dependency, so it can be easily added to any existing project.

And the post could end here. Indeed I wanted to insert the term "monadic" in the title, but I was afraid this would have scared away too many readers. So, if you are not interested in exploring the functional programming paradigm, you may consider stopping here. What I'm going to write below, won't add anything to the usefulness (or lack thereof) of the presented class template.

So you are still with me, perfect. To be honest, I took inspiration from the functional programming (FP from now on) paradigm in designing this Error template. In FP you may chain computations by using the monad concept.

Usually, because of the monad curse, when you manage to understand what a monad is, you lose the capability of explaining it to others. Despite this curse, I'll try to explain it.

The simplest way to look at a monad is to consider it a template interface or, even better, a type trait. The interface, parametrized on type T is composed of two methods

  • a static method that accepts a type T and builds a monad (you may consider this a constructor), and
  • a method that takes a function from T to U and produces a monad.

This structure although simple, allows the design of complex architectures and thanks to its mathematical roots enforces formal properties of the architecture. What interests the developer is that this idiom allows for writing reliable software with no surprises. You may think of this as a functional design pattern.

The two methods must have a number of properties to satisfy for this object to be called a monad. I won't go into such details for my class here, but if you are interested you may have a look at this post on my blog.

The functional programming paradigm is based on the concept of pure functions, i.e. functions that just compute a value from their arguments without interacting with anything else - no state, no globals, no I/O. These are not the functions that my Error class is targeted at - The Error class hosts the result from a function that performs interaction with the environment and returns a success/failure indication. So what is the advantage of using monads outside the functional programming realm?

First, it has to be noted that the monad itself is not bound to pure functions only. Monads like Option and Either as usually employed to carry the result of some impure function. Next, monads offer a standardized way of chaining operations, a pattern that is easily recognized and helps in understanding the sequencing and the flow of the operations. The reader may concentrate on the happy path while the monads ensure that errors are properly handled.

In this post, I showed an alternative approach to error handling based on a monadic-like chaining of function calls. This approach can be applied wherever an enumeration or an integral type is used for indicating the error condition. Consistently using this approach improves readability by fixing the layout of operations in the code, prevents copy-paste errors, and ensures that errors are properly handled and the computation is interrupted at the first error.

The additional code required to implement the proposed approach typically results in no additional binary code thanks to constexpr and inlining optimizations.




[ - ]
Comment by skledtkeNovember 24, 2023

Interesting post. I may play around with that.
My only stomach ache is when I encounter things like this:

"if( error == SUCCESS )"

I'd be skeptical of any place where they consider success to be an error :-P ;)

[ - ]
Comment by maxpaganiNovember 24, 2023

Hi skledtke,

   thank you for reading my post and taking the time to write a comment. Indeed I agree "error" is not really a good name for this. I picked this one because it is quite widespread as "Error Code" and quite often I see source code with Error enums that list all the error reasons and the success condition altogether.  Maybe the most notable case is Win32 error codes where the symbol ERROR_SUCCESS is defined to represent the success case.

    Maybe ReturnCode or ReturnStatus are better names, although a little wordy.

    The good news is that by using the approach in my post, you get the if( error == SUCCESS) out of sight.

    All the best

--

Max

To post reply to a comment, click on the 'reply' button attached to each comment. To post a new comment (not a reply to a comment) check out the 'Write a Comment' tab at the top of the comments.

Please login (on the right) if you already have an account on this platform.

Otherwise, please use this form to register (free) an join one of the largest online community for Electrical/Embedded/DSP/FPGA/ML engineers: