Proven C Book한국어 GitHub

92 Containers and algorithms

What to know first

chapter 46, Structs · structs
chapter 45, Dynamic memory · a growing array
chapter 88, The foundation · views and bounds

Looking back

Chapter 37 taught that a C array’s size is settled at compile time, and chapter 45 that it can be grown with realloc. Then where is it easiest to go wrong when making a “growing array” yourself?

A. In three places. First, calculating the size to grow to — the multiplication wrap-round seen in chapter 88 happens here. Second, the state on failure — when realloc fails the original pointer is still valid, and the common code that assigns the return value straight into the original variable loses that original (a leak). Third, pointers after the growth — a pointer that pointed at an element becomes invalid after reallocation. Rather than getting these three right afresh every time you make a container, it is better to use one made properly once.

The need for this chapter, and its context

By now the materials are all in hand — errors (86), views (87), allocators (88). Containers are the first place that uses all three at once, so they cannot be pulled earlier. And the answer to chapter 85′s fifth bug (unchecked callbacks) is here too: sorting and searching are exactly where callbacks are taken.

By the end of this chapter

The tools that hold many — the growing array, the intrusive list, the ring buffer, the hash map. And as the answer to chapter 85′s fifth bug (unchecked callbacks) and the performance trap attached to that place, we see sorting guaranteed even in the worst case and hashing that withstands attack. Chapter 37′s arrays and chapter 46′s structs become practical components here.

The questions this chapter answers

  1. How much slower is a keyed hash? And where does the random secret come from if there is no OS?

92.1 The life cycle of the four containers

First we see the four on one screen. How making, putting in, traversing and giving back differ between them is all in this example.

examples-en/ch92/tour.c

/* The life of four containers — made, filled, walked and given back.
   array, list, ring and map compared on one screen. */
#include <proven.h>

/* an intrusive list: the node lives inside the data */
typedef struct {
    int                id;
    proven_list_node_t link;      /* <- this one slot is the hook it hangs on the list by */
} task_t;

int main(void)
{
    proven_allocator_t alloc = proven_heap_allocator();

    /* ── (1) a growing array ─────────────────────────────────────── */
    proven_result_array_t ar = PROVEN_ARRAY_INIT(alloc, int, 2);
    if (!proven_is_ok(ar.err)) return 1;
    proven_array_t arr = ar.value;

    for (int i = 1; i <= 5; i++)
        (void)PROVEN_ARRAY_PUSH(&arr, int, i * i);

    proven_println("array  len={} cap={} elem_size={}",
                   PROVEN_ARG(arr.len), PROVEN_ARG(arr.cap), PROVEN_ARG(arr.elem_size));

    /* walked by index — no pointer is carried around */
    proven_print("       content:");
    for (proven_size_t i = 0; i < arr.len; i++)
        proven_print(" {}", PROVEN_ARG(*PROVEN_ARRAY_GET(&arr, int, i)));
    proven_println("");

    int last = 0;
    (void)PROVEN_ARRAY_POP(&arr, int, &last);
    proven_println("       pop -> {} (len {})", PROVEN_ARG(last), PROVEN_ARG(arr.len));
    PROVEN_ARRAY_DESTROY(&arr);

    /* ── (2) an intrusive list — not one allocation ──────────────── */
    task_t     items[3] = { { .id = 10 }, { .id = 20 }, { .id = 30 } };
    proven_list_t list;
    proven_list_init(&list);
    for (int i = 0; i < 3; i++)
        proven_list_push_back(&list, &items[i].link);

    proven_list_node_t *node, *tmp;
    proven_print("list   from the front:");
    PROVEN_LIST_FOR_EACH(node, &list) {
        task_t *t = PROVEN_LIST_ENTRY(node, task_t, link);
        proven_print(" {}", PROVEN_ARG(t->id));
    }
    proven_println("  (the node is inside the data, so no allocation is needed)");

    /* to unhook while walking, the SAFE version is used */
    PROVEN_LIST_FOR_EACH_SAFE(node, tmp, &list) {
        task_t *t = PROVEN_LIST_ENTRY(node, task_t, link);
        if (t->id == 20) proven_list_remove(node);
    }
    proven_print("       after removing 20:");
    PROVEN_LIST_FOR_EACH(node, &list) {
        proven_print(" {}", PROVEN_ARG(PROVEN_LIST_ENTRY(node, task_t, link)->id));
    }
    proven_println("");

    /* ── (3) a ring buffer — fixed size, the past is dropped ─────── */
    proven_result_ring_t rr = PROVEN_RING_INIT(alloc, int, 4);
    if (proven_is_ok(rr.err)) {
        proven_ring_t ring = rr.value;
        for (int i = 1; i <= 4; i++) (void)proven_ring_push(&ring, &i);

        int five = 5;
        proven_err_t full = proven_ring_push(&ring, &five);
        proven_println("ring   push when full -> err={} (it reports rather than overwrites)",
                       PROVEN_ARG((int)full));

        int v = 0;
        (void)proven_ring_pop(&ring, &v);
        proven_println("       pop -> {} (the one put in first)", PROVEN_ARG(v));
        proven_ring_destroy(&ring);
    }

    /* ── (4) a hash map — the key is owned or borrowed, as chosen ── */
    /* the map copies and owns the key (U8_OWNED) — a borrowing version exists too */
    proven_result_map_t mr = PROVEN_MAP_INIT_U8_OWNED(alloc, int, 8);
    if (proven_is_ok(mr.err)) {
        proven_map_t map = mr.value;
        int a = 1, b = 2;
        (void)proven_map_set_u8_owned(&map, PROVEN_LIT("alpha"), &a);
        (void)proven_map_set_u8_owned(&map, PROVEN_LIT("beta"), &b);

        const int *found = proven_map_get(&map,
                        (proven_map_key_t){ .str = PROVEN_LIT("beta") });
        proven_println("map    get(\"beta\") -> {}",
                       PROVEN_ARG(found ? *found : -1));
        proven_println("       get(\"absent\") -> {} (null when there is none)",
                       PROVEN_ARG((bool)(proven_map_get(&map,
                            (proven_map_key_t){ .str = PROVEN_LIT("absent") }) == nullptr)));
        proven_map_destroy(&map);
    }
    return 0;
}

