王道C++班级参考资料
——
C语言部分卷4指针
节2指针和数组

最新版本V2.0
王道C++团队
COPYRIGHT ⓒ 2021-2024. 王道版权所有

数组名和指针

Gn!

理解数组名和指针的关系,是学习整个章节的重点和前提。同时这个话题也是面试常问的问题之一。

在上一节,我们讲过指针变量需要手动初始化,实际上数组名可以直接用于指针变量初始化,例如:

数组名直接赋值给指针变量-演示代码

为什么呢?

这是因为,数组名可以近似看成指向数组首元素的指针,所以用数组名给指针变量赋值就相当于用指针给指针赋值,是将数组的首元素地址赋值给了这个指针变量。

如果你不深究原理、只追求写代码的话,这一句解释也就足够了。但作为一名优秀的C程序员,我们还是希望能够了解更多。

我们提出以下问题,然后逐一回答。

数组名到底是个什么东西?

Gn!

标准答案:数组名是一个代表数组在内存中起始地址的标识符,在大多数上下文中,数组名可以被视为指向首元素的指针。

进一步解释:

  1. 从功能上看,数组名的行为类似于一个固定指向首元素地址的,不可改变指向的指针。

  2. 从语义上讲,数组名代表的是整个数组,而不仅仅是地址。

数组名从功能上虽然可以看成一个指针,但该指针的指向不可改变。C语言中的数组名不能做任何赋值操作:

数组名不能做赋值操作-演示代码

例如,声明了一个数组"int arr[5]"时,数组名arr就代表了这个数组在内存中的起始地址(也就是首元素地址),而且不可被修改。

数组名在哪些场景中可以当成指针?

Gn!

实际上,在大多数上下文中,当你使用数组名时,它会自动(或“隐式地”)被转换为一个指向数组首个元素的指针。

以下是一些常见的场景,其中数组名会被当作指向首元素的指针使用:

  1. 数组名可以直接用于初始化一个指针变量,此时的数组名就当成首元素的指针使用。(上面代码提过)

  2. 当一个数组作为参数传给一个函数时,数组名隐式转换(也称退化)为指向其首元素的指针。(下面小节会讲)

  3. 数组名可以直接进行指针算术运算,此时的数组名就当成首元素的指针使用。(下面小节会讲)

数组名在哪些场景中不可以当成指针?

Gn!

虽然在许多场合,数组名表现得像指向其首元素的指针,但在某些场景中,将数组名当作指针是不恰当的。

以下是这些特定场景的说明:

&取地址运算。

在做取地址运算时,数组名代表整个数组,而不是首元素的指针。也就是说对数组名取地址,就是对数组变量取地址,得到的是代表整个数组的地址。

当然,变量的地址是变量内存空间的第一个字节的内存地址,对于数组而言就是首元素的地址。

也就是说:

数组名在视为指针时,和对数组名取地址得到的结果值是一样的,都是存储数组首元素地址的指针。但很明显,这两个指针的类型以及含义是截然不同的!!

以一个类型为int[5],即长度为5的int数组为例:

  1. 数组名视为指针时,存储的是首元素的地址,指向数组首元素。数组名作为指针时的类型是int *类型。

  2. 对数组名取地址时,得到的是整个数组变量的地址,虽然从地址值上它还是指向数组首元素,但类型变成了int[5] *。这个指针指向的是整个数组变量,而不是数组首元素。

以代码为例:

数组名取地址运算-代码示例

sizeof求大小运算。

在使用sizeof求大小时,数组名也是代表整个数组变量。此时求出来的是整个数组所占内存空间的大小。

比如:

sizeof和数组名的运算

基本上来说,除了上面两个场景,其他场景中的数组名都可以作为首元素的指针看待。

数组指针和指针数组

Gn!

"数组指针" 和 "指针数组" 是两种完全不同的概念,是面试中比较常考的话题,但:

  1. 数组指针是一个实际开发中比较罕见的语法,大家了解即可。

  2. 指针数组则比较重要,也比较常用。

下面将分别解释它们的定义、用途和区别:

数组指针

Gn!

数组指针(Pointer to an Array)

定义:

数组指针是一个指针,它是一个指向整个数组的指针。

