Why C needs a new type qualifier

Christopher Bazley, January 2023

Jeff Tracy thumps his desk (Thunderbird Six, 1968)

Introduction

At the start of the final big-screen outing for the original cast and crew of cult television show 'Thunderbirds', the normally level-headed patriarch of the Tracy family gives a run-down of the rescue craft in his fleet, then thumps his desk and says:

... and I'm telling you, Brains, in no uncertain terms, that we now need a Thunderbird Six!

There's just one problem: he's unable to provide a specification of the new vehicle. This gives his chief engineer a pickle of a problem and leaves the audience in suspense until the big reveal. It was a bit of a tame premise for a science fiction film released in 1968, when interest in 'Thunderbirds' was already waning. Nowadays, the film is mostly unknown apart from as the origin of the laughing puppets meme.

Interest in C programming also seems to be waning, since the emergence of rivals for its position as the dominant general-purpose low-level language. After fifty years, it's reasonable to ask why C should need a new type qualifier. Like Jeff Tracy, "I've got this feeling and I know I'm not wrong"; unlike Jeff, I know exactly what I want and why.

Philosophical underpinning

The single most important (and redeeming) feature of C is its simplicity. It should be (relatively) quick to learn every aspect of the language, (relatively) easy to create a compiler for it, and the language's semantics should follow (more-or-less) directly from its syntax.

People criticise C's syntax, but I consider it the foundation of the language. Any experienced C programmer has already acquired the mindset necessary to read and write code using it. Aside from the need to minimize incompatibilities, the syntactic aberrations introduced by C++ can be ignored. There are significant differences between the design philosophies of C and C++; most relevantly, the designer of C++ did not approve of C's mnemonic syntax for declarations.

"Pythonic" is sometimes used as an adjective to praise code for its use of Python-specific language idioms. I believe that an equivalent word "scenic" could be used to describe C language idioms, meaning that they conform to a mode of expression characteristic of C. I've tried to keep that in mind when evaluating syntax and semantics.

Inspiration from Python

For the past twenty years, I've mostly been coding in C. I had always considered C to be a strongly-typed language: it allows implicit conversions between void * pointers and other pointer types, and between enum and integer types, but those aren't serious shortcomings so long as the programmer is aware of them.

Recently, I switched to a team that writes code in a mixture of languages (including C++, Python, and Javascript). Writing code in languages that are dynamically-typed but with statically checked type annotations was a revelation to me. Our project uses MyPy and Typescript for static type checking.

The main thing that I grew to appreciate was the strong distinction that MyPy makes between values that can be None and values that cannot. Such values are annotated as Optional[int], for example. Any attempt to pass an Optional value to a function that isn't annotated to accept None is faulted, as is any attempt to do unguarded operations on Optional values (i.e. without first checking for the value being None).

Problem statement

In contrast to Python, C's type system makes no distinction between pointer values that can be null, and those that cannot. Effectively, any pointer in a C program can be null, which leads to repetitive, longwinded and unverifiable parameter descriptions such as "Non-null pointer to..." or "Address of X ... (must not be null)".

Such invariants are not usually documented within a function except by assertions, which clutter the source code and are ineffective without testing. Some programmers even write tests to verify that assertions fail when null is passed to a function, although the same stimulus would provoke undefined behaviour in release builds. The amount of time and effort that could be saved if such misuse were instead caught at compile time is huge.

What is a null pointer?

A null pointer is defined by 6.3.3.3 p3 of the ISO C standard:

If a null pointer constant or a value of the type nullptr_t (which is necessarily the value nullptr) is converted to a pointer type, the resulting pointer, called a null pointer, is guaranteed to compare unequal to a pointer to any object or function. Conversion of a null pointer to another pointer type yields a null pointer of that type. Any two null pointers shall compare equal.

Like any other pointer, a null pointer has a referenced type which may be qualified. Although all null pointers compare equal, it is a constraint violation to compare pointers of incompatible types (as per 6.5.10). Translators can reason about the referenced type of a pointer that points to no object or function, e.g. to diagnose a constraint violation upon assignment to a const-qualified lvalue.

Pointers can be dereferenced using the indirection operator. The only constraint is that the operand shall have pointer type, therefore no diagnostic message is required when the operand is a null pointer. (This is generally impossible to determine at translation time anyway.)

The semantics of indirection are defined by 6.5.4.3 p4 of the ISO C standard:

If the operand points to a function, the result is a function designator; if it points to an object, the result is an lvalue designating the object. If the operand has type "pointer to type", the result has type "type". If an invalid value has been assigned to the pointer, the behavior of the unary * operator is undefined. (Among the invalid values for dereferencing a pointer by the unary * operator are a null pointer...)

An lvalue is defined by 6.3.3.1 p1 of the ISO C standard:

An lvalue is an expression with an array type or a complete object type that potentially designates an object; if an lvalue does not designate an object when it is evaluated, the behavior is undefined.

Many programs contain lvalues that do not always designate an object when evaluated, including as a result of applying the indirection operator to a null pointer. C lacks mechanisms to help programmers avoid creating such lvalues.

Pointer arithmetic is limited by 6.5.7 p9 and p10 of the ISO C standard:

When an expression that has integer type is added to or subtracted from a pointer, the result has the type of the pointer operand. If the pointer operand points to an element of an array object, and the array is large enough, the result points to an element offset from the original element such that the difference of the subscripts of the resulting and original array elements equals the integer expression.

