🚀 Modern C (C11 → C23)
C23: the grand tour
▶ Open the interactive lesson — free, no signupRemember 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:
#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;
}$ 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;
constexprobjects 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:
#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;
}$ 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:
#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;
}$ 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
| attribute | meaning |
|---|---|
[[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
- Empty initializer:
int arr[100] = {};— zero everything, no more{0}folklore. - Enums grow up: pick the underlying type —
enum flags : unsigned char { … };— and enumerators may exceedintrange. #embed: include a binary file's bytes directly in an array — the end of xxd-generated header files:
/* 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?
| feature | gcc | clang |
|---|---|---|
| bool/true/false, nullptr, typeof, auto, {}, attributes | 13+ | 16+ |
_BitInt | 14+ | 14+ (it pioneered as _ExtInt) |
constexpr objects | 13+ | 19+ |
#embed | 15+ | 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.