C Programming Language

Basics #01: Pointer

# Basic Tutorial,  # Programming Language,  # C Programming Language

Last Updated: 2020-01-01


前置知识

在阅读本文档前,请先阅读本教程的整体介绍,详见 Introduction


前言

指针是 C 语言中最重要的核心概念,这门语言的重难点几乎无一不是和指针有着千丝万缕的联系。只有当你理解了指针以后, C 语言的大门才会向你敞开,你才算是一只脚踏入了这个经典的编程语言的殿堂。

当然,指针也是 C 语言初学者最大的噩梦。一方面,指针的概念相对较难理解,且深入理解需要对内存和地址的概念有一个最基本的了解;另一方面,指针的语法也相对复杂且容易出错,一旦出错也较难 DEBUG 。基于上述原因,初学者需要投入足够的精力才能较好地掌握指针相关的知识。

本文假设读者已经基本掌握除了指针以外的大部分基础语法,包括逻辑控制结构、循环结构、函数等,但假设对指针一无所知。如果是对指针略知一二、但是感觉难以理解,或是感觉自己对指针的理解不够透彻的读者,这篇文章也同样能够帮到你。

为便于描述,除非特别声明,本文中假设在程序的运行环境中 int 类型的长度为 3232 位,且程序运行在 3232 位环境。对于在 3232 位和 6464 位环境中存在差异的情况,文中会给出说明。


内存和地址

在介绍指针之前,我们先介绍数据在计算机中究竟是如何存储的,也就是内存和地址的相关概念。虽然绕了一点点弯子,但我认为,先理解内存和地址非常有利于深入理解指针。

事实上这些概念没有本节中所描述的这么简单,在高年级课程中你会接触到,或者是读者自己有兴趣的话可以自学计算机组成原理的相关知识。在这里,我仅以初学者能接受的程度去介绍这些知识,省略了所有可以省略的细节。

在计算机软件的最底层实现中,一切数据都是以二进制的形式存储的,即长度不同、语义不同的 0/10/1 串。我们称一个 0011 为一个 比特 (bit) ,也称为 比特位 ,这是计算机数据的最小单位。

由于一个比特位能容纳的信息太有限,单独一位的用处并不大,再加上一些其他的原因,大多数现代计算机都不是一位一位地看待数据,而是将 88 位合成一组来看待。这样的一个长为 88 bit 的基本单位称为一个 字节 (byte) 。字节是计算机中最小的数据存储单位。

为了存储更大的值,计算机还会把两个或更多的字节合成一组,称为一个 字 (word) 。字的相关概念与本文无关,因此不再赘述。

大多数现代计算机都是按字节编址的,也就是将字节作为内存地址的最小单位,内存中的第 1,2,...,n1, 2, ..., n 个字节的地址编号为 0,1,...,n10, 1, ..., n-1 ,如下图所示。

而计算机程序会在内存中存储一些值,可能是 char 类型的数值,那么它将占用 11 个字节的内存;也可能是 short 或 int 类型,那么它将占用 2244 个字节的内存。(我们假设 int 类型的长度是 3232 位,下同)

下面给出了一个省略细节的示例。对于下面这段代码:

char ch = 'A';
int a = 127800;
short b = 65535;

假设它们在内存中按照声明的顺序连续存储(当然,这个假设事实上并不成立),那么它们在内存中可能如下图所示:

当然,数据在内存中事实上是以二进制形式存储的,且我们一般习惯用十六进制来表示地址编号,而上文中的示例图均使用十进制数,仅是为了让初学者看起来易懂。地址的起始位置事实上也是不确定的。

要记住具体的地址编号实在是太笨拙了,因此所有的高级语言都提供了按名访问的特性,也就是说,程序只需要通过变量名而不是具体地址值来访问内存中的数值:

当然,按名访问是由编译器为我们实现的。当程序被转换到最底层的语言时,变量名都会被转换为对应的具体地址值,事实上硬件机器还是通过地址值来访问内存的。

一言以蔽之:每一个变量都对应着一个地址,及其占用的内存大小。

一定要分清变量的内容和地址,例如在上例中变量 chch 的值是 6565 ,而其地址是 290290 ;变量 bb 的值和地址则分别是 6553565535295295

另外,内存的大小是有限的,地址空间的大小也是有限的,在 XX 位环境中通常用 XX 位长度来记录地址值,也就是说 3232 位环境的地址值的取值范围为 [0, 2321][0,\ 2^{32} - 1] ,可以用 unsigned int 类型表示。

注:大多数现代计算机都以 8 位为一个字节,也都按字节编址,但是并非总是如此,实际上存在以其他位数为一个字节的情况,也存在按字编址的情况。为便于读者理解那些重要的内容,上文将这些内容省略,并在此处进行解释,以免发生误会。事实上初学者无需关心这些,因为你实际上几乎遇不到这样的机器。

注:数据实际存储时还涉及编译器优化、大小端存储方式、内存对齐等问题,上文将这些细节省略,因为只是学习指针的话,不需要知道这些对初学者而言有些过于复杂的知识。


基本概念