When two pointers are subtracted, both shall point to elements of the same array object, or one past the last element of the array object; the result is the difference of the subscripts of the two array elements.

Since a null pointer does not point to any object, arithmetic on null pointers has undefined behaviour. C lacks mechanisms to help programmers avoid performing arithmetic on null pointers.

Isn't this a solved problem?

Given that the issue of undefined behaviour caused by null pointer dereferences has been present in C since its inception, many solutions have already been attempted.

Static in parameter declarations

C99 extended the syntax for function arguments to allow static within [], which requires the passed array to be at least a specified size:

void *my_memcpy(char dest[static 1], const char src[static 1], size_t len);

void test(void)
{
    char *dest = NULL, *src = NULL;
    my_memcpy(NULL, NULL, 10); // warning: argument 1 to 'char[static 1]' is null where non-null expected
    my_memcpy(dest, src, 10); // no compiler warning
}

This syntax is not a general-purpose solution because it can only be used for function parameters. Even in parameter declarations, it has limited applicability because arrays of type void are illegal. This makes it unusable for declaring functions such as memcpy.

Historically, support for checking parameters specified using [static 1] was very limited. GCC 11.1.0 only produces a diagnostic message when a null pointer constant is specified directly as a function argument. Later versions of GCC introduced the static-analysis pass enabled by -fanalyzer, allowing GCC 14.1.0 to diagnose null pointer values stored in intermediate variables, including potential null pointer values originating from malloc.

GNU attribute

A GNU compiler extension (also supported by Clang and the ARM compiler) allows function arguments to be marked as not supposed to be null:

void *my_memcpy(void *dest, const void *src, size_t len) __attribute__((nonnull (1, 2)));

void test(void)
{
    char *dest = NULL, *src = NULL;
    my_memcpy(NULL, NULL, 10); // warning: argument 1 null where non-null expected
    my_memcpy(dest, src, 10); // no compiler warning
}
    

Since GCC began to support C23's attribute specifier syntax, an alternative spelling of the same attribute is available. For example, [[gnu::nonnull(1, 2)]] could be substituted for __attribute__((nonnull (1, 2))).

I find the __attribute__ syntax intrusive and verbose. It is also error-prone because attributes only apply to function declarations as a whole: it's easy to accidentally specify wrong argument indices, because the arguments themselves are not annotated. Clang extended the syntax to allow __attribute__((nonnull)) to be used within an argument list, but the GNU compiler does not support that.

Clang annotations

RFC: Nullability qualifiers (2015) proposed not one but three new type annotations: _Nullable, _Nonnull and _Null_unspecified. Support for these was added in version 3.7 of Clang, but GCC doesn't recognize them. Like GCC without -fanalyzer, Clang itself only detects cases where a null pointer constant is specified directly as a function argument:

void *my_memcpy(void *_Nonnull dest, const void *_Nonnull src, size_t len);
void test(void)
{
    char *dest = NULL, *src = NULL;
    my_memcpy(NULL, NULL, 10); // warning: Null passed to a callee that requires a non-null 1st parameter
    my_memcpy(dest, src, 10); // no compiler warning
}

However, Clang-tidy, a standalone tool based on Clang, can issue warnings about misuse of pointers that it is able to infer based on annotations and path-sensitive analysis:

<source>:9:3: warning: Null pointer passed to 1st parameter expecting 'nonnull' [clang-analyzer-core.NonNullParamChecker]
  my_memcpy(dest, src, 10); // no compiler warning
  ^         ~~~~
<source>:7:9: note: 'dest' initialized to a null pointer value
  char *dest = NULL, *src = NULL;
        ^~~~
<source>:9:3: note: Null pointer passed to 1st parameter expecting 'nonnull'
  my_memcpy(dest, src, 10); // no compiler warning
  ^         ~~~~

Clang's syntax is less verbose and error-prone than __attribute__, but the requirement to annotate all pointers as either _Nullable or _Nonnull makes code harder to read and write. Most pointers should not be null: consider the instance pointer passed to every method of a class. It's no longer safe to write such declarations in traditional style with economy of effort, and that's a deal-breaker for me. I also think the semantics of these annotations (discussed later) are far more complex than befits a simple language like C, and likely to cause confusion.

Because Clang's annotations are not type qualifiers, neither the compiler nor the static analyser diagnose some programmer errors that would be detected by existing type compatibility rules:

void foo(int *_Nullable x)
{
  void baz(int *_Nonnull *l);
  int *_Nonnull *k;
  k = &x; // no constraint violation or diagnostic
  baz(&x); // no constraint violation or diagnostic
}

Another confounding aspect of Clang's nullability attributes is that they appear to exert a kind of spooky action at a distance over pointers that either lack any explicit nullability attribute or from which it appears to have been explicitly cast away:

void bar(int *_Nonnull y);
void foo(int *_Nullable i)
{
  int *j;
  j = (int *)i;
  bar(j); /* warning: Nullable pointer is passed to a callee
             that requires a non-null 1st parameter */
}

Clang's static analyser treats the cast (int *) as equivalent to (int *_Null_unspecified), which is useless in this scenario. The "assumed null unless disproven" quality of i is preserved upon assigning it to j (with or without a cast), which appears to contradict the declared type of j. A solution is to cast explicitly to int *_Nonnull.