Output

array  len=5 cap=8 elem_size=4
       content: 1 4 9 16 25
       pop -> 25 (len 4)
list   from the front: 10 20 30  (the node is inside the data, so no allocation is needed)
       after removing 20: 10 30
ring   push when full -> err=2 (it reports rather than overwrites)
       pop -> 1 (the one put in first)
map    get("beta") -> 2
       get("absent") -> true (null when there is none)
containermakingallocationtraversalgiving back
arrayPROVEN_ARRAY_INIT(alloc, T, n)growsby indexPROVEN_ARRAY_DESTROY
listproven_list_init(&l)nonePROVEN_LIST_FOR_EACHunnecessary
ringPROVEN_RING_INIT(alloc, T, n)once, fixedby poppingproven_ring_destroy
mapPROVEN_MAP_INIT_U8_OWNED(alloc, T, n)grows (rehashing)by key lookupproven_map_destroy

Table 93.1

That only the list has no allocation stands out — because the node lives inside the data. So a list is the only container that cannot fail for lack of memory, and it is especially loved in embedded work (chapter 94).

92.2 The growing array

proven_array_t is a byte buffer that knows the element size and alignment. Open it up and why the type is filled in by macros becomes clear.

typedef struct {
    proven_allocator_t alloc;       /* it remembers the allocator given at creation */
    proven_byte_t     *data;        /* the byte buffer */
    proven_size_t      len;         /* the number of elements held now */
    proven_size_t      cap;         /* the number of elements it can hold */
    proven_size_t      elem_size;   /* the size of one element */
    proven_size_t      align;       /* the element's alignment requirement */
} proven_array_t;

That the array remembers the allocator differs from strings. A string takes an allocator per operation (chapter 90), while an array takes one at creation and keeps it inside — making push pass an allocator every time it grows would make the code noisy. So the allocator is not given again at destruction either (PROVEN_ARRAY_DESTROY(&arr)).

C having no generics, the type is filled in by macros.

examples-en/ch92/arr.c

#include <proven.h>
#include <stdio.h>

