Proven C Book한국어 GitHub

87 Errors are values

What to know first

chapter 51, Errors and contracts · errors as values
chapter 85, The five bugs shipped for fifty years · the unchecked return value

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

Errors are answered first among the five bugs because every other answer stands on them — views, allocators and strings all need “how does failure report itself” settled before their APIs can be drawn. What chapter 51 erected as a principle becomes a type here.

By the end of this chapter

We see the answer to chapter 85′s second bug — unconfirmed failure. The way of returning failure as a value, the bundle holding a value and an error together, and the device that makes the compiler protest if an error is thrown away. The discipline set up in chapter 51, “errors are values”, hardens here into a type.

The questions this chapter answers

  1. If it can be ignored with (void), is it not compulsory after all?
  2. But chapter 86′s proven_println had no such mark. Why is screen output alone an exception?
  3. Is deliberately making a failing allocator of any use in practice too?
  4. 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.

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.

codewhat happenedmainly where
PROVEN_OKsuccess (0)
ERR_NOMEMthe allocator could not hand out memory_create, _grow
ERR_OUT_OF_BOUNDSoutside the vessel — refused rather than truncatedappend, slice, array indexing
ERR_INVALID_ENCODINGthe UTF-8/UTF-16 was brokenstring conversion, hex/base64
ERR_INVALID_ARGan argument is outside the contract (null, 0, an unusable allocator)almost every entry point
ERR_IOthe outside world failedfiles and streams
ERR_NOT_FOUNDwhat was sought is not theremap lookup, opening a file
ERR_INVALID_STATEit cannot be done in the present statea closed stream, a destroyed object
ERR_NEED_MOREmore input is needed before judgingparsers and decoders
ERR_OVERFLOWa size calculation overflowedcreate, container growth
ERR_UNSUPPORTEDthis environment does not have that facilityOS features under freestanding
ERR_AGAINnot now — try againnon-blocking I/O
ERR_EOFthe end was reachedreading
ERR_BUSYsomebody else is using itlocks, the job queue
ERR_PERMISSIONthere is no permissionfiles
ERR_INVALID_FORMATthe format was wrongparsing, 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.

bundlethe type of valuewhere it is returned
proven_result_size_tproven_size_tlengths, counts, bytes written
proven_result_mem_mut_tproven_mem_mut_tallocators (chapters 88 and 89)
proven_result_mem_view_tproven_mem_view_tslicing (chapter 88)
proven_result_u8str_tproven_u8str_tmaking a string (chapter 90)
proven_result_buf_tproven_buf_tmaking a buffer
proven_result_cstr_tconst 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.

  1. Mark what you hold with a flag — one boolean such as has_x. If the resources are several, so are the flags.
  2. On failure everything gathers at one placegoto done. That use chapter 75 called “disciplined goto”.
  3. 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

situationexamplethe library’s handling
failure of the outside worldout of memory, file not found, out of roomreturn the error as a value
the caller’s contract violationa null that must not be, reusing a destroyed objectpanic (or undefined)
failure that may be ignoredconsole output failurereturn 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 single Result 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.