第 11 章:指针
本章介绍:
- 指针操作;
- 指针与结构体、数组和函数的配合使用。
指针是深入理解 C 语言的第一道真正难关。当我们需要从代码的不同位置访问对象,或者需要在运行过程中动态组织数据时,就会用到指针。
初学者把指针和数组混为一谈是出了名的,所以请注意:准确掌握相关术语可能要费些功夫。另一方面,指针又是 C 最重要的特性之一。它们帮助我们屏蔽平台细节,建立抽象并编写可移植代码。因此,学习本章时请保持耐心:它对理解本书后续大部分内容至关重要。
指针(pointer)是一种特殊的派生类型构造,它“指向”或“引用”某项事物。我们已经见过这种构造的语法:在一个类型(称为所引用类型)后加上字符 *。例如,p0 是指向 double 的指针:
double* p0;其基本思想是:一个对象(指针)指向另一对象所在的内存。
本章始终需要严格区分指针本身(箭头左侧)与指针所指向的未命名对象(箭头右侧)。
指针的第一项用途,是打破函数调用方代码与函数内部代码之间的屏障,使我们能够编写非纯函数。以下函数原型就是一例:
void double_swap(double* p0, double* p1);这里的两个函数形参各“指向”一个 double 类型的对象。double_swap 应当交换这两个对象的内容。例如,调用函数时,p0 和 p1 可以分别指向调用方定义的两个 double 对象 d0 和 d1:
函数得到这两个对象的信息后,就能真正交换它们的内容,而无须改变指针本身:
借助指针,该函数能直接修改调用方中的对象;不使用指针或数组的纯函数无法做到这一点。
本章将详细讨论各种指针操作(第 11.1 节),以及几种使指针具有特殊性质的类型:结构体(第 11.2 节)、数组(第 11.3 节)和函数(第 11.4 节)。
11.1 指针操作
指针是一项重要概念,因此 C 专门为它提供了多种语言操作和特性。最重要的是,有一些专用运算符可以处理指针与其所指对象之间的“指向”关系(第 11.1.1 节)。指针也被视为标量:它支持加偏移量(第 11.1.2 节)和减偏移量(第 11.1.3 节)等算术操作。指针拥有状态(第 11.1.4 节),其中还有专门的“空”状态(第 11.1.5 节)。
11.1.1 取地址运算符与间接运算符
一旦要完成纯函数无法表达的任务,事情就复杂起来了:我们必须操作函数局部对象以外的对象,而指针正是完成这项工作的合适抽象。
仍以前面的 double_swap 为例,用它交换两个 double 对象 d0 和 d1 的内容。调用时要用到一元取地址运算符 &,它让我们可以通过对象的地址引用该对象:
double_swap(&d0, &d1);取地址运算符产生指针类型的值,该类型可以用已经见过的 * 记法写出。函数可以实现为:
void double_swap(double* p0, double* p1) {
auto tmp = *p0;
*p0 = *p1;
*p1 = tmp;
}2
3
4
5
函数内部的指针 p0 和 p1 保存着函数要操作的对象的地址;在本例中,就是 d0 和 d1 的地址。但函数完全不知道这两个对象的名称,只知道 p0 和 p1。
要访问这些对象,需要使用取地址运算符的逆操作,也就是一元间接运算符 *。*p0 表示第一个实参所对应的对象;对前面的调用而言,它就是 d0。同理,*p1 就是 d1。
请注意,字符 * 在 double_swap 的定义中扮演两种不同角色:在声明中,它构成新的指针类型;在表达式中,它解引用指针所引用的对象。为了区分同一符号的两种用途,本书在 * 修饰类型时,通常让它紧贴左侧的类型,不留空格:
double* p0;而在表达式中解引用指针时,则让它紧贴右侧:
*p0 = *p1;练习 14
编写一个函数,接收指向三个对象的指针,并循环移位这三个对象的值。
回想第 6.2 节:指针除了保存有效地址,还可能为空或无效。
要点 11.1.1 #1
若程序执行把 * 用于无效指针或空指针,则执行失败。
实践中,这两种情况通常表现不同。无效指针可能访问并修改内存中的随机对象;由于它会闯入本不该触及的对象,往往造成极难追踪的缺陷。空指针则通常会在开发早期暴露出来,让程序干脆利落地崩溃——请把这当作一项优点。
11.1.2 指针加法
我们已经看到,有效指针保存着一个所引用类型对象的地址;但实际上,C 的假定还要更强。
要点 11.1.2 #1
有效指针引用一个由所引用类型构成的数组的首元素。
换言之,指针不仅可以引用所引用类型的单个实例,也可以引用一个长度未知的数组。
在语法层面,指针与数组这两个概念纠缠得更深。定义 double_swap 时甚至不必使用指针记法;用此前一直使用的记法,也可以写成:
void double_swap(double p0[static 1], double p1[static 1]) {
auto tmp = p0[0];
p0[0] = p1[0];
p1[0] = tmp;
}2
3
4
5
接口中的数组记法,以及用 [0] 访问首元素的写法,都是 C 语言内建的简单改写操作。后文还会看到更多此类规则。
通过简单的加法,就能访问数组的后续元素。以下函数对数组的所有元素求和:
double sum0(size_t len, double const* a) {
double ret = 0.0;
for (size_t i = 0; i < len; ++i) {
ret += *(a + i);
}
return ret;
}2
3
4
5
6
7
表达式 a + i 是指向数组第 i 个元素的指针。指针加法可以有多种写法,因此下面这些函数对数组求和的顺序完全相同:
double sum1(size_t len, double const* a) {
double ret = 0.0;
for (double const* p = a; p < a + len; ++p) {
ret += *p;
}
return ret;
}
double sum2(size_t len, double const* a) {
double ret = 0.0;
for (double const* const a_stop = a + len; a < a_stop; ++a) {
ret += *a;
}
return ret;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
在 sum1 的第 i 次迭代中,指针 p 逐个走过数组元素,直到它大于或等于 a + len;后者是第一个越过数组末尾的指针值。
在 sum2 中,a 引用数组的第 i 个元素。函数内部不再引用第 0 个元素,数组末尾的信息则由对象 a_stop 保存。
这些函数可以这样调用:
double s0_7 = sum0(7, &A[0]); // 整个数组
double s1_6 = sum0(6, &A[1]); // 最后 6 个元素
double s2_3 = sum0(3, &A[2]); // 中间 3 个元素2
3
遗憾的是,我们无法得知隐藏在指针之后的数组长度,所以必须把长度作为形参传入函数。第 6.1.3 节介绍的 sizeof 技巧在这里不起作用。
要点 11.1.2 #2
无法从指针还原数组对象的长度。
这是指针与数组之间的第一项重要差异。
要点 11.1.2 #3
指针不是数组。
如果通过指针把数组传入函数,务必保留数组的真实长度。因此,本书始终偏爱在指针接口中采用数组记法:
double sum0(size_t len, double const a[len]);
double sum1(size_t len, double const a[len]);
double sum2(size_t len, double const a[len]);2
3
它们与前面的接口完全相同,但能让随意浏览代码的人清楚看到:a 应当拥有 len 个元素。
11.1.3 指针减法与指针差
此前讨论的指针算术,是把整数与指针相加。还有一项逆操作,可以从指针中减去整数。若想逆序访问数组元素,可以这样写:
double sum3(size_t len, double const* a) {
double ret = 0.0;
for (double const* p = a + len; p > a;) {
--p;
ret += *p;
}
return ret;
}2
3
4
5
6
7
8
p 最初等于 a + len,每轮循环先递减、再访问元素。请注意,这个函数颠倒了求和顺序。(译者注:原书的 sum3 与 sum4 让 p 从 a + len - 1 开始,用 do … while (p > a) 循环,这样会漏掉 a[0];len 为 0 时还会算出数组之前的地址,导致未定义行为。此处代码已改正。)[1]
另一项操作称为指针差:它接收两个指针,计算二者相隔多少个元素。为了演示这项操作,我们把 sum3 扩展成一个新版本,让它检查一种错误情形:数组中有元素为无穷大。遇到这种情况时,函数要打印完整的错误消息,并把肇事元素返回给调用方:[2]
double sum4(size_t len, double const* a) {
double ret = 0.0;
for (double const* p = a + len; p > a;) {
--p;
if (isinf(*p)) {
fprintf(stderr,
"element %tu of array at %p is infinite\n",
p - a, // 指针差!
(void*)a); // 打印指针值
return *p;
}
ret += *p;
}
return ret;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
表达式 p - a 计算当前元素在数组中的位置。只有当两个指针引用同一数组对象的元素时,这项操作才有定义。否则程序就是错误的,并可能发生严重后果。
要点 11.1.3 #1
只能对指向同一数组对象元素的指针作减法。
指针差的值,就是相应数组元素索引之差:
double A[4] = {0.0, 1.0, 2.0, -3.0};
[[maybe_unused]] double* p = &A[1];
[[maybe_unused]] double* q = &A[3];
assert(p - q == -2);2
3
4
我们已经强调过,表示对象大小的正确类型是 size_t。它是无符号类型,而且在许多平台上与 unsigned 不同。指针差也有与之对应的类型:一般不能假定普通的 int 足以容纳所有可能的值。因此,标准头文件 <stddef.h> 提供了另一种类型。在大多数体系结构上,它就是与 size_t 对应的有符号整数类型,但我们不必在意这一点。
要点 11.1.3 #2
所有指针差都具有 ptrdiff_t 类型。
要点 11.1.3 #3
使用 ptrdiff_t 编码位置差或大小差的有符号值。
sum4 还展示了一种为调试而打印指针值的方法:使用格式说明 %p,并通过 (void*)a 把指针实参强制转换为晦涩的 void* 类型。暂且把这个做法当作固定套路接受下来;我们还不具备完全理解它所需的知识,第 12.4 节会给出更多细节。
要点 11.1.3 #4
打印指针值时,把它强制转换为 void*,并使用格式说明 %p。
11.1.4 指针的有效性
前面已经看到,必须谨慎对待指针所含(或不含)的地址。指针有一个值,也就是它所含的地址;这个值可以改变。
如果指针没有有效地址,就必须把它设为空。这一点极为重要,绝不能忘记。这样便于检查和追踪指针是否已经设置。
要点 11.1.4 #1
指针具有真值。
为了避免笨拙的比较,C 程序中经常能看到这样的代码:
char const* name = nullptr;
// 完成一些最终会设置 name 的工作
if (name) {
printf("today's name is %s\n", name);
} else {
printf("today we are anonymous\n");
}2
3
4
5
6
7
因此,控制所有指针对象的状态十分重要。必须保证指针对象要么为空,要么指向我们确实想操作的有效对象。
要点 11.1.4 #2
尽早把指针对象设为空。
大多数情况下,保证这一点最简单的办法是显式初始化指针对象(要点 6.2 #6)。
我们之前已经见过各种类型的表示,也就是平台在对象中存储特定类型的值的具体方式。对一种类型(例如 size_t)有效的表示,在另一种类型(例如 double)看来可能毫无意义。只要直接访问对象,C 语言的类型系统就能保护我们,不会混淆这些不同的表示:size_t 类型的对象始终会被当作 size_t 来访问,绝不会被错误地解释为毫无意义的 double 值。
若使用不慎,指针会打破这道屏障,使代码试图把 size_t 的表示解释成 double。更一般地说,C 甚至为这种情形专门起了名字:如果某个位模式按特定类型解释时没有任何意义,它就称为该类型的非值表示。[3]
要点 11.1.4 #3
如果程序执行访问了一个对象,而该对象具有其类型的非值表示,则执行失败。
其后果非常糟糕,请不要尝试。因此,指针不仅必须指向某个对象(或为空),该对象还必须具有正确类型。
要点 11.1.4 #4
解引用时,指针所指对象必须具有指针指定的类型。
一个直接推论是:指向数组边界之外的指针不得解引用。
double A[2] = {0.0, 1.0};
double* p = &A[0];
printf("element %g\n", *p); // 引用对象
++p; // 有效指针
printf("element %g\n", *p); // 引用对象
++p; // 有效指针,但没有对象
printf("element %g\n", *p); // 引用非对象:程序执行失败2
3
4
5
6
7
最后一行中,p 的值越过了数组边界。即使那里恰好是某个有效对象的地址,我们也对该对象一无所知。因此,即使 p 此时仍是有效指针,把那里存放的内容当作 double 访问也没有意义,C 通常禁止这种访问。
在前面的示例中,只要不执行最后一行的对象访问,指针加法本身就是正确的。指针的有效值包括所有数组元素的地址,以及刚好越过数组末尾的地址;如果不是这样,前面用指针加法写的 for 循环就无法可靠地工作。
要点 11.1.4 #5
指针必须指向有效对象、刚好越过该对象一个位置,或者为空。
所以,前例在最后一行之前都没有问题:最后一次 ++p 只是让指针刚好越过数组末尾一个元素。下面的版本也遵循同样的模式:
double A[2] = {0.0, 1.0};
double* p = &A[0];
printf("element %g\n", *p); // 引用对象
p += 2; // 有效指针,但没有对象
printf("element %g\n", *p); // 引用非对象:程序执行失败2
3
4
5
但下面这个示例可能在加法操作处就崩溃:
double A[2] = {0.0, 1.0};
double* p = &A[0];
printf("element %g\n", *p); // 引用对象
p += 3; // 无效的指针加法:程序执行失败2
3
4
要点 11.1.4 #6
如果程序执行计算出的指针值超出数组对象边界(也不是刚好越过一个元素),则执行失败。
11.1.5 空指针
你可能一直在纳闷:关于指针已经讲了这么多,为什么从没用过宏 NULL?原因在于,C23 以前把空指针常量定义为“值为 0 的泛型指针”,而这个最初的构想并不成功。
C 有空指针的概念,它对应任意指针类型的空值。[4]
double const* const nix = nullptr;
double const* const nax = nix;2
nix 和 nax 都是值为空的指针对象。但遗憾的是,空指针常量并不像名称看起来那样简单。
首先,这里的“常量”指编译时常量,而不是受 const 限定的对象。因此,上述两个指针对象都不是空指针常量。
其次,C23 以前允许这种常量采用的类型十分奇特:它可以是值为 0 的任意整数类型常量表达式,也可以具有 void* 类型。其他指针类型都不允许。至于 void* 这种“类型”的指针,第 12.4 节会介绍。
C 标准对宏 NULL 可能展开成什么规定得相当宽松,只要求它是空指针常量。因此,C23 以前的编译器可以选择以下任一种展开:
| 展开 | 类型 |
|---|---|
0U | unsigned |
0 | signed |
'\0' | signed(值为 0 的整型字符常量;译者注:原书作“枚举常量”,有误) |
0UL | unsigned long |
0L | signed long |
0ULL | unsigned long long |
0LL | signed long long |
(void*)0 | void* |
常见值为 0、0L 和 (void*)0。[5]
务必注意,C 标准没有规定 NULL 背后的具体类型。人们常用它来强调自己说的是指针常量,可在许多平台上它根本不是指针类型。在没有完全掌握的语境中(尤其是第 17.4.2 节将讨论的可变实参数量函数),使用 NULL 甚至很危险。眼下,我们采用最简单的方案即可。
要点 11.1.5 #1
使用 nullptr,不要使用 NULL。
NULL 掩盖的东西比它说清的还多;而且 C 编译器总在努力保持向后兼容,所以你永远无法确定自己得到的究竟是哪一种形式。
11.2 指针与结构体
指向结构体类型的指针对绝大多数 C 代码都至关重要,因此语言制定了一些特殊规则和工具,以简化这种典型用法。例如,考虑计算两个 struct timespec 之差的任务。(译者注:原书此处作“规范化 struct timespec”,与下面的代码不符,已改正。)下面的函数使用指针形参,可以直接操作相应对象:
timespec.c:计算时间差
以下实现使用 double 计算时间。假定 double 具有 52 位尾数,若要在追踪时间时不再进一步损失精度,可表示的最大时间差约为 4.5E6 秒,即 52 天。
double timespec_diff(struct timespec const* later,
struct timespec const* sooner) {
/* 小心:tv_sec 可能是无符号类型。 */
if (later->tv_sec < sooner->tv_sec) {
return -timespec_diff(sooner, later);
} else {
return (later->tv_sec - sooner->tv_sec)
/* 已知 tv_nsec 是有符号类型。 */
+ (later->tv_nsec - sooner->tv_nsec) * 1E-9;
}
}2
3
4
5
6
7
8
9
10
11
为了方便,这里使用了新的 -> 运算符。它的形状像箭头:左操作数是指针,“指向”右操作数所表示的底层结构体成员。它等价于 * 和 . 的组合。不用它的话,要达到同样效果,就得写成带括号的 (*a).tv_sec,而不是 a->tv_sec。这种写法很快会变得笨拙,所以大家都使用 -> 运算符。
请注意,a->tv_nsec 这样的构造并不是指针,而是 long 类型的对象,也就是数本身。
再看第 10.2.2 节引入的有理数类型 rat。清单 10.1 中操作该类型指针的函数可以写成:
void rat_destroy(rat* rp) [[__unsequenced__]] {
if (rp) {
*rp = (rat){};
}
}
rat* rat_init(rat* rp,
signed sign,
size_t num,
size_t denom) [[__unsequenced__]] {
if (rp) {
*rp = rat_get(sign, num, denom);
}
return rp;
}
rat* rat_normalize(rat* rp) [[__unsequenced__]] {
if (rp) {
*rp = rat_get_normal(*rp);
}
return rp;
}
rat* rat_extend(rat* rp, size_t f) [[__unsequenced__]] {
if (rp) {
*rp = rat_get_extended(*rp, f);
}
return rp;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
rat_destroy 保证清除对象中可能存在的全部数据,并把所有位设为 0。其余三个函数只是对已知纯函数的简单包装。它们先用指针的真值检验其有效性,只有指针有效时才引用相应对象。因此,即使指针实参为空,也能安全使用这些函数。
练习 21
按照清单 10.1 的声明实现 rat_print。该函数应使用 -> 访问其 rat* 实参的成员,输出形式应为 ±nom/denum。
练习 22
组合 rat_normalize 与 rat_print,实现 rat_print_normalized。
四个函数都会检查并返回其指针实参。这是一种便于组合函数的策略,下面两个算术函数的定义展示了这种做法:
rat* rat_rma(rat* rp, rat x, rat y) [[__unsequenced__]] {
return rat_sumup(rp, rat_get_prod(x, y));
}2
3
rat_rma(rational multiply add,有理数乘加)清楚地表明了自身用途:把另外两个函数实参的乘积,加到 rp 所引用的对象上。加法由下面这个函数完成:
rat* rat_sumup(rat* rp, rat y) [[__unsequenced__]] {
size_t c = gcd(rp->denom, y.denom);
size_t ax = y.denom / c;
size_t bx = rp->denom / c;
rat_extend(rp, ax);
y = rat_get_extended(y, bx);
assert(rp->denom == y.denom);
if (rp->sign == y.sign) {
rp->num += y.num;
} else if (rp->num > y.num) {
rp->num -= y.num;
} else {
rp->num = y.num - rp->num;
rp->sign = !rp->sign;
}
return rat_normalize(rp);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
rat_sumup 是一个更加复杂的例子,其中对指针实参应用了两个维护函数。
练习 23
实现清单 10.1 中的 rat_dotproduct,使其计算 *rp 后返回。
结构体类型指针还适用另一项特殊规则:即使结构体类型本身未知,也可以使用其指针。这种不透明结构体经常用于严格分离库的接口与实现。例如,可以在包含文件中这样公开一个虚构类型 toto:
/* struct toto 的前向声明。 */
struct toto;
struct toto* toto_get(void);
void toto_destroy(struct toto*);
void toto_doit(struct toto*, unsigned);2
3
4
5
使用 struct toto 时,程序员和编译器都不需要更多信息。函数 toto_get 可以用来取得 struct toto 类型对象的指针,而不必关心定义这些函数的翻译单元究竟怎样定义了该结构体。编译器也能应付这种情况,因为它知道所有结构体指针都具有相同的表示,与底层类型的具体定义无关。
这类接口经常利用空指针的特殊性。在前例中,toto_doit(nullptr, 42) 可能就是有效用法。因此,许多 C 程序员不喜欢把指针隐藏在 typedef 中:
/* 前向声明 struct toto_s,并用类型 toto 隐藏指针。 */
typedef struct toto_s* toto;
toto toto_get(void);
void toto_destroy(toto);
void toto_doit(toto, unsigned);2
3
4
5
这是有效的 C 代码,却掩盖了一个事实:nullptr 是 toto_doit 可能接收的特殊值。
要点 11.2 #1
不要在 typedef 中隐藏指针类型。
只为结构体引入 typedef 名称则不同,这正是我们此前的做法:
/* struct toto 的前向声明及 typedef 名称 toto。 */
typedef struct toto toto;
toto* toto_get(void);
void toto_destroy(toto*);
void toto_doit(toto*, unsigned);2
3
4
5
这里,接口接收指针这一事实依然清晰可见。
挑战 12:文本处理器
能否使用双向链表为文本处理器存储文本?基本思路是用结构体表示一“块”文本,其中包含一个字符串(文本本身),以及分别指向前一块和后一块的指针。
- 能否编写一个函数,在指定位置把一块文本拆成两块?
- 能否编写一个函数,合并相邻的两块文本?
- 能否遍历整篇文本,把它整理成每行一块?
- 能否编写一个函数,打印整篇文本,或者一直打印到文本因屏幕大小而被截断的位置?
11.3 指针与数组
现在可以攻克理解数组与指针关系时的主要障碍了:C 使用同一套语法访问指针和数组元素,而且会把函数的数组形参改写成指针。对经验丰富的 C 程序员而言,这两项特性都提供了方便的快捷方式;但初学者需要花些功夫才能消化。
11.3.1 数组访问与指针访问相同
无论 A 是数组还是指针,以下结论始终成立。
要点 11.3.1 #1
表达式 A[i] 与 *(A + i) 等价。
若 A 是指针,第二个表达式很容易理解:它只是说明同一表达式也可以写成 A[i]。把数组访问记法用于指针,往往能提高代码可读性。但这种等价并不意味着原本没有数组的地方突然出现了数组对象。如果 A 为空,A[i] 应当干脆利落地崩溃,*(A + i) 也一样。
若 A 是数组,*(A + i) 则让我们第一次看到 C 最重要的规则之一发挥作用,这项规则称为数组到指针退化。
要点 11.3.1 #2:数组退化
对数组 A 求值会得到 &A[0]。
C 中之所以不存在“数组值”,以及由此带来种种困难,根源正在于此(要点 6.1.2 #2)。只要数组出现在需要值的语境中,它就退化成指针,所有附加信息也随之丢失。
11.3.2 数组形参与指针形参相同
由于会发生退化,数组无法用作函数实参。即使真有带数组形参的函数,也没有办法用数组去调用它:传入函数的数组会在调用前退化成指针,于是实参类型便无法匹配。
但前文确实见过带数组形参的函数声明,它们为何能够工作?C 所用的技巧是把数组形参改写成指针。
要点 11.3.2 #1
在函数声明中,任何数组形参都会改写成指针。
请花些时间琢磨这项规则及其含义。理解这项“首要特性”(或者说性格缺陷),是轻松编写 C 代码的关键。
回到第 6.1.5 节的示例,那些用数组形参声明的函数,也可以声明成:
size_t strlen(char const* s);
char* strcpy(char* target, char const* source);
signed strcmp(char const* s0, char const* s1);2
3
这些形式完全等价,任何 C 编译器都应当能够交替使用它们。
选择哪一种写法,是习惯、文化或其他社会背景的问题。本书遵循的规则是:如果某项内容按约定不能为空,就采用数组记法;如果它表示基础类型的单个元素,并且可以用空值表示特殊情形,就采用指针记法。
如果形参在语义上是数组,只要可能,还要注明预期的数组大小。为此,通常最好把长度放在数组或指针之前:
double double_copy(size_t len,
double target[len],
double const source[len]);2
3
这样的接口本身就把一切都说清楚了。处理二维数组时,情况会更有意思。典型的矩阵乘法可以写成:
void matrix_mult(size_t n, size_t k, size_t m,
double C[n][m],
double const A[n][k],
double const B[k][m]) {
for (size_t i = 0; i < n; ++i) {
for (size_t j = 0; j < m; ++j) {
C[i][j] = 0.0;
for (size_t l = 0; l < k; ++l) {
C[i][j] += A[i][l] * B[l][j];
}
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
该原型等价于下面这个可读性较差的版本:
void matrix_mult(size_t n, size_t k, size_t m,
double (C[n])[m],
double const (A[n])[k],
double const (B[k])[m]);2
3
4
也等价于:
void matrix_mult(size_t n, size_t k, size_t m,
double (*C)[m],
double const (*A)[k],
double const (*B)[m]);2
3
4
请注意,把最内层维度改写成指针后,形参类型便不再是数组,而是指向数组的指针,所以无须继续改写后续维度。
要点 11.3.2 #2
只有数组形参的最内层维度会被改写。
采用数组记法有很多好处:可以毫不费力地把 VLA 的指针传入函数,并在函数内部照常用下标访问矩阵元素;追踪数组长度也几乎不需要什么技巧。
要点 11.3.2 #3
长度形参应当声明在数组形参之前。
道理很简单:在首次用到长度的位置,它们必须已经是已知的。
遗憾的是,C 通常不会保证带数组长度形参的函数总能得到正确调用。
要点 11.3.2 #4
函数数组实参的有效性必须由程序员保证。
若数组长度在编译时已知,编译器或许能够发出警告;但数组长度是动态值时,基本只能靠你自己,务必小心。
还要注意,前述原型中的矩阵 A 和 B 具有受 const 限定的基础类型。这种写法从 C23 起才能一贯使用,因为 C23 为提高易用性修改了相关规则。在 C23 以前,下面的代码会产生错误,因为形参 A 和 B 的目标类型与实参的限定不同:
double matA[2][2] = {{0, 0}, {0, 0}};
double matB[2][2] = {{0, 0}, {0, 0}};
double matC[2][2] = {{0, 0}, {0, 0}};
matrix_mult(2, 2, 2, matC, matA, matB);2
3
4
过去,这里的规则与使用指针的指针(而非数组指针)的函数相同:
void fake_mult(size_t n, size_t k, size_t m,
double** C,
double const** A,
double const** B);
double** fakeA = (double*[2]){
(double[2]){0, 0},
(double[2]){0, 0},
};
double** fakeB = (double*[2]){
(double[2]){0, 0},
(double[2]){0, 0},
};
double** fakeC = (double*[2]){
(double[2]){0, 0},
(double[2]){0, 0},
};
// 错误:*fakeA 与 *A 不兼容。
fake_mult(2, 2, 2, fakeC, fakeA, fakeB);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
11.4 函数指针
取地址运算符 & 还可以用于另一种构造:函数。我们讨论 atexit(第 8.8 节)时已经见过这个概念,atexit 就是一个接收函数实参的函数。相应的规则与前面介绍的数组退化相似。
要点 11.4 #1:函数退化
不带后续圆括号的函数名,会退化成指向函数入口的指针。
在类型声明和函数形参中,函数与函数指针在语法上也和数组相似:
typedef void atexit_function(void);
/* 同一类型的两个等价定义,其中隐藏了一个指针。 */
typedef atexit_function* atexit_function_pointer;
typedef void (*atexit_function_pointer)(void);
/* 同一函数的五项等价声明。 */
void atexit(void f(void));
void atexit(void (*f)(void));
void atexit(atexit_function f);
void atexit(atexit_function* f);
void atexit(atexit_function_pointer f);2
3
4
5
6
7
8
9
10
11
12
这几种函数声明方式语义等价,哪一种最易阅读,足以引发长篇争论。第二种写法带有 (*f) 圆括号,很快就会变得难读;第五种则因把指针隐藏在类型中而不受欢迎。其余写法中,作者个人稍微偏爱第四种,而不是第一种。
C 库有多个接收函数形参的函数。我们已经见过 atexit 和 at_quick_exit。头文件 <stdlib.h> 中还有一对函数,为搜索(bsearch)和排序(qsort)提供泛型接口:
typedef int compare_function(void const*, void const*);
void* bsearch(void const* key,
void const* base,
size_t n,
size_t size,
compare_function* compar);
void qsort(void* base,
size_t n,
size_t size,
compare_function* compar);2
3
4
5
6
7
8
9
10
11
12
二者都接收数组 base,并在其上完成各自的任务。首元素地址作为 void 指针传入,所以全部类型信息都会丢失。为了正确处理数组,函数必须知道各元素的大小 size 以及元素数量 n。
此外,它们还接收一个比较函数形参,由它提供元素之间的次序信息。借助这种函数指针,bsearch 和 qsort 变得非常通用:任何允许对值排序的数据模型都能使用它们。base 形参引用的元素可以是任意类型 T,例如 int、double、字符串或应用定义的类型;只要 size 正确描述 T 的大小,并且 compar 所指函数知道如何一致地比较 T 类型值即可。
这种比较函数的一个简单版本如下:
int compare_unsigned(void const* a, void const* b) {
unsigned const* A = a;
unsigned const* B = b;
if (*A < *B) {
return -1;
} else if (*A > *B) {
return +1;
} else {
return 0;
}
}2
3
4
5
6
7
8
9
10
11
约定是:两个实参指向需要比较的元素。如果认为 a 小于 b,返回值严格小于 0;如果二者相等,返回 0;否则返回严格大于 0 的值。
返回类型 int 似乎暗示,整数比较可以写得更简单:
/* 无效的整数比较示例。 */
int compare_int(void const* a, void const* b) {
int const* A = a;
int const* B = b;
return *A - *B; // 可能溢出!
}2
3
4
5
6
但这样并不正确。例如,若 *A 很大(如 INT_MAX),而 *B 为负,二者之差的数学值可能大于 INT_MAX。
由于接口使用 void 指针,使用这套机制时应当把类型转换封装起来:
/* 为 unsigned 提供搜索与排序的头文件。 */
/* 这里不用 inline;我们始终使用函数指针。 */
extern int compare_unsigned(void const*, void const*);
inline unsigned const* bsearch_unsigned(
unsigned const key[static 1],
size_t nmemb,
unsigned const base[nmemb]) {
return bsearch(key, base, nmemb, sizeof base[0], compare_unsigned);
}
inline void qsort_unsigned(size_t nmemb, unsigned base[nmemb]) {
qsort(base, nmemb, sizeof base[0], compare_unsigned);
}2
3
4
5
6
7
8
9
10
11
12
13
14
bsearch(binary search,二分搜索)查找与 key[0] 比较相等的元素;找到时返回该元素,否则返回空指针。它假定数组 base 已经按比较函数给出的次序排好序,借此加快搜索。尽管 C 标准没有明确规定,但可以预期,在一次 bsearch 调用中,compar 被调用的次数不会超过
若 bsearch 找到与 *key 相等的数组元素,就返回该元素的指针。请注意,这会在 C 的类型系统上钻出一个洞:它返回的是未限定指针,而元素的有效类型可能受 const 限定。务必谨慎。本例直接把返回值转换成 unsigned const*,所以 bsearch_unsigned 的调用方甚至不会看到未限定指针。从 C23 开始,bsearch 实际上已经升级为同名的类型泛型宏,绕开了这项缺陷;见第 18.1.7 节。
qsort 这个名字来自快速排序算法。标准没有规定必须采用哪种排序算法,但比较次数预计应与快速排序一样,处于
void* 是一种万能的对象指针类型,可以充当指向任何对象类型的泛型指针;函数指针却没有这样的泛型类型,也没有相应的隐式转换。
要点 11.4 #2
函数指针必须采用完全匹配的类型。
之所以需要如此严格,是因为不同原型的函数可能采用截然不同的调用约定,[6] 而指针本身不会记录这些信息。
以下函数存在一个微妙问题:形参类型与比较函数所要求的类型不同。
/* 另一个无效的 int 比较函数示例。 */
int compare_int(int const* a, int const* b) {
if (*a < *b) {
return -1;
} else if (*a > *b) {
return +1;
} else {
return 0;
}
}2
3
4
5
6
7
8
9
10
尝试把它用于 qsort 时,编译器应当报告函数类型错误。前面借助中间的 void const* 形参实现的版本,效率应与这个无效示例几乎相同,却能保证在所有 C 平台上都正确。
通过 (...) 运算符调用函数和函数指针时,规则与数组、指针及 [...] 运算符相似:
double f(double a);
/* 对 f 的等价调用,以及抽象状态机中的步骤。 */
f(3); // 退化 -> 调用
(&f)(3); // 取地址 -> 调用
(*f)(3); // 退化 -> 解引用 -> 退化 -> 调用
(*&f)(3); // 取地址 -> 解引用 -> 退化 -> 调用
(&*f)(3); // 退化 -> 解引用 -> 取地址 -> 调用2
3
4
5
6
7
8
要点 11.4 #3
函数调用运算符 (...) 作用于函数指针。
因此,从抽象状态机的角度看,指针退化总会发生,函数也总是通过函数指针调用。第一种“自然”调用隐藏了对标识符 f 的一次求值,其结果就是函数指针。
掌握这些规则后,几乎可以像使用函数一样使用函数指针:
/* 头文件中。 */
typedef int logger_function(char const*, ...);
extern logger_function* logger;
enum logs {
log_pri,
log_ign,
log_ver,
log_num,
};2
3
4
5
6
7
8
9
10
这里声明了一个全局对象 logger,它将指向某个打印日志信息的函数。借助函数指针,模块的使用者可以动态选择具体使用哪个函数:
/* 一个 .c 文件(翻译单元)中。 */
extern int logger_verbose(char const*, ...);
static int logger_ignore(char const*, ...) {
return 0;
}
logger_function* logger = logger_ignore;
static logger_function* loggers[] = {
[log_pri] = printf,
[log_ign] = logger_ignore,
[log_ver] = logger_verbose,
};2
3
4
5
6
7
8
9
10
11
12
13
14
这段代码定义了实现这一方案所需的工具。特别要注意,函数指针可以用作数组的基础类型(这里就是 loggers)。初始化数组时用到了两个外部函数(printf 和 logger_verbose)和一个静态函数(logger_ignore);存储类不属于函数接口的一部分。
和其他指针类型一样,logger 也可以赋值。可以在启动阶段的某处写下:
if (LOGGER < log_num) {
logger = loggers[LOGGER];
}2
3
此后便能在任何地方使用该函数指针,调用对应函数:
logger("Do we ever see line %lu of file %s?",
__LINE__ + 0UL,
__FILE__);2
3
这次调用使用特殊宏 __LINE__ 和 __FILE__,分别表示行号和源文件名。第 17.3 节会详细讨论它们。
使用函数指针时,必须始终意识到它给函数调用引入了一层间接访问。编译器必须先取出 logger 的内容,再调用该地址处的函数。这会带来一定开销,在对时间要求苛刻的代码中应当避免。
挑战 13:泛型导数
- 能否扩展挑战 2 和挑战 5 中的实数导数与复数导数,使它们接收函数
和值 作为形参? - 能否利用泛型实数导数实现求根的牛顿法?
- 能否求出多项式的实零点?
- 能否求出多项式的复零点?
挑战 14:泛型排序
- 能否把挑战 1 中的排序算法扩展到其他排序键?
- 能否把针对不同排序键的函数归并成与
qsort具有相同签名的函数,也就是接收数据的泛型指针、大小信息和比较函数? - 能否把挑战 10 中对排序算法的性能比较扩展到 C 库函数
qsort?
本章小结
- 指针可以引用对象和函数。
- 指针不是数组,但会引用数组。
- 函数的数组形参会自动改写成对象指针。
- 函数的函数形参会自动改写成函数指针。
- 对函数指针赋值或通过它调用函数时,类型必须完全匹配。