Conclusion

I've seen Clang's nullability qualifiers described as "enormous and useless noise, while providing doubtful value" and the very idea of annotating pointers called a "naive dream". I agree with the first statement, but not the second: other languages have shown that null safety is achievable and useful, whilst C lags with competing partial solutions that are confusing, error-prone, and intrusive.

Attributes and annotations must be hidden behind macros for compatibility between tools. They provide no value (other than documentation) to developers using translators which do not support path-sensitive analysis. Not all developers use special build machines costing thousands of pounds: I do a lot of coding on a Raspberry Pi, using a toolchain that dates from the 1980s but is still actively maintained (and recently gained support for C17). For me, having a rapid edit-compile-run cycle is paramount.

Even tiny compilers such as cc65 can check that the addresses of objects declared with const or volatile are not passed to functions that do not accept such pointers, because the rules for type compatibility are simple (for the benefit of machines and people). This is exactly the niche that the C language should be occupying.

I postulate that improved null safety does not require path-sensitive analysis.

Syntactic and semantic precedents

Type qualifiers (as we understand them today) didn't exist in pre-ANSI C, which consequently had a stronger similarity between declarations and expressions, since qualifiers can't appear in expressions (except as part of a cast).

The second edition of 'The C Programming Language' (K&R, 1988) says only that:

Types may also be qualified, to indicate special properties of the objects being declared.

Notably, the special properties conferred by const, volatile, restrict and _Atomic all relate to how objects are stored or how that storage is accessed — not the range of values representable by an object of the qualified type.

Is the property of being able to represent a null pointer value the kind of property that should be indicated by a type-qualifier? Restrictions on the range of values representable by an object are usually implied by its type-specifiers (although long, short, signed and unsigned are intriguingly also called "qualifiers" by K&R, presumably because their text predates ANSI C).

Pointers are a special type of object though. Multiple levels of indirection can be nested within a single declaration, as in the following declaration of baz (an array of pointers to arrays of pointers to int):

int bar;
int *foo[2] = {NULL, &bar};
int *(*baz[3])[2] = {&foo, NULL, NULL};

It's therefore necessary to specify whether null is permitted for every level of indirection within a declarator (e.g. for both baz[3] and (*baz[3])[2]). The only existing element of C's existing syntax that has such flexibility is a type-qualifier.

It's not meaningful to specify whether null is permitted as part of the declaration-specifiers (e.g. static int) on the lefthand side of a declaration, because this property only applies to pointers. The restrict qualifier already has this limitation.

Here's an example of how the above declaration might look with Clang's nullability qualifiers:

int bar;
int *_Nullable foo[2] = {NULL, &bar};
int *_Nullable (*_Nullable baz[3])[2] = {&foo, NULL, NULL};

Syntactically, this may look like a perfect solution; semantically, I will argue that it is not!

A variable of type char *const (const pointer to char) can be assigned to a variable of type char * (pointer to char), but a variable of type const char * (pointer to const char) cannot. After a learner internalizes the knowledge that qualifiers on a pointer target must be compatible, whereas qualifiers on a pointer value are discarded, this rule can be applied to any assignment or initialization:

int *const x = NULL;
int *s = x; // no warning
int *volatile y = NULL;
int *t = y; // no warning
int *restrict z = NULL;
int *r = z; // no warning

One might not expect the same laxity to apply to the _Nullable and _Nonnull qualifiers, because they relate to the assigned value, not the storage access properties of a particular copy of it. Despite that, Clang-tidy allows an assigned value to be _Nullable unless the type of the assigned-to-object is qualified as _Nonnull:

extern int *_Nullable getptr(void);
int *_Nullable z = getptr();
int *q = z; // no warning
int *_Nonnull p = z; // warning: Nullable pointer is assigned to a pointer which is expected to have non-null value
*q = 10; // warning: Nullable pointer is dereferenced

This compromise between the traditional semantics of assignment (discard top-level qualifiers) and the semantics needed to track nullability (ensure compatible qualifiers) looks like a weak basis for null safety; however, it is mitigated by the fact that the static analyser tracks whether a pointer value may be null regardless of its type. In turn, that makes it impossible to tell what constraints apply to a pointer value simply by referring to its declaration.

A related issue is that top-level qualifiers on arguments are redundant in a function declaration (as opposed to definition) because arguments are passed by value. Callers don't care what the callee does with its copy of a pointer argument — only what it does with the pointed-to object.

Consequently, such qualifiers are ignored when determining compatibility between declarations and definitions of the same function. The normative part of an argument declaration is to the left of the asterisk:

void myfunc(const char *const s);
//          ^^^^^^^^^^  ^^^^^
//          Normative   Not normative
//          vvvvvvvvvv  vvvvvvvv
void myfunc(const char *restrict s)
{
}

Notably, this rule also applies to restrict-qualified arguments, despite an apparent conflict with a principle stated in WG14's charter:

Application Programming Interfaces (APIs) should be self-documenting when possible

Interfaces cannot be self-documenting when qualifiers (such as restrict) that are part of the contract may differ between the function definition and the function declaration used by callers.

The same laxity should not apply to the _Nullable and _Nonnull qualifiers, because they relate to the passed value, not its storage access properties. Despite that, Clang ignores any differences between rival declarations of a function, except in cases where contradictory qualifiers were used.