声明:

例如,要声明一个指向包含5个整数元素的数组的指针,你可以这样做:

数组指针的声明语法

数组指针声明的语法中,非常关键的就是"(*指针名)",一定要加上小括号,你知道为什么吗?

实际上是因取下标运算符([])的优先级高于解引用运算符(*),不加小括号整体就是一个数组即指针数组,而不是数组指针。

用途:

数组指针通常在函数参数中用来指向和传递二维数组。但一来二维数组比较少用,二来就算要用二维数组传参,直接按下面的写法就可以了:

数组指针的常见用途

数组指针的语法仅作为了解就可以了。

指针数组(重点)

Gn!

指针数组(Array of Pointers)

定义:

指针数组是一个数组,它是一个元素都是指针变量的数组。

声明:

例如,要声明一个包含5个整数指针元素的数组,你可以这样做:

指针数组的声明语法

这个声明和上面"数组指针"的声明有一个非常明显的差别:

"*指针名"没有带上小括号

这样区别的原因主要是因为:"[]"的运算优先级比"*"高

如果带上括号变成"(*指针名)[5]",则表示整体就是一个指针类型,而且是指向数组的指针类型,于是整体就叫数组指针。

反之如果不带括号变成"*指针名[5]",则表示整体就是一个数组类型,而数组当中的元素是指针类型,于是整体叫指针数组。

用途:

指针数组最常见的用途就是存储字符串数组。除此之外,它们还在多种数据结构中发挥重要作用,例如函数指针数组、动态数据结构等。

常量指针和指针常量

Gn!

常量指针和指针常量大概是C语言有关指针的所有概念中,最容易被混淆的,所以也常出现在面试问题中用于"恶心"人。之所以这么说,是因为它们实际上完全不同,只不过是由于中文翻译问题导致的"人为混淆"。

首先,常量指针和指针常量都是在说指针类型,下面来讲一下它们的区别:

常量指针

Gn!

常量指针 (Constant Pointer)

含义:指针本身是一个常量,指针无法指向一个新的对象。但可以利用该指针修改指向的对象!

定义形式:

常量指针的定义语法形式

建议:

我不建议在任何场景下,直接提"常量指针"四个字,因为这四个字实在无法描述语法本来的含义。当你看到"类型* const 指针名"这样的语法时:

  1. 你可以直白的用中文讲"const修饰指针,指针本身是一个常量,无法修改指针的指向"。

  2. 你也可以直接提它的英语单词"Constant Pointer"(不可变的指针)。

指针常量

Gn!

如果说常量指针的翻译还有一点点靠谱,那么指针常量这个翻译更像是牵强附会和胡扯。

指针常量 (Pointer to Constant)

含义:无法利用指针修改指向的内容,但指针本身可以指向不同的对象。

定义形式:

指针常量的定义语法形式

注意事项:

不要将"指针常量"语法理解成指向一个常量的指针,实际上该语法只意味着"无法利用该指针修改指向的内容",指针指向的实际是一个变量也完全可以。

建议:

我不建议在任何场景下,直接提"指针常量"四个字,因为这四个字实在无法描述语法本来的含义。当你看到"const 类型* 指针名"这样的语法时:

  1. 你可以直接用中文讲"这是一个无法修改指向内容的指针"

  2. 也可以记住它的英文单词"Pointer to Constant"

指向不可变内容的常量指针

Gn!

把上面两个语法综合一下,就得到一个"指向不可变内容的常量指针"(Constant Pointer to Constant)

这样的指针既不能修改指向,也无法修改指向的内容:

指向不可变内容的常量指针-演示代码

数组作为参数传递

Gn!

在C语言中,允许形参的类型是一个数组,也就是说在调用函数时可以传入一个数组。但C语言是值传递的,难道要将整个数组复制一份传递给函数吗?这当然不太现实,实际上:

当数组名作为参数传递时,它会退化成指向数组首元素的指针。比如:

  1. 将一个int类型数组作为参数传递给函数时,传递给函数的,是该数组首元素的int* 类型指针

  2. 将一个double类型数组作为参数传递给函数时,传递给函数的,是该数组首元素的double* 类型指针

