I've been looking into and experimenting with various paradigms for error handling in C++.
My goals for a robust error handling mechanism would be:
- Enforce handling the error cases (in other words, make it more difficult to NOT handle the error case).
- i.e. don't let things like
result.value()->do_stuff()
slide without proper checks that*result.value()
actually exists and is valid. (my issue with things likestd::expected
btw, nothing is really stopping you from not callingresult.has_value()
and just directly proceeding toresult.value()
) - regarding exceptions, nothing is stopping you from just not writing
try
andcatch
. plus you have no idea what exceptions could be thrown without spelunking into the library's source code.- maybe Java was right all along with checked exceptions!
- i personally like golang style with
result, err != nil; if err != nil {...}
. though unlike golang, C++ does not enforce variables to be used (in golang, if you forgot to deal witherr
, you'd get a compiler error). I suppose you could turn on-Wunused-variable
and treat warnings as errors. still, C++ doesn't actually have multiple return values so you could just pack the result into a single variable and skip dealing with the error.
- i.e. don't let things like
- Try to leverage the compiler as much as possible, instead of trusting things to go right at runtime.
- Accept that user/client error cases are valid states of the program and encode that into the program, instead of trying to bury them under the rug or treat them as footnotes
- Be able to support complex error data types generically, to support rich and customized error data collection and output.
Currently I'm limited to C++17, so I'll be using that in this demo. Here is a Result
class I came up with to support the above paradigm:
namespace util { template<typename Data, typename Error> class Result { public: Result(Data&& data) : _result(std::move(data)) {} Result(Error&& error) : _result(std::move(error)) {} using DataFunction = std::function<void(Data&&)>; using ErrorFunction = std::function<void(Error&&)>; [[nodiscard]] bool handle(DataFunction&& data_func, ErrorFunction&& error_func) { if (std::holds_alternative<Error>(_result)) { error_func(std::move(std::get<Error>(_result))); return false; } data_func(std::move(std::get<Data>(_result))); return true; } private: std::variant<Data, Error> _result; }; }
It's essentially a wrapper around std::variant
.
You would call handle()
and provide two lambda functions to "unwrap" the variant in order to actually get the real data (or the error). In data_func
and error_func
, you deal with the Data
case and Error
cases separately.
handle
returns a bool that's false if the Error state was returned (with a [[nodiscard]]
to discourage ignoring of the bool). The purpose of the bool is to allow for writing early return logic (since you wouldn't be able to do that inside of error_func
).
To test out the ergonomics of this Result
class, I made a small sample program that parses a "fake" configuration file format. The format is just a bunch of key=value
pairs separated by newlines. I'll use snippets from there to explain.
Here is an example of Result
class usage:
static util::Result<FakeConfig, InitError> init(const std::string& filename) { using Reason = InitError::Reason; std::ifstream file(filename); if (!file.is_open()) { return InitError(Reason::INVALID_FILE); } std::unordered_map<std::string, std::string> dictionary; int line_num = 1; std::string line; while (std::getline(file, line)) { if (line.size() > 0) { std::string key, value; std::unique_ptr<InitError> error; if (!split(line, "=").handle( [&key, &value](std::pair<std::string, std::string>&& tokens) { std::tie(key, value) = tokens; }, [](SplitError&& split_error) { } )) { return InitError::from_line(filename, line_num, Reason::SYNTAX_ERROR); } if (dictionary.find(key) != dictionary.end()) { return InitError::from_line(filename, line_num, Reason::KEY_ALREADY_DEFINED); } dictionary[key] = value; } ++line_num; } return FakeConfig(std::move(dictionary)); }
Since Result
defines Result(Data&&)
and Result(Error&&)
constructors, we can just return whichever type we want directly (either the FakeConfig
object or InitError
in this case).
Here is an example of how a caller would use a function that returns a Result
type:
std::unique_ptr<FakeConfig> config; if (!FakeConfig::init(filename).handle( [&config] (FakeConfig&& fc) { config = std::make_unique<FakeConfig>(std::move(fc)); }, [&filename] (FakeConfig::InitError&& error) { std::cerr << "Error initializing from file: " << filename << std::endl; using Reason = FakeConfig::InitError::Reason; switch (error.reason) { case Reason::INVALID_FILE: { std::cerr << "Invalid file" << std::endl; break; } case Reason::SYNTAX_ERROR: { std::cerr << "Syntax error on line " << error.line_num << std::endl; std::cerr << "\t\t" << error.line << std::endl; break; } case Reason::KEY_ALREADY_DEFINED: { std::cerr << "Key already defined on line " << error.line_num << std::endl; std::cerr << "\t\t" << error.line << std::endl; break; } } } )) { // early return logic return -1; } // continue main program logic with *config ...
We must handle()
the return type of FakeConfig::init()
. We do this by providing data_func
for the success case (here, we move the FakeConfig
return type into the config
pointer), and providing error_func
(here we just print out an actual error message based on the contents of the InitError
return type).
If handle()
is false, then we just return early. Otherwise, we can continue with *config
.
Here is the full code. Compiled with g++
and -std=c++17
on macOS.
#include <cctype> #include <fstream> #include <functional> #include <iostream> #include <string> #include <unordered_map> #include <variant> namespace util { template<typename Data, typename Error> class Result { public: Result(Data&& data) : _result(std::move(data)) {} Result(Error&& error) : _result(std::move(error)) {} using DataFunction = std::function<void(Data&&)>; using ErrorFunction = std::function<void(Error&&)>; [[nodiscard]] bool handle(DataFunction&& data_func, ErrorFunction&& error_func) { if (std::holds_alternative<Error>(_result)) { error_func(std::move(std::get<Error>(_result))); return false; } data_func(std::move(std::get<Data>(_result))); return true; } private: std::variant<Data, Error> _result; }; } // // simple example to demonstrate use case: // parse a text file containing "key=value" pairs, newline separated. // // the file could look like the below: // // dog=max // cat=tom // tiger=tigger // donkey=eeyore // // this is less trivial than something like division (and checking division by zero or not) // but otherwise a fairly easy example to understand for demonstrating the Result class // namespace { std::string fetch_line(const std::string& filename, int line_num) { std::ifstream file(filename); int l = 1; std::string line; while (std::getline(file, line)) { if (l == line_num) { return line; } ++l; } return ""; } struct SplitError {}; util::Result<std::pair<std::string, std::string>, SplitError> split( const std::string& str, const std::string& delim ) { size_t delim_location = str.find(delim); if (delim_location == std::string::npos) { return SplitError(); } std::string key = str.substr(0, delim_location); std::string value = str.substr(delim_location + delim.size()); if (key.size() == 0 || value.size() == 0) { return SplitError(); } return std::make_pair(key, value); } bool is_all_alphanum(const std::string& str, size_t start) { if (start >= str.size()) return false; for (size_t i = start; i < str.size(); ++i) { if (!std::isalnum(str[i])) { return false; } } return true; } } class FakeConfig { public: FakeConfig(FakeConfig&&) = default; struct InitError { enum class Reason { INVALID_FILE, SYNTAX_ERROR, KEY_ALREADY_DEFINED // disallow re-assignment of keys } reason; int line_num; std::string line; InitError(InitError&&) = default; InitError(InitError::Reason r) : reason(r) {} static InitError from_line(const std::string& filename, int line_num, Reason reason) { std::string line = fetch_line(filename, line_num); InitError error(reason); error.line_num = line_num; error.line = line; return error; } }; static util::Result<FakeConfig, InitError> init(const std::string& filename) { using Reason = InitError::Reason; std::ifstream file(filename); if (!file.is_open()) { return InitError(Reason::INVALID_FILE); } std::unordered_map<std::string, std::string> dictionary; int line_num = 1; std::string line; while (std::getline(file, line)) { if (line.size() > 0) { std::string key, value; std::unique_ptr<InitError> error; if (!split(line, "=").handle( [&key, &value](std::pair<std::string, std::string>&& tokens) { std::tie(key, value) = tokens; }, [](SplitError&& split_error) { } )) { return InitError::from_line(filename, line_num, Reason::SYNTAX_ERROR); } if (dictionary.find(key) != dictionary.end()) { return InitError::from_line(filename, line_num, Reason::KEY_ALREADY_DEFINED); } dictionary[key] = value; } ++line_num; } return FakeConfig(std::move(dictionary)); } struct GetError { enum class Reason { ILLEGAL_KEY, KEY_NOT_FOUND, } reason; std::string key; GetError(Reason r, const std::string& k) : reason(r), key(k) {} }; util::Result<std::string, GetError> get(const std::string& key) { using Reason = GetError::Reason; bool is_legal_key = key.size() >= 1 && std::isalpha(key[0]) && is_all_alphanum(key, 1) ; if (!is_legal_key) { return GetError(Reason::ILLEGAL_KEY, key); } if (_values.find(key) == _values.end()) { return GetError(Reason::KEY_NOT_FOUND, key); } return std::string(_values[key]); } private: FakeConfig(std::unordered_map<std::string, std::string>&& values) : _values(values) {} std::unordered_map<std::string, std::string> _values; }; int main(int argc, char** argv) { if (argc < 2) { std::cerr << "usage: ./parser filename" << std::endl; return -1; } std::string filename = argv[1]; std::unique_ptr<FakeConfig> config; if (!FakeConfig::init(filename).handle( [&config] (FakeConfig&& fc) { config = std::make_unique<FakeConfig>(std::move(fc)); }, [&filename] (FakeConfig::InitError&& error) { std::cerr << "Error initializing from file: " << filename << std::endl; using Reason = FakeConfig::InitError::Reason; switch (error.reason) { case Reason::INVALID_FILE: { std::cerr << "Invalid file" << std::endl; break; } case Reason::SYNTAX_ERROR: { std::cerr << "Syntax error on line " << error.line_num << std::endl; std::cerr << "\t\t" << error.line << std::endl; break; } case Reason::KEY_ALREADY_DEFINED: { std::cerr << "Key already defined on line " << error.line_num << std::endl; std::cerr << "\t\t" << error.line << std::endl; break; } } } )) { return -1; } std::string input; do { std::cout << "Enter a key (or ctrl-C to quit): "; std::cin >> input; std::cout << "\t"; if (!config->get(input).handle( [&input] (std::string&& value) { std::cout << input << " -> " << value << std::endl; }, [&input] (FakeConfig::GetError&& error) { std::cerr << "Could not get key: " << input << std::endl << "\t"; using Reason = FakeConfig::GetError::Reason; switch (error.reason) { case Reason::ILLEGAL_KEY: { std::cerr << "\"" << input << "\" is not a legal key" << std::endl; break; } case Reason::KEY_NOT_FOUND: { std::cerr << "key \"" << input << "\" was not found" << std::endl; break; } } } )) { // no early return, just continue with the next input } std::cout << std::endl; } while (true); return 0; }
And some sample output
./parser animals.txt Enter a key (or ctrl-C to quit): tiger tiger -> tigger Enter a key (or ctrl-C to quit): donkey donkey -> eeyore Enter a key (or ctrl-C to quit): asdfqwef Could not get key: asdfqwef key "asdfqwef" was not found Enter a key (or ctrl-C to quit): asdf@@ Could not get key: asdf@@ "asdf@@" is not a legal key Enter a key (or ctrl-C to quit): ^C
./parser animals Error initializing from file: animals Invalid file
Error initializing from file: animals_error.txt Syntax error on line 4 tigertigger=
I think this error handling paradigm may be in other languages, but I couldn't find anything similar in C++. This might be essentially makeshift pattern matching?
Would appreciate feedback and please point out any potential pitfalls. Particularly feedback focused on the Result
class itself and the usage examples. The FakeConfig code itself is just a silly demo program to actually test out this idea.