It is permissible to write [] instead of * in a parameter declaration, to hint that an array is passed (by reference) to a function. One might expect this [] syntax to be incompatible with qualifying the type of the pointer (as opposed to the type of array elements). On the contrary, Clang allows nullability qualifiers to appear between the brackets:

void myfunc(const char s[_Nullable]); // s may be a null pointer

This syntax is not intuitive to me (usually [] indicates an index or size) but it does follow 6.7.5.3 of the C language standard:

A declaration of a parameter as "array of type" shall be adjusted to "qualified pointer to type", where the type qualifiers (if any) are those specified within the [ and ] of the array type derivation.

Thinking outside the box

An essential feature of a new type qualifier expressing 'may be null' is that this property must not be lost when a qualified pointer is copied (including when it is passed as a function argument).

Qualifiers on a pointed-to type must be compatible in assignments, initializations, and function calls, whereas qualifiers on a pointer type need not be. The fact that every programmer has internalized this rule makes me reluctant to propose (or embrace) any change to it for nullability qualifiers on a pointer type.

I'm tempted to say that both restrict and the Clang annotations _Nullable and _Nonnull are in the wrong place. The restrict qualifier frees an optimizer to generate more efficient code, almost like the opposite of volatile. Isn't the quality of being aliased a property of an object, rather than any single pointer to it?

At the heart of C's syntax is the primacy of fundamental types such as int. Every declaration is a description of how a chain of indirections leads to such a type. Can we reframe the 'may be null' property as a quality of the pointed-to object, rather than the pointer?

Yes!

const int *i; // *i is an int that may be stored in read-only memory
volatile int *j; // *j is an int that may be stored in shared memory
_Optional int *k; // *k is an int for which no storage may be allocated

I chose the name _Optional to bootstrap existing knowledge of Python and make a clear distinction between this qualifier and _Nullable. I also like the idea of Python giving something back to C.

_Optional is the same length as _Nullable and only one character longer than volatile. C's syntax isn't known for its brevity, anyway. (Think not of functions such as strcpy, but of declarations such as const volatile unsigned long int.)

Modifying a const object only has undefined behaviour if the object was originally declared as const, which is not always the case when an object is modified by dereferencing a pointer from which a const qualifier was cast away. Likewise, accessing an _Optional object will only have undefined behaviour if the pointer used to access the object is actually null.

Read-only objects are often stored in a separate address range so that illegal write accesses generate a segmentation fault (on machines with an MMU). Likewise, null pointer values encode a reserved address, which is typically neither readable nor writable by user programs. In both cases (const and _Optional), a qualifier on the pointed-to object indicates something about its address.

Modifying an object designated by an lvalue that has const-qualified type only has undefined behaviour if the object was actually defined as const; otherwise, it is merely a constraint violation. Likewise, an lvalue that does not designate an object because it is the result of dereferencing a null pointer only has undefined behaviour if evaluated. Could potential null pointer dereferences also be moved to the domain of constraint violations?

Unlike assignment to a variable with const-qualified type, no error should be reported when compiling code which accesses a variable with _Optional-qualified type. Were that my intent, I would have proposed a name like _None rather than _Optional. Requiring the _Optional qualifier to be cast away before accessing a so-qualified object would be tiresome and would sacrifice type safety for null safety. I do not think that is a good trade-off.

Despite this limitation, the new qualifier is useful:

Here is some example usage:

void foo(int *);

void bar(_Optional int *i)
{
    *i = 10; // optional warning of unguarded dereference

    if (i) {
        *i = 5; // okay
    }

    int *j = i; // warning: initializing discard qualifiers
    j = i; // warning: assignment discards qualifiers
    foo(i); // warning: passing parameter discards qualifiers
}

Here's an example of complex declarations that I used earlier, updated to use the proposed qualifier:

   int            bar;

   _Optional int  *foo[2] = {NULL, &bar};
// ^^decl-spec^^  ^^decl^

   _Optional int  *(*qux[3])[2] = {&foo, &foo, &foo};
// ^^decl-spec^^  ^^declarator^

   _Optional int  *_Optional (*baz[3])[2] = {&foo, NULL, NULL};
//                            ^^decl^
// ^^decl-spec^^  ^^pointer^ ^^dir-decl^^
//                ^^^^^^declarator^^^^^^^

Let's break it down:

Qualification of types other than referenced types

Parameter declarations using [] syntax can be written more naturally using an _Optional qualifier than using Clang's _Nullable qualifier:

void myfunc(_Optional const char s[]);

With the above exception, it isn't useful to declare a non-pointed-to object as _Optional (although so-qualified types will exist during expression evaluation). Such declarations could be disallowed, like similar abuse of restrict, to avoid confusion.

Consequently, there is no need to create another class of undefined behaviour when an attempt is made to access an object defined with an optional-qualified type through use of an lvalue with non-optional-qualified type. (The equivalent semantics for existing qualifiers are given by 6.7.4.1p7.)

Optional pointer versus pointer-to-optional

The following code declares p, a pointer to an optional pointer, and q, a pointer to a const pointer:

int *_Optional *p, *const *q;

(It is not syntactically valid to declare an optional pointer directly.)

Note that an 'optional pointer' is not a pointer whose value may be null; it's a pointer obtained by dereferencing a pointer that may be null. If *p is used as an lvalue and it does not designate an object because p is null, then the object (of type int *_Optional) designated by *p does not exist.

