| 目录 |

5 指针——瑟瑟发抖吧!

“怎么才能到卡内基音乐厅?”
“练习!”

——20 世纪出处不详的笑话

指针是 C 语言中最令人畏惧的东西之一。事实上,正是它让这门语言变得有挑战性。可为什么呢?

因为说实话,它们能让电击顺着键盘窜上来,把你的双臂焊死在原地,让你这辈子都被诅咒似的,守着这门 20 世纪 70 年代的语言敲键盘!

真的吗?嗯,也不是真的。我只是想帮你把成功的路铺好。

取决于你来自哪种语言,你可能已经理解了引用的概念——一个变量引用某个类型的对象。

这非常相似,只不过在 C 里,我们必须更明确地区分:我们说的是引用本身,还是它所引用的那个东西。

5.1 内存与变量

计算机内存存放着各种各样的数据,对吧?它能存 float、int,或者你手头的任何东西。为了方便管理内存,每个字节的内存都用一整数来标识。这些整数随着你在内存中向上移动而依次递增44。你可以把它想象成一排编了号的盒子,每个盒子装一个字节45的数据。如果你来自有数组的语言,也可以把它想成一个大数组,每个元素装一个字节。代表每个盒子的那个数,就叫它的地址。

不过,并非所有数据类型都只占一个字节。例如 int 通常是四个字节,float 也一样,但这实际上取决于系统。你可以用 sizeof 运算符确定某个类型占多少个字节的内存。

// %zu 是 size_t 类型的格式说明符

printf("an int uses %zu bytes of memory\n", sizeof(int));

// 在我的机器上打印 "4",但可能因系统而异。

内存趣事:当你的某个数据类型(比如常见的 int)占用不止一个字节的内存时,组成该数据的各个字节在内存中总是彼此相邻的。有时它们的顺序和你预期的一致,有时则不然46。虽然 C 不保证任何特定的内存顺序(这取决于平台),但通常仍然可以写出与平台无关的代码,让你根本不必考虑这些烦人的字节顺序问题。

所以总之,如果我们能继续往下走,为指针的定义来一阵鼓点、再配上点不祥的背景音乐,那么:指针就是一个存放地址的变量。这时候,请想象《2001 太空漫游》里的那段古典配乐。当当当当当当当——当!

好吧,这里可能有点用力过猛了,是吧?指针其实没多少神秘的。它就是数据的地址。就像 int 变量可以存放值 12,指针变量可以存放数据的地址。

这意味着下面这些说法指的都是一回事,即一个表示内存中某个位置的数:

我会把这些词互换着用。是的,我刚把位置也塞了进来,因为表示同一个意思的词永远不嫌多。

而指针变量存放的就是那个地址编号。就像 float 变量可能存放 3.14159 一样。

想象你有一堆 Post-it® 便利贴,它们全都按顺序编了号,号就是它们的地址。(第一张的索引编号是 0,下一张是 1,依此类推。)

除了表示它们位置的编号之外,你还可以在每张上写另一个你随便选的数。它可以是你养的狗的数量。也可以是火星周围卫星的数量……

……或者,它也可以是另一张便利贴的索引!

如果你写的是你养的狗的数量,那它就只是一个普通变量。但如果你在那里写的是另一张便利贴的索引,那它就是指针。它指向另一张便条!

另一个类比是房子的地址。你可以有一栋具备某些特点的房子:有院子、金属屋顶、太阳能,等等。或者你可以有那栋房子的地址。地址和房子本身并不是一回事。一个是完整的房子,另一个只是几行文字。但房子的地址是那栋房子的指针。它不是房子本身,但它告诉你上哪儿去找它。

在计算机里,我们对数据也可以这么做。你可以有一个存放某个值的数据变量。而这个值位于内存中某个地址上。你还可以另有一个指针变量,存放那个数据变量的地址。

它不是数据变量本身,而像房屋地址一样,告诉我们到哪里去找它。

有了它,我们就说我们拥有一个指向该数据的“指针”。而且我们可以顺着这个指针去访问数据本身。

