Guide to constexpr in C23 for Immutable Values

The C23 revision of the ISO C standard introduces the constexpr specifier, providing a standardized mechanism to declare named, type-safe, compile-time constant objects. This article examines how constexpr operates in C23, how it differs from traditional const variables and #define preprocessor macros, its syntax and initialization rules, and the practical advantages it brings to modern C development.

The Purpose of constexpr in C23

Historically, the C language lacked a way to define a strictly typed, scoped, compile-time constant. Developers relied on #define preprocessor macros or enum declarations to establish values known at translation time.

The standard const qualifier in C merely designates an identifier as "read-only" at runtime. A const variable does not constitute a constant expression under standard C rules; consequently, it cannot reliably be used for array dimensions in non-VLA (Variable Length Array) contexts, case labels in switch statements, or static assertions.

C23 resolves this limitation by adopting the constexpr specifier for object declarations. Unlike C++, C23 limits constexpr strictly to object declarations—it cannot be applied to functions.

Defining Immutable Values with constexpr

When an object is declared with constexpr, it becomes an immutable value evaluated strictly at compile time.

#include <stdio.h>

constexpr int MAX_BUFFER = 1024;
constexpr double PI = 3.141592653589793;

int main(void) {
    // Used as a standard integer constant expression
    int buffer[MAX_BUFFER]; 
    
    switch (getchar()) {
        case MAX_BUFFER: // Valid in C23
            break;
        default:
            break;
    }
    return 0;
}

Because MAX_BUFFER is a genuine constant expression, the array buffer is a fixed-size array rather than a VLA, ensuring predictable stack allocation.

Key Rules and Semantics

The constexpr specifier enforces several constraints to guarantee that the declared object remains fully immutable and resolvable during compilation:

  1. Mandatory Initializer: Every constexpr object must be defined with an explicit initializer. Tentative or uninitialized declarations are forbidden.
  2. Constant Expression Requirement: The initializer must evaluate to a translation-time constant expression. It cannot depend on runtime variables, dynamic function calls, or side effects.
  3. Implicit const Qualification: Applying constexpr to an object automatically imparts the const qualifier to its type. Attempting to modify a constexpr object results in a compilation error.
  4. Scope and Storage: Unlike preprocessor macros, constexpr symbols respect lexical scoping rules. Storage for a constexpr object is only allocated if the program explicitly takes its address (e.g., &MAX_BUFFER), allowing the compiler to optimize the symbol away entirely in standard evaluation paths.
  5. Composite Types: Structures and arrays can be declared with constexpr, provided all members or elements are initialized with constant expressions:
struct Point {
    int x;
    int y;
};

constexpr struct Point ORIGIN = { .x = 0, .y = 0 };
constexpr int MATRIX[2][2] = { {1, 0}, {0, 1} };

constexpr vs. const vs. #define

Feature #define const constexpr (C23)
Evaluation Time Preprocessing phase Runtime (read-only) Compile time
Type Safety None (textual replacement) Full Full
Scope Rules Global (file-level) Block / File scope Block / File scope
Usable in case labels Yes No Yes
Usable in _Static_assert Yes No Yes
Debugger Friendly Poor (often stripped) Good Good

Summary

The C23 constexpr specifier bridges a long-standing gap in the C language by unifying the type safety and scoping of const with the compile-time evaluation guarantees of preprocessor macros. By utilizing constexpr, developers can write safer, cleaner, and more optimizable code without depending on untyped #define directives for compile-time constants.