因此在C语言中,当函数需要数组作为参数时,我们有两种常见的格式:

  1. 格式一,直接使用数组类型作为形参类型,(一维数组)不需要指定长度。比如:

    数组作为形参-格式一

    此代码就表示func函数调用时,需要传入一个int类型数组。

    但传入数组本质上就是传入首元素的指针,所以此函数也允许传入一个int* 类型的指针变量。

  2. 格式二,使用数组元素对应的指针类型作为形参类型。例如:

    数组作为形参-格式二

    这一段代码看起来表示func函数需要传入一个int*类型的指针,也就是一个int类型变量的地址。但由于数组名本身会退化为指向首元素的指针,所以我们依然可以传入一个int类型数组。

    也就是说,下面这段代码完全是可以正常运行的:

    数组作为形参格式二-函数调用演示

总结:

两种方式的共同点:

  1. 在函数调用时,两种格式都允许传入数组或指针。

  2. 在数组传入函数后,函数内部得到的都是一个指针。

区别在于:

  1. 第一种方式在语义上看起来更直观,因为它明确表明“我需要一个数组作为输入”。

  2. 第二种方式可能更符合C程序员操作指针的习惯,指明了数组参数传递的本质。

在以后的学习工作中,具体用什么可以视实际情况而定,而其实它们也没什么太大的区别。

作为参数传递时,数组名会退化为首元素的指针,为什么叫退化呢?

之所叫退化,而不是进化,主要原因是:

  1. 信息丢失数组在传递时失去了其数组长度的信息。例如,如果你有一个大小为10的数组,当它被传递到函数时,函数不再知道它的长度。

  2. 语义变化:在数组没有传入函数时,它代表整个数组。但在函数内部,该名称只是一个指针,只代表数组的第一个元素。这是一个从"更丰富"到"更简单"的转变,所以用“退化”这个词来描述是恰当的。

总之,我们讲数组“退化”为指针,主要是指在传递数组时丢失了其原有属性,而只保留了最基本的,即首元素的地址。

数组长度无法通过数组名传递

Gn!

将数组作为参数传递给函数,实际传递的是指向数组首元素的指针。那么我们需要思考两个问题:

  1. 函数知道此数组的长度吗?

  2. 在函数体当中,可以利用sizeof运算符来计算数组的长度吗?

这两个问题的答案显然都是否定的

  1. 当你将数组作为参数传递给函数时,函数只得到了首元素的指针,函数没有得到任何关于数组的其他信息,包括长度。(退化)

  2. 数组作为参数传递时会退化为指针,所以sizeof运算符实际上会返回指针变量的大小,而不是数组的大小。在许多平台上,例如32位系统,指针大小为4字节;在64位系统上,指针大小为8字节。

若想要在函数当中操作数组的元素,数组长度显然是必然需要知道的数据。那怎么办呢?

所以在定义需要数组作为参数的函数时,普遍还需要一个参数用于表示数组长度。例如:

数组作参数函数需要传入数组长度-代码示例

明确这一点后,下面我们将探讨如何使用指针在函数体内部操作数组元素——其实还是使用数组的取索引运算。

在函数体中操作数组元素

Gn!

数组作为参数传递给函数,函数实际上得到的只是一个首元素指针的副本。但即便如此,也完全不影响——我们仍然可以在函数当中直接使用数组索引语法来访问操作数组元素。

比如一个用于求和的函数,代码如下:

求一个int数组的元素之和-参考代码

运行程序,结果是:

sum arr = 6

另一个值得注意的细节是:

数组作为参数传递给函数,在函数体内部是可以修改数组元素的。原因很简单:虽然值传递得到的只是指针的拷贝,但拷贝指针仍然指向原本的数组。

比如下列代码:

将一个int数组元素归0-参考代码

当传入的len是数组的长度时,clear函数可以将一个int数组的所有元素全部置为0。

但实际上只要不传入大于数组长度的数(导致数组越界,引发未定义行为),这个长度可以任意传入,这样就可以灵活实现不同的功能。

这也体现了C语言当中,数组作为参数传递时的灵活性。

C语言为什么要将数组传递时退化为指针?