现在你对内存和地址有了一个最简单的认知,这已经足够了。接下来我们将尽可能细致地介绍指针的概念。

如前文中的例子所示,对于普通的变量而言,变量的内容会直接存储在其对应的内存地址处,由高级语言编写的程序可以通过变量名直接访问。

所谓的 指针 (pointer) 是一种语义特殊的变量,在指针变量中存储的内容是 其他变量的内存地址值

以下面这段代码为例,其中声明并定义了 a, b, ca,\ b,\ c 三个 int 类型的变量,和 p, qp,\ q 两个指向 int 类型的指针,其中 pp 指向 aa ,而 qq 指向 cc

int a = 112;
int b = -1;
int c = 102400;
int * p = &a;
int * q = &c;

我们用 T * 来声明指向一个类型为 T 的变量的指针,用 &a 表示变量 aa 的具体地址值。更加具体的关于指针的语法详见下文,此处请读者先注重理解指针的概念。

注:为了让读者可以自行尝试验证,我特地选取了满足假设“在内存中按照声明的顺序连续存储”的例子,前提是程序需要在 32 位的环境运行。

这些变量在内存中可能如下图所示:

可以看到,变量 pp 中的值为 &a ,也就是变量 aa 的地址。

每一个变量都对应着一个地址。指针变量中的内容就是一个变量(地址)的地址,但是指针变量本身也是对应着一个地址。

因为编译器屏蔽了高级语言对具体地址值的控制,程序运行时变量的具体地址是难以确定的,所以我们习惯于用箭头等方式来形象地表示指针的指向。假设程序运行时 aa 的地址是 100100 ,那么变量 p, qp,\ q 中的内容可能如下:

注意,由于 int 类型的长度是 3232 位且程序是在 3232 位的环境运行,所以这些变量的地址值依次是 100, 104, ..., 116100,\ 104,\ ...,\ 116 而不是 100, 101, ..., 104100,\ 101,\ ...,\ 104

你可以通过一个指针变量来间接地访问另外一个变量,例如上例中,如果我们有办法以变量 pp 中的值作为地址去访问内存,那么我们就间接地访问到了变量 aa 中的内容。事实上存在这样的方法,通过一个指针访问它所指向的地址的过程称为对指针的 解引用 (dereference)

我们用 *p 表示对指针变量 pp 的解引用。很多初学者会把指针的解引用和声明混淆,下文会对此给出详细解释。

在高级语言的层面,对指针解引用的过程可以粗略地分为两个步骤:

  • 按变量名访问指针变量 pp ,获取 pp 的内容,记为 vpv_p

  • 按具体地址 vpv_p 访问到某个变量(地址),获取该地址处存储的内容。

而在访问一个普通的变量时,我们只有第一个步骤:

  • 按变量名访问变量 aa ,获取 aa 的内容。

事实上,取出指针变量中存储的这个地址值的过程,和访问一个普通的变量的过程是没有任何区别的,区别只是我们取出这个值后,将其视为地址,并再次进行内存访问。


基本语法

声明

我们用 T * 来声明一个指向类型为 T 的变量的指针。

例如,声明并定义一个指向 int 类型的指针 pp 的代码如下所示:

int * p;

运算符 * 与 int 和 pp 之间的空格不影响语法,也就是说下面两种写法和上面是等价的:

int* p;
int *p;

也存在关于哪种写法更好的争论,也各有各的理由。我认为这并不重要,使用你喜欢的写法就好,但建议你在你的程序中使用统一的某种写法,否则会显得很混乱。

需要注意的是,下面的代码声明并定义了一个指针 pp 和一个普通变量 qq ,而不是你想象中的两个指针;

int * p, q;

C 语言的语法就是这么规定的,所以也不要问为什么是这样,没有为什么。

基于这个理由,我个人习惯于使用第一种或第三种写法,因为第二种写法可能会导致上述的误导:

int * p, * q;
int *p, *q;

如果你喜欢第二种写法,则建议你在声明多个指针时分成多句声明,以免粗心产生错误:

int* p;
int* q;

取地址

aa 是一个变量,我们用 &a 表示变量 aa 的具体地址值,其中 & 在此处作为 取地址运算符 (address operator)

例如,声明并定义一个指向 int 类型的指针 pp ,并将其初始化指向变量 aa 的代码如下所示:

int a = 1;
int * p = &a;

变量 aa 和变量 pp 的类型必须是匹配的,也就是说,设变量 aa 的类型为 T ,则变量 pp 的类型就应当是 T * 。如果指针和被指向的变量的类型不匹配,那么多半是不合法的,存在极大的隐患,具体详见下文。

另外,如果我们把 pp 定义的赋值分开,其语法与你想象中的并不相同,是 p = &a 而非 *p = &a

int a = 1;
int * p;
p = &a;

因为在声明语句中,运算符 * 的含义是“告诉编译器,这个变量是一个指针”。下文会对此给出更详细的解释。

当然,通过取地址运算符所得到的不是一个变量(更专业地讲,得到的是一个右值而非左值),因此下列代码是不合法的:

int a = 1;
int b = 2;
&a = &b;

这很好理解,编译器当然不会允许你去修改一个变量的地址。

解引用

pp 是一个指针变量,我们用 *p 表示对指针变量 pp 的解引用,其中 * 在此处作为 解引用运算符 (dereferencing operator)

例如,对指针 pp 解引用,并将其值赋给其他变量的代码如下所示:

int a = 1;
int * p = &a;
int b = 2;
b = *p;
printf("%d\n", b);

对指针 pp 解引用,并修改 pp 所指向变量的内容的代码如下所示:

int a = 1;
int * p = &a;
*p = 2;
printf("%d\n", a);

当然,我们也可以直接对一个地址解引用:

int a = 1;
*&a = 2;

事实上,单从结果上而言第 22 行代码和直接的 a = 2 毫无区别,但是它涉及更多的操作,经历了取地址和解引用的额外过程。这样的代码运行效率低下且可读性差,因此没有人会在实际中使用这种表达式,这毫无意义,你只需要能够理解它就可以了。

区分声明和解引用

很多初学者会把指针的解引用和声明弄混淆。如你所见,运算符 * 在声明语句和其它表达式语句中的含义是不同的:

  • 在声明语句中,运算符 * 的含义是“告诉编译器,这个变量是一个指针”,例如 int * 事实上是一体的;

  • 否则,运算符 * 的含义是解引用运算符。

请读者一定要对此加以区分。下面给出一段代码示例帮助读者加深印象:

int a = 1;
int b = 2;
int * p = &a; // 声明并定义一个指针变量 p ,且将其初始化指向变量 a
int * q;      // 声明并定义一个指针变量 q
q = &a;       // 将 q 初始化指向变量 a
*q = 3;       // 对指针 q 解引用,并修改 q 所指向变量的内容为 3
*p = b;       // 对指针 p 解引用,并修改 p 所指向变量的内容为变量 b 的内容
p = &b;       // 令指针 p 指向变量 b
p = q;        // 令指针 p 指向和指针 q 相同的目标

同时附上代码运行到第 4, 5, 6, 7, 8, 94,\ 5,\ 6,\ 7,\ 8,\ 9 行时,程序内存的可能的图示:


空指针

C 语言标准明确定义了 空指针 (NULL pointer) 的概念,它作为一个特殊的指针变量,表示不指向任何东西。要将一个指针变量设为空指针,你可以将其值设为 C 标准库中的宏定义 NULL

int * p = NULL;

在不同的编译环境中,对 NULL 的定义可能不同,它可以被定义为 ((void*)0) , 00L ,但是可以看到只是最后被转换的类型不同,其数值均为零值。

对空指针的解引用是不合法的:

int * p = NULL;
int a = *p;

空指针是非常有用的,例如你想实现一个函数,其功能是查找数组内是否存在某个数 xx ,若存在则返回其首次出现的位置的地址。思考当查找失败时你应该返回一个什么值?你需要这样的一个值:其类型为指针,且可以用于表示“什么也没有”——那就是空指针:

int * find_first(int arr[], int n, int x) {
    int i;
    for (i = 0; i < n; i ++) {
        if (arr[i] == x) {
            return &arr[i];
        }
    }
    return NULL;
}

注意,由于空指针的数值是 00 ,下列代码是可以正常运行的:

int * p = find_first(a, n, x);
if (p == 0) {
    // ...
}

但是我们并不推荐这样做。在复杂的代码中,这样的表达式会引起误解,使阅读者无法准确理解 if 条件的语义。建议在判断空指针时严格使用宏定义 NULL 而不是字面值 00


野指针和悬挂指针

未被初始化的指针被称为 野指针 (wild pointer) 。就如同普通的变量一般,访问未被初始化的指针变量会触发未定义行为。之所以特别存在野指针的概念,只是因为对野指针解引用也会触发未定义行为,而这是未被初始化的普通变量所不具备的特点。

以下面的代码为例,当运行到 printf 语句时,指针 pp 是野指针,而 qqspsp 不是:

void f() {
    int a = 1;
    int * p;
    int * q = &a;
    static int * sp;
    printf("666");
}

如果一个指针所指向的内存地址被释放,那么这个指针将会成为 悬挂指针 (dangling pointer) ,也称为 悬垂指针 。访问悬挂指针是“合法的”(例如为悬垂指针重新设置其所指的内存地址),但是对悬挂指针解引用会触发未定义行为。

悬挂指针的问题一般是由动态内存分配导致的,如果在释放动态分配的内存时,没有妥善处理那些指向这块内存的指针,就会产生悬挂指针。以下面的代码为例,对指针 pp 的第二次解引用就是不合法的:

int * p = (int *)malloc(sizeof(int));
*p = 1;
free(p);
*p = 2;

如果释放了动态内存,则应当将所有指向这块内存的指针重新赋值。如果这些指针暂无其它用途,则通常将其设为空指针:

int * p = (int *)malloc(sizeof(int));
int * q = p;
free(p);
p = NULL;
q = NULL;

同样地,我们也不应当容忍任何野指针的存在,应当将所有暂无初值的指针都初始化为空指针。

