Kodewick
第 18 课,共 30 课目录

指针出事

第 18 课,共 30 课 · 第 3 部分 · 指针 · 约 15 分钟

指针好不好用,全看它里面的地址对不对。地址错了,C 不会拦你:程序照样去读、去写那个错的地方,可能马上崩溃,可能过很久才出怪事,也可能一直看不出来。这是 C 里最难查的一类错。这一课讲最常见的四种。每个出错的例子都会停在出事的那一行:点「看看是怎么出错的」,回放一遍是怎么走到这一步的。

错指针里存的是
野指针一个乱的地址:从来没赋过值
空指针NULL:什么也不指
越界数组末尾后面的地址
悬空指针一个已经不在了的变量的地址

野指针

第 15 课见过没赋值的指针。真的程序里,它通常是这样出现的:只有某种情况才给指针赋值,而这种情况一次也没发生。

main.c
1#include <stdio.h>2 3int main(void) {4    int prices[4] = {12, 30, 7, 18};5    int *cheap;6    for (int i = 0; i < 4; i++) {7        if (prices[i] < 5) {8            cheap = &prices[i];9        }10    }11    printf("5 元以下:%d\n", *cheap);12    return 0;13}
输出
use of uninitialized value

发生了什么: 停在第 11 行:「cheap」还没有赋值就被用了。真 C 里这会是一个乱数(每次运行都可能不一样)。

没有哪个价格低于 5,第 8 行一次也没跑,到第 11 行 cheap 里还是乱的。我们的引擎停在这里。点单步,一圈一圈看 cheap:它始终没有箭头。

办法是每个指针一声明就给它一个值。这里写 int *cheap = NULL;,用之前再检查一下,就像下一节那样。

空指针

NULL 是老老实实地说「没有地址」。找东西的函数,找不到时常常返回 NULL。下面这个函数找到了就返回那个元素的地址:

main.c
1#include <stdio.h>2 3int *find(int *codes, int count, int target) {4    for (int i = 0; i < count; i++) {5        if (codes[i] == target) {6            return &codes[i];7        }8    }9    return NULL;10}11 12int main(void) {13    int codes[4] = {101, 205, 310, 422};14    int *p = find(codes, 4, 999);15    printf("找到:%d\n", *p);16    return 0;17}
输出
load of null pointer

发生了什么: 停在第 15 行:这个指针是 NULL(空指针),不能通过它读写。

没有 999,所以 p 是 NULL,第 15 行的 *p 顺着它什么也找不到。在真的电脑上,程序一般当场崩溃(「segmentation fault」,段错误)。不过空指针比野指针好得多:它能检查出来。

main.c
1#include <stdio.h>2 3int *find(int *codes, int count, int target) {4    for (int i = 0; i < count; i++) {5        if (codes[i] == target) {6            return &codes[i];7        }8    }9    return NULL;10}11 12int main(void) {13    int codes[4] = {101, 205, 310, 422};14    int *p = find(codes, 4, 999);15    if (p == NULL) {16        printf("没找到\n");17    } else {18        printf("找到:%d\n", *p);19    }20    return 0;21}
输出
没找到

走出数组

指针走数组,也会像下标一样走过头(第 11 课)。最常见的原因是该写 < 的地方写了 <=:

main.c
1#include <stdio.h>2 3int main(void) {4    int temps[5] = {21, 23, 19, 25, 22};5    int total = 0;6    for (int *p = temps; p <= temps + 5; p++) {7        total += *p;8    }9    printf("总和:%d\n", total);10    return 0;11}
输出
stack-buffer-overflow

发生了什么: 停在第 7 行:越过了「temps」的末尾(越界了)。

temps + 5 是紧跟在最后一个元素后面的位置。指针停在那里是允许的,循环才能拿它来比;但在那里读 *p 不行。点单步,看箭头一格一格往后挪,最后指到最后一格的外面,引擎就停在那里。改成 p < temps + 5。

指向已经不在的变量

函数的局部变量放在它的栈帧里,函数一返回,栈帧就没了(第 8 课)。指向其中一个变量的指针,就指着一片空:它悬空了。

main.c
1#include <stdio.h>2 3int *make_total(int a, int b) {4    int total = a + b;5    int *q = &total;6    return q;7}8 9int main(void) {10    int *p = make_total(3, 4);11    printf("%d\n", *p);12    return 0;13}
输出
stack-use-after-return

发生了什么: 停在第 11 行:这是一个已经返回了的函数里的局部变量「total」,它已经不在了。

地址是经过 q 交出去的,gcc 连警告都没有(直接写 return &total;,它才会警告)。点单步:p 的箭头先指进 make_total 的框里,接着这个框没了,箭头那头什么也不剩。到第 11 行 *p 顺着它找过去,引擎就停在这里。点「看看是怎么出错的」回放一遍。

