“怎么才能到卡内基音乐厅?”
“练习!”——20 世纪出处不详的笑话
指针是 C 语言中最令人畏惧的东西之一。事实上,正是它让这门语言变得有挑战性。可为什么呢?
因为说实话,它们能让电击顺着键盘窜上来,把你的双臂焊死在原地,让你这辈子都被诅咒似的,守着这门 20 世纪 70 年代的语言敲键盘!
真的吗?嗯,也不是真的。我只是想帮你把成功的路铺好。
取决于你来自哪种语言,你可能已经理解了引用的概念——一个变量引用某个类型的对象。
这非常相似,只不过在 C 里,我们必须更明确地区分:我们说的是引用本身,还是它所引用的那个东西。
计算机内存存放着各种各样的数据,对吧?它能存 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 的指针。
那么……这一切都挺好。你现在可以成功地取得一个变量的地址并把它打印到屏幕上了。这总算能给简历添上一笔了,对吧?接下来你就该揪住我的后颈,礼貌地问我指针到底有什么用。
问得好,我们会在赞助商的广告之后马上谈到它。
ACME ROBOTIC HOUSING UNIT CLEANING SERVICES. YOUR HOMESTEAD WILL BE
DRAMATICALLY IMPROVED OR YOU WILL BE TERMINATED. MESSAGE ENDS.
欢迎回到新一期《Beej 指南》。上次我们讲到如何使用指针。这一次,我们要做的是把指针存到一个变量里,以便以后使用。你可以通过变量名之前、类型之后的星号(*)来识别指针类型:
嘿,我们这里有一个指针类型的变量,它可以指向其他 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 在颠倒世界里的样子。
可以把指针变量看作通过指向另一个变量来引用它。在 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 的某种别名。
很好,但为什么?为什么要做这些?
此时此刻,你一定在想:自己学了这么多关于指针的知识,却完全没有实际用途,对吧?我是说,如果直接写 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”!一般规则是:如果你希望函数修改你传入的东西,并且让你能看到结果,你就必须传入指向那个东西的指针。
NULL 指针任何指针类型的指针变量都可以被设为一个特殊值,叫做 NULL。这表示该指针不指向任何东西。
int *p;
p = NULL;由于它不指向任何值,解引用它就是未定义行为,而且很可能会导致崩溃:
int *p = NULL;
*p = 12; // 崩溃,或者别的什么大概很糟的事。最好避免。尽管 NULL 指针被它的发明者称为“十亿美元的错误”50,但它仍是一个很好的哨兵值51,也是一种通用指示,表明某个指针尚未初始化。
(当然,和其他变量一样,除非你显式地让它指向某个地址或 NULL,否则指针指向的是垃圾。)
声明指针的语法有时会有点古怪。让我们看看这个例子:
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。
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 关心的是表达式的类型,而不是表达式中的变量本身。