这么做理由是显而易见的。其一,因为你可以判断一个指针是否为空指针,却没有办法判断一个指针是否为野指针或悬挂指针:

if (p != NULL) {
    *p = 2;
}
// if (p is a wild/dangling pointer ?)
// no way to check this !

其二,相比空指针而言,野指针和悬挂指针是十分危险的,因为你没有任何办法判断一个指针是否为野指针或悬挂指针,访问它们的行为也无法在编译期或运行期被判别,这可能导致不可预料的后果。而访问空指针的行为在编译期或运行期都很容易判别,即便你在程序中不慎对空指针解引用,大部分运行环境也能将其检测出来,并及时终止程序——程序崩溃虽然也不是什么好事,但总比程序产生随机不确定的错误要好得多。


指针的本质

请记住: 指针变量只是一种被赋予了特殊语义的变量,它的内容本质上就是一个无符号整数。 只不过这个整数代表了一个内存地址,所以你可以通过这个地址间接地访问另外一个变量。

基于你之前所学的内容,下面的代码可能看起来很奇怪,但是事实上是正确的:

int a = 1;
unsigned int addr_a = &a;
int b = *((int *)addr_a);

当然,这基于本文开头所述的条件:假设在程序的运行环境中 int 类型的长度为 3232 位,且程序运行在 3232 位环境。在这样的条件下,指针的长度和 int 类型的长度只是恰好相同而已,若是在 6464 位环境,程序的第 22 行会发生溢出导致丢失信息,那么程序的第 33 行将触发未定义行为。所以要避免在需要支持可移植性的程序中写出上述代码。

不过下面的代码将会报告编译错误,因为编译器不允许对不是地址的变量进行解引用操作:

int a = 1;
unsigned int addr_a = &a;
int b = *addr_a;

我们需要进行强制类型转换,这样编译器就会认为程序员知道自己在做什么,而不会认为你写下了错误的代码。

我们知道,变量类型的意义是为了告诉编译器如何操作数据。因此,所谓的指针类型,就是告诉编译器可以通过这个变量间接操作数据。

基于上述原因,如果我们能预先确定某个变量的内存地址,我们可以完全不通过取地址运算符来初始化指针:

int a = 1; // 假设我们预先知道变量 a 的地址是 1020 ,当然,你不可能知道
int * p = (int *)1020;

指针的内容只是具有特殊含义的整数值而已,取地址符只是用来获取那个特殊的整数值的,不要在心中把这些东西太过于“神化”。

在上述代码示例中,我们显然不能在编译期确定一个局部变量 aa 的地址,代码并不合法,但是下列代码就是合法的了(只要用户的输入合法):

int a = 1, b = 2, c = 3;
unsigned int addr;
printf("The address of a, b, c is : %u, %u, %u\n", &a, &b, &c);
while (1) {
    printf("Input the address that you want to read from (zero to exit) : ");
    scanf("%u", &addr);
    if (addr == 0) {
        break;
    }
    printf("The value in [%u] is %d\n", addr, *((int *)addr));
}

在我本机的某次运行结果如下:

The address of a, b, c is : 6422256, 6422260, 6422264
Input the address that you want to read from (zero to exit) : 6422260
The value in [6422260] is 2
Input the address that you want to read from (zero to exit) : 6422256
The value in [6422256] is 1
Input the address that you want to read from (zero to exit) : 6422264
The value in [6422264] is 3
Input the address that you want to read from (zero to exit) : 0

虽然这有些蠢,不过这只是为了向读者展示这种操作的可行性,如果实际中真的要使用类似这样的操作,代码肯定比这复杂,至少得能够处理用户的错误输入,否则程序会很容易崩溃。


指针作为函数参数

我们知道,在 C 语言中函数传参是以值传递的方式进行的,因此我们无法在被调用的函数中修改调用者所在作用域中的变量:

void swap(int a, int b) {
    int t = a;
    a = b;
    b = t;
}
int main(void) {
    int a = 1, b = 2;
    swap(a, b);
    printf("a = %d, b = %d\n", a, b);
    return 0;
}

上述代码的输出结果如下:

1 2

但是我们借助指针就可以彻底解决这个问题:

void swap(int * a, int * b) {
    int t = *a;
    *a = *b;
    *b = t;
}
int main(void) {
    int a = 1, b = 2;
    swap(&a, &b);
    printf("a = %d, b = %d\n", a, b);
    return 0;
}

上述代码的输出结果如下:

2 1

我把这部分内容的更多细节放在函数的文章中具体介绍,详见 C 语言的 03 - Function 文档。


指针和数组

在 C 语言中,指针和数组之间的关系十分密切,通过数组下标所能完成的任何操作都可以通过指针来完成。

存在这样的一种说法:使用指针比使用数组下标的程序运行效率更高,但代码会看起来更复杂。事实上,这个说法是不太准确的,还是要看实际情况。

在本节中,我们均以这个长为 77 的数组 aa 为例。为便于描述,假设在以下所有代码示例均包含 aann 的声明。

