Home | Manual | Static Analysis | Playground

Cake - C23 and Beyond

Conventions and Contracts in C

Ownership, nullability and initialization, written down and checked

The examples were checked with Cake 0.15.28.

Preface

C trusts the programmer, and most of its bugs come from that trust: a null pointer, a double free, a value never set. Every C program already follows conventions (this pointer is never null, that function frees what it receives), but they live in comments or in the programmer's head. The annotations described here write those conventions down as contracts in the declarations, and a flow analysis checks both sides of every contract. Removing them does not change what the program means; they exist only to be checked, as _Noreturn and [[nodiscard]] already are.

This matters more as more C is written by AI tools: a model reads the contracts, and when it breaks one, the analysis reports it in a single line it can act on.

Chapter 1 is a quick tour, in the style of Kernighan and Ritchie; the chapters after it go over the same ground more carefully, and Appendix A has the precise, numbered rules. Every example can be compiled with cake or run in the playground.

Chapter 1 - A Tutorial Introduction

Let us begin with a quick introduction. Our aim is to show the essential ideas through real programs, without getting bogged down in details, rules and exceptions.

1.1 Getting Started

The checks are off by default. A file turns them on with this line at its beginning:

#pragma safety enable

In that file, the compiler reads the annotations _Nullable, _Nonnull, _Owner, _Out, _Dtor, _Clear, _Uninitialized and _View, and warns when the code does not agree with them.

Before any rule, a question. You are given only this declaration; the body of the function is somewhere you cannot see:

struct point* find_point(int id);

Can you use the pointer it returns without testing it? If you assume it is never null and you are wrong, the program may crash where it uses the pointer. If you assume it may be null and you are wrong, you wrote a test that never fails. We are choosing the default, the reading used when the declaration says nothing, and the better default is the one whose mistake costs less: for a result, it may be null.

Now a second declaration, and again you cannot see the body:

void point_print(struct point* p);

Can you pass it a null pointer? If you assume it accepts null and you are wrong, the program may crash inside point_print, if it uses p without testing it. If you assume it needs a valid pointer and you are wrong, you tested before the call when you did not have to. Choosing the default the same way, the better reading for a parameter is: do not pass null.

So what comes out of a function may be null, and what goes in must not be. To say otherwise, the declaration uses _Nullable on a parameter or _Nonnull on a result. These defaults are also the ones that need the fewest annotations: most parameters are never null and most results may be. In Cake's own sources, about 87% of pointer parameters are non-null and about 90% of pointer results may be null, and moving to these defaults removed about 72% of the nullability annotations.

A member is like a result: it was written somewhere else, and you read it later. A local variable needs no rule at all, because the analysis sees every assignment to it.

Declaration When nothing is said To say the opposite
parameter non-null _Nullable
result may be null _Nonnull
member may be null _Nonnull
local variable followed by the analysis

Here is a member that promises more than the default:

#pragma safety enable

struct person {
    const char* _Nonnull name; /* never null */
    const char* nickname;      /* may be null */
};

void print(const char* s);

void f(const struct person* p)
{
    print(p->name);            /* ok */
}

void g(struct person* p)
{
    p->name = 0;               /* warning 35 null assigned to non-nullable */
}

A nullable pointer may be null; a non-nullable pointer is never null. That is the whole idea, and it is surprisingly far-reaching. f can use p->name without a test because the declaration of struct person promises it is there; g breaks that promise, and is told so. nickname says nothing, so it may be null, and every reader must test it.

1.2 Testing Before Use

A nullable pointer must be tested before it is used.

#pragma safety enable

struct point { int x; int y; };

struct point* find_point(int id);

int main()
{
    struct point* p = find_point(1);
    p->x = 1;     /* warning 33 possible null dereference */
    if (p)
        p->x = 1; /* ok, narrowed to non-null */
}

Inside the if, the analysis knows p is not null. We say the condition narrows p. Nothing special has to be written for this; the test the programmer would write anyway is enough.

1.3 Owners

Some pointers are responsible for what they point to. A file returned by fopen must reach fclose exactly once. Such a pointer is an owner, and it is declared with _Owner.

#pragma safety enable

typedef struct _FILE FILE;
FILE* _Owner fopen(const char* name, const char* mode);
int fclose(FILE* _Owner p);

void f(void)
{
    FILE* _Owner f = fopen("a.txt", "r");
    if (f == 0) return;
    FILE* _Owner g = f; /* g is the owner now */
    fclose(g);
}

