0%

C 和 C++ 中的指针

指针是 C 语言的精髓。——无名氏
指针是学业不精的程序员的大杀器。——Liam Huang

C 和 C++ 中的指针,是语言中相当犀利的工具。但是它也是一把双刃剑,用得不好,就是毁灭整个工程的大杀器。此篇老调重弹,讲一讲 C 和 C++ 中的指针。

此篇别名:可能是中文网络中关于 C 和 C++ 指针最好的文章。

数据、指令和内存

在冯诺依曼体系中,程序的数据和指令,是存放在同一空间中的。在 Linux 中,它们存放在进程的虚拟内存空间中。(相关内容可以关注「程序员的自我修养」系列文章,目前有://)因此,对于进程来说,数据和指令其实没有本质的差别;其区别仅在于如何理解和使用虚存空间中的内容——如果读取虚存空间某个位置的值来使用,这块内容就是数据,如果执行虚存空间某个位置的代码,这块内容就是指令。决定如何理解和使用虚存空间中的内容的因素,是类型。具体到 C 和 C++ 语言来说,对应数据的那部分内存,当中存储的自然就是数据;对应函数的那部分内存,当中存储的就是指令。

因此,对于虚存空间中的内容来说,有两个关键要素:

  • 它在哪里(内存地址是多少);
  • 它具有哪些属性、能做哪些事情(它的类型是什么)。

指针是对内存区域的抽象

C 和 C++ 中的指针,是一种特殊的复合类型。指针变量中存放着目标对象的内存地址,而与指针相复合的类型,则说明了相应内存区域中的内容具有哪些属性,以及能做什么事情。也就是说,在内存空间某块区域中的内容,原本可以是不可解读的;但是,如果有一个描述这块内存区域的指针存在,我们就能找到它(地址的作用),并且合理地使用它(类型的作用)。因此,我们说:指针是对内存区域的抽象

定义和使用指针

指针的定义

在 C 和 C++ 中定义指针变量是很简单的,和定义普通的变量基本是一样的。所有的区别,仅在于我们需要在变量名称前使用解引用符号 * 来标记这是一个指针。

1
2
int *ip1, *ip2;         // ip1 和 ip2 都是指向 int 类型变量的指针变量
double d, *dp; // d 是 double 类型变量,dp 是指向 double 类型变量的指针变量

在上述定义中,我们看到,ip1, ip2, dp 是三个指针——因为在它们之前用 * 号标记处他们是指针;而 d 是一个普通的 double 类型变量。同时,我们注意到,ip1ip2 在定义之时,就确定了他们是指向 int 类型的变量。这意味着,被 ip1ip2 指向的内存,在使用 ip1ip2 进行访问的时候,将被当做是 int 类型的对象来对待。同理,dp 指向的内存,在使用 dp 进行访问的时候,将被当做是 double 类型的对象来对待。

回顾一下,我们在第一节中提到,内存空间中的内容有两个关键要素:地址和类型。在上述定义过程中,我们通过类型与解引用符号 * 相结合,已经确定了类型。如果要正确使用指针,我们还应该让指针记录一个地址。

获取对象的地址

上面说到,我们应该在定义指针之后,记录一个地址。在 C 和 C++ 中,我们需要使用取地址符号 & 来获取对象的地址。

1
2
int val = 42;
int *p = &val; // &val 返回变量 val 的地址,记录在指向 int 类型变量的指针里

(绝大多数情况下,)指针的类型和对象的类型需要严格匹配。例如,你不能用一个指向 int 类型的指针变量,保存一个 double 类型的对象的地址。

1
2
3
4
5
6
double dval = 0.0;
double *pd1 = &dval; // 正确:pd1 保存 double 类型变量 dval 的地址
double *pd2 = pd1; // 正确:pd1 是 double 类型的指针,可以赋值初始化同样类型的 pd2

int *pi1 = &dval; // 错误:不能用指向 int 类型变量的指针保存 double 类型变量的地址
int *pi2 = pd1; // 错误:pd1 是 double 类型的指针,不能将其赋值给 int 类型的指针

访问指针指向的对象

在下例中,指针 p 记录了变量 val 的地址。因此,我们可以通过解引用指针 p 来访问变量 val

1
2
3
4
5
6
7
int val = 42;
int *p = &val; // &val 返回变量 val 的地址,记录在指向 int 类型变量的指针里
cout << *p << endl; // 通过指针 p 访问变量 val,输出 val 的值:42

*p = 360; // 通过指针 p 改变变量 val 的值
cout << *p << endl; // 通过指针 p 访问变量 val,输出 val 的值:360
cout << val << endl; // 输出 val 的值:360

空指针和空类型的指针

空指针是不指向任何对象的指针,在实际编程中,通常使用空指针作为指针变量有效性的判断标准。

C 语言和老版本 C++ 的空指针字面值是 NULL,它定义在 stdlib 当中;新版本的 C++ 使用 nullptr 作为空指针字面值。C++ 还支持用字面值常量 0 初始化指针变量,被这样初始化的指针变量会是一个空指针。

1
2
3
4
5
6
7
int *p1 = NULL;         // C 风格的空指针初始化
int *p2 = nullptr; // C++ 风格的空指针初始化
int *p3 = 0; // 使用字面值常量 0 初始化空指针

if (nullptr == p1) { // 思考一下为什么不是 p1 == nullptr
; // do something
}

空类型的指针,指的是形如 void *pv 的指针。这是一类特殊的指针;这里的空类型,不是说没有类型,而是说空类型的指针,可以用于存储任意类型对象的地址。

1
2
3
4
5
6
7
double pi = 3.14;
void *pv = &pi; // 使用 void * 存放了一个 double 类型对象的地址
double *pd = &pi;
pd = pv; // 错误:不能将空类型的指针赋值给其他类型的指针
pv = pd; // 正确:空类型的指针可以接受任意类型的指针赋值
pd = (double *)pv; // 正确:C 风格的强制类型转换
pd = reinterpret_cast<double *>(pv); // 正确:C++ 风格的强制类型转换

让我们回顾一下指针的两个要素:地址和类型。由于空类型的指针可以接受任意类型对象的地址,所以,当编译器拿到一个空类型的指针的时候,它无法知道应该按照何种方式解释和使用指针中记录地址中的内容。因此,空类型指针能够做的事情非常有限:做指针之间的比较、作为函数的输入或输出、赋值给另外一个空类型指针。

理解指针的定义

再探变量声明

在 C 和 C++ 中,变量的声明包括一个基本数据类型(或者类类型),以及一组声明符。定义指针使用的解引用符号 * 是类型修饰符,它是声明符的一部分。因此,在下列语句中,int 是基本数据类型,*p 是声明符,* 是类型修饰符作为声明符的一部分存在。

1
int *p;

在同一个变量定义语句中,基本数据类型只能有一个,但是可以有多个形式相同或不同的声明符。这也就是说,同一个语句可以定义出不同类型的变量。

1
2
3
// pi 是指向 int 型变量的指针;val 是 int 型变量
int *pi = nullptr,
val = 1024;

理解稍微复杂的指针定义

因为指针本身也是变量,所以它当然也是存储在虚存空间里的。因此,我们当然也可以定义一个指向这一指针的指针。比如:

1
2
3
int val  = 1024;
int *p = &val;
int **pp = &p;

我们需要仔细理解一下 pp 的定义。理解这类稍微复杂的定义语句,一个基本的办法就是:从最靠近变量名字的地方开始,一层一层剖析变量的类型。我们来看

  • 距离 pp 最近的是一个解引用符 *,这预示着 pp 是一个指针,它指向 int * 类型的变量;
  • 再来看 int *,距离 *pp 最近的,依然是一个解引用符,这意味着 *pp 也是一个指针,它指向 int 类型的变量;
  • 因此 pp 是一个指向指向 int 类型变量的指针的指针

你可以仔细斟酌一下这段内容。

const 与指针

常量的值在生存期内不允许改变。这一特性经常是有用的:可以定义一个常量,然后在多个地方使用;当认为这个常量的值不合适的时候,修改它的定义,即可在所有使用到它的地方生效(而无需依次手工修改);此外,还可以防止程序意外修改这个值。定义常量,只需要在基本类型前,加上 const 关键字即可;它是 constant 的缩写,意为常量。

1
const double pi = 3.141592653580793;

const 与指针牵扯到一起,就有些复杂了。至少有以下几种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int val = 0;                // int 型变量
const int cnst = 1; // int 型常量

int *pi = &val; // pi 本身是变量,通过 pi 访问的也是变量
// 正确:将变量地址赋值给变量的指针
pi = &cnst; // 错误:不允许将常量的地址赋值给变量的指针

const int *pci = &cnst; // pci 本身是变量,通过 pci 访问的是常量 (point to const)
// 正确:将常量地址赋值给常量的指针
pci = &val; // 正确:允许将变量地址赋值给常量的指针

int *const cpi = &val; // cpi 本身是常量,通过 cpi 访问的是变量
// 正确:允许将变量地址赋值给变量的指针
int fake = 2; // int 型变量
cpi = &fake; // 错误:cpi 本身是常量,不能在定义之外赋值

const int *const cpci = &val;
// cpci 本身是常量,通过 cpci 访问的也是常量
// 正确:允许将变量地址赋值给常量的指针
cpci = &fake; // 错误:cpci 本身是常量,不能在定义之外赋值
cpci = &cnst; // 错误:cpci 本身是常量,不能在定义之外赋值,哪怕是常量的地址

因为变量可以是常量,而指针本身也可以是常量。因此在变量和指针两个维度,都可以选择是否为常量。这样一来,就像上面代码展示的那样,当 const 与指针牵扯在一起的时候,就有 4 中可能性。为了区分这两个维度,我们引入顶层 const底层 const 的概念:

  • 顶层 const:指针本身是常量。此时,指针在定义初始化之外,不能被赋值修改。称指针为指针常量。
  • 底层 const:指针指向的变量是常量。此时,不能通过解引用指针的方式,修改变量的值。称指针为常量的指针。

指针与数组

数组的名字被当做指针使用

在 C 和 C++ 中,指针与数组有非常紧密的联系。实际上,使用数组的时候,编译器通常都是在操作指针。这里我们从两个角度说明数组名在很多时候被当做是一个指针。

1
2
3
4
5
6
7
8
9
int nums[] = {1, 2, 3};
int *p = &(nums[0]);
if (p == nums) {
printf("true!\n");
}
size_t i = 0;
for (i = 0; i != 3; ++i) {
printf("%d\n", p[i]);
}

如果你执行这一小段代码,那么,不出意外的话,程序会在终端上打印 true!,以及 nums 中的三个数字。这预示着,指针变量 p 保存的内容(也就是 nums[0] 的地址)和 nums 保存的内容是完全一样的;同时,编译器以相同的方式去解释 pnums。显然 p 是一个指向 int 型变量的指针,那么 nums 也就是一个指针了。

C++11 标准引入了 auto 关键字,它能够在定义变量时,探测初始值的类型,并为新定义的变量设置合适的类型。我们看看 auto 关键字作用于数组名字的时候,会发生什么。

1
2
3
4
int nums[] = {1, 2, 3};
auto what = nums;
int val = 42;
what = &val;

这份代码在 C++11 标准中,可以顺利通过。这说明 what 的类型,经由 auto 检测,是 int *

这两个例子,足以说明:当数组名字被当做是一个值来使用的时候,它就相当于是一个指针。

也不是全部时候

当然,也不是全部时候,数组名字都被当做是简单的指针。比如,前作中的使用方式表明,在数组名字被传入 sizeof() 运算符的时候,它会被当做是一个真实的数组来看待。

数组指针可以自增

数组的指针,可以像 C++ 中 std::vector 的迭代器那样进行自增操作。这句话不是太严谨,因为,实际上是先有数组的指针,再有 std::vector 的迭代器;迭代器实际上是仿造数组的指针设计的功能。

前文提到,数组的指针,实际上是一个指向数组元素类型的对象的指针。数组的指针进行自增、自减运算,实际是将指针所指的位置,沿着数组向后或者向前移动一个元素。

1
2
3
4
5
6
7
int nums[] = {0,1,2,3,4,5};
size_t len = sizeof(nums) / sizeof(nums[0]);
int *iter;
int *end = nums + len; // end 是尾后指针
for (iter = nums; iter != end; ++iter) {
printf("%d\n", *iter);
}

像这样的用法,就和 std::vector 的迭代器几乎没有差别了。

数组指针可以进行加减运算

数组的指针还可以进行加减运算。比如,在上述例子中 iter += 2,就是将 iter 指针沿着数组向后移动 2 个元素。

两个指针如果指向同一个数组中的元素,那么它们可以做差。做差得到的结果是两个指针之间的距离,这个结果可以是负数(实际类型是 ptrdiff_t)。例如,在上述例子中在循环体末尾处,如果进行 end - iter 计算,则其结果表示当前尚未打印的元素的数量。

数组下标与指针加减

上面提到,数组指针可以进行加减运算:数组指针与整数的加减,实际是将指针沿着数组进行移动,得到的结果还是一个指针。既然结果是指针,那么就可以解引用,访问数组中的元素。因此有

1
2
3
4
5
6
7
8
9
int nums[] = {0,1,2,3,4};
size_t len = sizeof(nums) / sizeof(nums[0]);
int *p = nums;
size_t i = 0;
for (i = 0; i != len; ++i) {
if (nums[i] == *(p + i)) {
printf("true!\n");
}
}

不出意外的话,这一小段代码会连续打印五行 true!。这提供了另一种访问数组内元素的方法;而事实上,在使用下标访问数组元素的时候,编译器都会转换成类似 *(nums + i) 的形式。也就是说,通过指针运算和解引用来访问数组元素,其实是更加本质的方式。

函数与指针

函数与指针,基本上是指针相关的话题中,最复杂的一个了。

让函数返回一个数组的指针

我们知道,函数在返回的过程中,会对返回值进行拷贝。因此,一个无法拷贝的对象,是无法被函数返回的。(也可以参考此篇)数组是不能被拷贝的,所以函数无法直接返回数组。为了达到返回数组的目的,我们只能寄希望于让函数返回数组的指针(在 C++ 中还可以返回数组的引用)。

为此,我们需要了解,如何定义一个返回数组指针的函数。

首先,我们看一组数组的定义:

1
2
3
4
int arr[10];        // arr 是一个数组,长度是 10,元素类型是 int 型
int *parr[10]; // parr 是一个数组,长度是 10,元素类型是 int *,也就是数组中存的是指针
int (*p)[10] = &arr;
// p 是一个指针,它指向一个长度是 10 元素类型是 int 型的数组

这样一来,就不难构造返回数组的指针的函数定义了。

1
2
3
4
5
6
7
8
int *(func(param_list))[10];
// 错误:func 是一个函数,param_list 是它的参数
// 它尝试返回一个长度为 10,元素类型为 int * 的数组
// 而数组是无法返回的
int (*func(param_list))[10];
// 正确:func 是一个函数,param_list 是它的参数
// 它返回的是一个指针
// 这个指针指向了一个长度为 10 元素类型是 int 型的数组

于是,我们得到了此类函数定义的一般形式:

1
element_type (*func(param_list))[dimension]

函数的指针

终于到这个话题了,呼……

上文提到,无论是数据(变量)还是指令(函数),都是存放在虚存空间的。因此,既然有变量的指针,那么也一定会有函数的指针。这就是我们这一小节需要讨论的函数指针;这会逐渐引出本文最复杂的话题。

一个函数的类型,取决于它的输入和输出。这也就是说,一个函数的类型,应当包含它的返回值类型和参数列表。比如下面定义的函数,用于比较两个 int 型数据是否相等(这是一个仅用于示例而没有实际用处的函数):

1
bool isEqual(int, int);

对于一个函数来说,如果你能拿到它的定义,就很容易能取得它的类型:只需要去掉函数名字就可以了。因此,定义一个指向该类型的函数指针,并不困难。

1
2
3
bool isEqual(int, int);
bool (*pfunc)(int, int) = &isEqual; // 定义了一个函数指针,指向 isEqual
bool (*pfunc)(int, int) = isEqual; // 一个等价定义

在这里,pfunc 就是一个函数指针,它指向一个 bool (int, int) 类型的函数。也就是说,这类函数接收两个 int 型的参数,并返回一个 bool 类型的值。

值得一提的是,当函数名字作为值使用时,它会自动地转换成指针(有点像数组名字,不是吗)。因此,在函数指针的初始化或者复制的过程中,取值运算符是可选的。于是,上述两个定义语句是等价的。另一方面,函数指针作为函数调用使用时,它会自动转换成函数名(有点像数组指针,不是吗)。因此,这种情况下,解引用运算符是可选的。请看下例:

1
2
3
4
5
6
bool isEqual(int, int);
bool (*pfunc)(int, int) = isEqual;

bool res1 = isEqual(1, 2); // 通过原函数名调用
bool res2 = (*pfunc)(1, 2); // 一个等价调用:通过函数指针,解引用调用
bool res3 = pfunc(1, 2); // 另一个等价调用:函数指针自动转换成函数名

值得一提的是,不同类型的函数的指针之间,不存在任何的类型转换:你不能期待一个接受两个 int 型参数并返回 bool 值的函数的指针,经过类型转换,就能接受三个 int 型的参数。因此,对于重载的同名函数,他们的函数类型是不一样的,因此函数指针也不能是同一个。

将函数指针作为参数传入另一个函数

在传参的过程中(除了 C++ 的传引用),也是伴随着拷贝的过程。因此,一个对象如果不能拷贝,那么它就不能作为参数传入函数。无疑,函数是不能拷贝的,因此你无法将函数作为参数直接传给另一个函数。但是,指针是可以拷贝的,因此,你可以将函数指针作为参数,传给另一个函数。

1
2
3
4
5
void addIfEqual(int lhs, int rhs, bool pfunc(int, int));
// addIfEqual 的第三个参数是一个函数定义
// 它会自动地转换成一个函数指针的参数
void addIfEqual(int lhs, int rhs, bool (*pfunc)(int, int));
// 一个等价定义:显式地注明第三个参数是函数指针

于是,你可以这样使用这个函数:

1
addIfEqual(1, 1, isEqual);

此时,函数名 isEqual 作为引数被传入,它起到的是「值」的作用。因此,isEqual 被自动地转换成指向该函数的指针。

让函数返回一个函数的指针

同样地,与数组类似,虽然我们无法返回一个函数,但是我们可以返回一个函数指针。我们回到 isEqual 的函数指针的定义上来:

1
bool (*pfunc)(int, int) = isEqual;

在这里,pfunc 是一个指针,它指向了一个函数;该函数接收两个 int 型参数,并返回一个 bool 值。因此,仿造之前「返回数组指针的函数的定义形式」,我们不难得出:

1
outer_return_type (*func(param_list))(outer_param_list)

这里,func(param_list) 是当前需要定义的函数;outer_return_typeouter_param_list 分别是当前定义的函数返回的函数指针对应函数的返回值类型和参数列表。

登峰造极的 (*(void(*)())0)();

这恐怕是一个会令所有 C/C++ 程序员战栗不已的函数调用语句。因此,在解释这个语句之前,我愿意先给出它的含义,安抚读者战栗的心灵。它表示:访问内存地址 0,将它作为一个参数列表和返回类型均为空的函数,并执行函数调用。(这是一个特殊场景下的函数调用,不用纠结为什么会调用 0 位置上的函数)

类型定义与 C 风格的类型强制转换符

C 风格的类型强制转换符应该不是个稀罕玩意儿。比如 (double)a 就能将变量 a 强制转换为 double 类型。在这个简单的例子里,我们希望能够找到一些朴素的规律,破解这一登峰造极而又令人战栗的函数调用语句。

同样以 double 类型及相关指针类型为例,我们首先看下面的代码:

1
2
3
4
5
double a;       // 定义了一个 double 型的变量
double *b; // 定义了一个 double * 型的变量(double 型的指针)

(double) c; // 将变量 c 强制转换为 double 类型
(double *) d; // 将变量 d 强制转换为 double * 类型

我们不难发现,类型转换和对应类型的变量定义,有着千丝万缕的联系:首先去掉变量定义语句末尾的分号,然后去掉变量定义语句中的变量名,最后给剩余的部分加上括号——一个 C 风格的类型强制转换符,就得到了。

破解谜题

我们知道 void(*pfunc)(); 定义了一个函数指针 pfunc,它指向的函数参数列表为空、返回值类型也为空。因此,(void(*)()) 就是一个 C 风格的类型转换符。

因此,(void(*)())0 会将 0 转换成一个函数指针,然后交给 * 解引用,最后传入参数(空的参数列表 ()),执行函数调用。

在 C++ 中,这个函数调用应该写作

1
(*reinterpret_cast<void(*)()>(0))();

显而易见,这个写法,相较 C 风格的类型强制转换符,要清晰明朗得多。因此,请不要再吐槽 C++ 风格的强制转换是「语法盐」了。


谨以此文,作为献给自己的生日礼物。:)

俗话说,投资效率是最好的投资。 如果您感觉我的文章质量不错,读后收获很大,预计能为您提高 10% 的工作效率,不妨小额捐助我一下,让我有动力继续写出更多好文章。