Menu

C 语言指针教程:完整指南与示例

指针是保存内存地址的变量。本页从零开始讲清这个概念:& 和 * 运算符、指针的声明和解引用、为什么指针类型很重要,以及说明指针为何存在的 swap() 函数。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

程序里的每个变量都存放在内存的某个位置,而每个“位置”都有一个编号,也就是它的地址。指针只是保存这种编号的变量。这就是全部的概念,本页其余内容都是语法和由此带来的结果。

指针在 C 语言中如此重要,是因为 C 交给每个函数的都是参数的副本。拿到数字 7 的函数可以随意修改它的副本,调用者的变量纹丝不动。改为把地址交给函数,它就能顺着地址回去修改原来的变量。数组、字符串、动态内存,以及你将来用 C 构建的每一种数据结构,都建立在这一个能力之上。

内存、地址,以及为什么每样东西都有地址

当你写下 int score = 42; 时,编译器会预留几个字节的内存,并给它们起一个你可以使用的名字。这些字节还有一个数字地址。把内存想象成一条很长的街道,房子都有门牌号:score 就是住在 0x7ffd4c2a 号的那户人家,名字是给你看的,号码是给机器用的。

& 运算符用来取得这个号码:

运行一下。三个地址各不相同,因为三个变量占据不同的字节,而且每次运行地址都会变化。这很正常,也是你永远不该把地址写死的原因。注意 (void *) 强制转换:标准规定 %p 接收的是 void *,传入其他类型的指针严格来说是未定义行为。养成转换的习惯。

地址不可预测并不是问题,因为你几乎从不关心地址具体是多少,你只关心自己拿到了它。

声明指针

指针的声明要写明它指向的类型:

int    *p;      // p can hold the address of an int
double *q;      // q can hold the address of a double
char   *name;   // name can hold the address of a char

把 int *p; 读作“*p 是一个 int”,即对 p 解引用得到一个 int。以后 C 再抛给你多复杂的声明,这种读法都适用。

* 写在哪里是风格问题,编译器并不在意:int* p、int *p 和 int * p 声明的是同一个东西。大多数 C 代码写成 int *p,这有一个实际的理由:

int* a, b;   // a is a pointer to int; b is a PLAIN int, not a pointer
int *a, *b;  // both are pointers - the * binds to the name, not the type

* 属于声明符,而不是类型。把它写在名字旁边能让这一点一目了然。

& 和 *:两个运算符

所有工作都由两个运算符完成,而且它们恰好相反。

  • &x:取地址。得到 x 所在的地址。
  • *p:解引用。到 p 中的地址去,取出住在那里的对象。

最后两行就是指针存在的全部意义。没有任何语句直接给 age 赋值,age 却变了,因为 *p = 31 的意思是“把 31 存到 p 保存的地址上”,而那个地址正是 age 的地址。

下面是它在内存中的样子:

      p                        age
 +------------+           +----------+
 | 0x7ffd1c40 | --------> |    31    |
 +------------+           +----------+
 at 0x7ffd1c38            at 0x7ffd1c40

 p    -> the address 0x7ffd1c40
 *p   -> the int stored there, 31
 &age -> 0x7ffd1c40, the same address p holds
 &p   -> 0x7ffd1c38, where p itself lives

注意最后一行:指针本身也是内存中某处的一个变量,所以它也有自己的地址。这不是脑筋急转弯,只是规则一致而已,指向指针的指针(int **)就是这样工作的。

因为 & 和 * 互相抵消,*&age 就是 age,&*p 就是 p。

为什么指针类型很重要

指针保存地址,而地址只是一个数字,那编译器为什么非要知道它指向的是 int 还是 double?原因有两个:

  1. 要读多少字节。 解引用 int * 读取 4 个字节(通常如此);解引用 double * 读取 8 个字节。没有类型,*p 就毫无意义。
  2. 如何解释这些字节。 同样的位模式,当作 int 是一个数,当作 float 就是完全不同的另一个数。

而指针本身的大小并不取决于它指向什么,地址就是地址:

在 64 位机器上,三个指针都是 8 字节,而它们指向的东西分别是 4、8 和 1 字节。正是被指向的类型让 *p 和指针运算有了意义。