The _Owner on f can be left out. fopen returns an owner, and a local initialized with it must take the responsibility, or the file would be lost; so FILE* f = fopen("a.txt", "r"); declares an owner too. This holds only for the result of a call: a local initialized from an owner object is a view of it, not an owner (A5.3), so g above keeps its _Owner.

Assignment between owners moves the responsibility: after g = f, it is g that must be closed, and f must not be. Passing an argument to an _Owner parameter, as in fclose(g), is also a move. After fclose the file no longer exists, so using f again is a mistake:

#pragma safety enable

typedef struct _FILE FILE;
FILE* _Owner fopen(const char* name, const char* mode);
int fclose(FILE* _Owner p);

void f(void)
{
    FILE* _Owner f = fopen("a.txt", "r");
    if (f == 0) return;
    fclose(f);
    fclose(f); /* warning 32 'f' was moved */
}

Experiment 1-1. In the example above, the first fclose(f) is removed. Open it in the playground and observe the result.

So is forgetting to move it at all:

#pragma safety enable

typedef struct _FILE FILE;
FILE* _Owner fopen(const char* name, const char* mode);
int fclose(FILE* _Owner p);

void f(void)
{
    FILE* _Owner f = fopen("a.txt", "r");
    if (f == 0) return;
}  /* warning 29 'f' still owns its resource */

1.4 Uninitialized Local Variables

A local variable that was not initialized has no value. Reading it is a mistake:

#pragma safety enable

int main()
{
    int i;
    int j = i; /* warning 30 assigning a possibly uninitialized object 'i' */
}

Often a variable is left uninitialized on purpose, because a function will fill it. That function declares its parameter with _Out:

void point_init(_Out struct point* p);

This declaration is a contract between point_init and its callers. Each side is checked separately.

The caller. It may pass an object that holds nothing yet, and after the call it may read every member:

#pragma safety enable

struct point { int x; int y; };

void point_init(_Out struct point* p);

int main()
{
    struct point pt;  /* uninitialized */
    point_init(&pt);  /* ok */
    int x = pt.x;     /* ok, point_init wrote it */
}

The implementation. It may not read *p, and it must write every member before it returns. Forgetting one is reported here, in point_init, not at the call:

#pragma safety enable

struct point { int x; int y; };

void point_init(_Out struct point* p)
{
    p->x = 0;
} /* warning 71 _Out parameter 'p->y' is not initialized at exit */

Every annotation works this way: the caller is checked against what the declaration requires, and the implementation against what it promises.

1.5 One Function at a Time

The analysis never looks inside a function that is called. It reads the declaration and believes it. That is why the annotations matter: they are the contract between the function and its callers, and each side is checked against it separately.

When a declaration says nothing, the caller must assume the worst. Here g might have released x.text, so p may point to memory that no longer exists:

#pragma safety enable

void free(void* _Owner _Nullable p);

struct X { char* _Owner text; };

struct X f();
void g(struct X* p);

int main()
{
    struct X x = f();
    char* p = x.text;
    g(&x);
    if (p)
    {
        char c = *p; /* warning 31 lifetime of '*p' has ended */
    }
    free(x.text);
}

Experiment 1-2. In the example above, the declaration of g is changed to void g(const struct X* p). Open it in the playground and observe the result.

If g is declared void g(const struct X* p), it promises not to touch x, and the warning goes away. We will come back to this in Chapter 4.

A file-scope object is treated the same way. To each function it is a parameter that does not appear in the list: the function knows only what its declaration says, and must leave it valid when it returns.

Chapter 2 - Pointers and Nullability

2.1 Where _Nullable Goes

_Nullable and _Nonnull apply to the pointer itself, so they go after the *, where const would go for a constant pointer. Each declaration has a default, and only the exception is written:

Declaration Default Exception
parameter non-null at entry; inside the function it is a local _Nullable
result nullable _Nonnull
member nullable _Nonnull
local variable nullable, its value is followed by the analysis _Nonnull keeps it non-null
void f(struct X* _Nullable p); /* p may be null */
struct X* _Nonnull g(void);    /* never returns null */
struct Y { struct X* _Nonnull x; };

Writing the default is redundant and is reported (warning 122). A cast has no nullability, because the analysis follows the value.

2.2 A Pointer to a Pointer

Ask the question of 1.1 again, one level deeper. You see only this declaration:

void next_token(struct token** pp);

pp is a parameter, so it is not null. But *pp is a pointer that someone else wrote: the caller before the call, the function during it. To whoever reads it, it is like a member: written somewhere else, read later. So the same answer applies: it may be null. The function tests it before use, and so does the caller after the call.

