The C Path — learn C, visually

🚀 Modern C (C11 → C23)

C23: the grand tour

⏱ 14 min · free interactive lesson · quizzes, visualizations & a real compiler

▶ Open the interactive lesson — free, no signup
Why you're learning this

Remember pulling in a whole header just to get true and false, or discovering that NULL is secretly the number 0? The newest edition of C fixes those everyday annoyances you've been living with all course. This tour shows you the C you'll be reading for the next decade — and tells you exactly which parts your compiler already supports today.

C23 (the 2024 edition of the official C standard) is the biggest update to C since C99. Its theme: make the clean way the default way — real booleans, a null pointer that finally has a type of its own, honest constants, and a bag of features C programmers had been faking with macros for decades. Fair warning: your playground compiler here is older, so this lesson's examples are read-and-believe; to run them yourself you'll need gcc 13+ or clang 16+ with -std=c23 (older gcc spells it -std=c2x).

The everyday wins

bool, true, and false are now keywords — no more <stdbool.h>. nullptr is a null pointer constant with its own type nullptr_t, fixing the old ambiguity where NULL might be plain 0 (an int!). constexpr makes genuine compile-time constant objects — something const never was in C. And auto + typeof let you name types you'd rather not spell:

tour1.c — needs gcc 13+ / clang 16+, -std=c23
#include <stdio.h>

int main(void) {
    bool ok = true;                /* keywords — no <stdbool.h>      */
    int *p = nullptr;              /* typed null (type: nullptr_t)   */

    constexpr int max_users = 64;  /* a REAL compile-time constant   */
    int table[max_users];          /* fixed-size array, not a VLA!   */
    static_assert(max_users % 8 == 0, "must be a multiple of 8");

    auto half = max_users / 2;     /* type inferred: int             */
    typeof(half) other = 7;        /* "same type as half" — int      */

    printf("ok=%d slots=%zu half=%d other=%d p-null=%d\n",
           ok, sizeof table / sizeof table[0], half, other,
           p == nullptr);
    return 0;
}
terminal
$ gcc -std=c23 -Wall tour1.c -o tour1 && ./tour1
ok=1 slots=64 half=32 other=7 p-null=1
# printf has no bool/nullptr conversions — %d for bool and a
# comparison for the pointer do the job
💡

Why constexpr matters: in C17, const int n = 64; int a[n]; gives you a variable-length array, because a const variable is not a constant expression — one of C's oldest gotchas (and why everyone used #define or enum for sizes). A constexpr object is a true constant: usable in array sizes, case labels, and static_assert. Note C23 has constexpr objects only — no constexpr functions; that's still C++-land.

🧠 Checkpoint: In C17, why was const int n = 64; int a[n]; a VLA, and what fixes it in C23?

  • It wasn’t a VLA; nothing to fix
  • const variables aren’t constant expressions; constexpr objects are
  • VLAs were removed in C23
  • The fix is static const
Show answer

const variables aren’t constant expressions; constexpr objects are — In C, const means "read-only", not "known at compile time" — so n can’t size a fixed array or label a case. constexpr n = 64 is a genuine integer constant expression, closing a 30-year gap with C++.

New numbers: _BitInt, binary literals, digit separators

Need exactly 12 bits — for a hardware register, a file format, an FPGA interface? _BitInt(N) is an integer type of exactly N bits, for any N up to at least 64 (gcc on x86-64 allows 65535!). Unlike char/short, small _BitInts do not promote to int behind your back. Alongside: binary literals (0b1010) are finally standard, and ' separates digit groups:

tour2.c — needs gcc 14+ / clang 14+, -std=c23
#include <stdio.h>

int main(void) {
    /* exactly 12 bits, with a binary literal + digit separators: */
    unsigned _BitInt(12) reg = 0b1111'1111'1111uwb;   /* 4095 */

    unsigned _BitInt(4) nibble = 15uwb;
    nibble++;                       /* wraps mod 2^4              */

    int budget = 1'000'000;         /* separators work everywhere */

    printf("reg    = %u (max of 12 bits)\n", (unsigned)reg);
    printf("nibble = %u (15 + 1 wrapped)\n", (unsigned)nibble);
    printf("budget = %d\n", budget);
    return 0;
}
terminal
$ gcc -std=c23 tour2.c -o tour2 && ./tour2
reg    = 4095 (max of 12 bits)
nibble = 0 (15 + 1 wrapped)
budget = 1000000
# wb/uwb suffixes make _BitInt literals; unsigned _BitInt wraps
# like any unsigned type — just at YOUR chosen width