int a[] = {0, 1, 2, 3, 4, 5, 6};
int n = 7;

数组的连续性

下面的代码声明并定义了一个指向 a0a_0 的指针 pp ,并通过 pp 来间接访问 a0a_0

int * p = &a[0];
int x = *p;

这看起来和之前没什么不同,我们只需要和往常一样获取数组元素的地址并将其赋给指针变量即可。它的特殊之处在于,如果我们有一个指向某个数组元素的指针,那么我们就可以对这个指针进行一些特殊的运算。

我们知道,数组中的元素在内存中一定是连续存储的,这就创造了机会——编译器对普通变量的存放位置是不可控的,即便你连续声明几个变量,它们在内存中也不一定是挨在一起的。但是数组中的元素一定是按顺序连续存储的,例如:

int i;
for (i = 0; i < n; i ++) {
    printf("%u%c", &a[i], (i == n - 1 ? '\n' : ' '));
}

上述代码在我本机上的某次运行结果如下:

6356708 6356712 6356716 6356720 6356724 6356728 6356732

在不同的地方运行 a0a_0 会具有不同的地址值,但是 ai (i>0)a_i\ (i > 0) 的地址值将会被 a0a_0 的地址所确定。

基于上述原因,除了解引用以外,指针还具备如下合法的运算:

  • 指针和整数之间的加减法;

  • 指针的自增自减;

  • 指针和指针之间的减法;

  • 指针和指针之间的比较关系。

注意,参与上述运算的指针必须是指向数组中的元素的,否则将会触发未定义行为。

指针和整数之间的加减法

如果一个指针 pp 指向数组元素 aia_i ,那么指针 p+xp + x 将指向数组元素 ai+xa_{i+x} ,其中 xx 为整数且假定 i+xi + x 没有发生越界。例如:

int * p = &a[1];
printf("p(%u) + %d = (%u)\n", p, 1, p + 1);
printf("p(%u) + %d = (%u)\n", p, 3, p + 3);
printf("p(%u) - %d = (%u)\n", p, 1, p - 1);

上述代码在我本机上的某次运行结果如下:

p(6356712) + 1 = (6356716)
p(6356712) + 3 = (6356724)
p(6356712) - 1 = (6356708)

由此可见,当指针加上或减去一个整数 xx 时,指针的值事实上是加上或减去了 sxs * x ,其中 ss 为指针指向的类型的长度(字节个数)。因为 int 类型在此处的长度为 44 字节,所以 p+1p + 1 会使 pp 中的实际地址值 +4+4

也就是说,如果 aa 是 short 类型的,ppshort * 类型的,那么运行结果可能会是这样的:

p(6356724) + 1 = (6356726)
p(6356724) + 3 = (6356730)
p(6356724) - 1 = (6356722)

这样的特性对程序员来说非常有益,这使得我们在对指针加上或减去一个整数时,不需要关心指针的具体类型,而只需要考虑我们希望指针向前或向后移动多少位。语言会自动地帮我们将其转换为具体的数值。

我们也可以直接对 p+xp + x 解引用,例如:

int * p = &a[4];
printf("*p = %d\n", *p);
printf("*(p + %d) = %d\n", 1, *(p + 1));
printf("*(p - %d) = %d\n", 2, *(p - 2));

上述代码的运行结果如下:

*p = 4
*(p + 1) = 5
*(p - 2) = 2

另外需要注意的是,如果 p+xp + x 超出了数组的合法地址范围,那么对 p+xp + x 解引用等同于数组越界,将会触发未定义行为。

指针的自增自减

指针也可以进行自增或自减,等同于 +1+11-1 ,前缀或后缀均可,例如:

int * p = &a[0];
printf("%u\n", p);
p ++;
printf("%u\n", p);
printf("%u\n", --p);
printf("%u\n", p++);

上述代码在我本机上的某次运行结果如下:

6356708
6356712
6356708
6356708

注意有一个新手容易弄错的地方。考虑下面的代码:

int * p = &a[0];
int x = *p++;

根据运算符优先级,上述代码事实上等同于:

int * p = &a[0];
int x = *(p++);

这会令 x = *p 然后令 p ++ 。如果你希望做的事情是令 x = *p 然后令 a[0] ++ ,那么代码应该写成:

int * p = &a[0];
int x = (*p)++;

指针和指针之间的减法

基于指针和整数之间的加减法,我们可以定义指针和指针之间的减法,将得到二者之间相差的距离,例如:

int * p = &a[1];
int * q = &a[4];
int delta = q - p;
printf("q - p = %d\n", delta);

上述代码的运行结果如下:

q - p = 3

这很好理解,毕竟根据之前的定义有 q=p+3q = p + 3 ,因此 p, qp,\ q 相减即可得到 33

再给出一个示例:

printf("%d\n", &a[5] - (&a[0] + 3));

上述代码的运行结果如下:

2

需要注意的是,参与运算的两个指针必须指向同一个数组中的元素,否则是不合法的。此外,指针之间的加法也是不合法的。

指针和指针之间的比较关系