This holds at every level of a type: a pointer reached through another pointer may be null, unless the declaration says otherwise with _Nonnull. The default is part of the type, so struct token** means the same in a parameter, a result, a member or a local.

void next_token(struct token* _Nonnull* pp); /* *pp is never null: the function may use it    */
                                             /* without a test, and must not write null to it */

With _Nonnull both sides are held to it: the caller must pass a non-null *pp, the function must not store null into it, and after the call the caller may use it without a test.

2.3 Leaving the Path

Any way of leaving a path narrows what comes after it, not only an if around the code. An early return is the most common:

#pragma safety enable

struct point { int x; int y; };

void point_print(const struct point* _Nullable p)
{
    if (p == 0) return;
    int x = p->x;   /* ok, p is non-null here */
}

break, continue, goto and throw work the same way.

2.4 What a Call Forgets

A test holds only until something may change the object. A function that receives a non-const pointer may change every object reachable from it, so after the call a nullable member must be tested again.

#pragma safety enable

struct person {
    const char* _Nonnull name;
    const char* nickname;
};

void print(const char* s);
void update(struct person* p);

void f(struct person* p)
{
    if (p->nickname)
    {
        update(p);
        print(p->nickname); /* warning 35 may be null */
    }
}

With void update(const struct person* p) the test would still hold. The analysis assumes that nothing reachable from *p changes (Section 6.2).

Because of this, const improves the static analysis: the caller keeps what it knew. That is why the implementation of a function is warned when a pointer parameter could be const:

#pragma safety enable

struct point { int x; int y; };

int point_sum(struct point* p) /* warning 82 'p' could be const */
{
    return p->x + p->y;
}

2.5 Objects Under Construction

There is one moment when a non-nullable member may be null: while the object is being built. struct person p = {} sets every member to zero, name included; so does calloc for an object on the heap. That is allowed, but the object may not be used until name is set.

#pragma safety enable

struct person {
    const char* _Nonnull name;
    const char* nickname;
};

void use(const struct person* p);

void f1(void)
{
    struct person p = {}; /* ok, p.name is null for now */
    p.name = "Ana";
    use(&p);              /* ok */
}

void f2(void)
{
    struct person p = {};
    use(&p);              /* warning 35 'p.name' may be null */
}

Experiment 2-1. In f2, p.name is set before use(&p). Open it in the playground and observe the result.

Chapter 3 - Ownership

3.1 Two Kinds of Pointers

An owner is an object that holds a link to another object. That link is the principal one: ownership of the object is transferred only through it. Other pointers to the same object are views; they can look at it, but cannot move it.

If the principal link is broken, by overwriting the owner or by letting it go out of scope, nothing is left that can release the object. Its control is lost.

At run time an owner and a view are the same plain pointer. The difference is only in what the program promises to do with them.

The rules are short:

3.2 Assignment

Assigning one owner to another transfers the ownership. Assigning an owner to a non-owner does not: the non-owner becomes a view, and the original owner still has to be moved. When the owner is released, the view is no longer valid, and the analysis follows it.

#pragma safety enable

void free(void* _Owner _Nullable p);

struct X { int i; };

void f(struct X* _Owner p)
{
    struct X* view = p;
    free(p);
    view->i = 1; /* warning 31 lifetime of 'view->i' has ended */
}

After a move between owners, the source can still be used to read the object. A move only changes who must release the object, not whether it is alive. When the move is visible in the same function, the analysis knows the object is still alive and accepts the use. When the owner is moved into a function, the analysis cannot see what the function did with it, so it assumes the lifetime may have ended:

#pragma safety enable

void destroy(char* _Owner p);
char* _Owner strdup(const char* s);
int puts(const char* s);

void f(void)
{
    char* _Owner s1 = strdup("hello");
    if (s1 == 0) return;
    char* _Owner s2 = s1;
    puts(s1);    /* ok, the object is alive */
    destroy(s2);
    puts(s1);    /* warning 31 lifetime of '*s1' has ended */
}

3.3 Owner Members

A struct with an _Owner member owns what that member points to. Overwriting the member without releasing it first loses the object:

#pragma safety enable

char* _Owner strdup(const char* s);
void free(void* _Owner _Nullable p);

struct X { char* _Owner text; };

void f(struct X* p)
{
    p->text = strdup("a"); /* warning 26 'p->text' discarded without releasing it first */
}

void x_destroy(_Dtor struct X* p)
{
} /* warning 29 'p->text' still owns its resource */