(尽管这看起来还不太有用,但一旦与函数调用结合使用,这一切就会变得不可或缺。请耐心听我讲到那里。)

那么,假设我们有一个 int,想要一个指向它的指针,我们需要的就是某种取得那个 int 地址的方法,对吧?毕竟,指针保存的正是数据的地址。你猜我们会用哪个运算符来取得 int 的地址呢?

好吧,说来你可能不信——亲爱的读者,这大概会让你大吃一惊——我们用的是 address-of 运算符(它恰好就是一个 & 符号:“&”)来找到数据的地址。就是 & 符号。

那么,作为一个简单的例子,我们为 printf() 引入一个新的格式说明符,让你可以打印指针。你已经知道 %d 如何打印十进制整数了,对吧?那么,%p 打印的是指针。这个指针看起来会像一个垃圾数字(而且它可能以十六进制47而不是十进制打印),但它其实只是数据在内存中的索引。(如果数据是多字节的,那就是数据的第一个字节在内存中的索引。)在几乎所有情况下,包括本例,打印出来的数字实际值对你都不重要,我在这里展示它只是为了演示 address-of 运算符。

#include <stdio.h>

int main(void)
{
    int i = 10;

    printf("The value of i is %d\n", i);
    printf("And its address is %p\n", (void *)&i);
}

上面的代码包含一个强制类型转换,我们把表达式 &i 的类型强制转换为 void*。这样做是为了避免编译器在这里发出警告。这些内容我们都还没有讲过,所以现在先忽略上面代码中的 (void*),假装它不存在。

在我的计算机上,它会打印:

The value of i is 10
And its address is 0x7ffddf7072a4

如果你好奇的话,那个十六进制数换算成十进制是 140,727,326,896,068(就是奶奶那辈人用的十进制)。这就是变量 i 的数据在内存中的索引。它是 i 的地址。它是 i 的位置。它是一个指向 i 的指针。

等等——你有 140 TB 内存? 没错!你没有吗?不过我这牛吹得可没边了;我当然没有(约 2024 年)。现代计算机使用一种神奇的技术,叫做虚拟内存48,它让进程以为自己独占了你计算机的整个内存空间,而不管实际有多少物理内存作为后备。所以,尽管地址是那么大的一个数字,它却被我 CPU 的虚拟内存系统映射到了某个较低的物理内存地址。这台计算机有 16 GB 内存(同样是约 2024 年,不过我运行的是 Linux,所以这已经足够了)。TB 级的内存?我是老师,不是靠 .com 发家的亿万富翁。这些事我们谁都不用操心,除了我并不特别富有这一点。

它之所以是指针,是因为它能让你知道 i 在内存中的位置。就像写在纸片上的家庭住址告诉你到哪里能找到某栋房子一样,这个数字向我们表明在内存的什么地方可以找到 i 的值。它指向 i。

再说一遍,一般来说我们并不真的关心这个地址的具体数值是多少。我们只关心它是指向 i 的指针。

5.2 指针类型

那么……这一切都挺好。你现在可以成功地取得一个变量的地址并把它打印到屏幕上了。这总算能给简历添上一笔了,对吧?接下来你就该揪住我的后颈,礼貌地问我指针到底有什么用。

问得好,我们会在赞助商的广告之后马上谈到它。

ACME ROBOTIC HOUSING UNIT CLEANING SERVICES. YOUR HOMESTEAD WILL BE
DRAMATICALLY IMPROVED OR YOU WILL BE TERMINATED. MESSAGE ENDS.

欢迎回到新一期《Beej 指南》。上次我们讲到如何使用指针。这一次,我们要做的是把指针存到一个变量里,以便以后使用。你可以通过变量名之前、类型之后的星号(*)来识别指针类型:

int main(void)
{
    int i;  // i 的类型是 "int"
    int *p; // p 的类型是“指向 int 的指针”,也就是“int 指针”
}

嘿,我们这里有一个指针类型的变量,它可以指向其他 int。也就是说,它可以保存其他 int 的地址。我们知道它指向 int,因为它的类型是 int*(读作“int 指针”)。

