Constants, Enumerations and Initialization

This section documents C23 (ISO/IEC 9899:2024), per ISO/IEC JTC1/SC22/WG14’s freely available working draft N3220, which WG14 documents as differing from the published standard only editorially — the reference these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against the WG14 draft and cppreference.com’s C reference before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

C has four different ways to say "this value does not change" — const objects, enum constants, macro constants and (new in C23) constexpr — and they are not interchangeable. This page covers all four, plus every way to give an object its initial value.

const Objects

const means this object must not be modified through this name. It does not mean "compile-time constant":

#include <stdio.h>

int main(void)
{
    const int limit = 100;
    const double gravity = 9.80665;

    // limit = 101;                 // error: read-only variable is not assignable

    int table[100];                 // fine at block scope, but this is a VLA if limit is used
    printf("%d %g %zu\n", limit, gravity, sizeof table);
    return 0;
}

The subtlety that makes const unlike other languages: const int limit = 100; is not a constant expression in C, so before C23 it could not size a file-scope array or appear in a case label. That is exactly the gap enum, macros and now constexpr fill.

const is at its most valuable on pointer parameters, where it documents and enforces "I will not write through this":

#include <stddef.h>

// The pointee is const; the pointer itself is not.
size_t count_spaces(const char *text)
{
    size_t n = 0;
    for (const char *p = text; *p != '\0'; ++p) {
        if (*p == ' ') {
            ++n;
        }
    }
    return n;
}

See Pointers for const char * vs. char * const vs. const char * const.

Enumerations

An enum declares a set of named integer constants. Unlike const, these are constant expressions:

#include <stdio.h>

enum Color {
    COLOR_RED,              // 0
    COLOR_GREEN,            // 1
    COLOR_BLUE = 10,        // explicit
    COLOR_INDIGO,           // 11 -- continues from the previous value
    COLOR_VIOLET = 11       // duplicates are allowed
};

// The idiomatic pre-C23 way to declare a constant usable at compile time:
enum { BUFFER_SIZE = 4096 };

int main(void)
{
    char buffer[BUFFER_SIZE];               // legal: BUFFER_SIZE is a constant expression
    enum Color c = COLOR_BLUE;

    switch (c) {
    case COLOR_RED:   puts("red");   break;
    case COLOR_GREEN: puts("green"); break;
    case COLOR_BLUE:  puts("blue");  break;
    default:          puts("other"); break;
    }

    printf("%d %d %zu\n", (int)c, COLOR_INDIGO, sizeof buffer);
    return 0;
}

Before C23, every enumeration constant had type int and the enumerated type had an implementation-defined compatible integer type. Two C23 changes fix long-standing complaints:

  • Enumeration constants that do not fit in int are allowed, taking a wider type.

  • An enumeration may declare a fixed underlying type, which pins its size, its signedness and its constants' type:

#include <stdint.h>
#include <stdio.h>

enum Opcode : uint8_t {         // C23: exactly one byte, unsigned
    OP_NOP  = 0x00,
    OP_LOAD = 0x01,
    OP_HALT = 0xFF
};

enum Flags : unsigned long long {
    FLAG_NONE = 0ULL,
    FLAG_HUGE = 1ULL << 40      // does not fit in int -- legal in C23
};

int main(void)
{
    printf("%zu %u %llu\n", sizeof(enum Opcode), (unsigned)OP_HALT, (unsigned long long)FLAG_HUGE);
    return 0;
}

A trailing comma after the last enumerator has been legal since C99 — keep it, so adding a member is a one-line diff.

Macros vs. constexpr

Three ways to write "the maximum number of items is 64", with different trade-offs:

#include <stdio.h>

#define MAX_ITEMS_MACRO 64              // 1. preprocessor: no type, no scope, no debugger symbol
enum { MAX_ITEMS_ENUM = 64 };           // 2. enum: type int, scoped, constant expression
constexpr int MAX_ITEMS_CONSTEXPR = 64; // 3. C23: typed, scoped, constant expression

int main(void)
{
    int a[MAX_ITEMS_MACRO];
    int b[MAX_ITEMS_ENUM];
    int c[MAX_ITEMS_CONSTEXPR];         // C23: a real constant, so this is not a VLA

    printf("%zu %zu %zu\n", sizeof a, sizeof b, sizeof c);
    return 0;
}
Form When to use it

#define

