关于C数组类型语义的讨论

Lobsters Hottest 论文

摘要

本文解释了C数组类型的令人困惑的行为,包括它们退化为指针、sizeof和函数参数等例外情况,并将其与函数类型进行比较,提出了一种数组和指针严格分离的心理模型。

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

缓存时间: 2026/05/24 23:02

# C 语言的数组类型很奇怪;以及相关话题 来源:https://anselmschueler.com/blogposts/2025-c-pointers/ 在本文中,我将解释我觉得它们奇怪的地方,我会做哪些不同的处理,并顺便聊聊一些相关的事情。 严格来说,数组类型 `T[n]`(对于某个 *n*)与指针类型 `T *` 是不同的。类型为 `T[n]` 的值表示内存中一连串连续的 `T` 值,长度为 *n*。 但实际上你无法直接引用类型为 `T[n]` 的值。任何本应是该类型的表达式都会立即被转换为指针,类型为 `T *`,即指向第一个元素的指针。 由于数组下标运算符 `arr[ix]` 实际上作用于指针,行为类似于 `*(arr + ix)`,所以你可以基本上将数组当作指针来使用。 一个重要的例外情况是 `sizeof arr`,它返回的是 `sizeof(T)` × *n*。 ```c int arr[3] = {10, 20, 30}; int *arr_ptr = arr; size_t arr_size = sizeof(arr); size_t ptr_size = sizeof(arr_ptr); // 这两个值可能(而且很可能)不同 ``` 此外,在函数签名中,你给参数指定的任何数组类型实际上都会被解释为指针。表示大小的 *n* 被完全丢弃了。这意味着,作为上述例外的另一个例外,在参数为 `T arr[n]` 的函数内部使用 `sizeof arr` 将 ***不会*** 得到 `sizeof(T)` × *n*。 ```c size_t foo(char buf[6]) { return sizeof(buf); } char msg[6] = "!! ??"; size_t msg_size = sizeof(msg); size_t msg_size_in_fn = foo(msg); // 这两个值可能(而且很可能)不同 ``` 注意,你可以写 `char buf[static 8]` 来“强制”指定长度,但这实际上只是意味着如果你传入一个指向更短数组的指针,行为是未定义的。类似于 `restrict`,它只是在帮助编译器进行优化。 另一种做法是,你可以使用指向数组的指针作为参数。不是将数组退化为 `T *`(指向第一个元素的指针),而是在调用处取引用,得到 `T (*)[n]`。在运行时,它们实际上是相同的东西,但这种方式保留了长度信息。不过,写起来很不方便,也容易让人困惑。 ```c size_t foo(char (*buf)[6]) { return sizeof(*buf); } char msg[6] = "?? !!"; size_t msg_size = sizeof(msg); size_t msg_size_in_fn = foo(&msg); // 这两个值将会相同 ``` ## 补充:函数 有趣的是,C 语言中还有第二种类型的行为与数组非常相似,但不像数组那样令人困惑。那就是函数类型。 与数组类似,函数值会立即强制转换为函数指针。但与数组不同的是,对指向函数的变量进行解引用(例如 `*fn`)仍然可以用与普通符号相同的方式调用该函数。 ```c void foo() {} (*foo)(); foo(); ``` 虽然对数组写 `&arr` 确实会得到指向数组的指针类型 `T (*)[n]`,但 `&fn` 与 `fn` 完全等价。这是因为数组 `arr` 退化的结果不是 `&arr`,而是 `&arr[0]`;而函数 `fn` 会自动转换为 `&fn`。 注意,对于数组和函数,当它们作为 `&` 运算符的参数时不会退化,这就是为什么 `&arr` 不是指向指针的指针。 另外,在函数参数列表中使用 `T fn()` 或 `T (*fn)()` 也是相同的——第二个会自动被修正为第一个,非常类似于数组类型自动被修正为指针类型。 ## 按值传递数组 从根本上说,数组类型类似于所有成员都是同一类型的结构体。但数组的使用方式往往与结构体不同。我们很少会取结构体第二个成员的地址。这可能是因为一个平移了起始位置的数组仍然是一个数组,只是大小不同。由于我们经常忽略或者不了解数组的大小,这使得处理数组成为一种很自然的方式。 我认为,如果 C 语言对数组和指针采用严格的分离,那么从心智模型上理解起来会容易得多。 数组应该像结构体一样工作。将一个 `char[5]` 传递给函数,应该实际传递数组中的五个值。这就像函数接收了五个 `char` 参数一样。 ```c int compute(int arr[3]) { arr[2] += arr[1]; arr[1] *= arr[0]; arr[0] *= (arr[1] + arr[2]); return arr[0] - arr[2]; } int arr[3] = {10, 20, 30}; int result = compute(arr); // arr 不会被修改 ``` 因此,指向数组的指针只涉及一层间接引用。如果你想把数组当作指针来用,就必须手动写 `&arr[0]` 来获取指向 `arr` 第一个元素的指针。 ```c void toggle(bool *flag) { *flag = !*flag; } bool arr[2] = {true, true}; toggle(&arr[1]); ``` 最明显的好处是,这能降低语言的学习难度。初学者很容易感到困惑:为什么在函数内部向数组写入会改变函数外部的数组,而对结构体却不是这样。 通常情况下,C 语言中引用的使用使其变得非常明确易懂。事实上,在这方面,C 语言比 Python(对象默认是指针)和 C++(参数可能根据函数签名以引用方式传递,但调用处无需修改)等语言要简单和容易理解得多。 最直接的缺点是数组会被频繁复制。我不认为这必然会否定这个想法。这只意味着你必须聪明地使用它,而且它给程序员提供了更多选择,而不是更少。(与 C++ 这样的语言相比,选择仍然不算多得离谱,如果你担心这个的话。) 当然,编译器也可以选择使用指针来实现这些数组,甚至可以有选择地这样做,只要符合它的目的。这可以保留更直观的语义。 ## `@` 运算符 如何从指针构造这样的数组呢?写成 `(char[3]){*arr, *(arr + 1), *(arr + 2)}` 会非常繁琐。幸运的是,有先例可循。 调试器 GDB 有一个表达式系统,它用 `@` 运算符扩展了 C 的语法,用于为内存地址赋予一个长度,使其成为数组。 不过,它实际上并不接受内存地址作为操作数。相反,它作用于像 `*ptr` 这样的表达式,这些表达式 *拥有* 一个地址,而不是那些 *本身是* 地址的表达式。 ``` (gdb) list 1 int main() { 2 int arr[4] = {10, 20, 30, 40}; 3 int *at_ix_1 = arr + 1; 4 } (gdb) break 4 (gdb) run Breakpoint 1, main () 4 } (gdb) print *at_ix_1 $1 = 20 (gdb) print *at_ix_1@1 $2 = {20} (gdb) print *at_ix_1@2 $3 = {20, 30} (gdb) print *(at_ix_1 + 1)@2 $4 = {30, 40} (gdb) print *(at_ix_1 - 1)@4 $5 = {10, 20, 30, 40} ``` (为了这个示例,GDB 的诊断输出已被略微简化) 这与诸如 `=` 之类运算符的工作方式类似。我们可以写 `*ptr = 2`,因为 `*ptr` 不仅仅是一个值,而是一个具有特定内存位置的值,可以对其进行写入。你不能写 `2 = 2`。我们将这些表达式称为 *位置表达式* 或 *左值*。 同样地,你可以写 `*ptr@10` 来得到一个数组,其第一个元素是 `*ptr`,后面还有 9 个元素。但你不能写 `2@10`。你必须先为 `2` 赋予一个位置。 ```c int x = 2; int x_arr[1] = x@1; ``` 我认为这是一种很简洁的操作符工作方式。理论上,它可以扩展到允许像这样的用法: ```c struct coords_3d { int x; int y; int z; } some_point; struct coords_2d { int x; int y; } some_point_projected = some_point.x@2; ``` 在这种情况下,这感觉有点不自然。我认为这可能是由于与数组不同,结构体类型的一部分并不那么容易与原结构体类型关联起来。我们很少处理只知道部分字段的结构体,而这可能类似于不知道大小的数组。结构体切片(例如在 Berkeley socket API 中)很少见,而且感觉有点像 hack。 我们将未知大小的数组理解为指针的方式,实际上是更广泛模式的一个例子:我们将无法直接处理的对象隐藏在某个不透明的句柄后面。然后,我们有一些方法来提供缺失的信息,以便实际操作该对象。 在 C 数组中,缺失的信息可能是长度,然后从各种来源提供。 我们可以将这些信息与数组一起存储,要么在内存中紧挨着数组,但位于静态偏移处,要么与指针一起存储在我们的局部变量中(或者指针可能位于的任何地方)。 将长度与指针一起存储就是我们所说的宽指针。例如,C++ 中的 `std::vector` 可能就是这样实现的,这也是 Rust 自动使用的方式,用于让你获得像 `&[T]` 这样的无大小类型(如数组)的引用,它会自动存储其长度。 实际上,在 C 语言中,每当我们使用 `size_t len, char *buf` 这样的参数时,我们已经这样做了。使用两个参数相当于使用一个包含两个成员的结构体,而如果我们将这个双成员结构体提取为自己的类型,它就是一个宽指针。 将额外数据存储在内存中实际数据之前,例如 C++ 中带有虚方法的派生类就是这样做的。注释 1 (https://anselmschueler.com/blogposts/2025-c-pointers/#footnote-1) 回到我改进后的 C 数组,你可以像这样来回转换: ```c char arr[4] = {'x', 'y', 'z', 'w'}; char *arr_ptr = &arr[0]; char arr_again[4] = *arr_ptr@4; ``` 在语法上,对数组进行切片非常自然: ```c int iota[4] = {0, 1, 2, 3}; int one_two[2] = iota[1]@2; ``` 显然,也可以使用语法 `ptr@n`,而不需要解引用。你仍然可以写出类似 `(&iota[2])@3` 这样的表达式。不过我觉得这样看起来不那么好看,而且对位置表达式等工作方式的理解帮助不大。 这里有一些粗糙的边缘情况。如果你只是移动数组的起始位置,你会写成: ```c int arr[2] = {10, 20}; arr = &arr[1]@1; ``` 但这需要显式写出新的长度。如果你有某种运算符可以通过 `sizeof(arr)/sizeof(T)` 来获取数组大小,你就可以用它。但即便如此,它仍然繁琐且难看。 三个明显的解决方案是:要么允许 `arr + 1`;要么使用特殊语法自动推断长度,例如 `arr[1]@...`;要么创建一个新的自定义运算符,例如 `arr +@ 1`。 由于实际上我无法重新设计 C 语言,而且目前我也没有在写一门新语言,并且这种需求可能并不常见,所以我不会给出具体的建议。 ## 补充:`->` 最后,我想提一下 `->` 运算符。它类似于 `@` 运算符,因为它处理的是指针还是位置表达式,多少有些随意。 目前,表达式 `ptr->foo` 表示 *值* `(*ptr).foo`,其中免费包含了一次解引用。要获取地址,你写 `&ptr->foo`。但同样可以很容易地将其定义为 `&(*ptr).foo`。这样,要获取值,你就得写 `*ptr->foo`。 目前,要从指向结构体的指针中获取嵌套值,你写 `ptr->foo.bar`。如果采用替代的 `->` 定义,你会写 `ptr->foo->bar`(对于指针来说)。 有人可能会说,`ptr->foo.bar` 表明实际上只追踪了一个指针,而 `ptr->foo` 本身不是一个指针。但替代语法也能表明这一点,因为你需要写 `*ptr->foo->bar` 才能真正得到值。 这是一种非常没有充分依据的感觉,而且可能完全错误,但我稍微偏好 `ptr->foo->bar`。完全在指针的领域内操作,在我看来,能稍微更好地反映编译器实际上只需要应用一个偏移量的事实。 但是 `ptr->foo.bar` 更能反映位置表达式、解引用运算符和取地址运算符之间简洁的相互作用。既然我上面对此大加赞赏,也许我的一些感觉是虚伪的。

相似文章

C语言中的一切皆为未定义行为

Hacker News Top

一位经验丰富的C++开发者认为,所有非平凡的C和C++代码都包含未定义行为,使得内存安全无法实现,并质疑这些语言在现代软件开发中的持续使用。

PHP 的古怪特性

Hacker News Top

一位开发者在使用了五年后反思 PHP 的古怪之处,重点介绍了其数组实现和类型系统的奇特之处。

对 APL 等数组语言的有原则性重新思考

Lobsters Hottest

本文提出了一种有原则性的方法来重新思考 APL 等数组语言,通过将变量建模为输入维度的函数,旨在相较于传统方法提高可读性和错误检查能力。

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

Lobsters Hottest

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

每个字节都很重要

Lobsters Hottest

本文通过Java和C语言的示例,阐述了理解CPU缓存行与数据结构布局对编程性能优化的重要性,讨论了多余字节的开销以及结构体数组与数组结构体之间的权衡。