数组退化为指针传递在C语言中具有以下好处:

  1. 传递效率:传递一个指针到函数中比复制整个数组内容到函数要快得多。这样无论数组有多大,传递给函数的总是一个固定大小的指针。

  2. 空间效率:如果你每次都复制整个数组传递到函数,这会消耗大量的内存。但传递指针只需要传递一个很小的变量。

  3. 修改原始数据:当数组退化为指针并被传递到函数时,函数可以通过这个指针来修改原始数据,而不仅仅是其副本。这为利用函数操作数组提供了灵活性。

  4. 灵活性:数组的类型是包含数组长度的,比如arr[1]arr[3]类型就不同。但退化为指针后,函数可以接受不同大小的数组传入。(int arr[5]包括长度在内都是数组的类型,而退化为指针无需考虑数组大小)

当然,这样的设计也带有一些局限性,如不能直接在函数内部获取数组的大小,比如指针操作带来了一定的风险。但总体来说,它为C语言提供了很大的便利,利大于弊。

课堂练习

Gn!

小练习:给定一个int数组,请你找出此数组的最大值和最小值。

最简单的、最容易想要的方式是:

  1. 定义两个函数,一个求最大值返回,一个求最小值返回。

  2. 在每个函数内部,循环遍历数组元素

  3. 假设第一个元素是最值,然后使用if判断,求出最终的最值。

参考代码如下:

遍历求数组元素最值-代码示例

除了这种比较基础的解法外,由于C语言允许将指针作为参数传递。

所以,我们还可以在函数内部,将计算出来的最大值和最小值赋值给通过指针传递的变量

参考代码如下:

利用指针不使用返回值进行数据操作-代码示例

两种解法都是可行的,主要差异在于:

  1. 由于只需要遍历一次数组就可以求出最值,方式二可能效率会高一点,尤其是数组比较大时。

  2. 从单一原则,复用性,可读性的角度考虑,方式一会好一些。

当然最终选择用什么方式,可以具体问题具体分析。

Gn!

扩展:传入参数和传入传出参数

基于C语言的值传递机制,以及指针的相关概念,我们可以把形参分为两种:

  1. 传入参数:传入参数是只供函数读取数据的参数,函数体内部不会修改参数的原始数据。

  2. 传入传出参数:传入传出参数既可以作为输入也可以作为输出。函数既可以读取这些参数的初始值,也可以修改它们,修改的结果在函数结束后反映在原始数据上。

尤其要注意指针类型的形参类型:

  1. 如果该指针类型使用"const"修饰,那么表示该指针传入后不需要修改指针指向的内容,这就是一个传入参数。

  2. 如果该指针类型没有使用"const"修饰,那么表示该指针传入后可以修改指针指向的内容,这就是一个传入传出参数。

实际上标准C语言和规定的C代码,对于指针类型形参的"const"使用都是非常重视的:

  1. 如果指针类型形参没有使用const修饰,那么至少表明函数内部的某些逻辑会修改指针指向的内容(具有修改的可能性)。

  2. 当某个函数确定不会通过传入的指针,修改指向的内容时,规范的C函数应当使用const修饰这个形参(如果连修改的可能性都没有,应当用const修饰)。

指针算术运算和数组

Gn!

在C语言中,当我们有一个指针变量时,可以对其执行一些基本的算术运算来修改指针的值,从而使其指向不同的内存地址。

指针算术运算的存在,让我们能够灵活地在内存地址间移动,这使得我们可以通过指针,高效的处理各种数据结构,如数组、链表等。

指针常见的,能够进行的算术运算包括:

  1. 指针加整数。

  2. 指针减整数。

  3. 指针自增和自减(本质还是加减整数)

  4. 两个指针相减。

  5. 指针比较。

指针算术运算并不仅仅是为数组设计的,但在实际应用中,指针算术运算最经常与数组结合使用。所以在这里,我们以数组为载体,来讲一下指针的算术运算。

以下所有讲解都基于以下声明:

指针算术运算-声明语句

指针可以指向数组的元素,比如:

指针赋值语句-演示

这样通过指针p就可以实现对数组元素的操作。

指针加整数

Gn!

当一个指针加上一个整数值时,表示将该指针存储的地址,增加该整数与指针指向的数据类型大小的乘积的字节数。