3.4 Two Resources

A pointer to a struct owns two things: the memory, and the object stored in it. The object may own other objects through its _Owner members, and those may own others. Owners and the objects they point to form a chain, and the owner at the top is responsible for the whole chain. free takes a void*; it knows nothing about the members and releases only the memory. Everything below it in the chain must be released first. If a link of the chain is lost, everything that hangs from it is lost too.

#pragma safety enable

void free(void* _Owner _Nullable p);

struct X { char* _Owner text; };

void x_delete_bad(struct X* _Owner p)
{
    free(p);       /* warning 29 'p->text' still owns its resource */
}

void x_delete(struct X* _Owner p)
{
    free(p->text); /* the object */
    free(p);       /* the memory */
}

Experiment 3-1. In x_delete, the two calls to free are swapped. Open it in the playground and observe the result.

3.5 Heap Objects Must Have an Owner

A function that returns an owner hands the object to its caller. Ignoring the result loses it.

#pragma safety enable

char* _Owner make(void);

void f(void)
{
    make(); /* warning 9 ignoring the result of _Owner type */
}

Chapter 4 - Functions and Their Contracts

4.1 The Plain Pointer Parameter

The most common parameter in C is struct X* p. With no annotation, it means:

The function may release an owner member, but it must put something back before it returns:

#pragma safety enable

char* _Owner strdup(const char* s);
void free(void* _Owner _Nullable p);

struct X { char* _Owner text; };

void f(struct X* p)
{
    free(p->text);
}                          /* warning 72 'p->text' was moved but never reassigned */

void g(struct X* p)
{
    free(p->text);
    p->text = 0;           /* ok */
}

void h(struct X* p)
{
    free(p->text);
    p->text = strdup("b"); /* ok */
}

A const pointer promises more: nothing reachable from *p changes, so the caller may keep using what it knew before the call (Section 6.2).

4.2 Output Parameters: _Out

_Out says the function initializes *p and does not read it. The function must write every member on every path, including the paths that fail.

#pragma safety enable

struct point { int x; int y; };

void point_init(_Out struct point* p)
{
    p->x = 0;
} /* warning 71 _Out parameter 'p->y' is not initialized at exit */

Reading *p before writing it is reading nothing:

#pragma safety enable

struct point { int x; int y; };

void point_init(_Out struct point* p)
{
    int i = p->x; /* warning 30 assigning a possibly uninitialized object 'p->x' */
    p->x = 0;
    p->y = 0;
}

4.3 Destructors: _Dtor

A destructor releases everything the object owns and leaves its memory to the caller. _Dtor says exactly that: on every path, every owner member is moved; after the call, the object holds nothing.

#pragma safety enable

char* _Owner strdup(const char* s);
void free(void* _Owner _Nullable p);

struct X { char* _Owner text; };

void x_destroy(_Dtor struct X* p);

void f(void)
{
    struct X x = {};
    x.text = strdup("a");
    x_destroy(&x);
    free(x.text); /* warning 30 possibly uninitialized 'x.text' */
}

A destructor must also accept an object that was only half built. If a constructor fails halfway, some members are still null, even members that are declared non-nullable. Inside a _Dtor function every pointer member may be null.

#pragma safety enable

_Clear void* _Owner calloc(unsigned long n, unsigned long size);
char* _Owner strdup(const char* s);
void free(void* _Owner _Nullable p);

struct X {
    char* _Nonnull _Owner name;
    char* _Nonnull _Owner email;
};

void x_destroy(_Dtor struct X* p)
{
    free(p->name);    /* ok, may be null */
    free(p->email);   /* ok, may be null */
}

struct X* _Owner x_create(const char* name, const char* email)
{
    struct X* _Owner p = calloc(1, sizeof * p);
    if (p == 0)
        return 0;

    char* _Owner s = strdup(name);
    if (s == 0)
    {
        free(p);
        return 0;
    }
    p->name = s;

    s = strdup(email);
    if (s == 0)
    {
        x_destroy(p); /* ok, p->email is still null */
        free(p);
        return 0;
    }
    p->email = s;

    return p;
}

Experiment 4-1. In the example above, x_destroy(p); is removed from the second error path. Open it in the playground and observe the result.

Experiment 4-2. In the same example, x_destroy takes a plain struct X* p instead of _Dtor struct X* p. Open it in the playground and observe the result.

4.4 Reset: _Clear

_Clear is a destructor that leaves the object zeroed and ready to be used again.

#pragma safety enable

