这一章我们讲一些中级和高级的指针用法。如果你对指针还不够熟练,请先复习前面讲指针和指针算术的那几章,再来碰这些内容。
既然可以有指向变量的指针,而变量本身又可以是指针,那么能不能有指向“本身是指针的变量”的指针呢?
可以!这就是指向指针的指针,它保存在一个“指针的指针”类型的变量里。
在深入之前,我想先帮你找到一种直觉,理解指向指针的指针是怎么运作的。
记住,指针只不过是一个数。这个数表示计算机内存中的一个索引,通常该索引处保存着我们因某种原因关心的值。
那个指针(它是个数)总得存放在某个地方。而那个地方就是内存,和其他一切东西一样140。
但既然它存放在内存中,它就必须有一个存放它的索引,对吧?这个指针在内存中必然有一个存放它的索引。而这个索引也是个数字。它就是该指针的地址。它就是指向指针的指针。
我们还是从前面章节里那个指向 int 的普通指针说起:
#include <stdio.h>
int main(void)
{
int x = 3490; // 类型:int
int *p = &x; // 类型:指向 int 的指针
printf("%d\n", *p); // 3490
}够直白吧?这里出现了两个类型:int 和 int*,我们把 p 设为指向 x。然后在第 8 行解引用 p,打印出值 3490。
但是,正如我们所说,我们可以有指向任何变量的指针……那么这是否意味着我们可以有指向 p 的指针?
换句话说,这个表达式是什么类型?
int x = 3490; // 类型:int
int *p = &x; // 类型:指向 int 的指针
&p // <-- p 的地址是什么类型?也就是指向 p 的指针?如果 x 是 int,那么 &x 就是指向 int 的指针,我们把它存进了 p,其类型是 int*。跟上了吗?(没跟上就把这段重读一遍,直到跟上为止!)
因此 &p 就是指向 int* 的指针,也叫“指向 int 指针的指针”。也叫“int-指针-指针”。
明白了吗?(没明白就把上一段重读一遍,直到明白为止!)
这个类型写成两个星号:int **。来看看实际用法。
#include <stdio.h>
int main(void)
{
int x = 3490; // 类型:int
int *p = &x; // 类型:指向 int 的指针
int **q = &p; // 类型:指向 int 的指针的指针
printf("%d %d\n", *p, **q); // 3490 3490
}下面我们为上面的值编造一些假地址作为示例,看看这三个变量在内存中可能是什么样子。下面这些地址值都是我为了举例而编的:
| 变量 | 存放地址 | 该处存放的值 |
|---|---|---|
x |
28350 |
3490——代码中的值 |
p |
29122 |
28350——x 的地址! |
q |
30840 |
29122——p 的地址! |
确实,我们在我的电脑上真跑一遍141,用 %p 把指针值打印出来,然后我用真实的地址(十六进制打印)再把同一张表做一遍。
| 变量 | 存放地址 | 该处存放的值 |
|---|---|---|
x |
0x7ffd96a07b94 |
3490——代码中的值 |
p |
0x7ffd96a07b98 |
0x7ffd96a07b94——x 的地址! |
q |
0x7ffd96a07ba0 |
0x7ffd96a07b98——p 的地址! |
可以看到这些地址除了最后一个字节之外都相同,所以只需关注那部分。
在我的系统上,int 是 4 字节,所以能看到地址从 x 到 p 增加了 4142,然后从 p 到 q 又增加了 8。在我的系统上,所有指针都是 8 字节。
它是 int* 还是 int** 有影响吗?其中一个会比另一个多占字节吗?不会!记住,所有指针都是地址,也就是内存中的索引。而在我的机器上,用 8 字节就能表示一个索引……索引处存的是什么并不重要。
现在看看上一个示例第 9 行做了什么:我们对 q 做了双重解引用,回到了原来的 3490。
关于指针和指向指针的指针,重要的一点是:
& 可以取得指向任何东西的指针(包括指向指针!)* 可以取得指针所指向的东西(包括指针!)所以你可以把 & 看作是用来制造指针的,而 * 则是它的逆运算——方向与 & 相反——用来得到所指向的东西。
就类型而言,你每用一个 &,类型上就多出一级指针。
| 如果你有 | 然后你执行 | 结果类型是 |
|---|---|---|
int x |
&x |
int * |
int *x |
&x |
int ** |
int **x |
&x |
int *** |
int ***x |
&x |
int **** |
而每次你使用解引用(*)时,它做的正好相反:
| 如果你有 | 然后你执行 | 结果类型是 |
|---|---|---|
int ****x |
*x |
int *** |
int ***x |
*x |
int ** |
int **x |
*x |
int * |
int *x |
*x |
int |
注意,你可以连续使用多个 * 来快速解引用,就像我们在上面的示例代码中用 **q 所做的那样。每多一个就会剥掉一层间接。
| 如果你有 | 然后你执行 | 结果类型是 |
|---|---|---|
int ****x |
***x |
int * |
int ***x |
**x |
int * |
int **x |
**x |
int |
一般而言,&*E == E143。解引用会“撤销”取地址。
但 & 的工作方式并非如此——你一次只能取一次地址,而且必须把结果存到一个中间变量里:
int x = 3490; // 类型:int
int *p = &x; // 类型:int *,指向 int 的指针
int **q = &p; // 类型:int **,指向 int 的指针的指针
int ***r = &q; // 类型:int ***,指向 int 的指针的指针的指针
int ****s = &r; // 类型:int ****,你懂意思了
int *****t = &s; // 类型:int *****const如果你还记得,像这样声明一个指针:
int *const p;意味着你不能修改 p。试图 p++ 会导致编译期错误。
但对于 int ** 或 int ***,这又该怎么算?const 该放在哪里,又表示什么?
先从简单的部分说起。紧挨变量名的 const 修饰的是那个变量本身。所以如果你想要一个不能改动的 int***,可以这么做:
int ***const p;
p++; // 不允许但接下来就有点奇怪了。
假如我们遇到这种情况:
我构建它的时候,会得到一条警告:
warning: initialization discards ‘const’ qualifier from pointer target type
7 | int **q = &p;
| ^这是怎么回事?编译器是在告诉我们:这里有一个 const 变量,而我们却把它的值赋给了另一个并非同样 const 的变量。“const 属性”被丢弃了,这多半不是我们想要的结果。
p 的类型是 int *const p,因此 &p 的类型是 int *const *。而我们又试图把它赋给 q。
可 q 是 int **!这个类型在第一个 * 上的 const 属性不一样!于是我们得到一条警告:p 的 int *const * 中的 const 被忽略并丢掉了。
只要保证 q 的类型至少和 p 一样 const,就能修好这个问题。
int x = 3490;
int *const p = &x;
int *const *q = &p;现在我们就满意了。
我们还可以让 q 更 const 一些。按上面的写法,我们说的是:“q 本身不是 const,但它指向的东西是 const。”不过我们也可以让两者都是 const:
int x = 3490;
int *const p = &x;
int *const *const q = &p; // 更多 const!这样也行。现在我们不能修改 q,也不能修改 q 指向的那个指针。
我们前面在好几个地方都隐约提到过这一点,但显然并非每个值都能塞进一个字节的内存里。事物(只要不是 char)会占用多个字节的内存。你可以用 sizeof 看出它占几个字节。也可以用标准的 & 运算符看出对象在内存中的第一个字节是哪个地址——它总是返回第一个字节的地址。
还有个有趣的事实!如果你遍历任意对象的各个字节,得到的就是它的对象表示。在内存中对象表示相同的两个东西是相等的。
如果你想遍历对象的表示,应该用指向 unsigned char 的指针来做。
我们自己写一个 memcpy()144 的版本,就干这件事:
void *my_memcpy(void *dest, const void *src, size_t n)
{
// 为 src 和 dest 建立局部变量,但类型是 unsigned char
const unsigned char *s = src;
unsigned char *d = dest;
while (n-- > 0) // 循环遍历给定的字节数
*d++ = *s++; // 把源字节复制到目标字节
// 大多数复制函数会返回指向 dest 的指针,作为一种便利
// 交给调用者
return dest;
}(里面还有一些后置自增和后置自减的好例子,供你研究。)
要注意,上面这个版本很可能比你系统自带的那个效率低。
但你可以把指向任何东西的指针传给它,它会把这些对象复制过去。可以是 int*、struct animal*,或者别的什么。
再看一个例子:把一个 struct 的对象表示字节打印出来,这样就能看到里面有没有填充,以及这些字节都是什么值145。
#include <stdio.h>
struct foo {
char a;
int b;
};
int main(void)
{
struct foo x = {0x12, 0x12345678};
unsigned char *p = (unsigned char *)&x;
for (size_t i = 0; i < sizeof x; i++) {
printf("%02X\n", p[i]);
}
}这里的 struct foo 是特意这样构造的,意在促使编译器插入填充字节(不过编译器并非必须这么做)。然后我们取得一个指向 struct foo 变量 x 第一个字节的 unsigned char *。
接下来,我们只需要知道 sizeof x,就能循环那么多个字节,把值打印出来(为了方便,用十六进制)。
运行它会输出一堆数字。我在下面加了注释,标出各个值都存在哪里:
12 | x.a == 0x12
AB |
BF | padding bytes with "random" value
26 |
78 | x.b == 0x12345678
56 |
34 |
12 |在所有系统上 sizeof(char) 都是 1,我们看到输出最上面的第一个字节里存放着我们在那里写入的 0x12。
接着是一些填充字节——在我的机器上,这些字节每次运行都不一样。
最后,在我的系统上 sizeof(int) 是 4,我们能看到末尾那 4 个字节。注意,它们和十六进制值 0x12345678 里的字节完全一样,只是奇怪地倒了过来146。
以上就算是掀开引擎盖,瞧了一眼内存中一个更复杂实体的字节。
NULL 指针与零下面这些可以互换使用:
NULL0'\0'(void *)0就我个人而言,我想表示 NULL 时总是用 NULL,不过你偶尔也会见到一些别的写法。虽然 '\0'(所有位都置零的一个字节)比较起来也会相等,但拿它跟指针比较很怪异;你应该用 NULL 和指针比较。(当然,在字符串处理中,很多时候你比较的是指针所指向的东西与 '\0',那是对的。)
0 被称为空指针常量,当它与其他指针比较或被赋给另一个指针时,会被转换为同类型的空指针。
你可以把指针强制转换成整数,反过来也行(因为指针不过是内存的一个索引),但你可能只有在做一些底层硬件相关的事情时才需要这么做。这类折腾的结果是实现定义的行为,因此不可移植。而且可能会发生怪事。
不过 C 确实给出了一项保证:你可以把指针转换成 uintptr_t 类型,然后还能再转换回指针,而不丢失任何数据。
uintptr_t 定义在 <stdint.h> 中147。
此外,如果你想用有符号的版本,可以用 intptr_t,效果相同。
安全的指针转换只有一种:
intptr_t 或 uintptr_t。void* 相互转换。两种!安全的指针转换有两种。
char*(或 signed char*/unsigned char*)相互转换。三种!安全的转换有三种!
struct 的指针与指向其第一个成员的指针之间相互转换。四种!安全的转换有四种!
如果你转换成指向另一种类型的指针,然后再访问它指向的对象,那么由于所谓的严格别名,行为是未定义的。
普普通通的别名,指的是同一个对象可以有多条访问途径。这些访问点互为别名。
严格别名规定,你只允许通过与对象兼容的类型的指针来访问该对象。
例如,下面这种肯定是被允许的:
int a = 1;
int *p = &a;p 是一个指向 int 的指针,而它指向的是一个兼容类型——也就是 int——所以我们稳了。
但下面这个不行,因为 int 和 float 不是兼容类型:
int a = 1;
float *p = (float *)&a;下面是一个做别名的演示程序。它取一个 int32_t 类型的变量 v,用指向 struct words 的指针给它起了个别名。这个 struct 里有两个 int16_t。这些类型并不兼容,所以我们违反了严格别名规则。编译器会假定这两个指针从不会指向同一个对象……而我们偏偏让它们指向同一个。这可有点淘气。
看看我们能不能弄坏点什么。
#include <stdio.h>
#include <stdint.h>
struct words {
int16_t v[2];
};
void fun(int32_t *pv, struct words *pw)
{
for (int i = 0; i < 5; i++) {
(*pv)++;
// 打印 32 位值和 16 位值:
printf("%x, %x-%x\n", *pv, pw->v[1], pw->v[0]);
}
}
int main(void)
{
int32_t v = 0x12345678;
struct words *pw = (struct words *)&v; // 违反严格别名规则
fun(&v, pw);
}看到我是怎么把两个不兼容的指针传给 fun() 的吗?其中一个类型是 int32_t*,另一个是 struct words*。
但它们指向同一个对象:那个初始化为 0x12345678 的 32 位值。
那么,如果我们去看 struct words 里的字段,应该就能看到这个数的两个 16 位半部分。对吧?
而在 fun() 的循环里,我们只是把指向 int32_t 的指针自增了。仅此而已。但由于那个 struct 指向同一块内存,它也应该被更新成相同的值。
那我们运行一下,得到这样的结果,左边是 32 位值,右边是两个 16 位部分。它们应该对得上148:
12345679, 1234-5679
1234567a, 1234-567a
1234567b, 1234-567b
1234567c, 1234-567c
1234567d, 1234-567d而它们确实对得上……直到明天!
我们试试用 -O3 和 -fstrict-aliasing 来编译:
12345679, 1234-5678
1234567a, 1234-5679
1234567b, 1234-567a
1234567c, 1234-567b
1234567d, 1234-567c它们差了一个!可它们指向同一块内存!怎么会这样?答案是:像那样给内存起别名是未定义行为。一切皆有可能,只不过都不是什么好事。
如果你的代码违反了严格别名规则,它能不能工作取决于别人决定怎么编译它。而这很让人泄气,因为这不在你的掌控之中。除非你是某种全知全能的神。
不太可能,抱歉。
可以用 -fno-strict-aliasing 强制 GCC 不使用严格别名规则。用 -O3 加上这个选项来编译上面那个演示程序,输出就符合预期了。
最后,类型双关(type punning)就是用不同类型的指针去看同一份数据。在严格别名规则出现之前,这种做法相当常见:
int a = 0x12345678;
short b = *((short *)&a); // 违反严格别名规则如果你想(相对)安全地做类型双关,请参见联合体与类型双关一节。
正如你在指针运算那一节了解到的,你可以用一个指针减去另一个指针149,得到它们之间相差的数组元素个数。
不过这个差值的类型由实现自行决定,所以不同系统可能不一样。
为了更具可移植性,你可以把结果存进 <stddef.h> 中定义的 ptrdiff_t 类型变量里。
int cats[100];
int *f = cats + 20;
int *g = cats + 60;
ptrdiff_t d = g - f; // 差值是 40 打印时,在整型格式说明符前面加上 t 即可:
printf("%td\n", d); // 以十进制打印:40
printf("%tX\n", d); // 以十六进制打印:28
函数不过是内存中一堆机器指令的集合,所以没有理由不能取得指向该函数第一条指令的指针。
然后调用它。
这在把一个函数的指针作为实参传给另一个函数时很有用。然后后者就可以调用传进来的那个东西。
不过麻烦的地方在于,C 需要知道这个“指向函数的指针”变量的类型。
而且它很想把细节全都知道。
比如“这是一个指向函数的指针,该函数接收两个 int 实参并返回 void”。
这些要怎么全都写下来,才能声明一个变量呢?
嗯,结果它看起来和函数原型非常像,只是多了几个括号:
// 声明 p 为一个指向函数的指针。
// 这个函数返回 float,并接收两个 int 作为实参。
float (*p)(int, int);还要注意,你不必给形参起名字。不过想给也可以,它们只是被忽略而已。
// 声明 p 为一个指向函数的指针。
// 这个函数返回 float,并接收两个 int 作为实参。
float (*p)(int a, int b);既然我们知道怎么声明变量了,那该给它赋什么值呢?怎么取得一个函数的地址?
原来这里有一个和获取数组指针一样的捷径:直接写函数名、不加括号就行了。(如果你愿意,也可以在它前面加一个 &,但没必要,也不符合惯用法。)
一旦有了指向函数的指针,你只要加上括号和实参列表就能调用它。
我们来看一个简单的例子:让一个指针指向某个函数,实际上就是给这个函数起了个别名。然后我们调用它。
这段代码会打印出 3490:
#include <stdio.h>
void print_int(int n)
{
printf("%d\n", n);
}
int main(void)
{
// 让 p 指向 print_int:
void (*p)(int) = print_int;
p(3490); // 通过指针调用 print_int
}注意 p 的类型是如何体现 print_int 的返回值和形参类型的。它必须如此,否则 C 就会抱怨指针类型不兼容。
再看一个例子,展示如何把一个函数的指针作为实参传给另一个函数。
我们要写一个函数,它接收两个整型实参,外加一个指向函数的指针,而那个函数对这两个实参做运算。然后它把结果打印出来。
#include <stdio.h>
int add(int a, int b)
{
return a + b;
}
int mult(int a, int b)
{
return a * b;
}
void print_math(int (*op)(int, int), int x, int y)
{
int result = op(x, y);
printf("%d\n", result);
}
int main(void)
{
print_math(add, 5, 7); // 12
print_math(mult, 5, 7); // 35
}花点时间消化一下。这里的思路是:我们把一个函数的指针传给 print_math(),它会调用那个函数来做点数学运算。
这样一来,我们只要传入另一个函数,就能改变 print_math() 的行为。你可以看到,我们在第 22-23 行就是这么做的:分别传入了指向函数 add 和 mult 的指针。
现在看第 13 行,我想大家都同意,print_math() 的函数签名真是一道风景。而且,你可能不信,和你能够构造出来的某些东西相比,这个其实还算相当直白150。
不过我们来消化一下。其实只有三个形参,只是它们不太容易看出来:
// op x y
// |-----------------| |---| |---|
void print_math(int (*op)(int, int), int x, int y)第一个 op 是一个指向函数的指针,该函数接收两个 int 作为实参并返回一个 int。这与 add() 和 mult() 的签名都相符。
第二个和第三个,x 和 y,就是普通的 int 形参。
慢慢地、有意识地让你的目光在这个签名上扫过,同时辨认出各个起作用的部分。对我来说,最醒目的一直是 (*op)( 这个片段——括号和星号。这就是它是指向函数的指针的明显标志。
最后,回到指针 II那一章,看一个使用内置 qsort() 的示例。