Values needed by the preprocessor itself (#if), string literals to be concatenated, and anything that must work on a pre-C23 compiler with a non-int type.

enum { … }

Integer constants on any C version — still the most portable typed-ish constant.

constexpr

C23 and later: the right default. It has a type (so constexpr double, constexpr uint8_t work), obeys scope, and appears in the debugger.

constexpr in C is deliberately much narrower than in C++: it applies to objects, not functions, and its initializer must be a constant expression.

constexpr is one of the last C23 features to land in compilers: it needs GCC 15 or later or Clang 19 or later. Until your toolchain baseline reaches that, enum { … } remains the portable way to declare an integer constant expression, and a static const object plus a macro covers the non-integer cases.

constexpr double TAU = 6.283185307179586;
constexpr unsigned char MASK = 0xF0;
constexpr int DERIVED = 8 * 8;              // constant expressions may be computed

// constexpr int bad = rand();              // error: not a constant expression

Initialization

An object that is not initialized has an indeterminate value — except for objects with static or thread storage duration, which are zero-initialized. Reading an indeterminate value is undefined behavior, so initialize at the point of declaration.

Scalars and Aggregates

#include <stdio.h>

struct Point { double x, y; };

int main(void)
{
    int i = 7;
    double d = 1.5;
    const char *s = "text";

    int primes[5] = { 2, 3, 5, 7, 11 };
    int padded[5] = { 2, 3 };               // remaining elements are zero
    int inferred[] = { 2, 3, 5 };           // size 3, deduced from the initializer

    int grid[2][3] = { { 1, 2, 3 }, { 4, 5, 6 } };
    struct Point p = { 3.0, 4.0 };          // positional
    char word[6] = "hello";                 // 5 chars + NUL exactly fits

    printf("%d %g %s %d %d %zu %d %g %s\n",
           i, d, s, primes[4], padded[2], sizeof inferred / sizeof inferred[0],
           grid[1][2], p.y, word);
    return 0;
}

Designated Initializers (C99)

Naming the members makes the initializer order-independent and self-documenting — and everything not named is zero-initialized:

#include <stdio.h>

struct Config {
    const char *host;
    int port;
    bool verbose;
    int retries;
};

int main(void)
{
    struct Config cfg = {
        .host = "localhost",
        .port = 8080,
        .retries = 3,               // .verbose is zero-initialized to false
    };

    int sparse[10] = { [0] = 1, [9] = 100 };            // array designators
    int ranges[6] = { [1] = 2, 3, 4 };                  // continues from index 1

    printf("%s:%d verbose=%d retries=%d sparse=%d,%d ranges=%d\n",
           cfg.host, cfg.port, (int)cfg.verbose, cfg.retries, sparse[0], sparse[9], ranges[3]);
    return 0;
}

Prefer designated initializers for any struct with more than two members: adding a field then cannot silently shift the meaning of existing initializers.

Zero and Empty Initializers

#include <string.h>

struct Big { int a; char name[64]; double values[16]; };

int main(void)
{
    struct Big zeroed = { 0 };      // classic: initializes the first member, zeroes the rest
    struct Big empty = { };         // C23: empty initializer -- zero-initializes everything
    struct Big cleared;
    memset(&cleared, 0, sizeof cleared);        // runtime alternative, no initializer needed

    return zeroed.a + empty.a + cleared.a;
}

{ } (C23) says what it means and works for any type, including one whose first member is itself an aggregate. Note that { 0 } and memset differ in principle: memset writes all-bits-zero, which is not guaranteed to be a null pointer or a zero floating-point value on exotic targets.

Compound Literals (C99)

A compound literal creates an unnamed object of a given type, in place — useful for passing a temporary struct or array to a function:

#include <stdio.h>

struct Point { double x, y; };

static double distance_from_origin(struct Point p)
{
    return p.x * p.x + p.y * p.y;               // squared, to avoid needing -lm here
}

static int sum(const int *values, size_t n)
{
    int total = 0;
    for (size_t i = 0; i < n; ++i) {
        total += values[i];
    }
    return total;
}

int main(void)
{
    double d = distance_from_origin((struct Point){ .x = 3.0, .y = 4.0 });
    int s = sum((int[]){ 1, 2, 3, 4 }, 4);

    struct Point *heapless = &(struct Point){ .x = 1.0, .y = 2.0 };  // block-scope lifetime

    printf("%g %d %g\n", d, s, heapless->x);
    return 0;
}

The lifetime rule is the one to remember: a compound literal at block scope lives until the end of the enclosing block, so returning a pointer to one is a dangling pointer. At file scope it has static storage duration and lives forever. C23 adds constexpr-qualified compound literals and storage-class specifiers on them.

static_assert

static_assert fails the compilation when an assumption about types or sizes is violated — the cheapest possible test:

#include <limits.h>
#include <stdint.h>

static_assert(sizeof(int) >= 4, "this code assumes a 32-bit or wider int");
static_assert(CHAR_BIT == 8, "this code assumes 8-bit bytes");
static_assert(sizeof(uint32_t) * CHAR_BIT == 32, "uint32_t must be exactly 32 bits");

struct Header { uint16_t magic; uint16_t version; uint32_t length; };
static_assert(sizeof(struct Header) == 8, "Header must have no padding for the wire format");

int main(void)
{
    static_assert(1 + 1 == 2, "arithmetic still works");    // block scope is fine too
    return 0;
}

In C23 the message is optional (static_assert(cond);) and static_assert is a keyword; C11 spelled it _Static_assert and required the message. Use it for every layout assumption a file makes — especially in code that reads binary formats.

See Also