char* _Owner strdup(const char* s);
void free(void* _Owner _Nullable p);

struct X { char* _Owner text; int n; };

void x_clear(_Clear struct X* p);

void f(void)
{
    struct X x = {};
    x.text = strdup("a");
    x_clear(&x);          /* x.text released, x is all zero */
    x.text = strdup("b"); /* ok, x.text is zero */
    free(x.text);
}

On a result, _Clear describes memory like calloc gives: every member is zero. _Uninitialized describes memory like malloc gives: nothing is set.

#pragma safety enable

void free(void* _Owner _Nullable p);
void use(int i);

struct X { int a; int b; };

_Uninitialized struct X* _Owner x_alloc(void);

void f(void)
{
    struct X* _Owner p = x_alloc();
    if (p)
    {
        p->a = 1;
        use(p->a); /* ok, written first */
        use(p->b); /* warning 30 'p->b' is uninitialized */
        free(p);
    }
}

4.5 Passing by Value Without Moving: _View

A struct passed by value moves its owner members into the function. With _View nothing is moved: the function may only look at them, and the caller still owns them.

#pragma safety enable

void free(void* _Owner _Nullable p);

struct X { char* _Owner text; };

void show(_View struct X x);

void f(void)
{
    struct X x = {};
    show(x);      /* not moved */
    free(x.text); /* ok, x is still the owner */
}

4.6 Putting Annotations Together

Annotations combine, and each keeps its own meaning. _Uninitialized struct X* _Owner _Nullable F(void) returns a pointer that may be null, that the caller must free, and whose object holds nothing yet. That is a precise description of malloc.

4.7 Parameters That May Be the Same Object: restrict

Two pointer parameters of the same type may point to the same object: the caller is free to pass f(a, a). The implementation must work in that case too. Both hypotheses are analysed together: at the entry of the function, the second parameter may point to its own object or to the object of the first. A problem that only appears when they are the same object is reported with warning 100. A test such as p == q is false in one hypothesis and true in the other, and both branches are analysed.

#pragma safety enable

void free(void* _Owner _Nullable p);

struct X { int i; };

void f(struct X* _Owner p, struct X* p2)
{
    free(p);
    p2->i = 1; /* warning 100: wrong when p2 is p */
}

restrict says the parameters never point to the same object. The implementation is then not checked for that case. The check moves to the caller: at each call, the arguments are compared with the values the analysis knows for them, and two arguments that may point to the same object, when one goes to a restrict parameter, are reported with warning 101. This includes the same object reached through different pointers, as in q = a; copy(a, q);.

struct X { int i; };

void copy(struct X* restrict dest, const struct X* src);

void f(struct X* a)
{
    copy(a, a); /* warning 101 */

    struct X* q = a;
    copy(a, q); /* warning 101: same object through another pointer */
}

4.8 The Result May Be an Argument

A pointer returned by a call may be a new object or one of its pointer arguments with the same pointed type (any argument when the result is void* or char*). The declaration of first does not say which, so both are hypotheses. A problem that only appears under the hypothesis that they are the same object is reported in that case.

#pragma safety enable

void* _Owner malloc(unsigned long n);
void free(void* _Owner _Nullable p);

int* _Nonnull first(int* p);
int* _Nonnull copy_of(int* restrict p);

int f(void)
{
    int* _Owner p = malloc(sizeof(int));
    if (!p) return 0;
    *p = 1;

    int* q = first(p);
    int* r = copy_of(p); /* restrict: r is not p */
    free(p);

    int v = *r;
    /* warning 31: in case first(p) == p */
    return v + *q;
}

A restrict parameter is never returned, and the implementation is checked for it: returning a pointer to the object of a restrict parameter is warning 102. An argument passed to an _Owner parameter is kept alive by the callee, so a pointer returned to it stays valid.

Chapter 5 - How the Analysis Sees a Program

5.1 Paths

The analysis reads a function the way the machine would run it, but it follows every path at once. At each point it knows the set of things each object may be: null or not, which value, owned or moved. A warning is a question asked of that set. "Can p be null here?" If any path says yes, the warning is reported.

Where paths meet, after an if, at a label, after a loop, the sets are joined:

#pragma safety enable

void f(int x)
{
    int a = 1;
    if (x)
        goto L;
    a = 2;
L:
    compile_assert(a == 1 || a == 2);
}

compile_assert fails to compile unless its condition holds on every path. It is the most direct way to ask the analysis what it knows.

5.2 Paths That End

A path that returns does not arrive anywhere. Code after it in the same block is never reached, and the analysis says so.