int main(void)
{
    proven_allocator_t alloc = proven_heap_allocator();

    proven_result_array_t made = PROVEN_ARRAY_INIT(alloc, int, 4);
    if (!proven_is_ok(made.err)) {
        printf("array creation failed\n");
        return 1;
    }
    proven_array_t arr = made.value;

    for (int i = 1; i <= 6; i += 1) {          /* past a capacity of 4 it grows by itself */
        if (!proven_is_ok(PROVEN_ARRAY_PUSH(&arr, int, i * i))) {
            printf("push failed\n");
            PROVEN_ARRAY_DESTROY(&arr);
            return 1;
        }
    }

    printf("count: %zu\n", arr.len);
    for (size_t i = 0; i < arr.len; i += 1) {
        printf("%d ", *PROVEN_ARRAY_GET(&arr, int, i));
    }
    printf("\n");

    PROVEN_ARRAY_DESTROY(&arr);
    return 0;
}

Output

count: 6
1 4 9 16 25 36 

PROVEN_ARRAY_INIT(alloc, int, 4) means “an array to hold ints, initial capacity 4”, and PROVEN_ARRAY_PUSH writes the type again to check at compile time that it matches. At the fifth element, which exceeded the capacity of 4, the array grew by itself — and that growth happens through the allocator (exactly chapter 89′s rule; the array remembers the allocator it was given at creation).

Counter-example. Holding an element pointer and then pushing

int *first = PROVEN_ARRAY_GET_MUT(&arr, int, 0);
(void)PROVEN_ARRAY_PUSH(&arr, int, 42);   /* the buffer may move here */
*first = 7;                               /* writes at the old address — use after free */

When the array grows the contents move to a new buffer and a pointer to the old address becomes invalid. It is the form in which the use after free learned in chapter 45 appears on a container. The rule is one — after changing a container, obtain it again by index. The habit of carrying an index rather than a pointer becomes the defence here.

92.3 The intrusive list — linking without allocation

proven_list_t is an intrusive linked list — the node does not hold the data; rather a link field is planted inside the data struct. It amounts to putting one proven_list_node_t link; slot inside a struct made in chapter 46.

typedef struct proven_list_node_t {
    struct proven_list_node_t *next;
    struct proven_list_node_t *prev;
} proven_list_node_t;

typedef struct {
    proven_list_node_t head;      /* a sentinel pointing at itself */
} proven_list_t;

That head is a sentinel is this implementation’s knack. In an empty list head.next and head.prev point at head itself, and so not one “is it null” check appears in the insertion and deletion code — a pattern the Linux kernel long used.

What is good about it? There is nothing to allocate separately in order to put something in the list — because the node is the data. A list can be used even in an environment with no heap (chapter 94), and hanging the same object on two lists at once is a matter of keeping two link fields. The price is that the data struct must know about the list.

Getting back the other way is the problem, and one macro solves it.

task_t *t = PROVEN_LIST_ENTRY(node, task_t, link);

It is the macro that works the struct’s starting address back from the address of the link member — the offsetof seen in chapter 46 used here in the flesh. This one line returning from node to data is nearly the whole of the intrusive list.

There are two traversal macros.

macrowhat it doeswhen
PROVEN_LIST_FOR_EACH(it, &l)traverses from the frontwhen only reading
PROVEN_LIST_FOR_EACH_SAFE(it, tmp, &l)holds the next node in advance★ when removing while traversing

Table 93.2

The star matters. Remove a node during traversal and its next becomes meaningless, so the loop loses its way. The _SAFE edition turns with the next node already in hand, so removing the present node is safe. It is why the example used it when detaching item

It is worth knowing too that both macros require the iteration variables to be declared in advance (proven_list_node_t *node, *tmp;). The macro does not put a declaration in the for’s initialiser — a kernel practice carried on from the C89 days.

92.4 The ring buffer — a stream of fixed size

proven_ring_t is a fixed-size circular buffer. It is used where a producer and a consumer come and go, for the most recent N of a log, and for streams such as audio and sensor samples where what has passed may be thrown away. The size being fixed, what to do when it is full is part of the contract — and here too the default is to report rather than quietly overwrite (the example’s err=2 is that confirmation).

There are only push and pop, and both copy the element. Putting in gives an address, and taking out gives the address of the place to receive it.

int v = 42;
proven_err_t e = proven_ring_push(&ring, &v);     /* copies the value in */
int out;
e = proven_ring_pop(&ring, &out);                 /* copies the value out */