当你给指针变量赋值时,赋值号右侧的类型必须与指针变量的类型相同。幸运的是,当你对一个变量取 address-of 时,得到的类型就是指向该变量类型的指针,所以像下面这样的赋值完全没问题:

int i;
int *p;  // p 是一个指针,但未初始化,指向垃圾

p = &i;  // p 被赋值为 i 的地址——现在 p“指向”i

赋值号左边是一个类型为指向 int 的指针(int*)的变量,右边是一个类型为指向 int 的指针的表达式,因为 i 是 int(对 int 取地址得到的就是指向 int 的指针)。一个东西的地址可以存进指向那个东西的指针里。

明白了吗?我知道这仍然不太说得通,因为你还没看到指针变量的实际用途,但我们正在一步一步来,免得有人掉队。那么现在,让我们来认识一下反取地址运算符。它有点像 address-of 在颠倒世界里的样子。

5.3 解引用

可以把指针变量看作通过指向另一个变量来引用它。在 C 的世界里,你很少会听到有人谈论“引用”或“引用关系”,我提起它只是为了让你对这个运算符的名字多一点理解。

当你拥有指向某个变量的指针(大致相当于“对该变量的引用”)时,你可以通过解引用这个指针,经由指针来使用原来的变量。(你可以把它想成对指针“去指针化”,但从来没人说“去指针化”。)

回到我们的类比,这大致就像看着一个家庭住址,然后走到那栋房子去。

那么,我说的“访问原来的变量”是什么意思呢?如果你有一个名为 i 的变量,又有一个指向 i 的指针 p,你就可以把解引用后的指针 p 完全当作原来的变量 i 来用!

你的知识差不多够看一个例子了。你最后需要知道的一点其实是:解引用运算符是什么?它其实叫做间接运算符,因为你是在通过指针间接地访问值。它还是星号:*。现在,别把它和前面声明指针时用的星号搞混了。它们是同一个字符,但在不同的上下文中有不同的含义49。

下面是一个完整的例子:

#include <stdio.h>

int main(void)
{
    int i;
    int *p;  // 这绝不是解引用——这是一个类型“int*”

    p = &i;  // p 现在指向 i,p 保存着 i 的地址

    i = 10;  // i 现在是 10
    *p = 20; // p 指向的东西(也就是 i!)现在是 20!!

    printf("i is %d\n", i);   // 打印“20”
    printf("i is %d\n", *p);  // “20”!解引用 p 和 i 是一样的!
}

记住,p 保存的是 i 的地址,你可以看到我们在第 8 行对 p 做了赋值。间接运算符的作用是告诉计算机使用指针所指向的对象,而不是使用指针本身。这样,我们就让 *p 成了 i 的某种别名。

很好,但为什么?为什么要做这些?

5.4 将指针作为实参传递

此时此刻,你一定在想:自己学了这么多关于指针的知识,却完全没有实际用途,对吧?我是说,如果直接写 i 就行,那 *p 又有什么用呢?

我的朋友,指针的真正威力要在你开始把它们传给函数时才显现出来。这为什么是件大事?你可能还记得,之前你可以把各种各样的实参传给函数,它们会被忠实地复制到形参里,然后你可以在函数内部操作这些变量的本地副本,最后返回一个值。

如果你想从函数里带回不止一份数据呢?我是说,你只能返回一个东西,对吧?如果我再用一个问题来回答这个问题呢?……呃,是两个问题?

当你把指针作为实参传给函数时,会发生什么?指针的副本会被放进对应的形参里吗?那还用说,当然会。还记得我之前喋喋不休地讲每一个实参都会被复制到形参里,函数使用的是实参的副本吗?这里也是一样。函数会得到指针的一个副本。

但是,巧妙的地方在于:我们会事先让指针指向某个变量……然后函数就可以解引用它手里的指针副本,回到原来的变量!函数看不到变量本身,但它当然可以解引用指向该变量的指针!

这就好比把家庭住址写在一张纸上,然后再抄到另一张纸上。现在你有两个指向那栋房子的指针,两者都能同样好地把你带到那栋房子。