#pragma safety enable

void f(int x)
{
    int a = 1;
    if (x)
    {
        return;
        a = 99; /* warning 68 */
    }
    compile_assert(a == 1);
}

5.3 Loops

A loop that may run zero times may leave a variable unset. When the first test of the condition is known to be true, the loop runs at least once, and the analysis knows it.

#pragma safety enable

void f(const char* s);

void g(int n)
{
    char* p;
    for (int h = 0; h < 1; h++) p = "";
    f(p);
}

void h(int n)
{
    char* p;
    for (int h = 0; h < n; h++) p = "";
    f(p); /* warning 30 */
}

5.4 One Report per Fact

When the same mistake is reachable from many paths, it is reported once. The message says how many other paths lead to it.

#pragma safety enable

void use(int* p);
int g;

void f(int a, int b)
{
    int* p = &g;
    if (a) p = 0;
    else if (b) p = 0;
    use(p); /* warning 35 */
}

Experiment 5-1. Three more else if branches set p to null. Open it in the playground and observe the result.

5.5 The Entry Flow

A function is analysed as if some code ran before it: the code of the caller, which the analysis does not see. That code is a set of ifs outside the function. Their decisions were made outside, so the body starts with both sides of each one, and they never join: the continuation is unknown.

new() below is any valid object (local, static or allocated, it is not known); it never fails. For

void f(int* _Nullable p1, int* _Nullable p2);

the entry flow is

if (/* p1 and p2 are the same */)
{
    if (/* not null */) p1 = new(); else p1 = 0;
    p2 = p1;     /* both null, or both the same object */
}
else
{
    if (/* not null */) p1 = new(); else p1 = 0;
    if (/* not null */) p2 = new(); else p2 = 0;
}

The if (not null) is _Nullable: without _Nullable it is only p1 = new();. The if (same) exists for two pointers that may point to the same object (section 4.7); restrict removes it.

With three parameters each one may be the same as any parameter before it:

void f(struct X* p1, struct X* p2, struct X* p3);
p1 = new();

if (/* p2 and p1 are the same */) p2 = p1;
else p2 = new();

if (/* p3 and p1 are the same */) p3 = p1;
else if (/* p3 and p2 are the same */) p3 = p2;
else p3 = new();

These are the 5 ways of grouping three pointers into objects: all the same, p1 == p2, p1 == p3, p2 == p3, all different. With _Nullable, each p = new(); becomes if (not null) p = new(); else p = 0;.

A restrict parameter is different from all the others, restrict or not; the others may still be the same object:

void f(struct X* restrict p1, struct X* p2, struct X* p3);
p1 = new();
p2 = new();

if (/* p3 and p2 are the same */) p3 = p2;
else p3 = new();

Pointers to different types are never the same object:

void f(struct X* p1, struct Z* p2);
p1 = new();
p2 = new();
``` A pointer parameter and a file scope
object the function uses have the same `if (same)`.

A call has the same entry flow for its result, made at the call instead of
at the root of the function:

```c
int* find(int* p);
int* r = find(a);
/* is */
if (/* r and a are the same */) r = a;
else if (/* not null */) r = new(); else r = 0;

The analysis does not choose one side; it follows them all, as it follows the two sides of an if.

All the cases:

Object Entry flow
parameter T* p p = new();
parameter T* _Nullable p if (not null) p = new(); else p = 0;
_Nullable T* p p = new();, and pointers read through it are _Nullable
_Out T* p p = new();, *p uninitialized
T** p p = new();, *p by the same rules
int i, other scalars any value
struct X s each member by these rules
two pointers p, q to the same type if (same) q = p; around them, except when one is restrict, both are _Owner, or both only read through const
pointer p and a used file scope object g if (same) p = &g;, except when p is restrict or _Owner
file scope object any value allowed by its type
result of T* _Nonnull f(...) r = new(); at the call
result of T* f(...) if (not null) r = new(); else r = 0; at the call
result _Clear / _Uninitialized *r zero / uninitialized
result and a pointer argument a if (same) r = a;, when *r and *a have the same type or r is void* or char*, except when a's parameter is restrict or _Owner, or r is _Owner
member x->m, first read if (not null) x->m = new(); else x->m = 0;
_Nonnull member x->m, first read x->m = new();
#pragma safety enable

void free(void* _Owner _Nullable p);
char* _Owner strdup(const char* s);

struct box { char* _Owner text; };

void box_copy(struct box* dest, const struct box* source)
{
    free(dest->text);
    dest->text = source->text ? strdup(source->text) : nullptr; /* warning 31 */
}