若存在指针p:

现在指针p指向的是数组下标为1的元素,那么下列代码:

表示将p指针中存储的,下标为1的元素的地址,加上 3 * 4 = 12个字节,也就相当于arr[1] --> arr[4]

指针加上整数-示意图

如果指针p指向数组元素 a[i],那么"p += j" ,意味着指针p将指向 a[i + j]。

实际上,当你学习这个知识点后,我们就可以揭开取索引运算符[]的神秘面纱:

索引运算符"[]"的原理

当你理解指针算术运算中加上整数的原理后,那么运算符"[]"的原理,就也可以一并解释了。

当我们使用索引运算符"[]"来访问数组的某个元素时,此时的数组名被视为数组首元素的指针,且该指针无法改变指向。(上面讲过)

当编译器编译到取索引运算符的代码时,编译器会将"[]"运算符还原成指针算术运算和解引用运算,也就是说:

索引运算符"[]"实际就是指针算术运算和解引用的语法糖,它的存在为程序员隐藏了底层的指针操作,让程序员能够更方便快捷地操作数组。

比如下列代码:

索引运算符和指针加法-示例代码

在上面数组传参的讲解中,我们直接在函数体当中用"退化为指针的数组名"进行了索引运算,如:

利用指针加法操作传参数组-示例代码

一个没用的小tips:

算术运算的加法天然是符合"交换律"的,指针加法也不例外。所以索引运算符其实可以这么写:

这是因为:

arr[5] 等价于 *(arr + 5),而*(arr + 5)等价于*(5 + arr)

所以arr[5] 就等价于 5[arr]

当然你最不要这么写,不然会显得你很聪明~

以上。

小练习

Gn!

假设有一个二维数组,如下:

指针加法-小练习

也就是这样的一个矩阵:

指针加法练习-矩阵图

对于下列表达式:

表达式的结果是什么类型?

表达式的结果是什么?

Rd!

第二题:

对于下列代码:

请问下列表达式的结果与a[1][1]相同的是:

  1. p[1] + 1

  2. *a[1] + 1

  3. *(p + 1) + 1

  4. *(*(p + 1) + 1)

指针减整数

Gn!

当一个指针减去一个整数值时,表示将该指针存储的地址,减去该整数与指针指向的数据类型大小的乘积的字节数。

若存在指针p:

现在,指针p指向的是数组下标为4的元素,那么下列代码:

表示将p指针中存储的,下标为4的元素的地址,减去 3 * 4 = 12个字节,也就相当于arr[4] --> arr[1]

指针减去整数-示意图

如果指针p指向数组元素 a[i],那么"p -= j" ,意味着指针p将指向 a[i-j]。

指针自增和自减

Gn!

使用 ++ 或 -- 实际就是指针自增整数1或自减整数1。

如果是在数组操作中,自增可以使指针指向下一个元素,自减则可以使指针指向前一个元素。

如下列代码:

指针自增和自减-参考代码

注意:由于自增自减运算符存在前后缀形式、主副作用,比较复杂,如无特殊需求尽量还是单独成语句,不要弄得太复杂。

两个指针相减

Gn!

两个指针相减,会返回的是两个指针之间的元素数差,而不是实际的字节差。

下面有两个指针:

两个指针相减-代码示例

对于数组而言,如果两个指针指向数组中的两个元素。p 指向 arr[i],q 指向 arr[j],那么 p - q 就等于 i - j。

注意:

两个指针相减的前提是它们必须指向同一个数组(或连续内存块),否则会产生未定义行为。

指针比较

Gn!

在C语言中,指针之间可以进行各种比较运算。包括两类:

  1. 判等运算( ==和!= )

  2. 关系运算(<、 <=、 >、 >=)

这些比较,都是基于指针所存储的地址来实现的。

判等运算( ==和!= )

Gn!

判等运算很简单,就是判断两个指针是否指向同一个地址。

比如:

判等运算-代码示例1

判等运算还常用于指针判NULL,比如:

判等运算-代码示例2

关系运算(<、 <=、 >、 >=)

Gn!

关系运算是比较什么呢?