>, <, >=, <= 对两个指针进行比较也是可能的,比较的结果取决于二者指向的数组元素在数组中的前后关系,例如:

int * p = &a[1];
int i;
for (i = 2; (p + i) < &a[5]; i ++) {
    printf("%d\n", *(p + i));
}

上述代码的运行结果如下:

3
4

和指针之间的减法一样,参与运算的两个指针必须指向同一个数组中的元素。

对指针使用数组的语法

如果指针指向数组中的元素,我们就可以对指针使用数组下标以实现解引用,例如:

int arr[5] = {0, 1, 2, 3, 4};
int * p = &arr[3];
printf("%d\n", p[1]);
printf("%d\n", p[-2]);

上述代码的运行结果如下:

4
1

如你所见, p[i]*(p + i) 这两种语法的运行结果是相同的,而且我们甚至可以使用负数,这并不违法,只要最终算出的地址没有越界。

对数组使用指针的语法

和指针类似, a[i]*(a + i) 这两种语法的运行结果是相同的。例如:

int a[5];
int t = *(a + 3);

当然,对二者分别取地址后得到的 &a[i]a + i 也是等价的:

int a[5];
int * p = &a[2];
int * q = a + 2;
printf("p(%u), q(%u)\n", p, q);
printf("(p == q) = %d\n", (p == q));

上述代码在我本机上的某次运行结果如下:

p(6356716), q(6356716)
(p == q) = 1

可见二者指向的是同一块地址。

如你所见,指针和数组的语法几乎可以互换,我们似乎总是可以将指针和数组名进行互换而不改变程序运行的结果。然而事实上并非如此,这里面并没有你想象中的那么简单。

数组名和指针

我们知道,下面的代码会把 a0a_0 的地址赋给指针 pp

int * p = &a[0];

上述代码也可以写成如下的形式,且二者是等价的:

int * p = a;

如果你在其它地方学习过指针的知识,你可能会有一个错误的认知:数组名就是数组首元素的地址。它的理由也很简单:

int a[5];
int * p = &a[0];
int * q = a;
printf("p(%u), q(%u)\n", p, q);
printf("(p == q) = %d\n", (p == q));
printf("(&a[0] == a) = %d\n", (&a[0] == a));

上述代码在我本机上的某次运行结果如下:

p(6356708), q(6356708)
(p == q) = 1
(&a[0] == a) = 1

这个实例和前文介绍的数组和指针之间语法的可互换性似乎足以佐证这个观点,然而事实并非如此。在上述代码中存在一个类似于隐式类型转换的过程,数组名在上述赋值和比较语句中都是先被转换成了指针类型,再参与表达式的计算的。

记住: 数组名和指针是不同的。 没人会认为数组等同于指针,但是有很多人认为数组名等同于指针,这是错误的。要想证明这一点很简单,例如:

printf("%d\n", sizeof(&a[0]));
printf("%d\n", sizeof(a));

上述代码的运行结果如下:

4
28

可见数组名至少包含了数组的长度信息,二者是不同的。事实上,二者之间的区别还远不止如此。

但是对于初学者而言,理解指针和数组的各种知识本就已经较为吃力,因此我不会在本文中对二者的区别进行详细介绍。你现在只需要知道:在进行各种运算时,数组名会被转换为等价于数组首地址的一个指针,但是数组名本身不是指针。知道它们不同就行了,具体是什么不同,可以等以后水平上去了再去了解。当然,如果你有兴趣,也可以直接去学。

关于数组名和指针的区别,详见 C 语言的 05 - Array Name is NOT a Pointer 文档。

指针数组

顾名思义,指针数组就是由指针变量构成的数组,数组中的每个元素都是一个指针变量。例如:

int * arr_p[5];
int a = 1;
int b = 2;
arr_p[0] = &a;
arr_p[1] = NULL;
arr_p[2] = &b;
arr_p[3] = arr_p[0];
*arr_p[3] = 3;

这和普通变量的数组本质上没什么区别,很好理解,但是要注意将指针数组和接下来要介绍的数组指针区分开来。

数组指针

数组指针指的是指向数组的指针。例如下面的代码中,指针 pp 可以指向一个长度为 55 的 int 数组:

int (*p)[5];

这个语法看起来有些奇怪,你可以姑且这样理解:用括号表示先将 *pp 结合,表示 pp 是一个指针。事实上 pp 的类型说明符是 int (*)[5]

注意一定要弄清楚,数组指针指向的是一块大小为 LsL * s 的空间,其中 L, sL,\ s 分别为数组长度和数组的基本类型,例如 int (*)[5] 类型的指针指向的是 54=205 * 4 = 20 字节大小的空间。

int arr[5] = {0};
int (*p_arr)[5];
p_arr = &arr;

下图展示了指针数组和数组指针之间的不同,其中指针数组对应于上一节中的代码示例,数组指针对应于本节中上文最邻近的代码示例:

下面的代码演示了数组指针的解引用的相关语法:

int arr[5] = {0, 1, 2, 3, 4};
int (*p_arr)[5] = &arr;
int a = (*p_arr)[1];
int b = *(*p_arr + 3);
int * p = *p_arr;
int c = *(p + 2);
int i;
printf("%d %d %d\n", a, b, c);
*(*p_arr + 2) = 5;
**p_arr = 6;
for (i = 0; i < 5; i ++) {
    printf("%d%c", arr[i], (i == 4 ? '\n' : ' '));
}

注意 *p_arrp_arrp\_arr 解引用将会得到 arrarr 本身,由此你就能理解对 a, b, p, ca,\ b,\ p,\ c 赋值的代码了,只是先通过解引用数组指针来得到数组,再像往常一样对数组进行操作。

上述代码的运行结果如下:

1 3 2
6 1 5 3 4

另外,下面的代码是不合法的:

int (*p_arr)[5] = &arr1;
*p_arr = arr2; // compile error

因为 C 语言禁止直接把一个数组赋值给变量,和下面的代码不合法是一个道理:

int a[5] = {1, 2, 3, 4, 5}, b[5] = {6, 7, 8, 9, 0};
a = b; // compile error

实际上我们很少会用到数组指针,除了将多维数组作为函数参数使用的时候。关于数组指针在函数传参中的使用,详见 C 语言的 03 - Function 文档。


字符指针和字符数组

字符指针和字符数组是初学者容易混淆的概念。下面两种声明语句并不相同,虽然它们看起来很相似:

char * p = "abcde";
char a[] = "abcde";

作为一名初学者,你无需深究其中的原因,我在此只介绍表层的你需要知道的知识。

当你用一段字符串来初始化一个字符指针时,并不是给这个指针动态分配了内存,而是在程序内存的一块特殊地段预先存储了字符串 "abcde" ,然后将指针 pp 指向这个字符串的首地址。

当你用一段字符串来初始化一个字符数组时,就像普通数组一样,静态地开辟了字符串长度 +1+1 (为 \0 自动留出)的空间。

字符数组是可以修改的,就像普通数组一样,但是用上述方法初始化的字符指针是不能修改其所指内容的。例如:

char * p = "abcde";
*(p + 1) = 'f';

上述代码是非法的,这将会导致未定义行为,程序可能会崩溃,也可能什么都不会发生。

当然,你可以修改这样的指针的指向,这不会引起任何问题:

char * p = "abcde";
char a[] = "uvwxyz";
char ch = 'f';
p = a;
p = &ch;

你也可以像其他类型一样,用字符指针去间接访问一个字符数组:

char a[] = "abcde";
char * p = &a[2];
*p = 'f';
*(p + 1) = 'g';
a[0] = *(p + 2);

总体来说,只有当你直接用一个字符串去初始化一个字符指针时,情况是特殊的,该指针会指向一个不可被修改的字符串常量的首地址。在其他情况下,字符指针并没有什么特殊之处。


指针和 const 修饰符

我们可以用 const 来修饰指针变量。根据修饰方式的不同,会产生下列三类不同的指针:

  • 指向常量的指针,又称为常量指针;

  • 常指针,又称为指针常量;

  • 指向常量的常指针。

指向常量的指针

指向常量的指针 (pointer to constant value) 指的是无法通过解引用修改被指向的变量的指针,又称为 常量指针 。下面的代码声明并定义了一个指向常量的指针 pp

int a = 1;
int b = 2;
const int * p = &a;

另外一种声明的语法和上面是等价的:

int const * p = &a;

你可以对 pp 解引用并获取其值,但不能对其进行修改,否则你会得到一个编译错误:

b = *p;
*p = 3; // compile error

你可以修改 pp 所指向的地址:

p = &b;

如你所见,被指向的变量本身并不一定被 const 修饰,不要被名字误导了。

常指针

常指针 (constant pointer) 指的是无法修改指向的指针,又称为 指针常量 。这就像你无法修改一个常量的值一样,指针所指向的地址就是指针变量的值。下面的代码声明并定义了一个常指针 pp

int a = 1;
int b = 2;
int * const p = &a;

你可以对 pp 解引用并对其进行任意操作:

b = *p;
*p = 3;

你不能修改 pp 所指向的地址:

p = &b; // compile error

常指针本质上就是一种常量,除了声明的语法比较奇怪以外,它并没有什么特别的地方。

区分的技巧

如你所见,单独看待指向常量的指针和常指针时,它们并不是很难理解,但是它们的语法却很容易混淆:

const int * p;
int const * p;
int * const p;
const int * const p;
int const * const p;

我这么放在一起,你真的分得清吗?

有一种区分的小技巧,就是用英语倒着阅读这些声明语句。我们把 * 看作是 “points to” ,即指向,那么:

const int * p;       // a [pointer] points to [const int]
int const * p;       // a [pointer] points to [int const]
int * const p;       // a [const pointer] points to [int]
const int * const p; // a [const pointer] points to [const int]
int const * const p; // a [const pointer] points to [int const]

由此可见,第 1, 21,\ 2 行是指向常量的指针,第 33 行是常指针,第 4, 54,\ 5 行是指向常量的常指针。希望这个技巧能够帮助你记忆和区分这些令人头大的语法。

