C 语言标准的主要修订版本 C23 将于今年发布。我们将浏览最新草案9的亮点和不足,然后深入探讨所有重大突破性变更的根源。侧边栏将分别用代码和歌曲来赞美 C 语言的惯用语和未定义行为。
与之前的重大修订版本 C117 一样,最新的标准引入了几个有用的功能。其中最重要(即使不是最令人兴奋)的功能是使编写安全、正确和可靠的代码变得更容易。例如,新的 <stdckdint.h>
头文件标准化了checked integer arithmetic(带溢出检查的整数算术)。
int i =...; unsigned long ul =...; signed char sc =...;
bool surprise = ckd_add(&i, ul, sc);
类型泛型宏 ckd_add()
计算 ul
和 sc
的和,“就好像两个操作数都以具有无限范围的有符号整数类型表示一样”。如果数学上正确的和适合有符号 int 类型,则将其存储在 i
中,并且宏返回 false
,表示“没有意外”;否则,i
最终会得到以明确定义的方式环绕的和,并且宏返回 true
。类似的宏处理乘法和减法。ckd_*
宏为包括 C 的“常用算术转换”在内的算术陷阱开辟了一条令人耳目一新的理智之路。
C23 还添加了新功能,以保护秘密免受窥探,并保护程序员免受自身错误的影响。新的 memset_explicit()
函数用于擦除内存中的敏感数据;与普通的 memset
不同,它旨在防止优化器省略擦除操作。旧的 calloc(size_t n, size_t s)
仍然分配一个由 n
个大小为 s
的对象组成的零初始化数组,但 C23 要求如果 n*s
会溢出,则返回空指针。
除了这些新的正确性和安全性辅助功能之外,C23 还提供了许多新的便利功能:常量 true
、false
和 nullptr
现在是语言关键字;值得庆幸的是,它们的含义符合您的预期。新的 typeof
功能使协调变量声明变得更容易。预处理器现在可以将任意二进制数据 #embed
到源文件中。使用新的标准 "={}
" 语法可以轻松地对堆栈分配的结构和可变长度数组进行零初始化。C23 理解二进制字面量,并允许使用撇号作为数字分隔符,因此您可以声明 int j = 0b10'01'10
,并且 printf
系列支持新的转换说明符,用于将无符号类型打印为二进制 ("01010101")。经典面试题“计算给定 int
中 1 的位数”的正确解决方案现在是 stdc_count_ones()
。
遗憾的是,关于 C23 的新闻并不全是好消息。新标准的非特性、误特性和反特性足够多且严重,程序员不应在仔细权衡风险和收益之前“升级”。旧标准(如 C99 和 C11)并非完美,但详细分析有时会得出结论,它们比 C23 更可取。
在回顾 C23 的问题之后,我们将讨论与现有代码和平共处的策略以及新代码中的风险缓解措施。
—Dennis Ritchie 论第一个 C 标准 4, 27
法律应该易于获取、易于理解且受治理者欢迎,并且它们应该与时代的变化保持同步。C23 缺乏这些优点。
标准 C 隐藏在付费墙后面:官方标准目前售价超过 200 美元,因此大多数程序员只能使用非官方草案。1 该标准经常使自己的作者感到困惑,关键部分甚至使经验丰富且受过良好教育的程序员感到困惑27;困惑的沉默不是同意。设法弄清楚标准实际含义的开发人员经常感到震惊。25,27 标准 C 的进展缓慢(例如,用 30 年和五个修订版来定义零等于零26),有时甚至根本没有进展。
进步意味着排干沼泽和围起焦油坑,但 C23 实际上扩大了 C 最臭名昭著的陷阱之一,以防粗心大意的人。从 C89 开始的所有 C 标准都允许编译器删除包含未定义操作的代码路径——编译器愉快地这样做,让程序员感到惊讶和愤怒。16 C23 引入了一种新的机制来实现令人震惊的省略:通过用新的 unreachable
注解标记代码路径,12 程序员向编译器保证控制永远不会到达它,从而明确邀请编译器省略标记的路径。C23 进一步授权编译器使用一个代码路径上的 unreachable
注解来证明删除完全不同的、未标记为 unreachable
的代码路径是合理的,而无需通知或警告:请参阅 N3054 第 316 页示例 1 中对 puts()
的讨论。9
未采取行动的主要令人失望之处涉及 C 编程的支柱:指针。比较指向不同对象(不同数组或动态分配的内存块)的指针仍然是未定义行为,这是一种礼貌的说法,即标准允许编译器疯狂运行,机器在运行时着火。16 标准的指针比较限制源于被遗忘的古代硬件架构,具有令人惊讶的后果。看似无辜的序列 a=malloc(...)
,然后 b=malloc(...)
,然后 if (a<b)...
是引发火灾的配方,并且无法在标准 C 中有效地实现标准 memmove()
函数。16 此外,经过大量讨论后,神秘且动机不良的“指针 zap”规则21 仍然有效:指向 free
'd 内存的指针类似于未初始化的指针,因此 free(p)
后跟 if (p==q)
是纵火工具。事情不必如此。
C23 未能纠正追溯到最早版本标准的误导。其 rand()
的示例实现仍然是相同的原始线性同余生成器,返回16 位整数——这种设计在本世纪初就应该被淘汰了。20 年前发明的 XORshift 随机数生成器将是一个更好的例子:它们简单快速,可容纳 32 位、64 位和 128 位机器字,并产生更优的随机序列。20
开发人员还应注意,C23 比早期的 C 标准更进一步偏离了 C++。C 是(主要)C++ 子集的概念比以往任何时候都更远离现实。10
可悲的是,错失的机会和与 C++ 的不兼容性并不是新标准最糟糕的方面。C23 将数十年完全合法的程序变成了莫洛托夫鸡尾酒。
realloc()
自 C89 起成为标准的 realloc
函数可以调整内存分配的大小。C23 毫无意义地禁止了一个有用的 realloc
功能,该功能经过 C89 到 C11 的精心设计和认可,这使得 C23 realloc
的通用性大大降低,并将火种塞进了无数按照早期标准编写的程序中。要理解最近禁令的愚蠢之处,我们必须回顾昔日功能齐全的 realloc
以及它完美适应的优雅惯用语。
C89 将 realloc
定义为包括 malloc
和 free
作为特殊情况
void *realloc(void *ptr, size_t size);
“realloc
函数将 ptr
指向的对象的大小更改为 size
指定的大小。如果 ptr
是空指针,则 realloc
函数的行为类似于指定大小的 malloc
函数……如果无法分配空间,则 ptr
指向的对象保持不变。如果 size
为零且 ptr
不是空指针,则释放它指向的对象。”
— C89,2 在 Plauger22 中逐字重复
许多真实世界的代码利用了 realloc
的通用性。示例包括 Linux 机器的搜索 $PATH
上的数十个可执行文件
$ echo foo | ltrace grep bar |& grep realloc
realloc(0, 128) = 0x55a17f5596f0
C89 和 C99 标准委员会强烈建议分配接口 malloc
、calloc
和 realloc
返回空指针以响应零字节请求。3,6 这意味着 realloc(p,0)
应该无条件地 free(p)
并返回 NULL
:在这种情况下不会发生新的分配,因此不可能发生分配失败。为简洁起见,让“zero-null(零空)”表示符合 C89/C99 指南的分配器实现。
realloc
的瑞士军刀方面起初令人望而生畏,但此接口奖励耐心研究。很快您就会意识到,经过深思熟虑的设计,zero-null realloc
能够实现优雅的动态数组,这些数组在所有情况下都能完全正确地工作,从而避免了笨拙且容易出错的代码来处理从零增长和缩小到零的特殊情况。
图 1 通过一个简单的堆栈说明了惯用的 realloc
,该堆栈随着每次 push()
而增长,并随着每次 pop()
而缩小。指针 S
和计数器 N
(第 1 行和第 2 行)表示堆栈:S
指向一个由 N
个严格正 int
组成的数组。由于它们是静态分配的,因此最初指针为 NULL
,计数器为零,表示一个空堆栈。函数 resize
(第 4-10 行)将堆栈调整为给定的新容量,在调用 realloc
之前检查算术溢出(第 6 行),并检查返回值以确定内存耗尽(第 8 行)。当请求非零新大小时返回 NULL
时,可以推断出分配失败;zero-null realloc
在第二个参数为零时也会返回 NULL
,但这并不表示分配失败,因为没有尝试分配。(检查 errno
无法使可移植代码检测到分配失败,因为 C 标准没有说明内存不足如何影响 errno
。)由于 zero-null realloc
的通用性,resize
函数无需考虑堆栈是从零增长还是缩小到零,还是以其他方式调整大小;一切都按预期工作,无需考虑。
图 1:使用 zero-null realloc
持续调整堆栈大小
图 1 的代码遵循 zero-null realloc
语义中隐含的一些简单规则。函数 push
和 pop
(第 12-23 行)仅通过 S
上的下标访问堆栈,因为 realloc
可能会将数组移动到内存中的不同位置。当 N
为零时,它们永远不会解引用 S
。resize
函数抵制了鲁莽的 S = realloc(S,...)
的诱惑,当分配失败时,这会破坏数组的入口点,从而导致内存泄漏和数据丢失。
30 年来,我一直在看到类似于图 1 的代码,从一位老同学的工作开始,他曾费心阅读精细手册;他代码的清晰度和简洁性给我留下了深刻的印象。在随后的几十年中,我反复在严肃的生产代码中发现了惯用的 realloc
,通常是在扫描 p = realloc(p,...)
错误时。
然后,想象一下,当我得知 C23 声明 realloc(ptr,0)
为未定义行为时,我的沮丧,这实际上是从 C89 到 C11 有意认可的广泛而典范的模式中抽走了地毯。先例就这样被抛弃了。将惯用的 realloc
代码编译为 C23,编译器可能会以最令人震惊的方式破坏源代码,并且您的机器可能会在运行时着火。16 更糟糕的是,重新编译不是引发火灾的先决条件:仅仅使用新的或“升级的”标准库重新链接现有的编译二进制文件就为灾难埋下了伏笔。如果您的标准库以动态链接共享库(例如,libc.so
)的形式实现,则运行来自过去的二进制可执行文件将在运行时加载最新的库,因此当您将共享库升级到 C23 时,请准备好灭火器。每个以三个标准世代预期的方式使用 realloc
作为 free
的程序都是一颗等待发生的炸弹,并且习惯于经典通用 realloc
的大量程序员需要重新教育。
对这种灾难性变化的直接解释非常没有说服力,而且显然很少注意到数十年健全的惯用用法:基本上,“realloc(p,0)
的实现各不相同,所以让我们放弃全部”,23 这颠倒了 C 标准化的粗体原则:“现有代码很重要,现有实现并不重要。”3,6 完整的悲惨历史揭示了一个更大且更令人不安的问题,这对 C 的未来来说是不祥之兆。
标准应该通过使可移植代码成为可能来引领通往更美好世界的道路。真正的标准化不可避免地需要赶猫——允许各种编译器和库实现蓬勃发展,同时强制执行合理的行为。realloc
事件的编年史表明,C 标准化如今并非如此运作。
当 C89 成形时,“零长度对象”的概念正在流传:支持者认为,对于零字节分配请求,应该返回指向此类对象的非空指针。
为什么要发出这样的请求?通常是由于算术错误。来自 malloc(0)
的非空指针有什么用?绝对没有任何用处,除了搬起石头砸自己的脚。
解引用这样的指针,甚至将它与任何其他非空指针进行比较都是非法的(回想一下,如果指针比较涉及不同的对象,则它们是可燃的)。翻阅计算编年史,您会发现很少有东西比零长度对象更无用,也很少有东西比指向它的指针更危险。毫不奇怪,类似物在计算之外的世界中很少见:尝试将金额为 0 美元的支票存入您的银行帐户。
C89 和 C99 都明智地“决定不接受零长度对象的概念”,但愚蠢地未能禁止它。正如我们前面看到的,他们强烈建议零空分配,但他们也勉强允许 malloc(0)
返回非空,作为对已经这样做的任性实现的特赦。3,6 这意味着 realloc(p,0)
可能需要分配一个新的零长度对象。并且这种分配可能会失败(就像尝试将现金存入银行帐户并同时取出 0 美元可能会失败一样——慢慢地对自己说,品味其荒谬之处)。到 C17 制定时,对于尝试为 realloc(p,0)
分配零长度对象的实现,如果分配失败,是否应该发生 free(p)
存在分歧。因此,C17 使此行为成为实现定义的,并宣布 realloc
-as-free
为过时,8 这为 C23 的彻底禁止奠定了基础。
总而言之,这场螺旋式下降始于一个值得 Monty Python 思考的概念,由于不负责任的妥协,它像滚雪球一样越滚越大并转移扩散。C23 realloc
混乱只是冰山一角。根本问题是标准未能标准化。展望未来,大麻合法化肯定会催生诸如分数长度对象、虚数长度对象和负长度对象等概念,每种概念都具有与零长度对象一样多的造成混乱的潜力。让我们希望未来的标准委员会能够鼓起勇气,不仅仅是调查现状,用圣水洒在大部分现状上,并将真正需要标准化的东西付之一炬。
您应该如何回应 C23?了解其对现有代码和尚未编写的代码的影响。仅在有充分理由的情况下才将旧代码编译为 C23,并且仅在验证它没有违反新标准中的任何约束后才这样做。如果您需要新的 C23 功能,请考虑将 C23 代码隔离在单独的翻译单元中;幸运的是,从不同源语言编译的目标代码文件可以链接在一起。请注意,对标准库的更改可能会强加不受欢迎的新语义——或废除所需的旧语义,如 realloc
——并且当动态链接库升级时,此类更改可能会在没有重新编译的情况下强加于旧代码。
— Linus Torvalds 论 C 标准25
如果您是那种独立思考并坚持认为行业工具应该易于理解且合理的类型的人,那么您就属于大多数人。如果您不喜欢现状,请与您的同事合作,游说您的编译器和库供应商以及标准委员会 ([email protected])。
要编写新代码,您必须跟踪当前的语言标准;要维护旧代码,您必须了解早期的标准。Kernighan 和 Ritchie18 提供了 C892 的经典描述;Plauger 记录了其标准库。22 Harbison 和 Steele13 涵盖了 C99。5 Klemens19 解释了 C117 中引入的有用功能。Hatton 详细介绍了安全关键 C 编码的注意事项。14
在 https://queue.org.cn/downloads/2023/Drill_Bits_09_example_code.tar.gz 下载示例代码。您将获得图 1 的堆栈和简单的包装器代码,该代码将任何符合标准的内存分配器转换为 zero-null 分配器。
1. 图 1 的堆栈牺牲了速度以换取清晰度和简洁性。实现一种更有效的设计,该设计分别跟踪容量和项目计数,并根据需要将容量调整为 2 倍。17
2. 如果您的 malloc(0)
返回非空,则需要多少次调用才能耗尽内存?需要多少次 0 美元的取款才能使您的银行破产?
3. 使用新的 C23 #embed
功能来实现文学可执行文件。15
4. 在真实代码和教科书中搜索 p = realloc(p,...)
错误(例如,Klemens19 第 253 页)。另请参阅 C89 基本原理3 第 101 页和 C99 基本原理6 第 160 页。
5. 如果您认为惯用 C 代码晦涩难懂,请回想一下关于 Perl 黑手党的旧笑话:他给你一个你无法理解的报价。列出您最喜欢的语言的最佳惯用语和最糟糕的滥用。
6. 检查 <limits.h>
中 INT_MIN
的 #define
。如果您看到类似 (-INT_MAX - 1)
的内容,为什么它不是更直接?请参阅 Gustedt11 第 46 页。
7. C178 声称是对 C11 的错误修复修订版。第 1 页上的单词 "toto
" 是否表示 (a) 编辑的音乐品味;(b) 没有人费心进行拼写检查文档;(c) 我们不再在堪萨斯州了;或 (d) 以上都不是?
8. 程序员 Yossarian 的应用程序需要新的 C23 memset_explicit
函数,但也需要 realoc(p,0)
具有明确的定义。如果这两个函数都位于 libc.so
中,Yossarian 是否陷入了 Catch-23 困境?他应该怎么做?
9. 按照 Shiffman24 的说法,编写一段苏格拉底式的对话,其中 C 语言发明者 Dennis Ritchie 质问 C 标准委员会。有关谈话要点,请参阅 Yodaiken27。
我谴责 C23 禁止优雅地使用经典内存分配函数。您为什么要关心?惯用语有什么重要意义?
流畅、惯用的代码比新手代码更准确、更清晰、通常也更简洁地表达程序员的意图。C 的惯用语并非过多或深奥,但要掌握它们,您必须攀登学习曲线。例如,上面的代码片段显示了大多数 C 程序员如何学习将数值变量折叠为布尔值。“bang-bang”惯用语在每种情况下都不是最好的,但它通常很方便,您必须识别它才能阅读专家代码。
当笨拙的替代方案会使固有的简单任务复杂化时,惯用表达就证明了它的实用性。例如,想象一家对宠物收取额外费用的酒店:第一只狗收费 10 美元,每增加一只狗额外收费 5 美元;其他动物也是如此,但常数不同。惯用代码自然、易于编写、紧凑,并且对于流畅的读者来说显而易见
petFee = !!ndogs * (10 + (ndogs-1) * 5) // risk: rugs
+ !!ncats * ( 7 + (ncats-1) * 3) // " furniture
+ !!nfish * (47 + (nfish-1) * 1); // " floods
Bang-bang 与大多数 C 惯用语一样,并非基于深奥的知识,而是基于对基本原理的透彻理解。忽略 bang-bang 选项的初学者了解逻辑非,但也许他们认为双重否定可能没有用。然而,流畅的程序员欣赏 "!" 运算符的特殊细微差别。掌握 double-bang 主要在于充分理解 single-bang。
Kernighan 和 Pike 详细讨论了编程惯用语。17 Klemens 描述了 C11 功能启用的酷炫惯用语。19 Yodaiken 解释了旨在提高性能优化的 C 标准的各个方面如何破坏系统编程惯用语。27
练习:修改上面的 petFee
公式,为不友好的组合物种添加额外费用:任何数量的狗和猫收费 30 美元,因为噪音;任何数量的猫和鱼收费 20 美元,因为水花。提示:当您将布尔值相乘时会发生什么?
这首模仿 Jefferson Airplane 经典歌曲“White Rabbit”的歌曲是关于编程迷幻药——C 中的未定义行为。标题指的是当编译器省略具有 UB 的代码路径时,您获得的空汇编代码文件。16 Helgrind 是 Valgrind 套件中的一个工具。Chris Lattner 创建了 Clang 编译器。
white.s
一个标志让它更快
一个标志让它更小
而已弃用的 -Wchkp
根本什么都不做。
去问 Lattner
我们是否应该使用 -Wall
。
如果你比较指针
跨段,你将会跌倒。
这就是一个吸食水烟的工作组
标准化了这一切。
去问 Lattner
他们做出了正确的决定吗?
当你的循环和表达式
从你说的位置起身时
Clang 只是发出了一些警告
Valgrind 运行缓慢,
去问 Lattner;
我希望他会知道。
当 -O3
的逻辑
调用你的代码已死
而 main()
任务向后写入
当工作线程向前冲时,
记住 Helgrind 所说的
锁定你的线程。锁定你的线程。
Jon Bentley、Hans Boehm、John Dilley、Kevin O'Malley 和 Charlotte Zhuang 审阅了草稿并提供了有益的反馈。Dilley 和 O'Malley 仔细审查了示例代码并推荐了有用的改进。Dhruva Chakrabarti 和 Pramod Joisha 回答了技术问题。我们感谢 C23 标准委员会成员 Aaron Ballman、Robert Seacord 和 JeanHeyd Meneide 的通信交流。
1. C 标准委员会(第 14 工作组)。文档; https://www.open-std.org/jtc1/sc22/wg14/www/wg14_document_log.htm。
2. C89 标准; https://web.archive.org/web/20161223125339/http://flash-gordon.me.uk/ansi.c.txt。
3. C89 基本原理,ANSI X3J11/88–151,1988 年 11 月。可通过 https://en.wikipedia.org/wiki/ANSI_C 获取。
4. Computer Business Review 工作人员。1988 年。拟议的 ANSI C 语言标准在评论期结束时受到批评; https://techmonitor.ai/technology/proposed_ansi_c_language_standard_draws_criticism_as_comment_period_ends。
5. C99 标准(草案 n1256)。2007 年。 https://www.open-std.org/jtc1/sc22/WG14/www/docs/n1256.pdf。
6. C99 基本原理。2003 年。 https://www.open-std.org/jtc1/sc22/wg14/www/C99RationaleV5.10.pdf。
7. C11 标准(草案 n1570)。2011 年。 http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf
8. C17 标准(草案 n2176)。 https://web.archive.org/web/20181230041359/http:/www.open-std.org/jtc1/sc22/wg14/www/abq/c17_updated_proposed_fdis.pdf
9. C23 标准(草案 n3054)。2022 年。 https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3054.pdf。
10. Ballman, A. 2022 年。WG14 文档 n3065:C xor C++ 编程; https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3065.pdf。 [也可在参考文献 1 中找到。]
11. Gustedt, J. 2019 年。Modern C,第二版。Manning; https://gustedt.gitlabpages.inria.fr/modern-c/。
12. Gustedt, J. 2021 年。WG14 文档 n2826v2。为不可达控制流添加注释; https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2826.pdf。 [也可在参考文献 1 中找到。]
13. Harbison, S. P., Steele III, G. L. 2002 年。C:参考手册,第五版。Prentice Hall。
14. Hatton, L. 1995 年。Safer C:为高完整性和安全关键系统开发软件。McGraw-Hill。
15. Kelly, T. 2022 年。文学可执行文件。acmqueue 20(5); https://queue.org.cn/detail.cfm?id=3570938。
16. Kelly, T., Gu, W., Maksimovski, V. 2021 年。薛定谔的代码:理论和实践中的未定义行为。acmqueue 19(2); https://queue.org.cn/detail.cfm?id=3468263。
17. Kernighan, B., Pike, R. 1999 年。编程实践。Addison-Wesley。
18. Kernighan, B. W., Ritchie, D. M. 1988 年。C 编程语言,第二版。Prentice Hall。
19. Klemens, B. 2014 年。21 世纪 C,第二版。O'Reilly Media。
20. Marsaglia, G. 2003 年。Xorshift RNG。统计软件杂志 8(14); https://www.jstatsoft.org/index.php/jss/article/view/v008i14/916。
21. McKenney, P. E., Michael, M., Mauer, J., Sewell, P., Uecker, M., Boehm, H., Tong, H., Douglas, N., Rodgers, T., Deacon, W., Wong, M. 2019 年。WG14 文档 n2443:生命周期结束指针 zap; https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2443.pdf。 [也可在参考文献 1 中找到。]
22. Plauger, P. J. 1992 年。标准 C 库。Prentice Hall。
23. Seacord, R. C. 2019 年。WG14 文档 n2464:零大小的重新分配是未定义的行为; https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2464.pdf。 [也可在参考文献 1 中找到。]
24. Shiffman, M. 2022 年。一个理性的人。Harper's Magazine(四月),15–16; https://harpers.org/archive/2022/04/steven-pinker-meets-socrates/。
25. Torvalds, L. 2018 年。Linux 内核邮件列表帖子; https://lkml.org/lkml/2018/6/5/769。
26. C FP 小组。2021 年。WG14 文档 n2670:零值比较相等; https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2670.pdf。 [也可在参考文献 1 中找到。]
27. Yodaiken, V. 2021 年。ISO C 如何变得不适用于操作系统开发。第 11 届编程语言和操作系统研讨会(PLOS '21)。 https://doi.org/10.1145/3477113.3487274。
Terence Kelly ([email protected]) 和 Yekai Pan 喜欢调查现状,用圣水洒在大部分现状上,并将他们不喜欢的部件付之一炬。
版权 © 2023 归所有者/作者所有。出版权已授权给 。
最初发表于 Queue vol. 21, no. 1—
在 数字图书馆 中评论本文