其实比较的就是指针中存储的地址值的大小。如果p1指向的地址,小于p2指向的地址,则p1 < p2为真。

一般而言,这个比较是没太大意义的。但是在使用数组的上下文中,是比较有用的:

因为数组元素在连续的内存地址中存储。所以,如果两个指针都指向同一个数组的不同元素,可以使用关系运算来确定哪个指针指向的元素在数组中的位置更前。

比如:

关系运算-代码示例

指针算术运算的限制和注意事项

Gn!

指针算术在C语言中是一个强大且有用的特性,但使用时也需要非常小心,因为不恰当的操作可能导致未定义的行为、程序崩溃或其他不可预测的结果。

以下是指针算术运算的一些限制和注意事项:

  1. 两个指针之间可以做减法,但不能做加法,指针也不能做乘除运算。

  2. 进行算术运算前,要确保指针不是野指针。(要将指针进行正确的初始化)

  3. 如果不确信指针是否为NULL,最好先判NULL。

  4. 只有指向同一个数组或连续的内存块的两个指针才可以被相减。

  5. 不同类型的指针不要做算术运算。

  6. 对数组的指针进行算术操作时,应确保操作后的指针仍然指向数组的元素,一旦超出数组的内存界限,即产生野指针。

课堂练习

Gn!

为了巩固指针算术运算,我们来完成下面一道编程练习题。

  1. 题目:反转数组/逆置数组(reverse)

  2. 描述:给定一个整数数组,定义一个函数,使用指针算术来反转数组中的元素。

  3. 示例:

    1. 输入:[1, 2, 3, 4, 5]

    2. 输出:[5, 4, 3, 2, 1]

  4. 要求:

    1. 不要使用额外的数组或数据结构。

    2. 只使用指针算术完成本题。

  5. 参考思路:

    所谓逆置数组,无非就是数组元素的交换。即第一个元素与最后一个元素交换,第二个元素与倒数第二个元素交换,以此类推。

    于是我们可以定义两个两个指针,开始时,一个指向数组的首元素,一个指向数组的尾元素。然后,使用这两个指针逐个交换元素,直到两个指针相遇或交叉。

参考代码:

数组元素的逆置-参考代码

当然这个题目完全可以不用指针完成,参考代码如下:

数组元素逆置(传统方式)-参考代码

两种方式基本没太大区别:

  1. 用指针算术运算来完成,是一种更具有C语言风格的方式。展现了C语言对于内存操作的底层能力。

  2. 用索引运算符完成则是一种数组操作的通用风格,实际上用Java操作数组逆置,代码几乎与此一致。

但不管怎么样,作为C语言程序员,理解并掌握这两种方法都是非常有益的。

利用指针来处理数组

Gn!

在以往的代码中,我们普遍还是将数组名结合索引运算符"[]"一起来操作数组元素。那么现在,我们可以直接利用指针来处理数组,这样写出来的代码会更加具有C语言风格。

传递数组片段

Gn!

既然将数组传递给函数,函数只不过是得到了一个首元素的指针。那么能不能传其余元素的指针呢?

当然是可以的,配合传递长度,我们可以实现向函数传递一个"数组片段"。

比如下列代码:

传递数组片段-示例代码

请注意,在这个方法中,你只是传递了一个指针和一个长度,而不是真正传递了一个数组片段。

这意味着函数内对数组的任何修改都将影响到原始数组。如果你想创建一个真正的子数组或片段,你需要新建一个数组。

利用指针遍历数组

Gn!

给定一个数组,我们可以直接利用指针来遍历数组中的元素,参考下列代码:

利用指针遍历数组-演示代码1

这个代码中需要注意的是:

arr[len]这个元素是不存在的,对它取地址后如果用指针变量接收,你将得到一个野指针。

但好在这里只是取地址运算,没有进行解引用,并且实际上也无法访问到这块内存区域,所以代码整体是合法的。

当然,我们已经知道数组名可以直接作为首元素指针使用,那么结合指针的算术运算,这个遍历还可以更简化:

利用指针遍历数组-演示代码2

这两个例子本质都是一样的:使用指针 i 从数组开始的位置遍历到结束的位置,和使用索引运算符没什么差别。