在不同指针类型之间赋值是编译器会报的错误,而且理由充分:pi = &d; 会让你读取 double 的 4 个字节,再把结果当成整数。

经典动机:swap()

下面是每门 C 课程都会拿出来的函数,因为它失败的方式恰好解释了指针。

函数报告说它交换了两个值,而 main 报告说什么也没发生。broken_swap 收到的是 x 和 y 的副本。它完美地交换了这两个副本,然后函数一返回,副本就不复存在了。

改为把地址交给它:

现在 a 和 b 仍然是副本,只不过是两个地址的副本,而地址的副本和原地址指向的是同一个变量。无论函数在哪里被调用,*a 都是 x。

这就是每个修改调用者数据的 C 函数背后的模式,也是函数参数背后的一般规律:C 语言没有引用传递,所以你传入指针再解引用。 这也是 scanf 需要 & 的原因:

int n;
scanf("%d", &n);   // scanf must be able to write into n, so it needs n's address

返回多个值

C 函数只能返回一个值。指针就是绕过这个限制的方法:为额外的结果传入地址。

返回值负责表示状态,指针负责带回结果。这种分工在 C 自己的标准库和操作系统 API 中随处可见。

指向指针的指针

既然指针也是变量,你就可以取它的地址,这个地址的类型是“指向 int 指针的指针”:

  pp              p               value
+------+       +------+        +------+
| &p   | ----> | &val | -----> |   8  |
+------+       +------+        +------+

当函数需要修改调用者持有的指针时,你就会真正用到 **,例如让调用者的指针指向新分配内存的分配函数;命令行参数中的 char *argv[] 也是如此。

四个值得提前了解的错误

使用从未指向任何地方的指针。 未初始化的指针保存的是那里原本就有的字节。对它解引用会读写一个随机地址。

int *p;      // p holds garbage
*p = 10;     // undefined behavior - likely a crash

给每个指针初始化,要么是真实的地址,要么是 NULL。相关的规范请参阅空指针。

混淆 *p 和 p。 p = 5; 把指针设为地址 5(毫无意义);*p = 5; 把 5 存到它指向的位置。编译器会对前者发出警告,警告值得认真看。

在 scanf 中忘了写 &。 scanf("%d", n) 把 n 的值当作地址传进去。它能编译通过(带一个警告),然后往那个数字所代表的内存里写数据。

返回局部变量的地址。 函数一返回,局部变量就消失了;这个地址就像一张已被拆除房屋的收据。

int *bad(void) {
    int local = 42;
    return &local;   // the caller gets a dangling pointer
}

如果你需要在调用结束后依然存在的内存,就去分配它,这正是动态内存的用途。

接下来学什么

指针不止于“保存一个地址”。给指针加上一个数,它会以所指类型为单位在内存中前进,这就是指针运算的主题;而在 C 中,数组名会退化为指向首元素的指针,所以一旦两者都了解了,指针和数组实际上是同一个话题。接下来读这两篇,C 语言的其余部分就不再像魔法了。

常见问题

C 语言中的指针是什么?

指针是一个变量,它的值是一个内存地址,也就是内存中另一个对象所在的位置。int *p = &age; 让 p 保存 age 的地址,之后 *p 就通过这个地址读写 age 变量。

C 语言中 * 和 & 有什么区别?

&x 是取地址运算符:它得到 x 所在的地址。*p 是解引用运算符:它到 p 中保存的地址去,取出那里的对象。两者互相抵消,所以 *&x 就是 x。

为什么 * 会出现两次,一次在声明里,一次在使用指针时?

它们是同一个符号的两种不同用法。在 int *p; 中,* 是类型的一部分,表示“p 是指向 int 的指针”。在 *p = 5; 中,* 是解引用运算符,表示“把 5 存到 p 保存的地址上”。把声明读成“*p 是一个 int”,两种用法就都说得通了。

C 语言为什么需要指针?

C 语言的所有参数都是按值传递的,所以函数无法修改调用者的变量,除非把地址交给它。指针还能让你高效地遍历数组、返回多个结果、构建链式结构,以及使用 malloc 在运行时分配的内存。

Coddy 编程语言插图

和 Coddy 一起学编程

开始