The Double Pointer Const Dilemma in C

If you have spent time writing C or interfacing with POSIX system APIs, you have likely run into the infamous pointer-to-pointer qualification problem. You have an array of strings (char **), you want to pass it to a function that only reads those strings without modifying them, so naturally, you expect the parameter to be declared as const char ** or const char * const *.

However, the C compiler immediately stops you with a warning or error:

void print_strings(const char **list);

char *args[] = {"alpha", "beta", NULL};
print_strings(args); // Warning: passing argument 1 of 'print_strings' from incompatible pointer type

Both the comp.lang.c FAQ (Question 11.10) and the POSIX.1 specification rationale discuss this behavior and suggest explicit casts (such as (const char * const *) or (const char **)) as workarounds. Yet, looking strictly at the ISO C standard's strict aliasing rules (ISO/IEC 9899 §6.5 ¶7), a nagging question arises: Does dereferencing such a cast pointer technically cause undefined behavior? And why did POSIX and comp.lang.c suggest this in the first place?

1. Why C Forbids Implicit Conversion to const char **

Before diving into the cast itself, it helps to understand why C disallows the assignment in the first place. The standard (ISO/IEC 9899 §6.5.16.1) includes an explicit example explaining the safety vulnerability:

const char **cpp;
char *p;
const char c = 'A';

cpp = &p;   // 1. Constraint violation in standard C!
*cpp = &c;  // 2. Perfectly valid: assigns 'const char *' to '*cpp'
*p = 'B';   // 3. Valid: modifies 'c', which was originally declared const!

If line 1 were permitted implicitly, line 3 would end up mutating read-only memory through p without any explicit casts. To preserve const-correctness, C forbids assigning char ** to const char **.

Why doesn't C allow const char * const * implicitly?

Unlike C++, where multi-level qualification conversions are allowed if every level has const added up to that point, C historically only checks qualifiers at the outermost indirection level (§6.5.16.1):

"both operands are pointers to qualified or unqualified versions of compatible types, and the type pointed to by the left has all the qualifiers of the type pointed to by the right"

In const char * const *, the type pointed to is const char * const. For char **, the type pointed to is char *. Because char * and const char * const are not qualified versions of each other (one points to char, the other points to const char), they are incompatible types under C's type system rules. Therefore, C cannot allow the implicit conversion without dedicated syntax rules.

2. Does Casting Violate the Strict Aliasing Rule?

Under the ISO C strict aliasing rule (§6.5 ¶7), an object may only have its stored value accessed through an lvalue of:

  • A type compatible with the effective type of the object,
  • A qualified version of a type compatible with the effective type,
  • A character type (e.g., char, unsigned char).

When you cast a char ** to const char * const *, the target function dereferences it to read an element of type const char *. But the underlying object in memory is of type char *.

Is const char * a qualified version of char *? No.

  • A qualified version of char * is char * const (a constant pointer to a mutable char).
  • const char * is an unqualified pointer to a constant char.

Strictly speaking, according to a hyper-pedantic reading of §6.5 ¶7, reading a char * through an lvalue of type const char * could be interpreted as an aliasing violation.

3. Why POSIX and the C FAQ Still Advise the Cast

Despite the pedantic strict-aliasing argument, POSIX and compiler writers deliberately treat this cast as standard, well-defined practice for several key reasons:

A. Identical Representation and Alignment Requirements

The C standard explicitly mandates (§6.2.5 ¶28):

"All pointers to qualified or unqualified versions of compatible types shall have the same representation and alignment requirements."

Because char and const char are qualified/unqualified versions of each other, char * and const char * are guaranteed by the standard to have the exact same binary layout, size, and alignment on every compliant platform. No pointer translation, padding, or conversion instruction can exist between them.

B. Practical Compiler Implementations and Aliasing

Compilers use strict aliasing (Type-Based Alias Analysis / TBAA) to determine if two memory writes/reads can interfere with one another (e.g., reordering writes between an int* and a float*). However, major production compilers (GCC, Clang, MSVC) do not treat char * and const char * as non-aliasing entities when optimizing code.

C. Why POSIX Settled on char *const argv[]

When POSIX standardized functions like execv and execve, they encountered this exact dilemma. In POSIX.1, the declaration was selected as:

int execv(const char *path, char *const argv[]);

Rather than using const char * const argv[], POSIX chose char *const argv[] precisely so that developers calling execv(path, argv) with a standard char **argv would not need to apply an explicit cast. The POSIX rationale lamented this design choice, acknowledging that const char * const argv[] was logically cleaner, but would have burdened millions of lines of existing C code with cast requirements.

Summary and Best Practices

  • In C: You cannot implicitly pass char ** to const char * const * due to C's shallow pointer-qualification rules.
  • Safety of the Cast: Explicitly casting (const char * const *) or (const char **) is universally safe in practice across all standard C compilers because pointers to char and const char share identical representation and alignment guarantees.
  • Designing APIs: If you are creating a C interface that takes an array of mutable strings without altering the array itself, follow POSIX's lead and declare it as char *const argv[] (or char *const *) to spare your users from having to write explicit casts.