🧠 Checkpoint: What is unsigned _BitInt(12)?

  • A 12-byte integer
  • A bit-field only usable in structs
  • An unsigned integer of exactly 12 bits
  • A gcc extension, not standard C
Show answer

An unsigned integer of exactly 12 bits — C23’s _BitInt(N) gives an integer of exactly N bits, usable anywhere a normal integer is — parameters, arrays, arithmetic. Value range here: 0 to 4095. Bonus: unlike short/char, small _BitInts don’t silently promote to int.

Attributes: [[...]]

C23 standardizes the double-bracket attribute syntax (shared with C++) — portable replacements for a zoo of __attribute__((...)) extensions:

attrs.c — needs gcc 13+ / clang 16+, -std=c23
#include <stdio.h>

[[nodiscard]] int reserve(int n) { return n > 0 ? 0 : -1; }

[[deprecated("use reserve() instead")]]
int old_reserve(int n) { return reserve(n); }

int main(void) {
    reserve(8);                     /* warning: value ignored     */
    old_reserve(8);                 /* warning: deprecated        */

    [[maybe_unused]] int dbg = 42;  /* no unused-variable warning */

    int x = 1;
    switch (x) {
    case 1:
        puts("one");
        [[fallthrough]];            /* intentional — no warning   */
    case 2:
        puts("two");
        break;
    }

    int zeros[4] = {};              /* C23 empty initializer      */
    printf("%d %d\n", zeros[0], zeros[3]);
    return 0;
}
terminal
$ gcc -std=c23 -Wall -Wextra attrs.c -o attrs
attrs.c:9:5: warning: ignoring return value of 'reserve', declared with attribute 'nodiscard'
attrs.c:10:5: warning: 'old_reserve' is deprecated: use reserve() instead
$ ./attrs
one
two
0 0
# attributes turn code-review comments into compiler-enforced rules
attributemeaning
[[nodiscard]]warn if a caller ignores the return value (great for error codes)
[[deprecated("why")]]warn on any use, with your migration hint
[[maybe_unused]]suppress unused-variable/parameter warnings, on purpose
[[fallthrough]]"yes, this switch case falls through intentionally"
[[noreturn]]the modern spelling from the noreturn lesson

🧠 Checkpoint: What does [[nodiscard]] on a function do?

  • Prevents the function being optimized out
  • Warns when a caller ignores its return value
  • Makes the return value constexpr
  • Stops the value being copied
Show answer

Warns when a caller ignores its return value — It’s for functions whose return value IS the point — error codes, handles, computed results. Ignoring such a value is almost always a bug (think: ignoring malloc’s result), and nodiscard makes the compiler say so.

The rest of the goodie bag

embed.c — needs gcc 15+ / clang 19+
/* The whole file logo.png becomes bytes in the array —
   at PREPROCESSING time. No xxd, no build scripts. */
static const unsigned char logo[] = {
    #embed "logo.png"
};

/* before C23, everyone generated this by hand:
   static const unsigned char logo[] = { 0x89, 0x50, 0x4e, 0x47, ... }; */

🤔 C23 quickie: constexpr int n = 3; auto x = n + 0.5; — what does printf("%zu", sizeof x); print on x86-64?

Think first

8. The expression n + 0.5 mixes int with double, so usual arithmetic conversions make it a double — and auto deduces exactly that: x is a double (8 bytes), not an int. auto saves typing, but the type it picks follows C’s ordinary expression rules — keep them in your head, not just in the compiler’s.

Can I actually use it?

featuregccclang
bool/true/false, nullptr, typeof, auto, {}, attributes13+16+
_BitInt14+14+ (it pioneered as _ExtInt)
constexpr objects13+19+
#embed15+19+

Compile with gcc -std=c23 -Wall (gcc 13: -std=c2x). Check __STDC_VERSION__ — C23 defines it as 202311L. Broad rule: on a 2024-or-later toolchain, everything above just works; on distro compilers a year or two older, the everyday wins work but check the table.

🎉 And with that, you've met every keyword in the C language — from auto (both meanings!) to _Static_assert. Next stop, Part 6: the standard library, where we put the whole language to work.

▶ Practice this lesson interactively (with live gcc)