我以前不知道的关于 K&R C 的一堆东西

Lobsters Hottest 新闻

摘要

这篇文章探讨了关于前 ANSI C(K&R C)的一些晦涩细节,包括 void 的缺失、不同的浮点类型以及简化的类型说明符规则。文章解释了该语言上下文相关语法是如何通过单遍编译器的限制来证明其合理性的。

<p><a href="https://lobste.rs/s/qrtxzl/bunch_stuff_i_used_not_know_about_k_r_c">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/07/15 13:44

# 我以前不知道的关于 K&R C 的那些事 来源:https://sebsite.pw/w/20260712-kandr.html 好吧,我一直在读《C 程序设计语言(第一版)》的“规范”。与此同时,我还看了 C89 的理据文档,以及第七版 Unix C 编译器的源代码。我还读了一些其他相关的东西。在做这个研究之前,我对 ANSI 之前的 C 真的知之甚少,但我发现了一堆东西,所以我就把我的发现记录在这里 :3 ## 等等,先别急,在正式开始之前,我想到了一件事 大家都知道,C 的语法是上下文相关、有歧义、很糟糕的。但如果我们考虑 C 的发展背景,它其实是有道理的。嗯,好吧,它并不是“有道理”,但至少是情有可原的。最初的 C 编译器是一个单遍编译器。这实际上也是 C 的一个特点——它甚至可以编写单遍编译器——这对于像他们当时使用的那些内存严重受限的系统来说是必要的。如果你在编写单遍编译器,那么你在解析时自然就已经在追踪作用域和标识符之类的东西了。所以,语法是否依赖于标识符是否引用一个 typedef,其实并不重要,因为只需要查一下就能知道,这并不会增加多少工作量;反正你迟早得查。而在现代 C 中,由于它那糟糕的作用域规则,特别是函数定义的原型中如果有嵌套函数声明器就会导致不连续的作用域,这使得正确处理这个问题非常困难。但在 K&R C 中这根本不是问题,因为函数原型作用域压根就不存在!所以,请不要误会,C 的语法确实糟糕、差劲、不好。上下文敏感性是个错误。*但是*,我能理解它从何而来,以及它在历史背景下是如何很好地完成任务的。 好了,下面才是真正有趣的东西: ## `void` 似乎是 ANSI 的发明 也许这是常识,但对我来说挺意外的。我想当时确实不需要它:函数隐式返回 `int`,但如果你不想返回任何有意义的东西,你完全可以不用 return 语句,或者使用不带表达式的 return。而且所有指针类型之间都可以互相赋值,所以 `char *` 可以当作“通用”指针来用。 ## 不同的浮点类型 当时没有 `long double`,但是有 `long float`,它是 `double` 的同义词。我很好奇这背后的历史/理据是什么。`long double` 是合理的,因为它避免引入新关键字;而 `long float` *本来* 也合理——如果 `double` 不是关键字的话——但两者可以互换使用这一点让我觉得很奇怪。 ## 类型说明符方面的一些更有趣的事 当时没有 `signed` 说明符;只有 `unsigned`。而且,类型说明符的有效组合要少得多:`unsigned`、`short` 和 `long` 是作为“形容词”与 `int` 一起使用的(或者与 `float` 一起用于 `long float`)。因此,`long` 本身无效,但 `long int` 是有效的。顺序不重要,所以 `int long` 也是有效的。类似地,`unsigned int` 存在,但单独的 `unsigned` 不存在,`unsigned char` 也不存在。而且一次只能使用一个形容词,例如,没有 `unsigned short int`(一些后来的编译器将其他无符号类型作为扩展实现,稍后会提到更多)。所以,这个想法是你几乎总是使用有符号整数,只不过 `char` 根据目标机器的效率决定是有符号还是无符号,而 `int` 也可以是无符号的。这让我很惊讶。 ## 整数提升的不同行为 说到无符号:《C 程序设计语言》中记录的 C 方言只允许 `unsigned` 说明符与 `int` 一起使用。但后来的编译器将 `unsigned short int` 和 `unsigned long int`(可能还有 `unsigned char`?)作为扩展允许了。但问题是,因为没有标准,没人能就无符号整数的整数提升应该如何工作达成一致。这在 K&R 中没有记录(因为当时不相关),所以出现了两种不同的实践:“无符号保留”和“值保留”。假设 `sizeof(short int) < sizeof(int)`。那么表达式 `-1 < (unsigned short int)0` 的结果是什么?“无符号保留”的实现会说结果是 0(假),因为操作数被提升为 `unsigned int`。“值保留”的实现会说结果是 1,因为所有可能的无符号 short 值都能放入(有符号)`int`,所以它变成有符号的。老实说,我很惊讶(但也高兴)标准最终在这个问题上采取了强硬立场,而不是仅仅把行为定义为实现定义。标准化的行为是“值保留”行为。理据文档中有这样一段话: > 无符号保留规则大大增加了 `unsigned int` 与 `signed int` 相遇并产生问题性有符号结果的情况数量,而值保留规则则最小化了这种对抗。因此,值保留规则被认为对于新手或粗心的程序员来说更安全。经过大量讨论,委员会决定支持值保留规则,尽管 Unix C 编译器已经朝着无符号保留的方向发展。 最后一句让我觉得特别有趣。编译器倾向于一种实践,但另一种实践却被标准化了。也许非 Unix 编译器通常采用值保留,而 Unix 编译器通常采用无符号保留?不确定。 ## 无符号整数常量 回到 K&R:由于无符号整数(看起来)有点像事后才想到的东西,所以没有 `U` 整数后缀(不过*有* `L` 整数后缀)。而且,根本就没有无符号常量!我的意思是:在标准 C 中,如果非十进制(即十六进制、八进制或二进制)整数常量无法放入 `int`,它会先尝试 `unsigned int`,然后再试 `long`(而十进制整数常量如果没有后缀则永远不会是无符号的)。K&R C 有*类似*的行为,但有一个重要区别:十进制整数常量的行为相同(除非结果不适合 `long`,在这种情况下,随便怎么样吧),但非十进制整数常量的行为是:*如果它们能放入 `unsigned int`*,则类型为 `int`。也就是说:假设 `sizeof(int) == 2`,0x8000 的类型是 `int`,所以该值会回绕为负数。之所以类型不是 `long int`,大概是因为十六进制/八进制常量常用于位掩码,而十进制常量用于实际数字,但有趣的是结果类型从来不是无符号的。 ## 8 和 9 是八进制常量中的有效数字 说到整数常量,是的,078 和 0100 表示相同的值。在八进制转义序列中也是如此(`\078` = `\100`)。 ## 缺少的转义序列 说到转义序列:没有 `\a` 或 `\v` 转义,也没有 `\x` 转义。不过,文档中明确说明,如果使用了无效转义,它*不是*错误;反斜杠只是被默默忽略。这意味着,尽管严格阅读 K&R 会认为 `\"` 在字符常量中不被识别,而 `\?` 是 ANSI 的发明(由三字符组的引入而成为必要,三字符组也是 ANSI 的发明),但这些转义实际上已经可以工作了。 ## 没有浮点数后缀 所有浮点常量都有类型 `double`。我想这在某种程度上是合理的,特别是因为 `long double` 还不存在。 ## 几乎一切都是 int 无符号整数是事后才想到的,加上 `int` 最初被设计为机器字,这合理地解释了为什么像 `sizeof` 和指针减法这样的操作产生 `int` 而不是 `size_t`/`ptrdiff_t`。实际上,`size_t` 和 `ptrdiff_t` 是 ANSI 的发明。在今天使用 `size_t` 的所有地方,K&R C(以及 Unix libc)都使用 `int`。这隐含的一个后果是类型的大小不能超过 `INT_MAX`。 ## 没有类型限定符 也就是说,没有 `const` 或 `volatile`。我早就知道这个了,但还是觉得挺有意思的。哦对了,有意思的是,C89 标准的早期草案中有一个 `noalias` 限定符(例如 `noalias char *`)。Dennis Ritchie 写了一篇长篇反驳文(https://port70.net/~nsz/c/c89/dmr-on-noalias.html),基本上是说这个设计从根本上就有缺陷,根本无法使其工作: > 委员会创造了一种不真实的语言,没有人能或愿意实际使用它。 它也批评了类型限定符的整体概念。`noalias` 在最终文档发布前被移除了。不过,《C 程序设计语言》第二版的初版在几个地方引用了 noalias 限定符(https://web.archive.org/web/20260310103149/https://s3-us-west-2.amazonaws.com/belllabs-microsite-dritchie/cbook/2ediffs.html),这些引用在第二次印刷中被删除了。 ## 字符串字面量表示不同的可变对象 文档中有明确说明且定义良好的行为是:在 K&R C 中,字符串字面量表示不同的可变对象(而标准 C 中不保证它们不同,且必须被视为不可变)。这就是为什么直到今天,尽管字符串字面量是不可变的,它们仍然具有 `char *` 类型:因为 `const` 限定符在 ANSI 之前不存在,所以为了与现有代码兼容,字符串字面量的结果类型没有使用它。Dennis Ritchie 实际上在他上面链接的对类型限定符的反驳/吐槽中指出了这一点: > 一个相关的观察是,字符串字面量的类型不是 `array of const char`。事实上,理据文档(88-004 版)说:‘然而,字符串字面量不具有 [此类型],是为了避免指针类型检查的问题,特别是与库函数相关的问题……’ 这番直白的表述难道不应该被视为承认 X3J11 的规则有毛病吗?委员会引入了 `const` 限定符,同时使字符串不可写,却无法将这两个概念联系起来,这简直是荒谬的。 但这就是 C 😎👉 ## `entry` 是一个保留关键字,似乎?? 这个关键字在语法中任何地方都没有被使用过,它只是被保留了。我对此完全不知道。 ## 行拼接并非随处可见 在标准 C 中,任何行都可以以 `\` 结尾来忽略换行并继续该行。在 K&R C 中却不是这样:`\` 只允许在字符常量、字符串字面量和预处理指令(如 `#define`)的行尾使用。标准委员会决定允许它在任何地方使用,因为这样做比只允许在特定位置要容易,而且也不会造成什么危害。 ## 没有带类型的函数参数列表 这一点可能不算特别冷门,但带有参数列表类型的函数声明器形式是 ANSI 的发明。在 K&R C 中,函数声明器只能使用 `()` 形式,定义除外,定义中有一个参数名列表。不过,虽然这一点广为人知,但有一个有趣的后果显示了语言中的一个巨大漏洞:没有办法声明可变参数函数。当函数是可变参数这一事实不会改变 ABI 的调用约定时,这没问题;但如果会改变,那就麻烦了。尤其是因为 printf 是一个可变参数函数,而且这不仅仅是 Unix 的事,printf 是所有 C 语言中的通用函数。而且,不事先显式声明 printf 就直接调用是很常见的;C89 理据明确指出,要求为 printf 提供原型是一个很大的无声的破坏性变更。 ## 在研究 K&R C 时学到的关于标准 C 的几件事 这类事情的有趣之处在于,尽管我正在研究标准前的 C,我仍然设法学到了标准 C 中的一些新东西,这些我之前居然错过了。想到了两件事:第一件是 `float` 参数会被隐式调整为 `double`,但*仅*在 K&R 形式中这样做,为了向后兼容。(当然,现在 K&R 形式在 C23 中已被移除,所以这已经无关紧要了。)第二件更令人尴尬。我真的应该早就知道这个,但不知何故我没注意到:移位表达式的左操作数*不会*与右操作数一起被提升,不像其他算术运算那样。所以:`1 << 1L` 的结果是 `int`,而不是 `long`。我之所以发现这一点,是因为 K&R C*会*提升左操作数,所以在 K&R C 中结果是 `long int`。 ## 具有函数类型的参数不会被调整为指针 说到函数参数:和标准 C 一样,数组参数会被调整为指针,但与标准 C 不同的是,函数参数*不会*被调整为指针;它们被完全禁止。C89 理据没有解释为什么他们决定添加这种转换,随便吧。 ## 函数指针在调用表达式中不会被隐式解引用 实际上,说到函数指针:在标准 C 中,可以直接调用函数指针,无需显式解引用。在 K&R C 中并非如此,必须显式解引用。注意,在几乎所有其他地方,函数到指针的转换仍然会发生;调用表达式是个例外,这种转换不会发生。 ## 我在 K&R 书中找到了函数定义语法中的一个错误 好吧,这个真的很有趣,哈哈。我不得不阅读编译器源代码来验证这确实是一个错误,因为老实说,如果语法真的就是 Beyond Fucked™,那也不会*那么*令人震惊。函数定义的语法如下: > Function definitions have the form > `function-definition: decl-specifiersopt function-declarator function-body` > The only decl-specifiers allowed among the decl-specifiers are extern or static; See §11.2 for the distinction between them. > A function declarator is similar to a declarator for a ‘function returning ...’ except that it lists the formal parameters of the function being defined. > `function-declarator: declarator ( parameter-listopt )` > `parameter-list: identifier` > `identifier , parameter-list` 你看到问题了吗?function-declarator(仅用于定义)被定义为一个普通的 declarator*后跟*一个参数列表。这不是 declarator 通常的工作方式;通常它们有螺旋优先级! ## 结构体和联合体限制更多 - 不能对结构体/联合体左值赋值(就像数组一样) - 函数不能接受或返回结构体或联合体 - 结构体声明的初始化器必须是大括号括起来的(复合) - 但是,复合初始化器*不能*用于 `auto`(栈分配)的结构体 - 复合初始化器也不能用于联合体(因此联合体和栈分配的结构体根本不能完全初始化或赋值;你必须分别给它们的字段赋值) ## 当控制流到达 main 结束时会发生什么? 在 C99+ 中,如果控制流到达 main 的结尾,它会隐式返回 0(作为一个特例)。值得注意的是,C89 中并不是这样,main 在这个方面没有特殊处理(至少在标准中没有),因此返回值是未定义的。但是,K&R 书中的示例从未在 main 函数末尾有 return 语句,因此隐含地,即使没有显式 return,它们也能成功退出。 总结一下:在标准化之前,从 main 末尾掉落(supposedly)是定义良好的,在标准中变成了未定义行为,然后 10 年后又重新添加了这个特例。多么有趣(公平地说,称其为标准化前的“定义良好”可能不准确;不同实现可能有所不同。而且许多实现继续允许省略显式 return)。

相似文章

用C语言搞怪,第&((int*)-8)[3]部分

Lobsters Hottest

一篇幽默的教育性文章,涵盖C语言基础知识,如前向声明、运算符优先级、无条件跳转和基本算术运算,并附带有意搞怪的代码示例。

K2 参考手册 (1998)

Lobsters Hottest

K2 参考手册 (1998) 提供了 Kx Systems 的 K2 数组编程语言的文档,包括语法、操作符和系统函数。

关于C扩展、可移植性和替代编译器

Lobsters Hottest

本文讨论了编写可移植C代码的实际挑战,这些挑战源于对非标准编译器扩展和glibc条件头文件的依赖,并通过构建C编译器的示例进行说明。

Unix中的古怪注释和奇怪行为

Lobsters Hottest

丹尼斯·里奇讲述了早期Unix源代码中一些古怪的错误信息和注释,包括著名的“values of β will give rise to dom!”以及那句不可思议的注释“You are not expected to understand this.”