I've been looking for a good way to improve error handling in a C++ library, with the goal of reducing the risk of bug-prone code while maintaining efficiency.

I stumbled across Andrei Alexandrescu's Expect the Expected talk on YouTube and am very much drawn to std::expected, which is expected (heh.. heh) to be part of the C++23 standard.

However, as proposed, std::expected has something I dislike: according to Alexandrescu, to be consistent with std::optional the operator* and operator-> methods have undefined behavior when a value is not set. To me, this makes it far too easy for a program to plod along with potentially corrupt state.

Luckily there is an option, which is to only use std::optional::value() instead, which throws bad_optional_access when the value is not set (even if the value type is a primitive.)

But what do we have for a std::expected-like implementation?

So far, I've looked at

both of which look great (though they use many concepts I'm still struggling to learn, I admit. If I were more confident in my ability, I'd write my own.)

However, my complaint is that these do not throw an exception like std::optional::value() does when trying to read an un-set value.

cpp::result<int, int> res;
res.value(); // totally fine for some reason - returns '0'.

One way to prevent this is to only use result types that do not have default constructors. But what I really want is to get an exception if I try to use a value that hasn't been set (at least, not explicitly.)

Am I overthinking this? If not - can anyone tell me whether there's an implementation (ideally C++14) that disallows using an un-set value?

2

There are 2 best solutions below

0
On

Throwing an exception from a function is not a good idea if you want to prevent the user from ignoring or forgetting the errors. The exceptions are invisible in the function signature and are therefore very easy to forget and ignore. If the errors don't happen very often, it is easy for code to pass all testing and code review and end up crashing in production. It is also difficult to know which exceptions the function can throw unless the function is exceptionally well documented. It is much better to not allow code that ignores errors to compile.

I don't know of any library that implements a result type for C++, which wouldn't allow code that ignores error paths to compile, but it is possible to use std::variant<T, E> and std::visit to implement a Result -class, which forces you to handle both the success and error cases and doesn't allow access to the result, when an error occurs. The idea is to create a match member function, drawing inspiration from Rust pattern matching, which forces the user to provide the implementations for both execution paths.

The match functions takes lambdas as arguments and uses the overloaded trick to create a handler for std::visit. Then it calls std::visit for the result. Here is a simplified example of how this would work, a proper implementation should consider the details more carefully.

#include <variant>
#include <string_view>
#include <iostream>

template<typename T, typename... Es>
class Result {
    template<class... Ts>
    struct Overloaded : Ts... { using Ts::operator()...; };
    // explicit deduction guide (not needed as of C++20)
    template<class... Ts>
    Overloaded(Ts...) -> Overloaded<Ts...>;

    std::variant<T, Es...> result;

  public:
    Result(decltype(result) value) : result(value) {}
    template<typename Type>
    Result(Type value) : result(value) {}

    template<typename... Lambdas>
    [[nodiscard]] auto match(Lambdas... lambdas) {
        Overloaded handler{lambdas...};
        return visit(handler, result);
    }
};

struct Error1 {
    std::string_view text;

    [[nodiscard]] std::string_view message() { return text; };
};

struct Error2 {
    std::string_view text;

    [[nodiscard]] std::string_view message() { return text; };
};

[[nodiscard]] Result<int, Error1, Error2> libraryFunction(int val) {
    if(val > 0) {
        return 0;
    } else if(val == 0) {
        return Error1{"Error 1"};
    } else {
        return Error2{"Error 2"};
    }
}

int main() {

    std::cout <<
    libraryFunction(1).match(
        [](int res) { return res; },
        [](auto err) -> int { std::cout << err.message() << std::endl; abort(); }
    )
    << std::endl;

    libraryFunction(0).match(
        [](int res) { std::cout << res << std::endl; },
        [](Error1 err) { std::cout << err.message() << std::endl; },
        [](Error2 err) { std::cout << err.message() << std::endl; abort(); }
    );

    libraryFunction(-1).match(
        [](auto res) {
            if constexpr(std::is_same<decltype(res), int>())
                std::cout << res << std::endl;
            else {
                std::cout << res.message() << std::endl;
                abort();
            }
        }
    );

    return 0;
}

The user can still provide an empty error handler, but it is very hard to forget it by accident here. It should also be pretty easy to notice in code review when an error needs to be handled and whether the handler is empty. If you add a new error type to the function, code which cares about the type of the error will also stop compiling until it is fixed. Exceptions are horrible from this point of view and peppering them everywhere in standard libraries makes a lot of code dangerous in embedded systems where exceptions and dynamic memory allocation are not allowed. Basing this on boost::variant2 instead of std::variant might also make sense to get rid of having to consider the valueless by exception -state.

The interfaces provided by std::expected allow the user to not handle the unexpected cases and the operator* interface even allows undefined behavior. It is also missing an equivalent of the match -method, which seems like the safest option. The problem with allowing any extra interfaces for the user, which allow errors to be ignored, is that they are likely to use them instead since most don't like error handling.

The naming of expected and exceptions is also poor for something meant for error handling. Calling errors exceptions or unexpected makes the programmer think errors are exceptional, less common and therefore less important. In many situations errors can actually be more likely and even more important to than the successful execution path.

0
On

Perhaps one should reconsider whether the behavior you want is a good idea.

It is important to remember that expected<T, E> is often used in cases where E is an error code, typically either an integer or an enumeration. This is very important, because many error codes default-construct to a value that specifically means "not an error". This is how std::errc works. This is how std::error_code works. This is how std::exception_ptr works (it defaults to not pointing at an error). Look at any C-based library that has error codes, and odds are good that the 0 value of the error code represents "not an error".

expected<T, E> of course carries with it whether it "has an error", so any "not an error" state of E is irrelevant. But because E is often just the C error code type, any culling out of "not an error" states is expected to be done by the code composing the expected<T, E>. That is, you report an error by doing this: if(error_code) return unexpected(error_code);

By making expected<T, E> default construct to a default error code value, you're essentially rolling the dice on whether the "has an error" state actually means that the expected has an error instead of just having an error code that means "not an error".