Thanks to this design no pointer into an element inside the ring leaks outward — holding a pointer in a circular buffer and having that place overwritten is a classic accident, and it was blocked by the interface.

92.5 The hash map, and data structures under attack

proven_map_t is an open-addressing hash map. Keys are integers or byte sequences, and string keys have two modes — a borrowed key (the caller keeps the bytes alive) and an owned key (the map copies and holds it). Chapter 89′s owning-borrowing distinction appears here as it is too.

The kind of key is settled at creation.

creation macrokeycaution
PROVEN_MAP_INIT_INT(alloc, T, n)an integer (key.id)the fastest
PROVEN_MAP_INIT_U8_BORROWED(alloc, T, n)a string (borrowed)★ the key bytes must outlive the map
PROVEN_MAP_INIT_U8_OWNED(alloc, T, n)a string (copied)a copy cost on insertion. safe in exchange

Table 93.3

The key is passed as one union — .id for an integer, .str for a string.

proven_map_key_t k = { .str = PROVEN_LIT("beta") };
const int *v = proven_map_get(&map, k);      /* null if absent */

Lookup answers with null. The reason it returns a pointer rather than a bundle is that “absent” is not a failure but a normal answer (distinct from chapter 87′s error branches). And that pointer points inside the map, so it becomes invalid the next time the map grows — the same rule as with arrays.

There are three functions for putting in. proven_map_set (general), proven_map_set_u8_owned (copying a string key in), and proven_map_set_with_scratch (giving the temporary memory separately). The last is used when the temporary buffers of internal work such as rehashing are to be obtained from another allocator — a device for keeping dead memory from piling up when running a map on an arena.

examples-en/ch92/wordcount.c

#include <proven.h>
#include <stdio.h>

/* Words counted, sorted and printed — a map, an array and a sort at once */
typedef struct { proven_u8str_view_t word; int count; } entry_t;

/* a comparator must be a total order: ties are broken consistently too (avoiding chapter 53's counterexample) */
static int by_count_desc(const void *a, const void *b)
{
    const entry_t *x = a, *y = b;
    if (x->count != y->count) return (x->count < y->count) - (x->count > y->count);

    proven_size_t n = x->word.size < y->word.size ? x->word.size : y->word.size;
    int c = proven_memcmp(x->word.ptr, y->word.ptr, n);
    if (c != 0) return c;
    return (x->word.size > y->word.size) - (x->word.size < y->word.size);
}

int main(void)
{
    proven_allocator_t alloc = proven_heap_allocator();
    const char *text = "the quick fox the lazy dog the fox";

    /* a map with string keys: by default a keyed hash that stands up to HashDoS */
    proven_result_map_t made_map =
        proven_map_create(alloc, 16, PROVEN_KEY_TYPE_U8_OWNED, sizeof(int), alignof(int));
    if (!proven_is_ok(made_map.err)) return 1;
    proven_map_t counts = made_map.value;

    proven_u8str_view_t all = proven_u8str_view_from_cstr(text);
    proven_u8str_view_t space = proven_u8str_view_from_cstr(" ");
    proven_size_t start = 0;

    for (;;) {
        proven_size_t hit = proven_u8str_view_find(all, start, space);
        proven_size_t end = (hit == PROVEN_INDEX_NOT_FOUND) ? all.size : hit;
        proven_u8str_view_t w = proven_u8str_view_slice(all, start, end - start);

        const int *seen = proven_map_get(&counts, (proven_map_key_t){ .str = w });
        int next = seen ? *seen + 1 : 1;
        if (!proven_is_ok(proven_map_set(&counts, (proven_map_key_t){ .str = w }, &next)))
            break;

        if (hit == PROVEN_INDEX_NOT_FOUND) break;
        start = hit + 1;
    }
    printf("distinct words: %zu\n", counts.len);

    /* the counts gathered into an array and sorted */
    proven_result_array_t made_arr = PROVEN_ARRAY_INIT(alloc, entry_t, 8);
    if (!proven_is_ok(made_arr.err)) return 1;
    proven_array_t list = made_arr.value;

    const char *words[] = {"the", "quick", "fox", "lazy", "dog"};
    for (size_t i = 0; i < sizeof words / sizeof words[0]; i++) {
        proven_u8str_view_t w = proven_u8str_view_from_cstr(words[i]);
        const int *c = proven_map_get(&counts, (proven_map_key_t){ .str = w });
        entry_t e = { .word = w, .count = c ? *c : 0 };
        if (!proven_is_ok(PROVEN_ARRAY_PUSH(&list, entry_t, e))) break;
    }

    proven_array_sort(&list, by_count_desc);   /* O(n log n) guaranteed even in the worst case */
    for (size_t i = 0; i < list.len; i++) {
        const entry_t *e = PROVEN_ARRAY_GET(&list, entry_t, i);
        printf("  %.*s = %d\n", (int)e->word.size, (const char *)e->word.ptr, e->count);
    }

    PROVEN_ARRAY_DESTROY(&list);
    proven_map_destroy(&counts);
    return 0;
}

