Home | Manual | Static Analysis | Playground
Cake - C23 and Beyond
-
Conventions and Contracts in C
- Preface
- Chapter 1 - A Tutorial Introduction
- Chapter 2 - Pointers and Nullability
- Chapter 3 - Ownership
- Chapter 4 - Functions and Their Contracts
- Chapter 5 - How the Analysis Sees a Program
- Chapter 6 - Limitations
-
Appendix A - Rules
- A1. Definitions
-
A2. Plain parameters:
struct X* p -
A3. Results:
struct X* F(void) -
A4.
_Nullableand_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
-
A16.
restrictand parameters that may alias
- Appendix B - Hypotheses
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:
- an owner must be moved exactly once before it goes out of scope;
- an owner must not be overwritten before it is moved;
- a non-owner may not be made into an owner.
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:
pis not null, and*pis a complete, valid object;- the function may change
*p, but must leave it valid; *pstill belongs to the caller.
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
- A1.1 Valid: the object and every object reachable from it are fully initialized, their non-nullable pointer members are non-null, and no owner among them is moved. For example, "
*pis valid" is a statement about*pand everything that can be reached throughp: its members, the objects they point to, and so on. - A1.2 Uninitialized: the object holds no value and no resources.
- A1.3 Nullable / non-nullable: parameters are non-nullable unless declared
_Nullable; results and members are nullable unless declared_Nonnull; local variables are nullable unless declared_Nonnull. Below the top level of a type, a pointer (reached through another pointer) is nullable unless declared_Nonnull. - A1.4 Empty: uninitialized, null or moved.
A2. Plain parameters: struct X* p
- A2.1 The implementation assumes
pis not null,*pis valid, andpdoes not own*p. The non-null contract holds at entry only: inside the functionpis a local, and assigning null to it is allowed (the analysis follows its value). - A2.2 At every exit
*pis valid. - A2.3 The caller must pass a non-null pointer to a valid object. After the call it must assume every object reachable from
*pmay have changed, and that its pointers into them are no longer valid. - A2.4 With
const, the analysis assumes that nothing reachable from*pis written or moved, so what the caller knew still holds (6.2). C protects only*p, so the implementation is checked for writes that break this assumption, and for pointers reachable from*pthat leave as non-const(passed,void*included; copied, also as&p->m->x; returned; or inside a struct copied by value) (warning 120).
A3. Results: struct X* F(void)
- A3.1 The result may be null (with
_Nonnullit is not null) and*resultis valid. The caller does not own it. - A3.2 The implementation must return a value on every path: reaching the closing
}of a non-void function leaves the result uninitialized (warning 98).mainis the exception, its end returns 0.
A4. _Nullable and _Nonnull
- A4.1 On a nullable parameter or result, null may be passed or returned, and the receiver must test before use.
- A4.2 On a nullable member, readers must test before use and writers may store null.
- A4.3 On a
_Nonnullmember, result or local, null cannot be stored or returned. - A4.4 Writing the default of the declaration (
_Nullableon a result, member or local;_Nonnullon a parameter) is redundant (warning 122). - A4.5 Nullability is part of the type only below the top level (
T* _Nullable* p); at the top level it is a contract of the declaration.
A5. Assignment a = b
- A5.1
aand its owner members must be empty. - A5.2
bmust be valid (A1.1). - A5.3 When
ais an owner,bmust be an owner too (or a null pointer constant); a view cannot be assigned to an owner. Owner to owner movesb. Owner to non-owner makesaa view of the objectbstill owns. - A5.4
= {}may leave non-nullable members null until they are set and before the object is used. - A5.5 Passing an argument and returning a value follow the same rules, with the parameter or the result as
a. Thereais a new object, uninitialized, so A5.1 always holds. - A5.6 A move changes only which owner must release the object. After
a = bbetween owners, the object is still alive and may be read throughb. Afterbis passed to an_Ownerparameter, the lifetime of the object may have ended and it must not be used throughb.
A6. _Out
- A6.1
*pis uninitialized at entry and must be fully initialized on every exit. Owner members not created are null. - A6.2 The caller must pass
*pempty (A1.4).
A7. _Dtor
- A7.1 The caller must pass a valid object (A1.1), with one exception: its non-nullable pointer members may be null, so a partially constructed object is accepted. The implementation assumes the same.
- A7.2 At every exit every owner member of
*pis moved.- A7.3 After the call*pis uninitialized and its memory is still the caller's. - A7.4
_Dtorcannot be combined withconst. - A7.5
_Dtormay be used only on the pointed type of a parameter.
A8. _Clear
- A8.1 On a parameter, the caller must pass a valid object (A1.1), without the exception of A7.1. At every exit every owner member is moved and every member is zero.
- A8.2 On a result, every member of
*resultis zero. - A8.3
_Clearmay be used only on the pointed type of a parameter or of a result (as incalloc). - A8.4
_Clearcannot be combined withconst.
A9. _Uninitialized
- A9.1
_Uninitializedmay be used only on the pointed type of a result:*resultis uninitialized.
A10. _View
- A10.1 An object declared
_Viewdoes not own its owner members: assigning to it, or passing it by value, moves nothing, and the source still owns them. Without_View, they are moved. - A10.2
_Viewcannot be used on a pointer: a pointer without_Owneris already a view. - A10.3
_Viewmay be used wherever an object is declared: parameters, local and file-scope variables, and members (for example_View struct X x2 = x;).
A11. _Owner
- A11.1 An
_Ownerparameter must be moved on every exit; the argument is moved. - A11.2 An
_Ownerresult must be moved by the caller. - A11.3 An object owns what its
_Ownermembers point to; they must be moved before the object is destroyed. - A11.4
_Ownermay be used wherever an object is declared: local and file-scope variables, members, parameters and results. It is not limited to pointers (_Owner intfor a file descriptor). - A11.5 A move changes the owner's state, so it is a write: an owner that is
constthrough a pointer cannot be moved (warning 99), nor an owner reachable from aconstobject (warning 120, 6.2). Aconstobject of our own (local, parameter by value) still moves its owners at the end of its lifetime.
A12. Combination
- A12.1 Annotations combine; each adds its own contract and does not change the others.
A13. Casts
- A13.1 A cast has no nullability at the top level; the analysis follows the value (warning 122).
- A13.2 Whether a cast may add or remove
_Owner(for example fromT* _OwnertoT*) is an open question.
A14. Flow analysis
- A14.1 The analysis runs on one function at a time. A call is described only by the declaration of the callee.
- A14.2 A file-scope object is not a parameter, but to each function it behaves as if it were one that does not appear in the parameter list. At entry it holds any value allowed by its type, and at every exit its contract must hold again.
- A14.3 At each point, every object has a set of possible states. Each state comes from at least one path. A diagnostic must be reported when some state supports it and must not be reported when none does.
- A14.4 Where paths meet, the set is the union of the arriving sets. A path that left by
return,break,continue,goto,throwor a noreturn call does not arrive. - A14.5 In the true branch of a condition only the states where it can be true remain; in the false branch, only those where it can be false.
- A14.6 Code no path reaches must be reported with warning 68, and its effects must not reach any later point. Code nested in it (the body of an
ifor a loop inside unreachable code) is unreachable too: nothing in it is reported, and warning 68 is reported once, at the start of the outer unreachable code. The body of a loop whose condition never holds is unreachable code. - A14.7 When the first test of a loop condition is true in every state, the zero-iteration path must not exist. A
dobody runs at least once. - A14.8 A fact supported by several paths is reported once.
- A14.9 An implementation that stops analysing a function must report warning 97 once and nothing else in that function.
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.
- A15.1 The value copied by an assignment, argument or return (the top level) may drop
_Nullable(T* _NullabletoT*); whether it is null there is checked by the flow analysis (A4.1). It may drop_Owner(A5.3), but it cannot gain_Owner. - A15.2 Through a pointer, the pointed object is shared, so its annotations must allow every access the destination can make:
- when the destination pointee is mutable,
_Nullableand_Ownermust match exactly at every level; - when the destination pointee is
const(read only, as inint * _Nullable const *), it may add_Nullablebut not remove it; - when the destination pointee is
_Out(write only), it may remove_Nullablebut not add it:char * _Out _Nullable *cannot receive&swhensischar *, because null may be stored ins(issue #523).
- when the destination pointee is mutable,
- A15.3 A function pointer may point to a function that accepts more and promises more: each parameter of the function may add
_Nullable(void (*pf)(int *p)may point tovoid f(int * _Nullable p), but not the opposite), and its result may remove_Nullable._Owneron parameters and results must match exactly. - A15.4 A
[[nodiscard]]function pointer may point to a function that is not[[nodiscard]]; a pointer without[[nodiscard]]cannot point to a[[nodiscard]]function. This is only between function types: the result of a call is a value, sostruct X * _Owner _Nullable p = malloc(sizeof *p);is fine even thoughmallocis[[nodiscard]]. - A15.5
[[noreturn]]/_Noreturnmust match exactly. - A15.6 Only
_Nullableand_Owner(and, between functions,[[nodiscard]]and[[noreturn]]) are compared._Clear,_Uninitialized,_Dtorand_Viewdescribe the state of an object and are not part of compatibility:struct X * _Owner _Nullable p = calloc(1, sizeof *p);is fine with_Clear void * _Owner _Nullable calloc(...).constand_Outonly select the A15.2 rule. - A15.7 These rules apply only when the extensions are on (
#pragma safety enable); otherwise compatibility is the one in C23 6.2.7. - A15.8 In
c ? a : bthe_Nullableof the result value is decided by the flow analysis, per branch (p ? p : 0may be null,p ? p : qis not), and_Ownerstays in the type. Below the value,aandbpoint to shared objects, so_Nullableand_Ownermust match at every pointee level:c ? (int * _Nullable *) pp1 : (int **) pp2is incompatible.
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
- A16.1 Two pointer parameters with the same pointed type, neither
restrict, may point to the same object. The implementation must be correct in that case. - A16.2 For each such pair, at the entry of the function, the second
parameter may also point to the object of the first: the hypothesis that
they are the same object. Each keeps its own nullability. A diagnostic that
only this hypothesis supports is reported as warning 100, with its message.
When that diagnostic is warning 101 (the two parameters reach a
restrictparameter of a call), warning 100 asks to addrestrictto one of them or to handle them being equal. - A16.3
restricton either parameter of a pair says they never point to the same object: there is no hypothesis for the pair. Two_Ownerparameters never point to the same object either: each owns its object, and the caller cannot pass one owner twice. Two parameters that only read throughconst(neither_Owner) have no hypothesis either: the same object changes nothing for them. - A16.4 The caller may pass the same object to two parameters, unless one of them is
restrict. Withrestrictthe caller is checked: two arguments of the call that may point to the same object, or one into the object of the other (&x->yandx), by the values the analysis knows for them, must be reported with warning 101. - A16.5 A pointer parameter may also point to a file scope object the function uses (
f(&g)), when the object has the pointed type. For each such parameter and object there is a hypothesis that the parameter points to the object, as in A16.2. The parser records the file scope objects each function uses.restrictand_Ownerparameters have no such hypothesis: an_Ownercannot own a file scope object. - A16.6 A non-owner pointer returned by a call may be a new object or one of
the objects the call's pointer arguments point to, when the pointed type
is the same as the result's (any argument when the result points to
voidorchar). Each is a hypothesis, exclusive with the others. A diagnostic that follows only from one says so: "in case first(p) == p: ...". An argument passed to arestrictparameter is never returned. An argument passed to an_Ownerparameter is kept alive by the callee and is not considered. - A16.7 A function must not return a pointer to the object of a
restrictparameter, or into it: warning 102.
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:
- The result gets a new pointee, as for any call.
- For each object a pointer argument points to at the call, with the same
pointed type (any argument for a
void*orchar*result), there is a hypothesis that the result points to it. Arguments ofrestrictand_Ownerparameters are skipped. - 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.
- 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:
- 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_Owneror twoconstviews are skipped. - The same for a parameter and each file scope object of the pointed type the function uses (the parser records them).
- 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. - 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:
- A
restrictparameter is not paired in B.2. The caller is checked instead: two arguments that may point to the same object, one of them to arestrictparameter, are warning 101. - A
restrictparameter is not a possible result in B.1. The callee is checked instead: returning a pointer to its object is warning 102.