87 Errors are values
What to know first
Looking back
Chapter 51 said C’s ways of reporting an error are only three — the return value, global state, and stopping the program — and that of these the return value is the most honest. Then how does one say both “it failed” and “here is the result” with a single return value?
A. There are three ways. Mix an impossible value into the result’s place (null, -1 — that trap seen in chapter 85), take the result out as an output parameter and use the return value for status alone, or hold both in one struct and return them together. proven uses the second and third together — and the third is possible because, as learned in chapter 46, a C struct can be returned by value.
The need for this chapter, and its context
By the end of this chapter
The questions this chapter answers
- If it can be ignored with
(void), is it not compulsory after all? - But chapter 86′s
proven_printlnhad no such mark. Why is screen output alone an exception? - Is deliberately making a failing allocator of any use in practice too?
- What is the price of this way?
87.1 Two shapes of return
The rule is simple. A function that can fail must return the failure as a value.
- If there is no result to return, it returns a single
proven_err_t. - If there is a result to return, it returns an
{err, value}bundle — with a name per type, such asproven_result_u8str_torproven_result_size_t.
proven_err_t is an enumeration and success is PROVEN_OK (0). The check is always the single proven_is_ok(err) — writing err == 0 would work too, but using the name lets the code survive a later change of representation.
87.1.1 Every error code
Failures have a name per kind, and there are sixteen in all. They are not to be memorised; it is enough to know what branches exist — most code divides only success from failure, and looks at the branch only when attempting recovery.
| code | what happened | mainly where |
|---|---|---|
PROVEN_OK | success (0) | — |
ERR_NOMEM | the allocator could not hand out memory | _create, _grow |
ERR_OUT_OF_BOUNDS | outside the vessel — refused rather than truncated | append, slice, array indexing |
ERR_INVALID_ENCODING | the UTF-8/UTF-16 was broken | string conversion, hex/base64 |
ERR_INVALID_ARG | an argument is outside the contract (null, 0, an unusable allocator) | almost every entry point |
ERR_IO | the outside world failed | files and streams |
ERR_NOT_FOUND | what was sought is not there | map lookup, opening a file |
ERR_INVALID_STATE | it cannot be done in the present state | a closed stream, a destroyed object |
ERR_NEED_MORE | more input is needed before judging | parsers and decoders |
ERR_OVERFLOW | a size calculation overflowed | create, container growth |
ERR_UNSUPPORTED | this environment does not have that facility | OS features under freestanding |
ERR_AGAIN | not now — try again | non-blocking I/O |
ERR_EOF | the end was reached | reading |
ERR_BUSY | somebody else is using it | locks, the job queue |
ERR_PERMISSION | there is no permission | files |
ERR_INVALID_FORMAT | the format was wrong | parsing, format strings |
Table 88.1
examples-en/ch87/codes.c
/* An error is a value — what those values really are, seen with the eyes. */
#include <proven.h>
static const char *name_of(proven_err_t e)
{
switch (e) {
case PROVEN_OK: return "PROVEN_OK";
case PROVEN_ERR_NOMEM: return "NOMEM (out of memory)";
case PROVEN_ERR_OUT_OF_BOUNDS: return "OUT_OF_BOUNDS (outside the vessel)";
case PROVEN_ERR_INVALID_ENCODING: return "INVALID_ENCODING (broken encoding)";
case PROVEN_ERR_INVALID_ARG: return "INVALID_ARG (argument outside the contract)";
case PROVEN_ERR_IO: return "IO (a failure of the outside world)";
case PROVEN_ERR_NOT_FOUND: return "NOT_FOUND (there is none)";
case PROVEN_ERR_INVALID_STATE: return "INVALID_STATE (cannot be done now)";
case PROVEN_ERR_NEED_MORE: return "NEED_MORE (more input is needed)";
case PROVEN_ERR_OVERFLOW: return "OVERFLOW (the computation overflowed)";
case PROVEN_ERR_UNSUPPORTED: return "UNSUPPORTED (not in this environment)";
case PROVEN_ERR_AGAIN: return "AGAIN (not now, try again)";
case PROVEN_ERR_EOF: return "EOF (the end was reached)";
case PROVEN_ERR_BUSY: return "BUSY (someone else is using it)";
case PROVEN_ERR_PERMISSION: return "PERMISSION (no permission)";
case PROVEN_ERR_INVALID_FORMAT: return "INVALID_FORMAT (the format is wrong)";
}
return "(unknown)";
}
int main(void)
{
proven_println("-- every error code --");
for (int i = PROVEN_OK; i <= PROVEN_ERR_INVALID_FORMAT; i++)
proven_println("{:>2} {}", PROVEN_ARG(i), PROVEN_ARG(name_of((proven_err_t)i)));
proven_println("");
proven_println("-- which code actually comes back --");
/* (1) the vessel is too small -> OUT_OF_BOUNDS */
proven_byte_t small[8];
proven_u8str_t s = proven_u8str_borrow(small, sizeof small);
proven_err_t e1 = proven_u8str_append(&s, PROVEN_LIT("Hello, world"));
proven_println("append 12 bytes into 8 -> {}", PROVEN_ARG(name_of(e1)));
/* failure atomicity: after the refusal the original is still untouched */
proven_println(" after failure, len = {}",
PROVEN_ARG(proven_u8str_as_view(&s).size));
/* (2) an argument breaking the contract -> INVALID_ARG.
Give it an unusable allocator (a value that is all zeros) and it is
caught before anything is made. */
proven_allocator_t nothing = (proven_allocator_t){0};
proven_result_u8str_t bad = proven_u8str_create(nothing, 16);
proven_println("create with a null allocator -> {}", PROVEN_ARG(name_of(bad.err)));
/* (3) slicing past the end -> OUT_OF_BOUNDS (it does not hand over what there is) */
proven_u8str_view_t v = PROVEN_LIT("abcdefgh");
proven_result_mem_view_t sl =
proven_mem_view_slice_checked(proven_mem_view_from_u8(v), 6, 4);
proven_println("slice 4 bytes from 6/8 -> {}", PROVEN_ARG(name_of(sl.err)));
/* (4) success is always 0, and the check is always the same one line */
proven_err_t ok = proven_u8str_append(&s, PROVEN_LIT("hi"));
proven_println("append 2 bytes into 8 -> {} (is_ok={})",
PROVEN_ARG(name_of(ok)), PROVEN_ARG(proven_is_ok(ok)));
return 0;
}
Output
-- every error code --
0 PROVEN_OK
1 NOMEM (out of memory)
2 OUT_OF_BOUNDS (outside the vessel)
3 INVALID_ENCODING (broken encoding)
4 INVALID_ARG (argument outside the contract)
5 IO (a failure of the outside world)
6 NOT_FOUND (there is none)
7 INVALID_STATE (cannot be done now)
8 NEED_MORE (more input is needed)
9 OVERFLOW (the computation overflowed)
10 UNSUPPORTED (not in this environment)
11 AGAIN (not now, try again)
12 EOF (the end was reached)
13 BUSY (someone else is using it)
14 PERMISSION (no permission)
15 INVALID_FORMAT (the format is wrong)
-- which code actually comes back --
append 12 bytes into 8 -> OUT_OF_BOUNDS (outside the vessel)
after failure, len = 0
create with a null allocator -> INVALID_ARG (argument outside the contract)
slice 4 bytes from 6/8 -> OUT_OF_BOUNDS (outside the vessel)
append 2 bytes into 8 -> PROVEN_OK (is_ok=1)
The latter part of the example shows this table in the flesh. Try to put twelve bytes into an eight-byte vessel and OUT_OF_BOUNDS comes — and the original is left untouched (the length is still 0). Give an unusable allocator and it is caught as INVALID_ARG before anything is made. Slicing outside the range too is a refusal, not “as much as there is”.
Two things are worth taking from here. First, ERR_INVALID_ARG is usually a bug in my own code — not a failure of the outside world but a contract violation, so it is to be mended rather than recovered from. Second, ERR_EOF and ERR_AGAIN are part of the normal flow. In a reading loop EOF is not an error but the ending condition (chapter 93).
87.1.2 The kinds of result bundle
A function with a value to return has one bundle per type. The naming rule being the same, the list need not be memorised — inside a proven_result_XXX_t there are always just err and value.
| bundle | the type of value | where it is returned |
|---|---|---|
proven_result_size_t | proven_size_t | lengths, counts, bytes written |
proven_result_mem_mut_t | proven_mem_mut_t | allocators (chapters 88 and 89) |
proven_result_mem_view_t | proven_mem_view_t | slicing (chapter 88) |
proven_result_u8str_t | proven_u8str_t | making a string (chapter 90) |
proven_result_buf_t | proven_buf_t | making a buffer |
proven_result_cstr_t | const char * | exporting as a C string (chapter 90) |
proven_fmt_result_t | (amount written and amount needed) | formatting (chapter 91) |
Table 88.2
Only the last row is of a different grain. For formatting, “success or failure” is not enough — if it was truncated you must know how much more was needed — so beside err it carries two numbers as well (we look at it closely in chapter 91).
examples-en/ch87/errval.c
#include <proven.h>
#include <stdio.h>
/* Work that can fail: two pieces appended to a string of a settled capacity.
The error comes back as a value, and the caller passes it upward as it is. */
static proven_err_t make_greeting(proven_allocator_t alloc,
const char *who,
proven_size_t cap,
proven_u8str_t *out)
{
proven_result_u8str_t made = proven_u8str_create(alloc, cap);
if (!proven_is_ok(made.err))
return made.err; /* the failure is handed upward */
proven_u8str_t s = made.value; /* value is used only after the check */
proven_err_t e = proven_u8str_append(&s, proven_u8str_view_from_cstr("Hello, "));
if (proven_is_ok(e))
e = proven_u8str_append(&s, proven_u8str_view_from_cstr(who));
if (!proven_is_ok(e)) {
proven_u8str_destroy(alloc, &s); /* having failed, the cleaning up is ours too */
return e;
}
*out = s;
return PROVEN_OK;
}
static void try(proven_allocator_t alloc, const char *who, proven_size_t cap)
{
proven_u8str_t s;
proven_err_t e = make_greeting(alloc, who, cap, &s);
if (!proven_is_ok(e)) {
printf("cap=%2zu who=%-8s -> failed with error code %d\n",
cap, who, (int)e);
return;
}
proven_u8str_view_t v = proven_u8str_as_view(&s);
printf("cap=%2zu who=%-8s -> \"%.*s\"\n",
cap, who, (int)v.size, (const char *)v.ptr);
proven_u8str_destroy(alloc, &s);
}
int main(void)
{
proven_allocator_t alloc = proven_heap_allocator();
try(alloc, "world", 32); /* roomy */
try(alloc, "world", 8); /* not enough room — it refuses rather than truncating */
printf("PROVEN_OK is %d, and every failure is non-zero\n", (int)PROVEN_OK);
return 0;
}
Output
cap=32 who=world -> "Hello, world"
cap= 8 who=world -> failed with error code 2
PROVEN_OK is 0, and every failure is non-zero
This example contains all of this chapter’s syntax. make_greeting sends the result out through an output parameter (out) and used the return value for status alone — on failure it passes it up as it is. And proven_u8str_create returns a bundle, so made.value is taken out only after checking. The order must not be reversed.
Counter-example. Taking out value before checking
proven_u8str_t s = proven_u8str_create(alloc, 64).value; /* dangerous */It finishes in one line and looks clean, but what comes into your hand on failure is a meaningless value. The bundle’s contract is “value has meaning only when err is PROVEN_OK”, so this code has skipped the contract. That on failure a struct filled with zeros usually arrives and it does not die immediately is rather the danger — the accident is put off until much later (that pattern from chapter 85).
The output of the second call compresses this part’s theme. On trying to put "Hello, world" into a capacity of 8 bytes, the library, instead of putting in as much as fits and declaring success, wrote nothing and returned a failure. It is the exact opposite choice from snprintf’s quiet truncation seen in chapter 85.
87.2 Throw it away and the compiler protests
Returning the error as a value is not enough by itself. As seen in chapter 85, a return value can be thrown away. So functions for which failure is meaningful have C23′s [[nodiscard]] attached (we saw the name in chapter 51). Throw the result away and the compiler really says this.
warning: ignoring return value of ‘proven_u8str_append’,
declared with attribute ‘nodiscard’ [-Wunused-result]
5 | proven_u8str_append(&s, proven_u8str_view_from_cstr("hi"));
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
note: declared here
123 | [[nodiscard]] proven_err_t proven_u8str_append(...);If the -Werror recommended in chapter 17 is turned on, this is not a warning but a build failure. What was “checking is optional” has become “it does not compile unless you check”.
Of course there are times when you really do want to ignore it. Then (void) is put in front.
(void)proven_u8str_append(&s, view); /* ignored knowingly */Q. If it can be ignored with (void), is it not compulsory after all?
A. Because the purpose of the compulsion is not “to prevent ignoring” but “to make ignoring visible”. An error thrown away with no mark is invisible in code review, while a line with (void) attached becomes a declaration that “this failure is deliberately ignored”. The very fact that it must be typed is the heart of it — it cannot be done by accident, only on purpose.
Q. But chapter 86′s proven_println had no such mark. Why is screen output alone an exception?
A. Because contracts have grades too. The failure of a write going to the console has conventionally been ignored (have you ever seen code that checks printf’s return value?), and making every line of output carry a (void) would bury the code in noise. So this library placed output in the grade that returns the error but does not compel a check. Conversely, the functions on the input side do have the mark — ignore the failure of a read and you treat “data not read” as though it had been read, replaying chapter 85′s second bug exactly. Where to attach the mark is itself a design judgement.
87.3 What remains after a failure — failure atomicity
There is a question that naturally arises after receiving an error. What state is the object the failed function was touching in now?
The library’s answer is failure atomicity — unless the documentation says otherwise, a failed operation leaves the target in the state it was in before being touched. If memory runs short while growing an array the existing elements are still alive, and if there is not enough room while appending to a string the original content stands. The second call of the example just now is that case — it failed, but no half-written string was left.
Why does this matter? Without failure atomicity a caller can do nothing after a failure but throw the object away. With it, “give up this addition and carry on with what has been gathered so far” becomes possible.
A common misconception. “If it fails we will end the program anyway, so what does the state matter”
For a short-running command-line tool that may be so. But long-running programs — servers, editors, games, firmware — must not die on one failure. If the whole server went down because handling one request failed for lack of memory, that would be the greater accident. Failure atomicity is the minimal condition that makes possible the recovery of “throw away only this request and take the next”.87.4 Raising a failure upward — together with the cleanup
Receiving errors as values raises one practical problem at once. If it fails in the middle, who gives back what has been taken so far? In a language with exceptions the stack unwinds and destructors handle it, but C has no such device (chapter 75). So an idiom is needed.
examples-en/ch87/cleanup.c
/* How not to leak a resource on a failure path — everything gathered in one
place and released in reverse. The `goto cleanup` idiom of chapter 75 meeting
proven's error values. */
#include <proven.h>
/* An allocator for imitating failure: from the nth allocation on it always fails. */
typedef struct {
proven_allocator_t base;
int budget; /* how many successes are left */
} failing_ctx_t;
static proven_result_mem_mut_t failing_alloc(void *ctx, proven_size_t size,
proven_size_t align)
{
failing_ctx_t *f = (failing_ctx_t *)ctx;
if (f->budget <= 0)
return (proven_result_mem_mut_t){ .err = PROVEN_ERR_NOMEM };
f->budget--;
return f->base.alloc_fn(f->base.ctx, size, align);
}
static proven_result_mem_mut_t failing_realloc(void *ctx, void *old_ptr,
proven_size_t old_size,
proven_size_t new_size,
proven_size_t align)
{
failing_ctx_t *f = (failing_ctx_t *)ctx;
if (f->budget <= 0)
return (proven_result_mem_mut_t){ .err = PROVEN_ERR_NOMEM };
f->budget--;
return f->base.realloc_fn(f->base.ctx, old_ptr, old_size, new_size, align);
}
static void failing_free(void *ctx, void *ptr)
{
failing_ctx_t *f = (failing_ctx_t *)ctx;
f->base.free_fn(f->base.ctx, ptr);
}
/* A function holding two resources. Wherever it fails, exactly what was taken is given back. */
static proven_err_t join_two(proven_allocator_t alloc,
proven_u8str_view_t a,
proven_u8str_view_t b,
proven_u8str_t *out)
{
proven_err_t err = PROVEN_OK;
bool has_x = false;
proven_u8str_t x; /* the first resource */
proven_u8str_t y; /* the second resource */
proven_result_u8str_t rx = proven_u8str_create_from_view(alloc, a);
if (!proven_is_ok(rx.err)) { err = rx.err; goto done; }
x = rx.value; has_x = true;
proven_result_u8str_t ry = proven_u8str_create_from_view(alloc, b);
if (!proven_is_ok(ry.err)) { err = ry.err; goto done; }
y = ry.value;
/* the two pieces joined — it grows if there is not enough room (why an allocator is needed) */
err = proven_u8str_append_grow(alloc, &y, proven_u8str_as_view(&x));
if (!proven_is_ok(err)) { proven_u8str_destroy(alloc, &y); goto done; }
*out = y; /* success: ownership of y is handed over */
/* x is given back below */
done:
if (has_x) proven_u8str_destroy(alloc, &x); /* if it was taken, it is always given back */
return err;
}
static void run(int budget)
{
proven_allocator_t heap = proven_heap_allocator();
failing_ctx_t ctx = { .base = heap, .budget = budget };
proven_allocator_t alloc = {
.ctx = &ctx, .alloc_fn = failing_alloc,
.realloc_fn = failing_realloc, .free_fn = failing_free,
};
proven_u8str_t out;
proven_err_t e = join_two(alloc, PROVEN_LIT("world"), PROVEN_LIT("hello, "), &out);
if (proven_is_ok(e)) {
proven_println("budget={} -> ok: \"{}\" (budget left {})",
PROVEN_ARG(budget), PROVEN_ARG(proven_u8str_as_view(&out)),
PROVEN_ARG(ctx.budget));
proven_u8str_destroy(alloc, &out);
} else {
proven_println("budget={} -> failed (code {}) — everything taken was given back",
PROVEN_ARG(budget), PROVEN_ARG((int)e));
}
}
int main(void)
{
run(0); /* it fails from the first allocation */
run(1); /* the first resource is taken and the second fails -> letting x slip is a leak */
run(9); /* everything succeeds */
return 0;
}
Output
budget=0 -> failed (code 1) — everything taken was given back
budget=1 -> failed (code 1) — everything taken was given back
budget=9 -> ok: "hello, world" (budget left 6)
This example deliberately inserts a failing allocator (once the budget runs out it necessarily gives NOMEM) and runs all three cases — failure from the first allocation, one taken and failure at the second, and everything succeeding. The middle case is the heart of it. x has already been taken while y failed, so simply returning here is a leak.
The pattern comes to three.
- Mark what you hold with a flag — one boolean such as
has_x. If the resources are several, so are the flags. - On failure everything gathers at one place —
goto done. That use chapter 75 called “disciplinedgoto”. - Clean up in reverse order of taking — what was taken later is given back first.
It is worth noticing too that after ownership passes with *out = y; on the success path, y is not destroyed thereafter. You must be able to point at the place where ownership passes with a single line of code — a function that cannot is usually one of blurred design.
Q. Is deliberately making a failing allocator of any use in practice too?
A. Of great use. The out-of-memory path is almost never executed in a real program, so in most codebases it is the least tested path. And a leak or double free there is the hardest of all to diagnose.
As chapter 89 will show, an allocator is simply a value, so a shell that “fails from the nth call” can be made in ten lines, as in the example, and inserted. Raise n from 1 and run the tests and you can pass through every failure point once, and running it with ASan or Valgrind (chapter 17) makes that path’s leaks show themselves plainly. It is the place where the decision that the library does not call malloc directly comes back as testability.
87.5 When there is nobody to return to — the panic
To return an error as a value there must be somebody to return it to. But in a place where the contract itself is broken there is no such somebody — if, for example, a null arrived in a place where there is no reason whatever to pass a null, that is not a failure but means the program’s logic is already wrong.
For such places the library has a panic path. It is the same spirit as chapter 51′s assert, differing in that the way it is handled can be swapped out so as to be usable in embedded work too (chapter 94).
There are only two doors.
void proven_panic(const char *msg); /* raise a panic */
void proven_set_panic_handler(proven_panic_handler_t h); /* swap the handler */The default handler does not return — it stops the program on the spot (the implementation is __builtin_trap()). And this swapping is what pays in embedded work. On a board with no console there is nowhere to print a message, so a handler is registered that lights an LED, kicks the watchdog, or reboots.
static void my_panic(const char *msg) {
(void)msg;
board_led_on(LED_FAULT);
for (;;) { } /* it does not go back */
}
/* at the program's starting place */
proven_set_panic_handler(my_panic);A handler must not return. If it returns, the validity of what an _or_panic function gave back is not guaranteed — a panic is the declaration that “from here the program’s premises are broken”. The exception is test code deliberately using a returning handler to confirm the panic path, and even then the value after it is not used.
The places where the library itself calls a panic can be counted on the fingers — the functions with _or_panic in the name (chapter 89′s arena allocation is representative) and a few places where the contract is plainly already broken. Everything else is returned as a value.
The distinction is best remembered like this.
Recap
| situation | example | the library’s handling |
|---|---|---|
| failure of the outside world | out of memory, file not found, out of room | return the error as a value |
| the caller’s contract violation | a null that must not be, reusing a destroyed object | panic (or undefined) |
| failure that may be ignored | console output failure | return the error but do not compel |
Table 88.3
In practice. Other languages that chose errors as values
This design is not C’s invention alone but a current common to recent systems languages. Go has functions return a result and an error side by side, and Rust wraps success and failure in the singleResult type and warns if it is ignored. Both are languages that decided not to use exceptions, and the reason is the same — the error paths must be visible in the shape of the code. Exceptions are convenient but erase from the signature “which failure jumps where from here”. Three different languages, in effect, found the answer to the second row of chapter 85′s table from the same direction.Q. What is the price of this way?
A. ifs multiply. There being no device like exceptions to sweep a deep failure up in one go, code that checks and passes upward attaches at every place a failure is met. That is half the reason the make_greeting of the example just now runs to some twenty lines. In exchange one thing is gained — where and what can fail is visible in the code as it stands. That there is no hidden failure is the thing this library sells.
Knowing the shape of errors, we now go down to what those errors protect — memory itself. The next chapter is bytes and views, and size calculation that does not overflow.