With different boxes the function is correct. But box_copy(&b, &b) is a valid call, and then source->text was freed one line before: the classic self-assignment bug. Nothing in the function looks wrong until both hypotheses are followed. The fix is to handle the case (if (dest == source) return;) or to rule it out with restrict, which moves the obligation to the caller.

The hypotheses are made where they start: at the entry of the function for parameters, at the call for a result. Then they are paths like any other, in the same pass. A problem that only one hypothesis has is reported with it: "in case q == p: ...". restrict removes a hypothesis (sections 4.7 and 4.8); Appendix B has the details.

5.6 Seeing the Paths: static_debug

static_debug() prints the paths the analysis is following at that point, and the values each one gave to each object. static_debug(x) prints only x. Here are the two sides of the if (same) of section 5.5:

#pragma safety enable
void f(int* p, int* q)
{
    *p = 1;
    *q = 2;
    static_debug(*p);
}
main.c:6:5: note: static_debug
root  <- current
├── q != p #1
│       *p  == 1         line 4
└── q == p #1
        *p  == 2         line 5

When p and q are different objects (q != p), *p is 1. When they are the same object (q == p), the write through q made it 2. So after this point *p is 1 or 2. Each value is printed under the path where it was set, with the line that set it. The manual (section 11.12) has the full notation.

Chapter 6 - Limitations

The rules describe what a declaration can say today. Some programs are correct but cannot be described, and some bugs cannot be seen. This chapter lists the ones a programmer will meet first.

6.1 Conditional Contracts

A conditional contract holds only under a condition, usually the result of the call. The best known is realloc: ownership of its argument moves only if the call succeeds. Such contracts cannot be written yet. What is missing is only a syntax to state them: the flow analysis already tracks states that depend on the result of a call, and would follow such a contract as it follows the existing ones. Where the analysis cannot follow, the programmer marks the line with /* lint N */, which says "warning N is expected here", silences it, and complains if it does not happen.

#pragma safety enable

void* _Owner realloc(void* _Nullable p, unsigned long size);
void free(void* _Owner _Nullable p);

void f(int* _Owner _Nullable p)
{
    int* _Owner q = realloc(p, 10 * sizeof(int));
    if (q == 0)
    {
        free(p); /* failed: p still owns the memory */
        return;
    }
    p = q;       /* lint 26 (realloc) */
    free(p);
}

Experiment 6-1. In the example above, the /* lint 26 (realloc) */ comment is removed. Open it in the playground and observe the result.

6.2 What the Analysis Assumes About const

In C, const struct X* p protects only *p: p->m->x can still be written. The analysis assumes more: after a call through const, nothing reachable from *p changed.

Warning 120 reports an implementation that breaks this: writing p->m->x through const p, or letting p->m out as non-const (passed, copied, returned, or inside a struct copied by value). void* follows the same rule: memset takes void*, the source of memcpy takes const void*. An explicit cast is taken as intended.

A move is a write too: it changes the owner's state. Moving p->head from const struct list* p is warning 99; moving p->head->next is warning 120. This applies only to const reached through a pointer: a const object of our own still moves its owners when its lifetime ends.

A function that hands out something reachable from a const argument as non-const (like strchr) should come in two versions: T* find(const C*) and const T* find_const(const C*).

6.3 Functions That Are Too Large

The analysis follows every path, and in a very large function the number of objects and paths can grow too much. When that happens the analysis stops, reports warning 97 once, and reports nothing else for that function. The next function is analysed normally. Splitting the function brings the analysis back.

6.4 Backward goto

A while or for loop is analysed as a loop: what one iteration does reaches the next. A label is not. A goto that jumps back to a label above it has no effect on what the analysis knows, so the result is too optimistic:

#pragma safety enable

void f(int n)
{
    int a = 0;
L:
    a = a + 1;
    if (n > 0)
    {
        n = n - 1;
        goto L;
    }
    compile_assert(a == 1); /* passes, but a may be any positive value */
}

Write the loop with while or for instead.

6.5 Arrays Whose Size Is Not Known

Indexes are checked only against arrays whose size is a constant. A variable length array, or a pointer to n items, has a size known only at run time, and an index past its end is not reported.

#pragma safety enable

void f(int n, int* p)
{
    int a[n];
    a[n] = 0;  /* out of bounds, not reported */
    p[n] = 0;  /* p points to n items: out of bounds, not reported */
}

Appendix A - Rules

This appendix states the rules more precisely.

