C++ gives you more ways to report a failure than any other language in common use, and no consensus on which to reach for. The same codebase will often contain all of them.
The map below is the full territory.
C++ ERROR HANDLING
|
+---------+---------+-----------+-----------+----------+
| | | | | |
RETURN ERROR EXCEPTIONS CONTRACTS TERMINATE NOEXCEPT
CODES OBJECTS (the promise)
| | | | |
errno, std:: throw/ assert, std::abort,
bool, error_code, catch, UB, terminate
int optional, RAII hardening handler
expected unwinding
(C++23)
One question decides most of it: is this failure expected or exceptional?
Expected failures are part of what the function does — parsing arbitrary input, opening a file that may not exist, looking up an absent key. The caller has to handle them, so they belong in the return type where the type system can force the issue.
Exceptional failures break the function’s contract. The invariant it promised no longer holds, and the immediate caller usually can’t help — so they belong in the unwinding path, propagating past the frames that can’t do anything to one that can.
Everything below follows from that split, including where it breaks down and where the language forces your hand regardless.
Return Codes
The oldest mechanism, inherited wholesale from C: the function hands back a value, and some subset of that value’s range means “it didn’t work”.
int rc = close(fd); // 0 on success, -1 on failure
FILE* f = fopen(p, "r"); // non-null on success, NULL on failure
bool ok = try_lock(); // the whole result, in one bit
What Motivates Them
The cost is visible and bounded. A return code is a value in a register — no allocation, no side tables, no runtime library. throw allocates the exception object (__cxa_allocate_exception) and walks unwind tables to find a handler, taking time proportional to the frames in between. Hard-realtime code can’t price that, so it doesn’t buy it.
They cross boundaries exceptions cannot. A C++ exception cannot propagate through a C stack frame, so every C API and extern "C" callback must report failure by value. That is a constraint, not a preference.
Some codebases have no choice. Much of embedded and games builds with -fno-exceptions, where throw is a compile error outright.
The modern form isn’t a fossil either: a [[nodiscard]] enum class Status is a defensible design today.
errno
C-era failures report whether through the return value and why through errno, which behaves unlike a normal variable.
It isn’t one. It’s a macro expanding to a modifiable lvalue, on glibc roughly:
#define errno (*__errno_location())
The indirection gives each thread its own copy, guaranteed since C11/C++11 — a truly global int would be useless in threaded code. The values are macros (ENOENT, EACCES, EINVAL, ERANGE), of which the C standard requires only EDOM, ERANGE and EILSEQ; the rest are POSIX.
Two rules, both routinely broken.
Success does not clear it. Nothing zeroes errno for you, and a library function is explicitly permitted to set it even when it succeeds — so it holds whatever the last failure left there. Establish failure from the return value first, then read errno.
An ambiguous return means you clear it yourself.
errno = 0;
long v = std::strtol(s, &end, 10);
if (end == s) { /* no digits at all */ }
else if (errno == ERANGE) { /* overflowed long */ }
strtol returns 0 for both "0" and "banana"; the return value genuinely cannot distinguish them.
The clobbering hazard
Because errno is out of band, anything running between the failure and the check can overwrite it:
if (fd < 0) {
log_error("open failed"); // if this touches any libc call...
printf("%s\n", strerror(errno)); // ...this reports the wrong error
}
Capture it first (int saved = errno;), then log. Signal handlers carry the same obligation in reverse: save errno on entry and restore it on exit, or you corrupt whatever the interrupted code was about to read.
Structurally, errno is global mutable state, out of band from the value it describes. It doesn’t travel with the result, nothing forces you to consult it, and unrelated code can destroy it before you do. Much of what follows is a reaction to that.
There Is No Single Convention
The caller has to know, per function, which scheme is in play:
FILE* f = fopen(p, "r"); // NULL means failure, ask errno why
int fd = open(p, O_RDONLY); // -1 means failure, ask errno why
int rc = pthread_mutex_lock(&m); // returns the error number DIRECTLY, errno untouched
The last two are the same standards family. And read() is three-way — -1 error, 0 end of file, positive byte count — so collapsing EOF into the failure branch is a bug people ship.
Where They Break Down
Ignorable by default.
fclose(f); // returns int; buffered write errors surface HERE, and nobody looks
[[nodiscard]] (C++17) helps without solving it: opt-in, often on a type you don’t own, silenced by a (void) cast, and no help against a check you get wrong.
Value and error share one channel. Every value that means “failed” is a value that can no longer mean itself. The usual dodge trades the problem for a worse one:
bool try_parse(std::string_view s, int& out); // what is `out` on failure?
You now hold an object you must not read, with nothing in the type system stopping you.
Constructors and operators have no return slot. A constructor has no return type; operator[] and operator+ have theirs spoken for. Hence two-phase initialisation:
Widget w; // constructed, but not usable
if (!w.init()) { ... } // the real constructor
That is where “valid but unusable” objects come from, and the m_initialised flag every method then has to check. It defeats RAII: the object’s lifetime no longer coincides with its usability. This gap is a principal reason exceptions exist in the language.
Propagation is manual and unchecked.
Status read_config(Config& out) {
Status s = open_file(...); if (s != Status::Ok) return s;
s = parse_header(...); if (s != Status::Ok) return s;
s = parse_body(...); if (s != Status::Ok) return s;
return Status::Ok;
}
Nothing verifies any of it, which is exactly where errors get quietly dropped.
The deeper cost is the one that motivates the rest of this post: a return code delivers the failure to the immediate caller, who usually cannot do anything about it. So it gets forwarded, and forwarded again. The frames in between neither cause the error nor handle it, yet each has to name it, check it, and pass it along.
Exceptions remove those frames from the conversation. std::expected keeps them in it, but makes the paperwork nearly free.
Pt 2 covers both, and the split they answer to: EXCEPTIONS and ERROR OBJECTS.