Output

distinct words: 5
  the = 3
  fox = 2
  dog = 1
  lazy = 1
  quick = 1

This one example contains all of this chapter’s tools — it counts with a map, gathers into an array, sorts and prints. Chapter 90′s views were used to cut the words, so string copying happens only once, when the map owns the key.

In practice. HashDoS — when hash maps became a target of attack

In 2011 several web frameworks collapsed at once through the same vulnerability. If an attacker chose keys that crowd into the same bucket and sent thousands of them as parameters in one request, insertion that had been 𝑂(1) on average became 𝑂(𝑛) and the whole degenerated to 𝑂(𝑛2), so a few requests stopped a server. The cause was that the hash function was public and collisions could be calculated.

Today’s prescription is a keyed hash — draw a random secret per process and mix it into the hash, and the attacker cannot precompute collisions. It is why proven’s proven_map_create uses SipHash-2-4 and a random seed by default for string keys, and conversely why there is a separate proven_map_create_trusted using the faster FNV-1a for cases where the keys all come from my own code. The default is the safe side, and the fast side states itself in the name — the principle met repeatedly in this part.

Q. How much slower is a keyed hash? And where does the random secret come from if there is no OS?

A. SipHash is slower than FNV-1a, but by an amount proportional to the string length, and the share hash computation takes in the whole of a map operation is mostly not large. The random secret is drawn once from the operating system’s source of randomness — and in an environment with no OS (chapter 94) there is nowhere to draw it from, so it falls back to FNV-1a. The library does not hide this fact but writes it in the documentation, and the grounds are clear: where there is no attacker there is no need for an attacker model either. There exists no outsider choosing keys inside your firmware.

92.6 Sorting with a worst-case guarantee

The two problems seen in chapter 85 — the unchecked comparator, and the algorithm that collapses in the worst case — are treated together here.

proven_array_sort is introsort. It begins as a fast quicksort and, if the recursion becomes too deep, switches to heapsort. So the average is as fast as quicksort and 𝑂(𝑛log𝑛) is guaranteed even in the worst case — the complexity attack seen in chapter 85 does not work. To carry the header’s wording over as it is: “𝑂(𝑛log𝑛) is not an average but a guarantee.”

On the comparator side the language cannot help, so the contract is stated in the documentation and in examples. The example’s by_count_desc is the model — descending by count, and ties broken by the word. It contrasts exactly with chapter 85′s counterexample (the comparator that sees only the first letter).

A common misconception. “Ties may be handled however you like”

They may not. A comparator must form a total order — 0 if equal, consistently greater and less, and the signs of cmp(a,b) and cmp(b,a) must be opposite. Break this and the result is not merely jumbled; depending on the implementation it may even trespass outside the array (because the partitioning algorithm judges its boundaries from the comparison results). A comparator that returns anything at all for ties is therefore a bug, not a taste.

92.7 Bytes into letters — hashes and encodings

We note the remaining tools in the same box too.

Recap

Containers in summary.

toolshapewhere it fitscaution
arraya growing contiguous arrayan ordered listpointers invalid after growth
listan intrusive linked listjoining without allocationthe data holds the link
ringfixed-size circularstreams, the most recent Nthe contract when full
mapopen-addressing hashfinding by keychoose whether the key is owned
array_sortintrosortsorting anythingthe comparator must be a total order
four hashesby purposemap, integrity, cryptography, anti-attackdo not mix the purposes

Table 93.4

That is as far as the world of pure computation — it all runs even with no operating system. In the next chapter we go outside. Files, streams, time, random numbers — the places that touch the OS.