前置知识: C

C23 上手:五个最有感的新特性

9 min高级

上手 C23 对日常写代码最有感的五个特性——数字分隔符、nullptr、bool 关键字、auto 与 [[attributes]],用 gcc -std=c23 验证标准级别,并分清「标准没开」与「编译器太旧」两类报错。

前置知识

  • 已完成 变量与常量:会声明变量、用过 const,知道宏能做文本替换。本文的 nullptr、auto 都建立在「宏与真类型的区别」上;
  • 会编译运行一个 C 程序(零基础起步 已带)。零基础起步见 C 语言零基础起步。

分工说明:520 与 530 合讲 C 新标准。本篇是主教学,解决「日常写代码的新手感」:让编译器进入 C23 模式、五个立刻改变手感的特性、报错时分清标准没开还是编译器太旧;C23 深水区 负责编译期深水能力(#embed、typeof、constexpr、ckd_*)、C2y 草案现状与升级策略。两篇示例不重复,本篇是 530 的地基。

学习目标

读完本文你将能够:

  1. 用 gcc -std=c23 编译程序,并用 __STDC_VERSION__ 的输出证明标准生效;
  2. 用数字分隔符与二进制字面量重写可读性差的长数字和位掩码;
  3. 不引头文件直接使用 bool/true/false,并用 nullptr 替代 NULL;
  4. 用 auto 让编译器从初始化器推断类型,说出它与 C++ 的 auto 的两处不同;
  5. 用 [[nodiscard]]、[[deprecated]] 把约定变成编译器警告,并读懂警告原文。

预计 45 到 60 分钟,含 1 组修改实验、2 个调试实录与 3 道练习。

1. 问题引入

C 语言上一次大更新还要追溯到 1999 年的 C99——此后二十多年,这门语言几乎原地踏步,C17 只是一次缺陷修订。直到 C23(正式名称 ISO/IEC 9899:2024,2024 年发布)让它重新现代起来。看一个给游戏存档工具补统计功能的下午,你会接连撞上三件小别扭:

c
#define MAX_SCORE 1000000        /* 一百万:数零数到眼花,多一个零没人发现 */
#include <stdbool.h>             /* 想写个布尔标志,还要专门引头文件 */
if (slot != NULL) { ... }        /* 空指针判断:NULL 是宏?是 0?说不清 */

根源是同一件事:C 的关键字太少,很多本该属于语言的能力长期靠宏硬撑。C23 一次还清了一部分。本文挑出对日常写代码最有感的五个,逐一上手。

2. 先让编译器说 C23

C23 特性不是「装了新编译器就有」,多数要显式打开标准开关。先做个探针,确认编译器现在按哪个标准工作:

c
/* check_std.c:打印编译器的标准身份证 */
#include <stdio.h>

int main(void) {
    printf("__STDC_VERSION__ = %ld\n", (long)__STDC_VERSION__);
    return 0;
}
bash
gcc --version | head -1
gcc -std=c23 check_std.c -o check_std && ./check_std

预期输出(版本号以你的机器为准):

text
gcc (GCC) 14.2.0
__STDC_VERSION__ = 202311L

202311L 就是 C23 的编号;不加 -std=c23 时,GCC 14 默认是 gnu17,会打印 201710L。用 #if __STDC_VERSION__ >= 202311L 做探测,让同一份代码在新旧编译器上自己选路,而不是赌编译器。

3. 特性一:数字分隔符,长数字不再数零

c
/* digit_sep.c:一百万到底是几个零 */
#include <stdio.h>

int main(void) {
    long budget   = 1'000'000;      /* 一百万,三位一撇 */
    unsigned mask = 0b1010'1010;    /* 位掩码按 4 位分组 */
    printf("budget = %ld\n", budget);
    printf("mask   = %u\n", mask);
    return 0;
}
bash
gcc -std=c23 digit_sep.c -o digit_sep && ./digit_sep

预期输出:

text
budget = 1000000
mask   = 170

分隔符 ' 不改变数值,纯粹给人看,而且编译器会检查它的位置——写错位置直接编译报错,比注释靠谱。写位掩码时(位运算 的日常),0b1010'1010 比 0xAA 更能看出「哪几位开了」。

4. 特性二:nullptr,空指针不再借用整数 0

NULL 是 <stddef.h> 里的宏,本质是整数 0(或 (void*)0);nullptr 是 C23 的关键字,有专属类型 nullptr_t,只能赋给指针:

c
/* nullptr_kw.c:空指针语义,编译器看得懂 */
#include <stdio.h>

void load_save(int *slot) {
    if (slot == nullptr) {
        puts("无存档,从零开始");
        return;
    }
    printf("进度: %d 分\n", *slot);
}

int main(void) {
    int score = 850;
    load_save(nullptr);
    load_save(&score);
    return 0;
}

预期输出:

text
无存档,从零开始
进度: 850 分

NULL 依然可用,旧代码离不开它;但 nullptr 把「这是空指针」写进了类型系统:int x = nullptr; 在 C23 下直接编译报错。这是限制,更是保护——整数和空指针从此不再被混用。

5. 特性三:bool/true/false 成为关键字

C23 之前,bool 是 <stdbool.h> 里的宏,true/false 也是宏。现在它们是真正的关键字,直接可用:

c
/* bool_kw.c:注意开头,没有 stdbool.h */
#include <stdio.h>

bool is_even(int n) {
    return n % 2 == 0;
}

int main(void) {
    bool game_over = false;
    printf("4 是偶数: %d\n", is_even(4));
    printf("sizeof(bool) = %zu\n", sizeof(bool));
    return 0;
}

预期输出:

text
4 是偶数: 1
sizeof(bool) = 1

bool 打印出来是 1/0,占 1 个字节(不是 1 位;按位打包用位域,见 位域)。<stdbool.h> 仍保留着兼容旧代码,新代码可以直接不写。

6. 特性四:auto,让编译器替你写类型

auto 在 C23 里改了含义:从「自动存储期」变成「从初始化器推断类型」。类型名冗长时特别省事:

c
/* auto_infer.c:类型名不用手写第二遍 */
#include <stdio.h>

struct Player { char name[16]; int level; };

struct Player *find_player(void) {
    static struct Player p = { "Lin", 12 };
    return &p;
}

int main(void) {
    auto score = 980;             /* int */
    auto ratio = 0.5;             /* double */
    auto who   = find_player();   /* struct Player * */

    printf("%s: %d (%.2f)\n", who->name, score, ratio);
    return 0;
}

预期输出:

text
Lin: 980 (0.50)

两个与 C++ 的关键差异:必须有初始化器(auto x; 编译报错);不能用于函数返回类型推导(auto f(void) {...} 不是合法的 C23)。C23 的 auto 只做一件事:把等号右边的类型搬到左边。

7. 特性五:[[attributes]],把约定交给编译器检查

团队约定里最常被违反的两条:「这个函数别再用了」「这个返回值不能丢」。属性语法让编译器替你盯着:

c
/* attrs.c:约定写进代码,编译器执行 */
#include <stdio.h>

[[nodiscard]] int calc_combo(int hits) {   /* 返回值不许丢 */
    return hits * 10;
}

[[deprecated("用 calc_combo")]]
int old_combo(int hits) {                  /* 旧接口,标记弃用 */
    return hits * 10;
}

int main(void) {
    printf("combo = %d\n", calc_combo(8));
    return 0;
}

预期输出:

text
combo = 80

程序正常、零警告——因为你没违反约定。标准属性还有 [[fallthrough]](标注 switch 有意穿透)、[[maybe_unused]](压掉未使用警告)、[[noreturn]](函数不返回)。更多属性见 属性与编译器扩展。

8. 核心概念:C23 现代化的三条线

  1. 把宏转正为关键字:bool/nullptr 的语义进入类型系统,编译器能替你查错,而不是靠宏展开后的巧合正确;
  2. 减少重复劳动:数字分隔符让人不再数零,auto 让类型名只写一遍——省的是出错机会;
  3. 把约定变成可检查的规则:[[attributes]] 让违反约定的代码在编译期暴露,无需等人 code review。

C23 一共改了几十处,本文只挑「写了立刻有感」的五个。编译期更强的能力,深水区 接着讲。

9. 修改实验:分隔符只是给人看的

把 digit_sep.c 里 budget 一行改成下面这样(分隔符位置故意不按三位一组),先预测输出再运行:

c
    long budget = 1'00'0000;

预期输出(先写下你的答案):budget = 1000000,mask = 170——数值完全没变。分隔符可以插在数字任意位置,编译器只要求「在数字中间」。顺手把 mask 改成 0b1111'0000,先心算十进制(240),再运行验证。

10. 常见错误与调试实录

实录一:error: unknown type name ‘bool’——忘了开标准

把 bool_kw.c 交给一个默认 C17 的构建命令:

bash
gcc bool_kw.c -o bool_kw      /* 没有 -std=c23,默认 gnu17 */

真实报错(截取关键行):

text
bool_kw.c:3:1: error: unknown type name 'bool'; did you mean '_Bool'?
    3 | bool is_even(int n) {
      | ^~~~
      | _Bool
bool_kw.c:8:20: error: 'false' undeclared (first use in this function)

读报错三步:unknown type name 'bool' 说明编译器不认识这个类型名;但代码写法没错,是「代码要求的标准」和「编译器当前标准」不匹配;看编译命令,没有 -std=c23,加上再编即消失。GCC 提示的 _Bool 是 C99 老写法,不加开关时它是退路。

实录二:warning: … declared with attribute ‘nodiscard’——你丢了返回值

把 attrs.c 的 main 里那行 printf 拆开,改成只调用、不接收:calc_combo(8);。重新编译,真实警告:

text
attrs.c:14:5: warning: ignoring return value of 'calc_combo' declared with attribute 'nodiscard' [-Wunused-result]
   14 |     calc_combo(8);
      |     ^~~~~~~~~~~~~

读报错三步:这是 warning 不是 error,程序照常生成——很多人因此当没看见,这正是它危险的地方;ignoring return value ... 'nodiscard' 指出你调用了 [[nodiscard]] 函数却没用返回值;真要丢弃(只关心副作用)就显式写 (void)calc_combo(8); 让意图可见,否则接住返回值。

排障速查与版本表

看到 unknown type name 'bool'、'nullptr' undeclared 这类错,先怀疑标准没开(加 -std=c23),仍报错再怀疑编译器太旧——按下表对照(出处:cppreference C23 支持页,验证于 2026-09):

特性GCC 最低Clang 最低
数字分隔符1213
nullptr、bool/true/false 关键字1316
auto 类型推断1318
[[attributes]]109

GCC 15 起默认标准已是 gnu23(以发布说明为准);MSVC 的 C23 支持仍在推进中,以 Microsoft 文档为准。

11. 实际项目中的使用场景

  • 固件与嵌入式(如 FoloToy-calendar 这类 ESP32 项目):0b 位掩码对着芯片手册写寄存器,见 嵌入式 C 编程;
  • 跨平台库与接口治理:开头一段 __STDC_VERSION__ 探测让同一份源码在新旧编译器上都编译通过;[[deprecated]] 标记旧接口,「换新函数」从口头约定变成编译器统计;
  • 需要兼容老工具链的第三方 SDK 就老实用 -std=c17:新特性是工具,不是炫技。

12. 小练习

预测题(5 分钟):先写下答案再运行验证:

c
auto x = 3.0 / 2;
printf("%d\n", x);

参考答案(先写再看):x 是 double(3.0/2 是浮点除法)。用 %d 打印 double 是未定义行为;-Wall 下会看到 warning: format '%d' expects argument of type 'int', but argument 2 has type 'double' [-Wformat=]。

修改题(15 分钟):在 bool_kw.c 基础上新增 bool is_leap(int year) 判断闰年(能被 4 整除但不能被 100 整除,或能被 400 整除),main 里依次验证 2000、1900、2024。验收:输出 1 0 1 三行;全程不出现 #include <stdbool.h>。

修 Bug 题(15 分钟):下面这份代码在 CI 上编译失败,真实报错如下。按「读报错三步」定位并给出两种修复:

c
/* buggy_std.c */
#include <stdio.h>

struct Save { int level; };

void print_save(struct Save *s) {
    if (s == nullptr) {
        puts("empty");
        return;
    }
    printf("level %d\n", s->level);
}

int main(void) {
    print_save(nullptr);
    return 0;
}
text
buggy_std.c:6:14: error: 'nullptr' undeclared (first use in this function)
    6 |     if (s == nullptr) {
      |              ^~~~~~~
buggy_std.c:6:14: note: each undeclared identifier is reported only once for each function it appears in
...
buggy_std.c:13:16: error: 'nullptr' undeclared (first use in this function)

参考要点:构建用的是 -std=c17,C17 没有这个关键字。修复一(首选):构建改 gcc -std=c23(需 GCC 13+);修复二(保底):改回 NULL 并 #include <stddef.h>。选哪种,取决于团队允不允许动构建脚本。

13. 与之前和之后的知识的关系

  • 往前:变量与常量 里 const 与 #define 的分工,正是深水区 constexpr 要接手的话题;位运算与位域 的掩码用 0b1010'1010 写,可读性上一个台阶;
  • 往后:C23 深水区 回答本文按下不表的「C2y 是什么状态」「老项目怎么迁到 C23」;
  • 更远:[[attributes]] 只是标准属性最小集,编译器专属属性大家族在 属性与编译器扩展;-std=c23 写进构建脚本见 构建系统。

14. 官方文档

15. 自我检查

  • 能不看资料写出「打印 __STDC_VERSION__ 并判断是否为 C23」的探针程序;
  • 能一口气说出五个特性的名字,并各用一句话讲清它解决了什么旧别扭;
  • 看到 unknown type name 'bool' 能立刻给出两个可能原因并按顺序排查;
  • 能说清 C23 的 auto 与 C++ 的 auto 至少两处不同。

本章总结

C23(ISO/IEC 9899:2024)结束了 C 语言二十多年的原地踏步。最有感的五件事:数字分隔符让长数字可数,nullptr 让空指针有类型,bool/true/false 不再借住头文件,auto 让类型名只写一遍,[[attributes]] 让约定变成编译警告。开关是 gcc -std=c23,身份证是 __STDC_VERSION__ = 202311L,排障第一步永远是分清「标准没开」还是「编译器太旧」。编译期更强的深水特性,下一篇见。

下一步

进入 C23 深水区:把资源文件编进可执行文件,写出类型安全的泛型宏,拦下整数溢出,然后回答两个工程问题——怎么查编译器支持矩阵,老代码库怎么一步步升到 C23。