稍微要注意的是,在循环内部,由于变量i已经变成了一个指针类型,所以要使用解引用运算符"*"

Rd

注意:

利用指针遍历数组为程序员提供了一种直接与内存交互的方式,这在某些较老的编译器平台上也许能够提升一点性能。但对于现代编译平台而言,由于现代处理器性能很强,再加上现代编译器的都会对代码进行优化,这一点点性能的提升就有点微不足道了。

所以在大多数情况下,不推荐大家使用这种方式遍历数组——提升的性能微不足道,可读性还不如传统遍历下标的方式强,容易出错。

比如下列代码,在使用指针时就犯了逻辑上的错误,你能找到吗?

利用指针遍历数组-错误代码

解引用运算符和自增自减结合

Gn!

解引用运算符和自增自减组合是C语言中关于指针操作的一个重要话题。它们常常在一起使用,尤其是在操作数组或字符串时。

当你把解引用运算符和自增自减相结合使用时,一个非常重要的问题就是运算符的优先级以及结合性。

你需要知道以下前置的知识点:

  1. 自增自减的运算优先级是高于解引用运算的。

  2. 自增运算符和自减运算符分为前缀形式 (++p) 和后缀形式 (p++)两种,它们的副作用都是使得变量自增自减1,但主要作用不同:

    1. 前缀形式的自增和自减的主要作用是将自增自减后的结果返回。

    2. 后缀形式的自增和自减的主要作用是直接返回变量的取值。

  3. 解引用运算符的结合性是从右到左的。

  4. 自增自减前缀形式"++p"的结合性是从右到左的。

  5. 自增自减后缀形式"p++"的结合性是从左到右的。

我们先来分析一个简单的、也是最常见的组合——"*p++"。

解引用运算符和自增自减组合-演示代码1

这段代码:

  1. p指针开始时,指向数组的首元素。

  2. 表达式"*p++"中,后缀自增运算符优先级更高,"p++"先运算,它被包含在表达式中是主要作用发挥作用,也就是返回p的值。副作用会将指针p加1,也就是会将它移动到数组下一个元素的位置。

  3. 所以*p++表达式的主要作用是返回*p的值。

  4. 最后执行赋值运算符"=",于是*p的值通过"="赋值给变量num,所以变量num的值就是数组第一个元素的值。

  5. 上述代码会在控制台输出一个"1"。

在所有解引用运算符和自增自减结合的情况当中,"*p++"是最常见,最常用的。如果你觉得这个语法实在晦涩,那么你暂时就只记住这一个就够了。

常见组合举例

Gn!

除此之外,还有一些其它常见的场景,我们用一张表格来描述。

解引用运算符和自增自减的一些常见组合
表达式含义分析
*p++ 或 *(p++)返回p当前指向位置的值,然后p移动到下一个位置上面已经分析过了
*++p 或 *(++p)p先移动到下一个位置,然后返回新位置的值前缀自增的优先级更高,于是先指针p自增,然后解引用,返回的是指针p自增后位置的值
++*p 或 ++(*p)*p先自增,然后返回自增后的值,指针位置不变前缀自增的优先级更高并且它的结合性从右到左,于是先自增整个*p的值,然后返回自增后的值。指针位置不变
(*p)++返回*p,然后将*p的值自增先计算*p并将值返回,随后*p自增

以下代码示例参考:

解引用运算符和自增自减组合-演示代码2

如何看待这种语法?

Gn!

对于C程序员,特别是初学者或那些没有经常使用指针操作的程序员来说,这种语法可能会显得有些晦涩和复杂,可能会让你觉得手足无措。

但实际上,理解这个语法能够极大的帮助你理解C语言的运算符以及指针,所以它还是很有学习的必要的。

为什么存在这种“晦涩”的语法?

  1. 效率:直接操作指针,尤其是在遍历数组和字符串时,往往比使用索引更加高效。因为这避免了多余的索引计算。

  2. 简洁性:对于熟悉此语法的程序员,这种写法使代码更加简洁,而简洁始终是C语言最重要的设计哲学之一。

但是,这并不意味着你必须使用这样的语法。其是在现代编程中,为了代码的清晰和可读性,很多公司的规范都建议不要使用容易引起混淆的复杂指针操作。(牺牲效率和简洁,提高可读性)