另外,指向常量的指针经常被称为常量指针,而常指针经常被称为指针常量,你同样要区分常量指针和指针常量这两个名字。

这个其实并不难区分,与其它常见的称呼对比即可:

  • 对于 int * 类型,也就是指向整型的指针,我们通常称之为整型指针,因此我们将指向常量的指针称为常量指针;

  • 对于 const int 类型,也就是常整型,我们通常称之为整型常量,因此我们将常指针称为指针常量。


指向自定义类型的指针

如果使用指向结构体或联合体这样的自定义类型的指针,其本质和指向基本类型的指针没什么区别。指针变量中仍然存储的是被指向变量的地址。在进行间接访问时,也是先对指针解引用,然后进行各种操作的。

不过,下面这份看起来正常的代码的语法是有一些问题的:

struct Block {
    int a;
    short b;
    char c;
};
struct Block block;
struct Block * p = &block;
*p.a = 1; // compile error

由于 *. 的运算符优先级问题,上述代码的第 88 行等同于 *(p.a) = 1 ,显然这不符合我们的预期,我们是打算先对 pp 解引用,再访问其成员 aa 的。为了解决这个问题,我们必须这样做:

(*p).a = 1;

这样就正确了,但是这也带来了一些麻烦:如果每次解引用结构体指针都要打上括号,那既麻烦也不美观。因此 C 语言为我们提供了一种等价的语法:

p->a = 1;

使用 -> 运算符可以使代码更加简洁,因此在使用结构体指针时,通常推荐使用这种语法代替普通的指针语法。

对于指向联合体的指针也是一样的,就不再赘述了。


多级指针嵌套

考虑这样一个问题,我们可以利用指针来间接修改一个普通变量的值,但是如果我们希望间接修改一个指针的指向呢?此时我们需要使用“指向指针的指针”:

int a = 1, b = 2;
int * p;
int ** pp = &p;
*pp = &a;
**pp = 3; // *(*pp) = 3;
p = &b;
*p = 4;

上述代码的示意图如下所示:

int * 视为一个整体,那么 pppp 的类型就是 (int *) * 。当我们通过 pppp 修改 pp 的指向时,相当于一次间接访问;当我们通过 pppp 修改 aa 的值时,相当于二次间接访问。只要你理解了指针,就应该能轻松地理解多级指针嵌套。

无论指针类型“嵌套”多少层,本质上都是一样的。

我们通常也只会用到“指向指针的指针”,因为有时我们需要在函数中修改指针参数的指向,更多级的嵌套极少需要使用。


动态内存分配

我把这部分内容放在专门的一篇文章中进行介绍,详见 C 语言的 04 - Dynamic Memory Allocation 文档。


函数指针

我把这部分内容放在函数的文章中具体介绍,详见 C 语言的 03 - Function 文档。


指针的意义

一些初学者,以及另一些从其他高级语言转到 C/C++ 语言的程序员,对 C/C++ 语言的指针特性大肆贬低。诚然 C/C++ 语言有其缺点和劣势,但指针绝不是其中之一。恰恰相反,指针是 C/C++ 语言在其擅长的领域表现优秀的最重要的原因之一。

的确,指针对于刚刚入门编程的人而言难度很大,而且裸指针写出的代码极易出错,其安全性缺乏保证,但这并不能抹除指针的优点。

其一, C/C++ 语言的许多语法都是基于指针的。例如,数组的变量名就包含 了数组首地址和数组长度两条信息,等等。

其二,指针提供了间接访问内存的手段。随着你对计算机专业知识的深入学习,你会逐渐意识到间接访问有多么重要。例如,我们可以避免在函数传参时发生大量无用拷贝,还可以在不同的函数中间接修改数据,等等。

其三,指针提供了动态分配内存的手段。

如果你还学过其他高级语言,请继续阅读:

然而,虽然 C/C++ 的指针提供了间接访问内存的手段,但是几乎每一门编程语言都提供了间接访问内存的手段,所以严格来说,这并不算是指针的优点。相比之下更为重要的是,指针这一手段几乎没有受到任何封装和保护,就是赤裸裸地直接访问目标内存。

许多高级语言都将指针的概念封装和保护起来,使得程序员完全不需要理解指针的概念就能编写程序,并且提供了安全性保障,绝大多数非法内存访问会被挡在底层之外。但是提供这些封装和保护是不需要代价的吗?当然需要,而且代价可不小。受保护的每次间接访问,都要被一层层逐渐转换为最底层的内存访问操作,还要进行安全性检查,这都是巨大的时间开销。因此越高级的语言,其平均运行效率往往越慢。

所以反过来想,虽然 C/C++ 语言的指针不安全,但是其运行效率是所有高级语言中最快的,因为指针本身就是赤裸裸的一次间接内存访问,没有多层的包装。指针的优点事实上就是指针的缺点。

在我其他的文章中你可以看到,为了程序运行效率而牺牲大量对程序员友好的东西,本就是 C/C++ 语言的一大特色,这也就是指针存在的理由。

学习指针的概念是很有意义的,因为这可以帮助你理解经过任何包装的间接内存访问方法。