This is like the existing rule that a 'const pointer' is not a pointer that may address an immutable object; it's a pointer that is not itself modifiable. If *q is used as an lvalue then the object (of type int *const) designated by *q cannot be modified.

Attempting to police this distinction is probably fruitless, since 'const pointer' and 'pointer to const' are already used interchangeably, causing considerable confusion.

A secondary reason that it does not make sense to describe _Optional int *p as an 'optional pointer' is that a null pointer is a pointer - it's just a pointer that does not point to any object or function. The pointer is not optional; the referenced object is.

Why _Optional rather than _Mandatory?

As mentioned earlier, function parameters that can legitimately be null are outnumbered by parameters that cannot be null without provoking undefined behaviour.

The very first sentence of K&R's book "The C programming language" is

C is a general-purpose programming language which features economy of expression...

Neither static array extents nor any alternative method of annotating function parameters as non-null (including a future _Mandatory qualifier) resemble "economy of expression".

The goal of keeping C "pleasant, expressive, and versatile" (as K&R described it) is incompatible with declaring interfaces like this:

bool coord_stack_init(coord_stack stack[static 1], size_t limit);
void coord_stack_term(coord_stack stack[static 1]);
bool coord_stack_push(coord_stack stack[static 1], coord item);
coord coord_stack_pop(coord_stack stack[static 1]);
bool coord_stack_is_empty(coord_stack stack[static 1]);

Or this:

bool coord_stack_init(coord_stack *_Nonnull stack, size_t limit);
void coord_stack_term(coord_stack *_Nonnull stack);
bool coord_stack_push(coord_stack *_Nonnull stack, coord item);
coord coord_stack_pop(coord_stack *_Nonnull stack);
bool coord_stack_is_empty(coord_stack *_Nonnull stack);

Or this:

bool coord_stack_init(coord_stack *stack, size_t limit) __attribute__((nonnull (1, 1)));
void coord_stack_term(coord_stack *stack) __attribute__((nonnull (1, 1)));
bool coord_stack_push(coord_stack *stack, coord item) __attribute__((nonnull (1, 1)));
coord coord_stack_pop(coord_stack *stack) __attribute__((nonnull (1, 1)));
bool coord_stack_is_empty(coord_stack *stack) __attribute__((nonnull (1, 1)));

Instead of this:

bool coord_stack_init(coord_stack *stack, size_t limit);
void coord_stack_term(coord_stack *stack);
bool coord_stack_push(coord_stack *stack, coord item);
coord coord_stack_pop(coord_stack *stack);
bool coord_stack_is_empty(coord_stack *stack);

An equally important reason to add _Optional instead of _Mandatory is that the assignment semantics for _Mandatory would need to be opposite to those for const and volatile (because _Mandatory is not a restriction on usage of a so-qualified pointer, therefore it should not be contagious).

Implicitly discarding a const qualifier provokes a diagnostic message:

const int *x = &y;
int *z = x; // warning: initialization discards 'const' qualifier
            // from pointer target type
const int *q = z; // no warning

As does implicitly discarding a volatile qualifier:

volatile int *x = &y;
int *z = x; // warning: initialization discards 'volatile' qualifier
            // from pointer target type
volatile int *q = z; // no warning

Implicitly discarding a _Mandatory qualifier would not provoke a diagnostic message; instead, a new type of diagnostic message might be generated upon implicitly acquiring the qualifier:

_Mandatory int *x = &y;
int *z = x; // no warning
_Mandatory int *q = z; // warning: initialization adds '_Mandatory'
                       // qualifier to pointer target type

Choosing defaults wisely so that they do not need to be constantly overridden is an important aspect of usability. Limiting the number of distinct rules and patterns makes regular languages easier to learn than irregular ones.

The implementation cost of adding a _Mandatory qualifier might not differ much from the cost of adding an _Optional qualifier, but I believe that its effect on those learning and using the language would be detrimental to the extent that it might not be used at all. The limited uptake of static array size expressions, despite their potential for annotating function parameters as non-null, seems to point in this direction.

A new qualifier indicating that a pointer may be null can only have been explicitly added to an existing program by its maintainer. Consequently, implementations can immediately begin to generate diagnostic messages about misuse of pointers to so-qualified objects. In contrast, implementations cannot reasonably begin to generate diagnostic messages about usage of pointers to unqualified objects when they introduce support for a qualifier indicating that a pointer cannot be null.

But any pointer can be null

A common objection to the idea of a qualifier expressing the property "this pointer can be null" is that any pointer can be null, therefore creating a new category of pointers that can be null would be meaningless.

This is based on a misconception about the meaning of the proposed _Optional qualifier: it does not mean "a pointer to a so-qualified type is permitted to be null"; it means "a pointer to a so-qualified type is assumed to be null in the absence of other information, for the purpose of generating diagnostics."

Calling a function, foo, with a null pointer argument in the following example illustrates that foo may receive a null pointer regardless of its parameter type:

void foo(char *i)
{
  if (!i) {
    puts("i is not a pointer to any object or function");
  }
  char k;
  _Optional char *j = i;
  k = *j; // diagnostic message because j is assumed to be null
  if (j) { // j could now be a pointer to an object or function
    k = *j; // no diagnostic message because j is constrained
  }
}

// Another translation unit:
int main(void)
{
  foo(NULL); // i becomes null although it is unqualified
  return 0;
}