那么,何时考虑使用这种语法呢?总得来说,建议以下场景:

  1. 系统编程,底层编程。

  2. 性能关键的代码。

  3. 字符串操作和数组遍历。

尤其是字符串操作和数组遍历操作,强烈建议大家记住这两个操作的代码,以及"*p++"这种形式。

利用自增自减结合解引用运算符来遍历数组,代码示例:

解引用运算符和自增自减组合-遍历数组

这个代码要比我们以往写的数组遍历要简洁一些,这就是很大的优势了。

当然它在可读性上比较差,也容易出错,具体选择什么可以根据自身情况而定。字符串相关的操作我们将在下一章节讨论。

指针作为返回值

Gn!

在 C 语言中,函数的返回值不允许返回一个数组,但我们可以返回一个指针作为其返回值。

数组名在大多数情况下被视为首元素指针,于是我们可以利用返回数组某元素的指针,间接实现将数组当作返回值。

将指针作为函数返回值是一个"高危操作",请务必注意:

永远不要返回指向当前栈帧区域的指针。

栈区数据会随着函数调用结束销毁,于是被返回的指针就指向了一片随机的未知区域。像这样:

曾经指向一片有效的内存区域,但后来这片区域被释放或销毁了,且仍未改变指向的指针,我们称之为"悬空指针(dangling point)"。

悬空指针最常见的场景,就是通过函数返回当前栈区内存区域的指针。

比如下列函数定义就是不允许的:

不要返回当前栈区的指针-代码示例

可以返回指向静态存储期限变量的指针。因为它们在整个程序的运行期间都有效。

比如下列代码:

返回静态存储期限变量的指针-代码示例

可以返回参数传递进来的指针。

返回参数传递进来的指针-代码示例

总的来说,虽然返回指针在 C 语言中是一个有用的工具,但也需要小心处理,以避免常见的错误和潜在的问题。

在后续的课程中,我们还会用到指针作为返回值。

野指针和悬空指针

总结一下野指针、空指针以及悬空指针:

  1. 空指针(NULL Pointer):空指针是指针变量的一个特殊取值,它表示指针未指向任何内存区域。

    1. 空指针为指针类型提供了一种安全的不可用标记,任何对空指针的操作都会导致程序崩溃,而不是未定义行为。

    2. 当声明一个指针变量时,如果暂时没有确切的地址要指向,就可以初始化为一个空指针。这样就可以避免因未初始化指针,而出现野指针。

    3. 建议在不确定指针是否为空指针的情况下,解引用指针先进行判NULL处理。

  2. 野指针(Wild Pointer):在C语言中,任何指向随机未知非法的内存区域的指针都叫野指针。

    1. 一个局部变量指针变量,若没有手动初始化,那么它就会指向一片随机未知的内存区域,这就是一个典型的野指针。

    2. 任何对野指针的操作都会导致未定义行为,可能导致程序崩溃,也可能导致程序计算出奇怪的、莫名其妙的结果。这是非常坑的。

    3. 避免野指针是C程序员指针操作永远需要注意的,这既需要程序员的细致耐心,也考验程序员的经验。

  3. 悬空指针(Dangling Pointer):在C语言中,悬空指针专指那些"曾经指向有效内存区域,但由于内存被释放销毁而没有改变指向,从而指向非法内存区域的指针"。

    1. 悬空指针是一种特殊的野指针,悬空指针一定是野指针。但野指针不仅仅包括悬空指针。

    2. 最常见的悬空指针就是返回当前栈区内存区域的指针。

    3. 使用悬空指针操作内存,同样会引发未定义行为,要像规避野指针一样规避悬空指针。

在最后,我们提几个规避野指针的建议:

  1. 永远不要返回指向当前栈帧区域的指针。

  2. 在一片内存区域被free释放后,养成好习惯,立刻将它设置为空指针。

  3. 在声明指针时,应该立即初始化它们。如果暂时没有合适的地址可以指向,应该初始化为空指针。

  4. 细致耐心的检查自己手动申请管理的内存区域,随时追踪该片内存区域的状态,在适当的时候进行free或设置空指针等操作。

以上。

THE END