在函数调用的情况下,其中一个副本存储在调用方作用域里的指针变量中,另一个则存储在作为函数形参的指针变量中。

举个例子!让我们回到之前那个 increment() 函数,不过这次我们要让它真正把调用方的值给递增了。

#include <stdio.h>

void increment(int *p)  // 注意,它接受一个指向 int 的指针
{
    *p = *p + 1;        // 给 p 指向的东西加一
}

int main(void)
{
    int i = 10;
    int *j = &i;  // 注意这个 address-of;它把该值变成指向 i 的指针

    printf("i is %d\n", i);        // 打印“10”
    printf("i is also %d\n", *j);  // 打印“10”

    increment(j);                  // j 是一个 int*——指向 i

    printf("i is %d\n", i);        // 打印“11”!
}

好!这里有几件事值得注意……尤其是 increment() 函数接受一个 int* 作为实参。在调用时,我们用 address-of 运算符把 int 变量 i 变成 int*,从而传入一个 int*。(记住,指针保存的是地址,所以我们通过 address-of 运算符来取得指向变量的指针。)

increment() 函数得到的是指针的副本。原来的指针 j(在 main() 中)和该指针的副本 p(increment() 中的形参)都指向同一个地址,也就是保存着 i 的值的那一个。(同样打个比方,就像两张写着同一个家庭住址的纸。)解引用其中任何一个,都能让你修改原来的变量 i!函数竟然能修改另一个作用域里的变量!太棒了!

上面的例子通常可以写得更简洁,直接在实参列表里使用 address-of 即可:

printf("i is %d\n", i);  // 打印“10”
increment(&i);
printf("i is %d\n", i);  // 打印“11”!

一般规则是:如果你希望函数修改你传入的东西,并且让你能看到结果,你就必须传入指向那个东西的指针。

5.5 NULL 指针

任何指针类型的指针变量都可以被设为一个特殊值,叫做 NULL。这表示该指针不指向任何东西。

int *p;

p = NULL;

由于它不指向任何值,解引用它就是未定义行为,而且很可能会导致崩溃:

int *p = NULL;

*p = 12;  // 崩溃,或者别的什么大概很糟的事。最好避免。

尽管 NULL 指针被它的发明者称为“十亿美元的错误”50,但它仍是一个很好的哨兵值51,也是一种通用指示,表明某个指针尚未初始化。

(当然,和其他变量一样,除非你显式地让它指向某个地址或 NULL,否则指针指向的是垃圾。)

5.6 关于声明指针的说明

声明指针的语法有时会有点古怪。让我们看看这个例子:

int a;
int b;

我们可以把它压缩成一行,对吧?

int a, b;  // 同样的事情

这样 a 和 b 都是 int。没问题。

但这个呢?

int a;
int *p;

我们能把它写成一行吗?能。但 * 该放在哪里?

规则是:* 要放在任何是指针类型的变量前面。也就是说,在这个例子里,* 不是 int 的一部分,而是变量 p 的一部分。

记住这一点,我们就可以这样写:

int a, *p;  // 同样的事情

要注意,下面这一行并没有声明两个指针:

int *p, q;  // p 是指向 int 的指针;q 只是一个 int。

如果程序员写成下面这行(合法的)代码,看起来可能特别有迷惑性,因为它在功能上与上面那行完全相同。

int* p, q;  // p 是指向 int 的指针;q 只是一个 int。

那么看看这个,判断哪些变量是指针,哪些不是:

int *a, b, c, *d, e, *f, g, h, *i;

我会把答案放在脚注里52。

5.7 sizeof 与指针

这里讲一点可能令人困惑、你时不时会看到的语法。

回忆一下,sizeof 作用于表达式的类型。

int *p;

// 打印“int”的大小
printf("%zu\n", sizeof(int));

// p 的类型是“int *”,所以打印“int*”的大小
printf("%zu\n", sizeof p);

// *p 的类型是“int”,所以打印“int”的大小
printf("%zu\n", sizeof *p);

你可能会在实际代码里看到最后那种带 sizeof 的写法。只要记住,sizeof 关心的是表达式的类型,而不是表达式中的变量本身。


| 目录 |