If the above program is executed, then "i is not a pointer to any object or function" is sent to the standard output stream before *j is evaluated (with undefined behaviour).

It is not necessarily redundant for programs to check for the value of a pointer to an unqualified referenced type (such as i) being null, and compilers must not make optimisations based on false inferences that pointers to unqualified referenced types cannot be null.

Whether to employ defensive programming techniques depends on the type of program or library being written, the security environment it operates within, and the trustworthiness of calling code (which may even be written in another language). It is also possible to create null pointers using memset. No language solution can fully guarantee that a program is free of null pointers whose nullability is not expressed through the type system.

A similar situation exists with the const qualifier: some programmers use it sparingly, and others not at all. Modifying an object through an lvalue of unqualified type is often an encapsulation violation whether it has undefined behaviour or not.

Treatment of null pointer constants

It would be invaluable for a diagnostic message to be produced whenever a null pointer constant is assigned to a pointer that does not have an _Optional-qualified referenced type.

The simplest way for users to implement the desired behaviour would be to define nullptr (or NULL) as a macro that expands to ((_Optional void *)0). The following example illustrates that by invoking GCC 14.2.0 with the command line argument -Dnullptr='((const void *)0)':

int main(void)
{
  void baz(int *);
  int *i = nullptr; // discards qualifier from pointer target type
  i = nullptr; // discards qualifier from pointer target type
  baz(nullptr); // discards qualifier from pointer target type
  return 0;
}

A better long-term direction for the language would be to follow the path established by GCC for treatment of string literals. GCC provides a compiler option to change the type of the static array generated from string literals:

-Wwrite-strings

When compiling C, give string constants the type const char[length] so that copying the address of one into a non-const char * pointer will get a warning; when compiling C++, warn about the deprecated conversion from string literals to char *.

An analogous compiler option could be provided to change the type of null pointers generated from null pointer constants, and those generated when an object is subject to default initialisation. This would be more powerful than a macro solution.

It is not easy to determine whether a given program can be translated with -Wwrite-strings (or a new option such as -Wnull-pointers) without generating diagnostics. Portability issues such as this are covered by Annex J of the ISO C standard. Writable string literals are described therein both as an example of undefined behaviour (J.2) and as a common extension (J.5.6).

Conversions from maybe-null to not-null

I presented the idea of warnings when a pointer-to-_Optional is passed to a function with incompatible argument types as an unalloyed good. In fact, such usage has legitimate applications.

Consider the following veneer for the strcmp function which safely handles null pointer values by substituting the empty string:

int safe_strcmp(_Optional const char *s1, _Optional const char *s2)
{
    if (!s1) s1 = "";
    if (!s2) s2 = "";
    return strcmp(s1, s2); // warning: passing parameter discards qualifiers
}

In the above situation, both s1 and s2 would both need to be cast before calling strcmp:

int safe_strcmp(_Optional const char *s1, _Optional const char *s2)
{
    if (!s1) s1 = "";
    if (!s2) s2 = "";
    return strcmp((const char *)s1, (const char *)s2);
}

The above solution would be detrimental to readability and type safety.

It could be argued that any mechanism to remove _Optional from the target of a pointer without first checking its value (at runtime) fatally compromises null safety. I disagree: C provides tools to write type-safe code, whilst allowing leniency where it is pragmatic to do so.

It might be possible to use some combination of _Generic and unqual_typeof to remove only a specific qualifier from a type (like const_cast in C++) but such casts would still clutter the code and therefore seem likely be rejected by programmers who prefer to rely solely on path-sensitive analysis.

What is required is a solution that accommodates both advanced compilers and compilers which report errors based only on simple type-compatibility rules. Compilers capable of doing so must be able to validate conversions from maybe-null to not-null in the same way as they would validate a real pointer dereference.

One of my colleagues suggested just such a solution:

int safe_strcmp(_Optional const char *s1, _Optional const char *s2)
{
  if (!s1) s1 = "";
  if (!s2) s2 = "";
  return strcmp(&*s1, &*s2);
}

This idiom has the benefit that it is already 'on the radar' of implementers (and some programmers) because of an existing rule that neither operator of &* is evaluated. It's searchable, easy to type (& and * are on adjacent keys), and not too ugly.

Do not underestimate the importance of &* being easy to type! I must have written it thousands of times by now. The alternatives that I considered would have made updating a large existing codebase unbearable.

The way I envisage this working is:

However, this proposal might entail a modification to the description of the address and indirection operators in the C standard:

If the operand [of the unary & operator] is the result of a unary * operator, neither that operator nor the & operator is evaluated and the result is as if both were omitted, except that the constraints on the operators still apply and the result is not an lvalue.

C++ does not currently allow indirection on an operand of type void *. This rule would either need to be aligned with C, or else C++ programmers would need to cast away the qualifier from _Optional void * in some circumstances, rather than using an idiom such as &*.

The devil in the details

I haven't yet explained how such an expression such as &*s would remove the _Optional qualifier from the type of a pointed-to object.

Whereas a qualifier that applies to a pointer type is naturally removed by dereferencing that pointer, a qualifier (such as _Optional) that applies to a pointed-to object is not:

int *const x;
typeof(&*x) y; // y has type 'int *' not 'int *const'
y = 0;