这个函数只要交回一个数,那就直接返回这个值:

main.c
1#include <stdio.h>2 3int make_total(int a, int b) {4    int total = a + b;5    return total;6}7 8int main(void) {9    int t = make_total(3, 4);10    printf("%d\n", t);11    return 0;12}
输出
7

值在栈帧消失之前就复制出来了。如果函数确实要交回一个地址,那这个地址指的东西必须比这次调用活得久:调用它的那个函数的变量、static 变量(第 9 课),或者堆上的内存(第 20 课)。

先猜一猜

larger 返回一个指针。会怎样?

1#include <stdio.h>2 3int *larger(int *a, int *b) {4    if (*a > *b) {5        return a;6    }7    return b;8}9 10int main(void) {11    int x = 3;12    int y = 9;13    int *big = larger(&x, &y);14    *big = 0;15    printf("%d %d\n", x, y);16    return 0;17}

const:只读不改

在类型前面加 const,这个指针就只能用来读:const int *a 的意思是「不能通过 a 改那个 int」。只读数组的函数正适合这样写。万一不小心写了,编译器会拦住:

main.c
1#include <stdio.h>2 3double average(const int *a, int count) {4    int total = 0;5    for (int i = 0; i < count; i++) {6        total += a[i];7    }8    a[0] = 0;9    return (double)total / count;10}11 12int main(void) {13    int scores[4] = {80, 90, 75, 85};14    printf("%.2f\n", average(scores, 4));15    return 0;16}
输出
main.c: In function 'average':
main.c:8:10: error: assignment of read-only location '*a'
    8 |     a[0] = 0;
      |          ^

assignment of read-only location 说的就是通过 const 指针写东西。删掉第 8 行就能运行。const 还告诉调用 average 的人:你的数组不会被动。

好习惯

  • 每个指针一声明就给它一个值:一个地址,或者 NULL。
  • 指针可能是 NULL 的时候,用之前先检查。
  • 走数组用 <,不用 <=;数组和它的个数一起传。
  • 不要返回局部变量的地址。
  • 函数只通过指针参数读东西,就给它加上 const。

练一练

写好代码,点检查。提示随便看,实在想不出来再看答案。

练习 1

没找到

第二次找什么也没找到,程序停在空指针上。加一个检查,这时显示 没找到。程序要先显示 找到:310,再显示 没找到。

要显示
找到:310
没找到
exercise1.c
1#include <stdio.h>2 3int *find(int *codes, int count, int target) {4    for (int i = 0; i < count; i++) {5        if (codes[i] == target) {6            return &codes[i];7        }8    }9    return NULL;10}11 12void report(int *p) {13    printf("找到:%d\n", *p);14}15 16int main(void) {17    int codes[4] = {101, 205, 310, 422};18    report(find(codes, 4, 310));19    report(find(codes, 4, 999));20    return 0;21}
输出
点「运行」或按 Ctrl+Enter
练习 2

返回值本身

average 返回了自己局部变量的地址,这个指针会悬空。改成直接返回那个 double,main 也跟着改。程序要显示 平均:7.50。

要显示
平均:7.50
exercise2.c
1#include <stdio.h>2 3double *average(int a, int b) {4    double result = (a + b) / 2.0;5    double *p = &result;6    return p;7}8 9int main(void) {10    double *avg = average(6, 9);11    printf("平均:%.2f\n", *avg);12    return 0;13}
输出
点「运行」或按 Ctrl+Enter
练习 3

多走了一步

这个循环读到了 prices 的外面,引擎会停下来。改好它,让它显示四个价格,每个一行。

要显示
12
30
7
18
exercise3.c
1#include <stdio.h>2 3int main(void) {4    int prices[4] = {12, 30, 7, 18};5    for (int *p = prices; p <= prices + 4; p++) {6        printf("%d\n", *p);7    }8    return 0;9}
输出
点「运行」或按 Ctrl+Enter

这一课学会了

  • 野指针是从来没赋过值的指针:每个指针一声明就给它一个地址或者 NULL。
  • 顺着 NULL 找过去,真的程序会崩溃;可能是 NULL 的时候,先检查 p == NULL。
  • 指针可以停在数组末尾紧后面的位置,但在那里读就是越界:循环用 <。
  • 返回局部变量的地址会留下悬空指针:栈帧已经没了。改成返回值本身。
  • const int *p 能通过 p 读,不能写;只读的参数就这样写。
  • 在我们的引擎里,这些错都会停在出事的那一行,「看看是怎么出错的」能回放经过。