| 目录 |

23 指针 III:指向指针的指针及其他

这一章我们讲一些中级和高级的指针用法。如果你对指针还不够熟练,请先复习前面讲指针和指针算术的那几章,再来碰这些内容。

23.1 指向指针的指针

既然可以有指向变量的指针,而变量本身又可以是指针,那么能不能有指向“本身是指针的变量”的指针呢?

可以!这就是指向指针的指针,它保存在一个“指针的指针”类型的变量里。

在深入之前,我想先帮你找到一种直觉,理解指向指针的指针是怎么运作的。

记住,指针只不过是一个数。这个数表示计算机内存中的一个索引,通常该索引处保存着我们因某种原因关心的值。

那个指针(它是个数)总得存放在某个地方。而那个地方就是内存,和其他一切东西一样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 *****

23.1.1 指向指针的指针与 const

如果你还记得,像这样声明一个指针:

int *const p;

意味着你不能修改 p。试图 p++ 会导致编译期错误。

但对于 int ** 或 int ***,这又该怎么算?const 该放在哪里,又表示什么?

先从简单的部分说起。紧挨变量名的 const 修饰的是那个变量本身。所以如果你想要一个不能改动的 int***,可以这么做:

int ***const p;

p++;  // 不允许

但接下来就有点奇怪了。

假如我们遇到这种情况:

int main(void)
{
    int x = 3490;
    int *const p = &x;
    int **q = &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 指向的那个指针。

23.2 多字节值

我们前面在好几个地方都隐约提到过这一点,但显然并非每个值都能塞进一个字节的内存里。事物(只要不是 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。

以上就算是掀开引擎盖,瞧了一眼内存中一个更复杂实体的字节。

23.3 NULL 指针与零

下面这些可以互换使用:

就我个人而言,我想表示 NULL 时总是用 NULL,不过你偶尔也会见到一些别的写法。虽然 '\0'(所有位都置零的一个字节)比较起来也会相等,但拿它跟指针比较很怪异;你应该用 NULL 和指针比较。(当然,在字符串处理中,很多时候你比较的是指针所指向的东西与 '\0',那是对的。)

0 被称为空指针常量,当它与其他指针比较或被赋给另一个指针时,会被转换为同类型的空指针。

23.4 指针与整数

你可以把指针强制转换成整数,反过来也行(因为指针不过是内存的一个索引),但你可能只有在做一些底层硬件相关的事情时才需要这么做。这类折腾的结果是实现定义的行为,因此不可移植。而且可能会发生怪事。

不过 C 确实给出了一项保证:你可以把指针转换成 uintptr_t 类型,然后还能再转换回指针,而不丢失任何数据。

uintptr_t 定义在 <stdint.h> 中147。

此外,如果你想用有符号的版本,可以用 intptr_t,效果相同。

23.5 把指针转换成其他指针

安全的指针转换只有一种:

  1. 转换成 intptr_t 或 uintptr_t。
  2. 与 void* 相互转换。

两种!安全的指针转换有两种。

  1. 与 char*(或 signed char*/unsigned char*)相互转换。

三种!安全的转换有三种!

  1. 在指向 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);   // 违反严格别名规则

如果你想(相对)安全地做类型双关,请参见联合体与类型双关一节。

23.6 指针差值

正如你在指针运算那一节了解到的,你可以用一个指针减去另一个指针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

23.7 指向函数的指针

函数不过是内存中一堆机器指令的集合,所以没有理由不能取得指向该函数第一条指令的指针。

然后调用它。

这在把一个函数的指针作为实参传给另一个函数时很有用。然后后者就可以调用传进来的那个东西。

不过麻烦的地方在于,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() 的示例。


| 目录 |