int b;
int const *a = &b;
typeof(&*a) c; // c has type 'int const *'
*c = 0; // error: read-only variable is not assignable

Consequently, modified semantics are required for the unary * operator, the unary & operator, or both.

It's tempting to think that the appropriate time to remove a maybe-null qualifier from a pointer is the same moment at which undefined behaviour would ensue if the pointer were null. I prototyped a change to remove the _Optional qualifier from the result of unary *, but found it onerous to add &* everywhere it was necessary to remove the _Optional qualifier from a pointer.

Moreover, many previously simple expressions became unreadable:

Whilst it would have been possible to improve readability by using more intermediate variables, that isn't the frictionless experience I look for in a programming language. (The same consideration applies to reliance on casts in the absence of modified operator semantics.)

The proposed idiom &*s is merely the simplest expression that incorporates a semantic dereference without accessing the pointed-to object. A whole class of similar expressions exist, all of which typically compile to a machine-level instruction to move or add to a register value (rather than a load from memory):

There is only one way to get the address of an object (excepting arithmetic), whereas there are many ways to dereference a pointer. Therefore, I propose that any _Optional qualifier be implicitly removed from the operand of the unary & operator, rather than modifying the semantics of the unary *, subscript [] and member-access -> operators.

The operand of & is already treated specially, being exempt from conversion from an lvalue to the value stored in the designated object, and from implicit conversion of an array or function type into a pointer. It therefore seems less surprising to add new semantics for & than *.

I also think that the use of the & operator to remove _Optional from the type of its argument is nicely mnemonic: it's axiomatic that that address of any object is not null.

Another class of expressions that generate an address from a pointer without accessing the pointed-to object are arithmetic expressions in which one operand is a pointer:

None of the above expressions affect the qualifiers of a pointed-to object in the result type: if the type of s is a pointer-to-const then so is the type of s + 1.

Although s + n is equivalent to &s[n] in current code, it does not occur often enough to justify modifying arithmetic operators to remove any _Optional qualifier from a pointed-to object. This also avoids the question of changes to prefix/postfix operators such as ++ and compound assignments such as +=. The alternative substitution of &*s + n is tolerably readable.

Function pointers

C's declaration syntax does not permit type qualifiers to be specified as part of a function declaration:

<source>:4:6: error: expected ')' [clang-diagnostic-error]
int (const *f)(int); // pointer to const-qualified function
     ^

A syntactic way around this limitation is to use an intermediate typedef name:

typedef int func_t(int);
const func_t *f; // pointer to const-qualified function

That doesn't solve the underlying problem, though. GCC does not warn about such declarations, but Clang does:

<source>:5:1: warning: 'const' qualifier on function type 'func_t' (aka 'int (int)') has unspecified behavior [clang-diagnostic-warning]
const func_t *f; // pointer to const-qualified function
^~~~~~

The C language standard currently says:

If the specification of a function type includes any type qualifiers, the behavior is undefined.

Making this behaviour well-defined (as in C++) would make the language safer, whereas extending the declaration syntax is beyond the scope of my proposal.

Migration of existing code

_Optional can be pre-defined as an empty macro (in the Makefile or equivalent) when invoking a compiler which does not support it.

Functions which consume pointers that can legitimately be null can be changed with only minor effects on compatibility. For example, void free(_Optional void *) can consume a pointer to an optional-qualified type, or a pointer to an unqualified type, without casting.

However, existing code which uses the address of free (e.g. as a callback function) and expects it to have the signature void free(void *) would fail to compile if the signature were instead void free(_Optional void *). Relaxing the type compatibility rules (for all qualifiers) would be useful but is beyond the scope of my proposal.

'Safe' wrappers for existing functions that produce null pointers could be written, for example _Optional FILE *safe_fopen(const char *, const char *) would produce a pointer that can only be passed to functions which accept pointers to optional-qualified types.

Requiring implementations to redefine the constant to which the NULL macro expands as ((_Optional void *)0) is unthinkable because it would invalidate all existing code. However, it might be useful to standardize an alternative macro for use in place of NULL. I have not specified such a macro because NULL is not a core part of the language.

Here is an example of one type of change that I made to an existing codebase:

Before

entry_t *old_entries = d->entries;
d->entries = mem_alloc(sizeof(entry_t) * new_size);

if (NULL == d->entries)
{
    d->entries = old_entries;
    return ERROR_OOM;
}

After

_Optional entry_t *new_entries = mem_alloc(sizeof(entry_t) * new_size);

if (NULL == new_entries)
{
    return ERROR_OOM;
}

d->entries = &*new_entries;

This pattern avoids the need to qualify the array pointed to by struct member entries as _Optional, thereby simplifying all other code which uses it. When nullability is part of the type system, more discipline and less constructive ambiguity is required. General-purpose struct types for which pointer nullability depends on specific usage become a liability.

Of course, programmers are free to eschew the new qualifier, just as many do not consider const correctness to be worth their time.

Proposed language extension

The _Optional qualifier is treated like existing qualifiers when determining compatibility between types, and when determining whether a pointer may be implicitly converted to a pointer to a differently-qualified type.

Prototyping in Clang

Updating Clang to recognize the _Optional qualifier was not as straightforward as I had hoped because a fixed number of address bits are allocated to store 'fast' qualifiers such as const and volatile. Increasing the number of bits used for that purpose required pointed-to objects to be allocated with coarser granularity. I didn't consider any performance effects of this change significant for a prototype.

Only three methods needed updating to implement special semantics (different from volatile and const) for the new type qualifier:

Having prototyped the above changes, I found that the new qualifier was already useful for finding issues caused by not handling null values defensively. This was exactly what I had hoped, because a new qualifier cannot be justified unless it provides value in the absence of static analysis.

Prototyping in Clang's static analyser

The core of Clang's static analyser does path-sensitive analysis to determine (for each step in execution of a program) whether a symbol is known to be null, known to be non-null, or could be either.

An existing checker understands _Nullable and _Nonnull attributes, which it uses to maintain extra metadata about whether memory regions supposedly contain nullable or non-null values. This is used when path-sensitive analysis alone cannot determine whether a value is null.

It was simple to update the nullability checker to treat pointers to _Optional types as equivalent to _Nullable. This ensures static analysis of code using my proposed type qualifier is at least as good as analysis using existing attributes, thereby removing one barrier to adoption.

This was not sufficient because the analyser ignores many instances of undefined behaviour. For example, it allows expressions like &self->super when selfis null. This latitude is also required because many commonly-used macros such as offsetof and container_of have undefined behaviour. The simplest definition of offsetof incorporates an explicit null pointer dereference:

#define offsetof(st, m) \
 ((size_t)&(((st *)0)->m))

Such expressions must be rejected when applied to pointers to _Optional values, otherwise it would not be safe to remove _Optional from a pointer target by use of my proposed &* idiom (or any equivalent). Effectively, qualifying a type as _Optional must enable an enhanced level of checking for undefined behaviour, which operates partly at a syntactic level rather than solely at the level of simulated memory accesses.

My proposed qualifier does not require the property of nullability to be tracked using extra metadata: if a pointer target's type is _Optional then that pointer may be null, regardless of its provenance. I therefore created a separate checker for optional values, instead of extending the nullability checker. This requires only checkPreStmtmethods to generate a warning unless pointers to _Optional values used in expressions are provably not null.

The ProgramState::isNull method used by pre-existing checkers was good at telling that a pointer definitely is null, but bad at telling that it definitely isn't null. For example, it returned 'not sure' in the following trivial case, which caused a spurious warning from my new checker:

int main(void)
{
  int p;
  int _Optional *q = &p;
  if (q) {
    *q = 0; // spurious warning
  }
  return 0;
}

When analyzing the above program, the statement if (q) does not create a constraint such as range [1, 18446744073709551615] for use in future inferences about the value of q. This is because SimpleConstraintManager::assumeInternal uses SValBuilder::evalCast to convert a pointer type to Boolean, which replaces the condition with 1 ("Non-symbolic memory regions are always true") if invoked on a pointer (such as q) that lacks an associated symbolic region.

I added code in isNull to do the same evalCast and check for a zero result if invoked on an expression which is not a constant and does not wrap a symbol. This merely aligns the result of isNull with the path already assumed to have been taken.

Clang does not generate ImplicitCastExpr<LValueToRValue> nodes for unary operators (prefix or postfix ++/--) or compound assignment operators (+=/-=) in its Abstract Syntax Tree. Consequently, I found it hard to verify that implicit removal of the _Optional qualifier from the result of such operations is safe and therefore decided against it.

It would have been possible to change only the semantics of the arithmetic operators + and -, but I didn't want to break an equivalence specified in 6.5.16.2 of the C standard:

A compound assignment of the form E1 op = E2 differs from the simple assignment expression E1 = E1 op (E2) only in that the lvalue E1 is evaluated only once.

Possible objections

The need to define a typedef name before declaring a pointer to an _Optional function is an undeniable drawback of qualifying the pointed-to type rather than the pointer type. I would argue that code clarity and documentation is often improved by composing complex declarations from type aliases, and that this limitation of the declaration syntax is outweighed by the benefit of regular semantics of actual usage.

Some may struggle to accept a novel syntax for adding nullability information to pointers, given the existence of more prosaic solutions. I can only urge them to consider whether a solution inspired by pointer-to-const is really such a novelty — especially in comparison to the irregular new semantics required when the pointer type itself is qualified.

Others may agree with Stroustrup (in "The Design and Evolution of C++", 1994) that C's syntax and semantics are a "known mess" of "perversities". Nevertheless, I believe that pointer nullability should be added in a way that conforms to long-established C language idioms rather than violating such norms (as C++ references do) in the hope of satisfying users who will never like C anyway.

Let's make it happen

If you've read this far, and you like what you've read, then the most important thing is that you don't do nothing. Changes to programming languages only happen with the collaboration and support of many people.

Here are some practical steps you can take:

Thank you for reading.

But what about the film?

At the risk of spoiling your enjoyment of a late-sixties flop, the denouement of 'Thunderbird 6' reveals the titular craft to be neither a faster-than-light spacecraft nor a hypersonic aircraft (both of which are teased in the opening). Instead, it is a Tiger Moth: a classic biplane that should be obsolete in the 21st century but has endured thanks to its simple, rugged, compact and lightweight design.

Surprisingly, this wins ex-astronaut Jeff Tracy's approval:

I'll buy it. I agree it's a good design. It has been built, and it sure has been tested!

This also reflects how I feel about the C programming language, and my hope for how others will receive my proposed extension.

Tiger Moth towing a banner: 'THE END' (Thunderbird Six, 1968)