地址、指针、数组¶
本部分内容力求完全理清 C 语言中的地址、指针、数组之间的关系。
类型系统速览¶
在正式开始之前,我们需要快速地回顾一下 C 语言的类型系统,并澄清一些各种教师和教材的错误。
平台
在我们的语境中,“平台”指的是一个 C 编译器的实现或是这样的一个编译器编译产生的程序(不做严格区分),而和操作系统关系不大(尽管操作系统可能会影响编译器的行为)。例如,“32 位平台”指一个 32 位 C 编译器编译得到的程序。需要注意,即使操作系统是 64 位的,如果编译器是 32 位的,编译得到的程序仍然是 32 位的并在运行时表现出 32 位的特性,因此其也是“32 位平台”,这是很常见的情况。相反的情况则不会发生,因为无法在 32 位操作系统上运行 64 位程序(除非使用虚拟化技术,这超出了我们的讨论范围)。
如果你了解命令行编译器的使用,可以通过 clang -v
或是 gcc -v
来查看编译器的版本信息,其中会包含这个编译器的三元组(triplet)。
整数类型有 char
、short
、int
、long
、long long
。除 char
与 int
外,其他类型可以后随一个额外的 int
(即 short int
、long int
、long long int
)。符号指示关键字(signed
/ unsigned
)可以位于任何整数类型前,但对类型的位宽无影响。除 char
外,其他整数类型默认为 signed
。
char
、signed char
、unsigned char
是三种不同的类型。尽管绝大多数平台上 char
表现得如同 signed char
,但它们仍是不同类型。
通常认为 float
是 32 位浮点数(IEEE binary32),double
是 64 位浮点数(IEEE binary64)。long double
的精度至少是 IEEE binary64,在多数平台上,它可能是 64 位、80 位、96 位或 128 位。
C 语言并未规定整数类型的严格位宽,但定义了整数类型的最小位宽1:
char
至少 8 位short
至少 16 位,且至少和char
一样长int
至少 16 位,且至少和short
一样长long
至少 32 位,且至少和int
一样长long long
至少 64 位,且至少和long
一样长
在几乎所有现代平台上,short
为 16 位,int
为 32 位,long long
为 64 位。而 long
的位宽争议较大,在 32 位平台上通常为 32 位,在 64 位平台上通常为 64 位。这取决于平台采用的数据模型(见下)。
C 语言同样未规定指针的位宽。在较多数平台上,指针的位宽与 long
相同,即 32 位或 64 位。
Definition 1. 数据模型
不同平台对这些整数类型宽度的选择和定义,统称为不同的数据模型。在 32 位和 64 位平台上,有 2 种广泛使用的数据模型,分别是:
- ILP32:
int
、long
与指针均为 32 位 - LP64:
int
为 32 位,long
与指针为 64 位
除此之外,还有 2 种更不常见的数据模型:
- LP32:
int
为 16 位,long
与指针为 32 位 - LLP64:
int
与long
为 32 位,指针为 64 位
几乎没有国内教材会重视并提及这些概念,由此衍生出大量模棱两可甚至错误的考试题目。对于较老的题目 ILP32 和 LLP64 可能更为常见,LP32 只会偶尔出现在上世纪的题目中,然而在自己的电脑上你几乎总会遇到 LP64。
C 标准
“C 标准”指 ISO/IEC 9899,定义了 C 语言的语法和语义的国际化标准文档。C 标准随时间推移不断更新,目前最新的版本是 C23,发布于 2024 年 10 月。然而由于众所周知的原因,国内绝大多数高校教材仍然以 C89(ANSI C,发布于 1989 年)作为教学标准。C89 中缺少许多目前广泛使用的特性,例如变长数组(VLA)、复合字面量、泛型选择表达式等。
内存模型¶
上面这个问题在某种角度上可以理解为对 C 语言内存模型的抽象(尽管对于理解真正的内存模型毫无帮助)。实际上,了解内存模型的最好方式就是直面它。我们已经知道,每个变量都需要在内存中占据相应的空间,而具体占用多少空间取决于变量的类型。我们第一步需要的是 printf
以及它的 %p
转换指示符。
unsigned char
是一个非常美妙的类型,因为 a) 它的大小恰好是 8 bit = 1 字节(你的平台上 1 字节可能不是 8 bit,但你的平台上 1 字节不是 8 bit 不太可能),而且在此基础上 b) 它的范围被严格定义为 [0, 255]。从这里开始,我们将会尽量使用严格的类型描述方法:变量 a
的类型是 unsigned char
。尽早开始习惯这样的说法是非常有用的,在讨论内存模型时,类型是最重要的信息,没有之一。另外,stdint.h
中定义了 unsigned char
的非常有用的别名:uint8_t
,当然可以顾名思义,它就是 8 位无符号整数。所以接下来为了方便我们会用 uint8_t
来代替 unsigned char
2。
上面这两行代码的输出可能形如 0x16bdd767f
,显然这是一个十六进制数,并且程序每次运行输出的值大概率都是不同的。这个数就代表着变量 a
在这次运行程序时在内存中的地址。回顾一下我们做的事情:定义了一个类型为 unsigned char
的变量 a
,然后在调用 printf
时取这个变量的地址(&a
)并用 %p
转换指示符输出之。
Theorem 1. 地址是一个无符号数。
是的,地址是一个无符号数。一个地址在本质上和 42U
没有任何区别。(唯一的区别可能在于大小;例如对于 LP64 数据模型(回忆一下数据模型是什么),地址是 64 位的,那么地址在本质上和 42UL
毫无区别。)稍晚些我们会看到如何证明这个“没有任何区别”,不过现在可以继续用 printf
做一些危险的操作。
这个写法可能对你很陌生,目前我们不必知道 PRIuPTR
是什么,只需这样写便是。它定义在 inttypes.h
里,所以这是必须要 #include
的。一个称职的编译器在编译上面的代码时一定会抛出一个 warning:
不过这不重要。这次运行的结果可能像这样:
做个转换就会知道,0x16bd2f67f 就是 6103955071。我们用 PRIuPTR
得到了一个十进制数,并且这个十进制数和 %p
输出的十六进制数相等。这是否已经在暗示什么东西?
我们接下来要进行更加危险的操作。(或者,从类型的角度来说,不如上面那个危险。)
uintptr_t
也定义在 stdint.h
中,它是一个 typedef
,定义为当前平台上足够存储任何地址的整数类型(默念这句话三遍。重点是它是整数类型)。PRIuPTR
就是用来输出 uintptr_t
的转换指示符,我们后面会看到它到底是什么意思。
很高兴,不再有烦人的 warning 了!
对的对的,仍然是用不同的进制表示出来的两个相等的数。在继续之前我们需要回顾一下这短短的 4 行代码做了什么。首先我们定义了一个类型为 uint8_t
的变量 a
,接着通过强制类型转换将 &a
(a
的地址)转换为了一个 uintptr_t
值并赋给一个 uintptr_t
类型的变量。最后我们用 %p
和 PRIuPTR
分别输出它们。
这个程序的输出可能是:
故弄玄虚到这里就够了。从 C 语言的视角,内存就是一大块线性连续的空间,可以认为它是一个巨大的 uint8_t
数组,变量存在于这个数组中,变量的地址就是其在数组中的位置。例如上面的例子中,变量 a
位于索引 0x16efeb61f 处,变量 b
位于索引 0x16efeb61e 处。当我们读取 a
b
的值时,实际上就是在内存空间的相应位置读取那个字节。这就是 C 语言的内存模型。
当然,还有一个小问题:内存中每个字节都有一个地址,然后,恰好 uint8_t
的大小也是 1 字节,所以用 uint8_t
来建模内存是非常合适的。那么对于大于 1 字节的类型(例如最常见的 int
),在内存中又如何表示呢?简单的答案是,变量会占据数个连续的字节。至于如何研究个中奥秘,我们会在后面看到。
数组¶
数组给人的感觉很简单吧?很遗憾,有一个坏消息:在这节结束时我们就不会这么想了。
数组当然很简单。数组是在空间上连续的数个同样类型的变量集合。(这里的“集合”指的是 collection,不是 set。)
%zu
是用于输出size_t
类型值的转换指示符,这是被typedef
为足够表示任何对象大小的整数类型。sizeof
运算符的结果类型就是size_t
。
变量 a
的类型是 uint8_t [4]
。记得输出一个变量的地址时一定要取地址!(我知道你在想什么,但是我们还没解锁那个话题,所以现在假装你什么特殊用法都不知道,把数组也当作普通的变量来对待。)输出可能像这样:
这就是空间上连续的含义。每个 uint8_t
变量在内存中占据 1 字节,所以数组 a
中的 4 个 uint8_t
变量就分别占据了从 0x16fb8b618 开始的 4 字节,整个数组占据的字节数就是其中所有单独变量的大小之和。非常简单吧?但还有一个神秘的结果:为什么 &a
的数值和 &a[0]
的数值一样?试着将数组 a
的类型改为 int [4]
,会得到一个不同的结果:
a
仍然与 a[0]
有着相同的地址,但除此之外我们会发现,现在每个地址值之间相差 4,原因是 int
的大小为 4 字节。那么 4 个 4 字节的 int
变量在内存中就总共占据连续的 16 字节。这也是空间上连续的表现。
如果你的记忆力足够好,你可能会回顾我们之前得到过的这个结果:
在这里
a
b
是单独的两个uint8_t
变量,并非数组成员,为什么它们的地址也是连续的?这与数组在空间上连续毫无关系,实际上它们也不一定必须连续,这是与平台相关的细节,不可将其与数组的行为归为一类。
OK,那么问题来了:类型 uint8_t [4]
占据多少字节?似乎是个很简单的问题,答案当然是 4。同理,int [4]
占据 16 字节。这些我们都已经通过 sizeof
验证过了。广泛来说,类型 T[N]
就占据 sizeof(T) * N
字节。结论很简单,但它对于后面的讨论是极其重要的。一个结论是,通过 sizeof(a) / sizeof(a[0])
可以得到定义数组时指定的大小。
另外,还有一点需要注意:提到“数组的大小”时,有时指数组中元素的个数,有时指数组占据的字节数,视语境而定。我们会尽量避免混淆,不过在其他地方遇到时需要注意。
数组的数组¶
在 C 语言中,“二维数组”实际上应该被称为“数组的数组”,这是一个准确得多的称呼。实际上“二维数组”也是个很有迷惑性的名字;到底什么叫“维”?把二维数组当作 i
行 j
列的矩阵真的对吗?我们三维生物能够直观理解最多三维的东西,那“四维数组”怎么想象,i
个 j
层 k
行 l
列的方块么?如果还有更多的维数怎么办?最为重要的是,从内存模型的角度来说,到底有没有“维数”这个概念?
在此我们只回答最后一个问题:显然,答案是根本没有。“维数”除了易于理解之外一无是处,而且“维数”和内存模型水火不相容,对数组的理解越依赖前者,要理解后者就越困难。所以从现在开始,忘记“N 维数组”这个词,忘记行列——只有数组、数组的数组、数组的数组的数组…
还是之前的那个程序,但这次将变量 a
的类型改为 int [4][5]
。
我们来解读这个结果。a
的类型是 int [4][5]
,这是一个大小为 4 的数组,其中的元素类型为 int [5]
,即含有 5 个 int
的数组。这就是“数组的数组”的含义——当我们在通过 a[i][j]
访问一个元素时,我们首先通过 a[i]
获得了一个 int [5]
类型的数组,然后再去取得其中索引为 j
的元素。同时,注意到地址之间的差值为 0x16fcdb5dc - 0x16fcdb5c8 = 20,即 int [5]
占据连续的 20 字节。整个数组占据的字节数就是 (sizeof(int) * 5) * 4
,即 80。从中我们也可以看出为什么“多维数组”的说法根本就站不住脚——因为内存空间是线性的!只有向后 4 个字节,或者向后 20 个字节,或者向后 80 个字节——但是从来没有行与列的概念。
在建立了“移动字节数”的概念后,要论证为什么 a[6][-13]
访问到 a[3][2]
就变得非常简单了。这相当于从 a
的地址开始先向后 120 个字节,再向前 52 个字节,就得到了 a[3][2]
的地址。这也说明在数组的数组的语境下,索引超出了声明时的大小并不一定就是越界访问,最终还是应该分析实际访问的地址是否在数组的范围内。
注意到我们在上面是如何一直将数组作为一个类型来看待的。int [4]
、int [5]
、int [4][5]
都是不同的类型,习惯这种说法会让后面的内容轻松很多。好了,现在试着将 a
的类型改为 int [4][5][6][7]
,然后自己来论证一番吧。
字符串到底是什么?¶
再回过头来看看另一个非常常见的问题:
变量 s
的类型是 char [7]
,别忘了最后的 '\0'
,这没什么新鲜。我们现在的问题是,字符串字面量 "Hello!"
是什么?这个问题的答案是 char [7]
3,而非你可能正在想的某个答案(我们现在还不认识那个东西!)。实际上,如果你知道 sizeof("Hello!")
的结果是 7,那么现在的这个类型结论就是显然的。
不要把
sizeof
与strlen
混淆。sizeof
是一个运算符,它返回一个表达式或类型的大小(以字节为单位),而strlen
是一个函数,传给它的参数必须是一个以'\0'
结尾的字符串,它返回这个字符串的长度(即第一个'\0'
之前的字符数)。思考一下为什么将"abc\0def"
传给sizeof
和strlen
得到的结果分别是 8 和 3。
另外,字符串必须以 '\0'
结尾,这是 C 语言的约定。言外之意是,如果你有一个字符数组,有一系列字符,如果这一系列字符没有以 '\0'
结尾,就不能称其为字符串,更不能将其当作字符串来处理,例如传递给期待字符串的地方——strlen
、printf
的 %s
转换指示符等等。
Callback: PRIuPTR
¶
但是,你可能不知道 C 语言中字符串还可以这样写:
这和上面的写法是等价的,此即字符串字面量的拼接。这个写法在某些情况下可能会更加方便,例如,PRIuPTR
的定义可能是这样的:
所以,当我们写 printf("%" PRIuPTR "\n", l);
时,经过宏展开就变为:
也就是 "%lu\n"
。这是 C 语言标准库提供给我们的工具——利用字符串字面量的拼接,我们可以通过使用 PRIuPTR
来输出 uintptr_t
类型的值,而无需关心它到底被定义为 unsigned long
还是 unsigned long long
。实际上 inttypes.h
中还有很多这样的定义,例如 PRIu8
就是用于输出 uint8_t
的转换指示符,这些宏定义都是为了跨平台服务的。
关于数组的内容就到这里,我们已经了解了数组的内存模型,以及为什么“多维数组”是个错误的概念。
指针来咯!
指针与地址¶
有没有发现,在上面的内容中我们一次都没有提到过“指针”?这是应该的,因为指针和地址是截然不同的东西。尽管很多人很多地方都认为地址和指针的区别很模糊,但为了彻底理解这对概念,我们在这里必须要严格划清界限。没有完全理解指针之前,在没有出现指针的地方提到“指针”只会造成迷惑。
Theorem 2.1. 地址不是指针,指针不是地址。
现在对了,这才是指针。
传统的说法是 p
是一个指向 uint8_t
的指针,但我们仍然应该使用我们的严格类型描述:
- 变量
a
的类型是uint8_t
。取a
的地址(&a
)会得到一个值。值&a
的类型是uint8_t *
。 - 变量
p
的类型是uint8_t *
。
所以对于第 3 行的定义,当我们看到 uint8_t *
时,我们立刻就知道变量 p
是什么了。以及一定要区分清楚指针的类型和指针指向的类型。指针 p
的类型是 uint8_t *
,它指向的类型是 uint8_t
。
Theorem 2.2. 指针是变量。
这非常重要!这里有两对对偶的概念:
42
是值而非变量,a
是变量而非值。&a
是地址而非指针,p
是指针而非地址。
还可以继续煽风点火。下面才是 Theorem 2.2 的完整表述:
- 地址是值,而指针是变量。
搞清楚这三点非常非常重要,重要到你必须确保自己已经没有疑问了再往下看。我们可以继续用强制类型转换来研究一些东西。
左值与右值
值与值之间亦有区别。严格来说,C 语言中每个表达式都属于三种值类别之一:左值、非左值对象与函数指代器。函数指代器就是函数名,但是民间经常认为函数指代器也是一种左值,并且把非左值对象称为右值。这种说法用一点严谨性换取了一点叙述的简洁性,但大体上是可接受的。
左右值之所以叫这个名字,历史原因是认为左值可以出现在赋值语句的左边,而右值只能出现在右边——但是这个说法并不准确,例如数组不能被赋值,但它仍是左值。另一个简单且要好很多(但仍然不完全准确4)的判断方式是,左值是可以取地址的值,而右值不可以。要完整叙述左值与右值的定义需要引入“左值表达式”和“右值表达式”的概念,这里不再展开。你可以阅读 cppreference 上的值类别一节。
输出可能是 0x16bacf61f, 6101464607, 0x16bacf61f, 6101464607, 42
,你可能已经开始觉得有些无聊。不就是几个相同的数倒来倒去,有什么区别?实际上每一行都可大做文章:
- 最重要的一点:
uintptr_t
是一个无符号整数类型,我们用PRIuPTR
以正确的转换指示符输出了它。它是整数类型这点非常非常重要,这意味着我们对它做加减乘除、做比较、位运算等一切可以在整数类型上进行的操作。而地址只能加整数、减整数、减另一个地址、与另一个地址做比较(与另一个地址做运算时,两种地址必须是同类型的,而且还有其他限制),并且地址的运算规则与整数完全不一样,我们会在后面看到。- 所以在第 5 行类型转换以后,不管对变量
m
做什么操作,都已经和变量a
完全没关系了。第 5 行执行后,两者唯一的关联就是m
存储的那个整数值在数值上等于a
的地址。不过只要你对m
做个运算,这点关联就也消失了。
- 所以在第 5 行类型转换以后,不管对变量
- 接着,在第 6 行,我们重新将
m
的值类型转换为uint8_t *
,这也是有说法的。这个值不一定必须是m
;假设你通过其他神秘的不可言说的途径获得了一个整数值,又恰好这个值在数值上等于a
的地址,那么这个时候将其类型转换为uint8_t *
,你就可以通过解引用它得到a
的值,在这里即42
。- …言外之意是,如果,你通过其他途径获得的一个整数值在数值上不等于某个变量的地址,把这个整数类型转换为
uint8_t *
类型的值是 OK 的,但是万不可解引用它;解引用无效的指针是未定义行为,你的程序几乎总会因为段错误而崩溃。
- …言外之意是,如果,你通过其他途径获得的一个整数值在数值上不等于某个变量的地址,把这个整数类型转换为
- 最后,我们分别输出了
&a
m
p
(uintptr_t)p
和*p
。&a
是地址,p
是指针(对它求值得到地址),所以这两者对应两个%p
输出的两个十六进制数。(uintptr_t)p
将p
的值类型转换为uintptr_t
,这是一个整型值。m
是一个uintptr_t
类型的变量,对它求值也得到一个uintptr_t
值,这两者对应PRIuPTR
输出的两个十进制数。*p
展示了,经过这一些数值上相等的类型转换后,我们仍然可以安全地解引用它,得到的就是原汁原味的变量a
的值。
以上,我们论证了地址和无符号整数的关系,并且看到了如何正确地用合适的变量(整型变量或是指针)来研究两者的关系。这也解释了为什么任何指针类型都的大小都相同——指针只需存储地址,而不关心那个地址指向的是什么类型的变量。
还记得我们之前提到的“地址和整型在本质上没有区别”么?这个话题我们算是论证了一半,稍晚一点我们会以无懈可击的手段结束证明。
大于 1 字节的类型;端序¶
既然 int
占据 4 字节,那这 4 字节是如何存储的呢?利用类型转换,我们得以研究 int
的内部结构——既然所有指针都占据同样字节数,那么将 T1 *
值转换为 T2 *
值应当也是可以的…吧?其实指针之间的类型转换规则由严格别名规则定义,违反它就会造成未定义行为。不过对于下面的写法是没问题的。
Tip
要在这里给出严格别名规则的定义有点难,因为其还涉及到有效类型的概念。如果感兴趣,可以试着阅读 cppreference 的对象与对齐。
- 这里没有违反严格别名规则,将某个类型的指针转换为字符类型的指针是允许的,而
uint8_t
就是unsigned char
。
输出如左边,很清晰地表现出了这 4 个字节的位置和值如何存储了 0x0000002a,即 42。将上面第 7 行的 42
改为 -813021694
,输出会像右边这样表现出这 4 字节是如何存储 0xcf8a4602 的。
这就牵扯出了端序的问题:既然你的内存的最小单位是字节,那么 4 字节的 int
要如何在内存中排列?符合我们第一直觉的是,例如对于 0x12345678,将其排列为 0x12 | 0x34 | 0x56 | 0x78
,这种排列方式叫做大端序。然而实际上绝大多数计算机采用的是小端序,即将其排列为 0x78 | 0x56 | 0x34 | 0x12
。端序的选择与 CPU 架构有关,几乎所有的现代设备都是小端序的——但大端序的平台也存在,而且在某些场景也是有用的。然而小端序相比大端序有一个很大的优势,我们之后会看到。
上面的写法是非常通用的,可以用来研究任何类型的内部结构。试着将 int
改为 double
,或是 int [4]
,或者 uint8_t *
等等。一个非常有趣的写法是下面这样,试着理解一下吧!
指向数组的指针、多级指针、指针的数组¶
广泛来说,类型 T *
即指向类型 T
的指针,至于 T
是什么,没有任何限制。然而语法上有时可能不能直接这样写——但总是可以借助 typedef
写成这种形式。
Definition 3. typedef
typedef
用来给一个类型取一个别名:
第三行,IntArray
就表示 int [5]
类型,所以后两行是等价的。有人会觉得 typedef
的语法很别扭;实则不然。当你要定义一个类型的别名时:
- 首先假装你要定义一个这个类型的变量,例如
int a[4][5]
; - 在最前面加上
typedef
,这样就得到了你想要的别名。
以及,typedef
并没有定义任何新类型,所以别名就是原类型,其背后不存在任何区别,或者什么隐式转换之类的。
那么显而易见地,如果想要定义一个指向 int [5]
的指针,可以这样写:
p
和 q
的类型是 int (*)[5]
。要想通过 p
q
访问数组 a
,按照指针的规则就先要解引用。
再者,指针它自己也是一个变量,需要占用数个字节来存储值,作为一个变量它也有自己的地址。这就是指向指针的指针,或者说多级指针的由来。
理解指针需要结合内存模型与类型:pp
的类型是 int **
,存储 p
的地址。解引用 pp
时得到 int *
类型的值,即 p
。再解引用一次得到 int
类型的值,即 i
。只要记住指针存储着的地址是一个整数值就不会有问题。
指针的数组,当然就是数个指针组成的数组,这就没什么好说的了。
指针算术¶
虽然官方的说法确实就是指针算术,但按照我们的风格,叫做地址算术应该会更好。到这里我们可以稍稍放宽一点指针和地址的界限。
%td
用于输出ptrdiff_t
类型的值,这是被typedef
为足够表示任意两个指针的差的有符号整数类型。
输出是 1 4
。问题来了:p
m
数值相等,q
n
数值相等。n - m
是 4 到现在应当很好理解,但为什么 q - p
是 1?当然,你大可以将第 6 行的 &a[1]
改为 &a[2]
&a[3]
&a[4]
中的任何一个,那就会分别得到 2 8
、3 12
、4 16
。
这就是指针算术,它遵循的规则与整数不同。我们首先叙述严格的定义;这涉及到两种情况:地址加减整数(或者整数加地址,因为加法是交换的),以及地址减地址。我们还需要一个便于叙述的概念:对于一个数组来说,合法地址指的是这个数组中元素的地址,或者最后一个元素再向后一个元素的地址(也就是说,如果数组 A
的大小是 N
,那么 &A[N]
也是合法地址,尽管读取 A[N]
的值是不行的)。
Definition 4. 指针算术
-
对于合法地址
&A[I]
和非负整数J
:&A[I] + J
和J + &A[I]
的结果是&A[I + J]
;&A[I] - J
的结果是&A[I - J]
。
结果也必须是数组
A
中的合法地址,否则行为未定义。 -
对于两个合法地址
&A[I]
和&A[J]
:&A[I] - &A[J]
的结果是(ptrdiff_t)(I - J)
。
I - J
需要能被ptrdiff_t
表示,否则行为未定义。
如果参与指针算术的不是数组中元素的地址,而是普通变量的地址,那么将这个地址视为大小为 1 的数组中首元素的地址。
多读几遍这个定义。前面的规则应当是很清楚的——至于最后一句基本就是在说,在指针算术的语境下,可以把一个普通变量看作大小为 1 的数组中的元素。有了严格定义后,要解读上面程序的输出就很容易了,因为指针相减得到的是两个数组元素的索引差,而将地址转换为整数后,计算的就是纯粹的数值差。索引差就等于数值差除以元素大小,“地址和整型在本质上没有区别”至此证毕。试着将 int
改为其他类型,例如 double
,加深一下印象吧。
这是目前我们见到的第一个真正危险的程序,你自己永远不应该写出这样的代码。这个程序得到的结果大概率会是 1 或 -1 中的其中一个,不过没有保证;得到其他的值也是有可能的,程序崩溃的概率虽然极小但也绝不是零。不同的编译器一般会在 1 和 -1 中选一个,甚至相同的编译器不同的编译选项也会影响结果。说它危险,是因为这涉及到了未定义行为。回顾上面指针算术的定义,a
b
并不是同一个数组的元素,所以其地址不可相减。相比之下,下面这个程序就是完全正常且良定义的,尽管你仍然大概率会得到 1 或 -1 的其中一个或是其他没有什么意义的值,但它仍然要比之前的那个程序温和得多,就因为它不是指针算术。
- 你应该猜到了
intptr_t
就是uintptr_t
的有符号变种。我们在这里没有用uintptr_t
,因为无法确定两个地址的差值是正还是负。无符号整数的运算结果仍然是无符号的。
Theorem 3. p[q]
全等于 *(p + q)
。
这实际上有个非常有趣的应用。例如 a[1]
也可以写作 1[a]
,这在语法上是完全正确的;再例如,思考一下为什么 b[a[i]]
可以写作 i[a][b]
,这也是语法正确且等价的(尽管从可读性的角度不推荐这么写)。
在了解指针算术后,要了解数组访问是怎么回事就很简单了。访问 a[i]
就是从 a
的地址向后移动 i
个元素大小,然后解引用,即 *(a + i)
。非常漂亮的结果,不是么?
实际上还有最后一朵乌云——再回顾一下指针算术的定义,思考一下 a
的类型是 int [5]
,并非指针,那为什么 a
可以参与指针算术呢?——这里发生了数组到指针的退化。
数组到指针的退化¶
数组声明 - cppreference.com
任何数组类型的左值表达式,当用于异于
- 作为取地址运算符的操作数
- 作为
sizeof
的操作数 - 作为
typeof
和typeof_unqual
的操作数(C23 起) - 作为用于数组初始化的字符串字面量
- 作为
_Alignof
的操作数(C11 起)
的语境时,会经历到指向其首元素指针的隐式转换。结果为非左值。
最后一句话是关键——a
(类型为 T[N]
)隐式转换为 &a[0]
(类型为 T *
)。这就是数组到指针的退化的含义。前面的条目都是在描述这种隐式转换何时发生——一言以蔽之就是将数组名作为一个表达式来使用的时候,比如运算、赋值给另一个指针、传给函数等等。这就是为什么下面的写法是错的:
我们可以提到数组名的本质了——数组名是不可修改的地址左值(而非有些地方可能提到的数组名是右值)。类比指针——指针自己是一个变量,有一块独立的内存空间存储地址,而数组名自身就是地址——它的所有元素都存储在这个地址开始的内存空间中。结合内存模型仔细思考这句话——这就是为什么数组名不能被赋值的原因——你不能改变一个地址本身!这也就是为什么对于数组 a
(类型为 T[N]
),对它取地址得到的是 T (*)[N]
类型的相同地址,它的首元素(&a[0]
)也是 T *
类型的相同地址——这个数组就在这里,它的元素也就在这里,你不能改变它的地址,所以取其地址仍然得到同一个地址,只是类型变了而已。
在理解了这一点之后,考虑下面这种写法:
我们知道 a
会退化为 int (*)[5]
类型的指针,在这里我们将其类型转换为 int *
。这样的写法是可以的,并且没有违反严格别名规则——相当于我们将 a
的 80 个字节看作连续的 20 个 int
类型的变量。所以 p[0]
一直到 p[19]
都是合法的,但 p[20]
就越界了。这种写法在某些情况下是有用的,例如需要将未知大小的数组的数组传给函数,而函数只接受指针的情况。
最后我们来鉴定几种最常见的数组到指针退化的情况。
第一种,即函数参数的传递。我们已经知道即使将参数写为数组的形式,在函数中这个参数仍然是个指针。将参数写为数组的形式,有时可能还带有大小,只是在代码层面提供了更多的信息,这个大小在运行时没有任何意义5,也不能通过 sizeof
等手段获取——因为它就不是数组,由于退化的存在,也不能是数组。下面的写法全都是等价的:
但是不要把它与下面这种情况混淆(我们马上会看到如何理解这种情况):
这也是为什么,在 int
为 32 位、指针为 64 位的平台上,下面的函数永远返回 2,而与参数中的数组大小无关:
因为它计算的是 sizeof(int *) / sizeof(int)
。
第二种,即字符串。我们已经知道字符串字面量的类型是 char []
,所以在使用字符串时,我们有两种写法——数组与指针:
- 我们已经说过,修改字符串字面量是未定义行为3。即使语法上这个指针可以不是
const
,但总应该加上const
——如果漏了,编译器会警告你。
这两种写法有本质上的区别。s1
是一个数组,它的 7 个字节就存储了 "Hello!"
的 7 个字符(没数错)。而 s2
是一个指针,这个变量只存储了一个地址,那个地址开始的 7 个字节才存储了 "Hello!"
的 7 个字符——至于这种情况下,这 7 个字符具体存储在哪里,是由编译器决定的。因为字符串字面量是不可修改的,所以编译器完全可以将它们存储在只读内存区域,尝试修改就会发生段错误。上面程序的输出可能是这样的:
注意到前面四个地址都非常接近,而后面四个地址是完全相同的,而且这两组地址之间显然差得非常远,一个现代的编译器得到的结果一定会有这种特征。因为 s1
s3
是数组,数组元素可以被修改,所以 s1
s3
一定要有自己独立的内存空间;而 s2
s4
是指针,指针只存储地址,所以指针变量自身存储在函数的栈上,而其指向的字符串字面量存储在另一块空间。编译器知道字符串字面量不可修改,因此它很可能会将程序中存在的多个相同的字符串字面量指向同一块内存空间,这是非常常见的优化手段。
第三种,也是最难讲清楚的一种,即数组的数组。实际上按照我们的思路就非常好理解,“数组的数组会退化为指向数组的指针”6:
仍然可以从很多角度理解这个问题,最好理解的,也就是从类型的角度,数组 a
的类型为 int [4][5]
,它的元素的类型为 int [5]
,所以 a
退化为 int (*)[5]
类型的指针。int (*)[5]
和 int **
本质上就是指向两种不同类型的指针——思考一下这里 T
是什么!从内存模型和指针算术的角度,数组是内存中一块连续的空间,考虑 a + 1
是什么——从 a
的地址开始,向后移动一个 int [5]
的大小,即 20 个字节,p + 1
也是如此。假设 q
的赋值能说得通,那么 q + 1
又代表什么含义呢?这就是为什么 int **q = a
不能成立,因为 int **
和 int (*)[5]
指向不同的类型,它们的指针算术规则也是不同的。
这也可以前面解释数组的数组作为函数参数时的情况了;也可以解释为什么在数组初始化时,只有数组本身的大小可以省略,而其元素类型的大小不能省略。现在 int [4][5][6][7]
会退化为 int (*)[5][6][7]
也就不奇怪了。如果能完全掌握本节内容,那么你对数组和指针关系的理解就已经超越了大多数人。
为什么小端序比大端序有优势¶
我们最后以一个轻松愉快的话题结束本节。我们已经知道,小端序和大端序的区别在于多字节数据的存储顺序,小(大)端序将最低(高)字节存储在最低地址。我们也知道为什么 (short)0x12345678 == 0x5678
,这是很简单的类型转换。现在,考虑下面的程序在小端序和大端序平台分别会有什么行为:
尽管第 5 行违反了严格别名规则,但我们现在在讨论端序,这本来就是平台相关的细节。在小端序平台上,i
的内存布局是 0x78 | 0x56 | 0x34 | 0x12
,因此将其转换为 short *
并解引用,我们会得到 0x78 | 0x56
,即 0x5678。大端序平台上,i
的内存布局是 0x12 | 0x34 | 0x56 | 0x78
,因此我们会取到 0x1234。这就是小端序的优势之一——如果需要从一个大整数中取出低位的数值,在小端序平台上不需要做任何额外的操作。
当然,上面这种写法也能用来判断平台的端序,只不过应该用 uint8_t
,这样就不用担心严格别名规则的问题。
函数指针¶
在函数指针之前,我们先来看看函数的类型。考虑一下我们上面的所有例子中,main
函数的类型是什么?答案是 int (void)
,这就是一个函数类型,它不接受参数,返回一个 int
类型的值。再比如,printf
的声明如下:
那么 printf
的类型就是 int (const char *, ...)
。从函数的类型我们就可以知道这个函数接受什么参数,返回什么值。我们同样可以 typedef
一个函数类型的别名,因此也就可以有指向函数的指针了。
p
q
的类型是 int (*)(int, int)
,即指向接受两个 int
类型参数,返回 int
类型值的函数的指针。注意到第 6 行取了 add
的地址,而第 10 行并没有取 xor
的地址——这是因为函数类型也存在退化。与数组类型相似,函数名本身就是函数的可执行代码所在的地址,在操作它的时候会隐式转换为函数指针。由于函数类型本身的限制——不可对函数类型应用 sizeof
运算符7,所以函数指针的指针算术是不允许的。
其实第 3 行的写法也很有意思,它是 add
与 xor
函数的声明。正常来写就是像下面这样,但通过用 typedef
,我们可以将函数声明写得像声明两个变量一样。当然这只限声明,定义还是要像正常的函数定义一样写。
与数组同样的,当函数类型作为函数参数时,实际上参数是一个函数指针。所以下面的两种声明是等价的,第 1 种写法并不会声明函数 g
,g
只是 f
的函数指针参数。
因为函数退化的存在,我们可以做这样的操作:
函数或函数指针可以无限次解引用——这背后没有什么魔法,只是因为每次解引用后退化又会再次发生。因此,(******add)(5, 7)
就是 (*&*&*&*&*&*&add)(5, 7)
,当然这种写法没有任何实际意义。另外,指向函数的多级指针不能用于调用函数:
原因仍然在于函数退化到指针并不改变地址,只是改变了类型。p
存储的地址是 add
的可执行代码的地址,而 p2
存储的地址是变量 p
的地址。
要定义函数指针的数组,只需给变量名后面加上 []
即可。如果真的有这种需求,那么用 typedef
会更加清晰。fun
的类型是 int (*[2])(int, int)
。
最后,下面是一个返回函数指针的函数。f
的类型是 int (*(int))(int, int)
,这是接受一个 int
作为参数,返回 int (*)(int, int)
类型函数指针的函数。当然这种情况下 typedef
肯定是更好的选择。顺带还有返回指向数组的指针的函数的写法。
void *
¶
我们来到了今天的最后一个话题。void
是一种不完整类型,因此不可定义 void
类型的变量。但是 void *
是一个合法的类型,它是舍弃了类型信息的指针。任何类型的指针都可以隐式转换为 void *
类型,反之同样成立8:
Tip
注意类型与限定符不同。例如 const
限定符,const T *
只可以隐式转换为 const void *
,而不能转换为 void *
——后者会产生编译警告。但是,void *
仍然可以隐式转换为 const T *
。
C 语言中限定符有 3 个:const
、volatile
、restrict
,分别有不同语义,统称为 cvr 限定符。限定符并不是类型的一部分。一言以蔽之,隐式转换不能“失去”限定——隐式转换得到类型的限定不能比原类型的限定更少。另外,对于指针,还要区分指针本身的限定与指向对象的限定——const int *
和 int *const
不同。
Question
如果定义 int a[2][3]
,那么下面哪些函数 f
的声明能让 f(a)
成立?
/* A */ void f(int (*p)[3]);
/* B */ void f(int **p);
/* C */ void f(void *p);
/* D */ void f(void **p);
答案
答案是 A 和 C。a
会退化为 int (*)[3]
类型的指针,所以 A 可以接受;void *
可以接受任何类型的指针,所以 C 也可以接受。B 不行,因为我们知道数组的数组不能退化为二级指针;D 也不行,因为 void **
与 void *
不同;void **
是指向 void *
的指针,它是有类型信息的。
void *
最常见于 malloc
/ free
函数:
它们操作 void *
是很自然的,因为 malloc
返回的是一块未初始化的内存,如何使用这块内存,存储何类型的数据,完全由程序员自己决定。free
也是一样,它只需要知道这块内存的地址,而不需要知道这块内存存储的是什么类型的数据。void *
同样可以用于在 C 语言中实现类似于泛型的功能,这是一种高级编程技巧。
当然,void *
还在其他地方有用武之地。最能展现 void *
优势的是下面两个函数:
void *bsearch(const void *key, const void *base, size_t nmemb,
size_t size, int (*compar)(const void *, const void *));
void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
bsearch
与 qsort
分别是 C 标准库提供的有序数组查找与数组排序函数9。以 bsearch
为例,它接受 5 个参数:
const void *key
:要查找的元素的地址;const void *base
:数组首元素的地址;size_t nmemb
:数组中的元素个数;size_t size
:每个元素的大小;int (*compar)(const void *, const void *)
:比较函数。
利用 void *
的特性,bsearch
得以实现对任意类型的数组进行查找。它不需要知道数组中元素的类型,只需要知道每个元素的大小,以及比较函数。在知道了元素大小后,它就可以实施正确的地址运算,找到数组中的元素,然后调用比较函数确定两个元素的大小关系。compar
函数的返回值应当是负数、零、正数分别表示第一个参数小于、等于、大于第二个参数,这是 C 语言中常见的比较函数的约定——strcmp
也是如此。qsort
也是类似的,只是它根据 compar
的结果对数组进行排序,并且没有返回值。
Tip
bsearch
期待数组是从小到大有序的,qsort
会将数组排序为从小到大有序。当然,这是 C 标准库的约定,你可以修改 compar
的行为——例如,当大于、等于、小于时返回负数、零、正数,就可以实现查找从大到小有序 / 排序从大到小的功能。
在调用 bsearch
和 qsort
时,搞清楚要操作的是什么类型是非常重要的。如果你要操作的类型是 T
,那么 size
应当是 sizeof(T)
,而 void *
对应 T *
。这一点在 T
本身就是指针类型时更容易搞混——思考一下字符串,即 T = const char *
的情况。
最后,下面是一个演示 bsearch
与 qsort
的完整例子。
注意 compare_ints
和 compare_strings
中我们是如何将 void *
转换为正确的类型的,特别是排序字符串的时候。
附录:判断值的类型¶
在 C++ 中,借助 RTTI 我们可以使用 typeid
运算符来判断一个值的精确类型,但 C 语言没有类似的机制。但我们仍然可以通过一些其他技巧判断一个值的大致类别——是某种指针、数组、函数还是其他类型。GCC 提供了一个内建函数 __builtin_classify_type
:
6.64 Other Built-in Functions Provided by GCC
Built-in Function: int __builtin_classify_type(arg)
Built-in Function: int __builtin_classify_type(type)
The __builtin_classify_type
returns a small integer with a category of arg
argument's type, like void type, integer type, enumeral type, boolean type, pointer type, reference type, offset type, real type, complex type, function type, method type, record type, union type, array type, string type, bit-precise integer type, vector type, etc. When the argument is an expression, for backwards compatibility reason the argument is promoted like arguments passed to ...
in varargs function, so some classes are never returned in certain languages. Alternatively, the argument of the built-in function can be a typename, such as the typeof specifier.
int a[2];
__builtin_classify_type(a) == __builtin_classify_type(int [5]);
__builtin_classify_type(a) == __builtin_classify_type(void *);
__builtin_classify_type(typeof(a)) == __builtin_classify_type(int [5]);
The first comparison will never be true, as a
is implicitly converted to pointer. The last two comparisons will be true as they classify pointers in the second case and arrays in the last case.
一言以蔽之,__builtin_classify_type
函数接受一个参数,这个参数可以是类型或值。它返回一个整数,每一个可能的返回值都代表某种类型。我们可以在 GCC 的源码中找到具体的定义:
不过什么值具体代表什么类型,我们并不需要关心,只需要知道它能区分类型即可。例如,你可能会质疑,如何证明 (******add)(5, 7)
实际上是 (*&*&*&*&*&*&add)(5, 7)
而不是 (&*&*&*&*&*&*&add)(5, 7)
?这时就可以用 __builtin_classify_type
来验证:
这份程序输出 5 5 10 10 5 10
,我们发现 __typeof__(main)
和 main
的结果是不同的,而 __builtin_classify_type(main)
的结果与 __builtin_classify_type(void *)
相同,这说明发生了函数到指针的退化。相比之下,通过使用 __typeof__
,我们直接获得了 main
与 ****main
的类型——结果与 __builtin_classify_type(void (void))
相同,说明 main
自身是一种函数类型,与指针类型有别。你可以换换不同的类型试试看,例如研究一下数组的退化。这是一个很有意思的工具。
要注意的是,__builtin_classify_type
是 GCC 的内建函数,因此上面的代码只能用 GCC 编译——Clang 还不支持 __builtin_classify_type(type)
的用法。
附录:多级指针的实际应用¶
多级指针最容易出现在与字符串相关的操作中。C 标准库就有很多这样的例子,我们之前见过的用 qsort
排序字符串就是多级指针一个很好的应用。另一个例子是 strtol
函数,它可以将字符串转换为长整型数。它的声明如下:
endptr
的类型是 char **
,它是一个指向 char
类型的二级指针,或者说指向字符串的指针。如果传递的 endptr
不为 NULL
,那么 strtol
会将转换过程中遇到的第一个非数字字符的地址(也就是,转换停止的地方)存储在 *endptr
中。这样调用者就可以知道转换到了哪里。
其他 C 标准库的例子还包括 strtok
等函数。另外,我们在自己的代码中也可以享受多级指针的便利,例如实现一个自适应的 strcat
函数:
当然,这个函数要求 dest
必须是动态分配的内存。这个函数的返回值是拼接后的字符串的地址,dest
会被修改为新的地址。在某些情况下这种写法是有用的。
另一个 C 标准库之外的例子是 pthread_create
函数,它用于创建一个新的线程:
int pthread_create(pthread_t *restrict thread,
const pthread_attr_t *restrict attr,
void *(*start_routine)(void *),
void *restrict arg);
start_routine
是新线程开始执行的函数,arg
是传递给这个函数的参数。尽管这里并没有出现多级指针,但我们可以利用多级指针来传递更多参数:
通过这种方式,我们可以传递任意多个参数给线程函数。当然,有语言洁癖的人可能会说这是一种对多级指针的滥用,应该使用结构体来传递参数——但它确实是一种很有趣且实用的技巧。以及不管是通过结构体还是多级指针,都需要注意内存的生命周期,在必要的时候传递动态分配的内存。
最后还有一个例子——main
函数。实际上 main
函数可以有三种不同的原型:
在后两种声明中,argv
和 envp
都是字符串的数组——但别忘了参数中的数组语法实际上是指针——所以 argv
和 envp
的类型都是 char **
。这也是多级指针的一个常见应用。
-
此事在 C11 Annex E 中亦有记载。 ↩
-
…但是
uint8_t
不一定非得是unsigned char
。 ↩ -
是的,这里没有
const
,这不代表它可以被修改。修改字符串字面量是未定义行为。这与 C++ 不同,C++ 中字符串字面量的类型是const char []
。 ↩↩ -
例如结构体中的位域是左值但不能取地址,再比如函数名既不是左值也不是右值,但是可以取地址,得到函数指针。 ↩
-
实际上还有一些更复杂的情况,如大小还可以加上
static
关键字,这时传入的数组必须至少为这个大小,否则行为未定义。但这仍然不会改变退化的事实。 ↩ -
还有很多相同含义的表述,比如“数组的退化只发生在第一维”等等,“二维数组不能退化为二级指针”等等,虽然我们已经强调过了维数的概念是大错特错的。 ↩
-
GCC 扩展允许对函数类型应用
sizeof
运算符,得到的结果为 1——但这不是标准 C 的一部分。 ↩ -
注意这只对 C 语言成立,在 C++ 中
void *
必须显式转换为其他类型的指针,推荐的方法是使用static_cast
。 ↩ -
不要被函数名所迷惑——C 并未规定这两个函数的实现必须使用二分查找与快速排序,也未保证它们的效率。 ↩