A1. Definitions

A2. Plain parameters: struct X* p

A3. Results: struct X* F(void)

A4. _Nullable and _Nonnull

A5. Assignment a = b

A6. _Out

A7. _Dtor

A8. _Clear

A9. _Uninitialized

A10. _View

A11. _Owner

A12. Combination

A13. Casts

A14. Flow analysis

A15. Compatibility of annotated types

_Nullable is checked in two places. The flow analysis checks the value (whether it can be null at that point, branch by branch). The type checks the pointed objects, because writes through a pointer happen later, where the flow analysis does not see them.

Examples. Each line says what could go wrong if it were accepted.

#pragma safety enable

void f_ptr(int * p);
void f_opt(int * _Nullable p);
int * g_opt(void);
int * _Nonnull g_nonnull(void);
void get(char * _Out _Owner _Nullable * p);
void free(void * _Owner _Nullable p);

void examples(int * p, int * _Nullable q, int * _Owner o, int c)
{
    /* A15.1 the copied value */
    int * a = q;                 /* flow: warning 35, q may be null here           */
    int * b = o;                 /* ok: b is a view of what o owns                 */

    /* A15.2 through a pointer: *pp is shared with the source */
    int * _Nullable * pp1 = &p;       /* error: *pp1 = NULL would make p null           */
    int ** pp2 = &q;             /* error: *pp2 is read as non-null, q may be null */
    int * _Nullable const * pp3 = &p; /* ok: read only, a non-null p is a valid _Nullable    */
    int * const * pp4 = &q;      /* error: read as non-null, q may be null         */

    char * _Owner s;
    get(&s);                     /* error: get may store null in s                 */
    free(s);

    /* A15.3 function pointers: calls through pf go to the function */
    void (*pf1)(int * p) = f_opt;      /* ok: f_opt accepts more than pf1 passes */
    void (*pf2)(int * _Nullable p) = f_ptr; /* error: pf2(NULL) would reach f_ptr     */
    int * _Nullable (*pg1)(void) = g_nonnull; /* ok: g_nonnull promises more          */
    int * (*pg2)(void) = g_opt;          /* error: g_opt may return null         */

    /* A15.8 ?: the value is decided by flow, branch by branch */
    int * r1 = q ? q : p;        /* ok: q is not null in the first branch          */
    int * r2 = q ? q : 0;        /* flow: warning 35, the second branch is null    */
    int * _Nullable * r3 = c ? pp1 : &p; /* error: int * _Nullable * and int ** share *r3    */

    free(o);
}
#pragma safety enable

[[nodiscard]] int h_nd(void);
int h(void);

void attributes(void)
{
    /* A15.4 the caller through the pointer must keep every promise of the function */
    [[nodiscard]] int (*pn1)(void) = h; /* ok: the result is used, h does not care */
    int (*pn2)(void) = h_nd;            /* error: pn2() would discard h_nd's result */
}

A16. restrict and parameters that may alias

Appendix B - Hypotheses

A hypothesis is a possibility the declarations do not rule out, such as two pointers being the same object. Each hypothesis is a branch, like a side of an if, and its values are alternatives of the same pass. A diagnostic that only a hypothesis supports names it: "in case b == a: ...".

B.1 The Result of a Call

At the caller, a pointer returned by a call is a new object or one of the arguments:

  1. The result gets a new pointee, as for any call.
  2. For each object a pointer argument points to at the call, with the same pointed type (any argument for a void* or char* result), there is a hypothesis that the result points to it. Arguments of restrict and _Owner parameters are skipped.
  3. The new pointee and each argument's object are exclusive sides, as in B.2: a write through the result changes one object per path.
  4. A problem found only under a hypothesis is reported in that case: "in case first(p) == p: ...".

All hypotheses are alternatives of one pass: nothing is analysed again.

B.2 Parameters That May Be the Same Object

In the callee, all hypotheses are made at the entry of the function:

  1. For each pair of pointer parameters with the same pointed type, the second parameter gets the object of the first as another value. Pairs with restrict, two _Owner or two const views are skipped.
  2. The same for a parameter and each file scope object of the pointed type the function uses (the parser records them).
  3. The parameter's own value and the new one are the two sides of the hypothesis, as the two sides of an if: they are never on the same path.
  4. A problem found only through the new value is reported as warning 100, "in case b == a: ...".

a == b is false on one side and true on the other: both branches are analysed. As in B.1, everything is one pass.

B.3 restrict

restrict removes a hypothesis, and the check moves to the other side: