C/C++ 指针,数组与退化
C/C++ 中数组与指针在使用上的关系及其复杂性是一个关键问题,由于指针能够嵌套,以及数组能够退化,导致 C/C++ 中指针和数组的使用变得更加难以理解,其中的规则对于初学者来说更是一种负担。
首先说明运算符结合律优先级:int *
> a[]
> *a
,后置递增运算符的优先级高于解引用运算符 。
注意,某些教材中会提到行指针和列指针,我认为这是非常错误的叫法,只有指向数组的指针和指向(数组)元素的指针这两种称呼。
为什么这么说呢,因为你 可以用一个指向数组元素的指针遍历整个数组,只要不越界就可以一直自增,不受列数限制,并且多维数组也更没有行列之分。
一维数组
先声明一个一维数组:int a[2] = {1,2};
,同时可以声明一个指向该数组(首元素)的指针:int(*b) = a;
,此时 *(++b) == 2
。
C 语言中,声明大于一切,变量的类型决定于变量的声明,因为 b 是 int 类型的指针,所以解引用 b 的结果是数字。
在上面 b 的声明中,b 的作用是指向单个数字,但是等号右边是数组而不是数字的地址。 字符指针 b 以这种方式被赋值,实际上是将数组的名字转换为了指向数组中首元素的指针 。这种转化被称作 退化 。
数组名退化后变为一个地址,赋值给指针 b,这就是 int(*b) = a;
的作用。
如果想要声明一个指针, 直接指向数组,不发生退化 ,需要这样写:int(*c)[2] = &a;
。
此时 (*c)
相当于 a 的一个别名,对 c 自增后,c 增加了整个数组的长度,指向这个数组结束地址的下一个地址 。
在 int(*c)[2] = &a;
这个声明中,指出了 c 解引用后数组的维度,这个维度和数组 a 的维度相同。
注意:对数组解引用和加减运算时一定经过退化 :int d = *a;
的结果是 1:数组在解引用之前发生了退化, 退化为指向数组元素的指针 ,再进行最后一级指针的解引用得到元素, 将数组名作为参数传递给函数时也发生退化,但是使用 sizeof 运算符时不发生退化,因为 sizeof 是运算符不是函数 。
二维数组
二维数组更复杂一点:int a2[2][3] = {1,2,3,4,5,6};
根据前文,可以得到指向这个二维数组的指针的声明是这样的:int (*b2)[2][3] = &a2;
同理,(*b2)
也可以认为是 a2
的一个别名,例如 (*b2)[0][0] == 1
。
但是, 多维数组实际上是数组的数组 ,所以还可以使用 *(*b2)[0]
即先解引用 b,得到数组,再解引用数组,这时得到的是数组退化后的一维数组的指针,此时再用下标运算符去访问元素。
那么 *(*(*b2))
也是可行的,首先对 b2 进行解引用,b2 是一个指向二维数组的指针,解引用后得到二维数组 a2
,再对 (*b2)
进行解引用,由于 (*b2)
代表数组,所以解引用数组时发生了退化,即 *(*b2)
是 a2 退化后再解引用,*(*b2)
是一个指向一维数组中元素的指针,对 *(*b2)
再进行解引用得到元素。
实际上还可以这样做:*((*(*b2)) + 1)
,即先解引用 b2 得到数组 a2,然后解引用 a2 得到一维数组中元素的指针,然后再对该指向元素的指针自增得到结果 2。
另一种写法就非常让人迷惑了:*(*((*b2) + 1));
注意 +1
的位置,这句的结果是 4:即先解引用 b2 得到数组 a2,然后对 a2 + 1 时也发生了退化,即 a2 先退化为指向一维数组的指针,再自增,即 ((*b2) + 1)
指向一维数组,此时解引用得到指向一维数组元素的指针,再解引用得到元素 4。
引用(C++)
引用的声明方法和指针是类似的,只不过引用的是对象本身而不是地址,例如 int (&e2)[2][3] = a2;
由于数组退化在实际应用中造成了非常多的误解,所以 C++ 设计了引用来解决数组和指针的一系列问题:引用是一个对象的别名,引用的类型严格等于被引用的对象, 引用没有退化 。
C 语言中退化的特殊使用
在写 C 语言代码,及使用库函数的时候经常会遇到一个问题:如何传递字符串。
对于 C 语言来说,操作字符串的标准库为了简化传递字符串的操作,实际上 会把一个指向字符的指针看为一个指向字符串的指针 ,在 读取这个字符串时依赖 ASCII 的第 1 个字符 ‘\0’ 来判断是否到达结尾。所以你肯定会遇到这种情况:
#include <string.h>
#include <stdio.h>
size_t long_of_str(char *char_str){//计算字符串长度
size_t i = 0;
while (*char_str != '\0') {
char_str++;//注意这点
i++;
}
return i;
}
char char_of_str(char *char_str, size_t offset){//得到字符串中某个字符
size_t i = 0;
while (i < offset && char_str[i] != '\0'){
++i;
}
return char_str[i];//注意这点
}
int main(void){
char str[] = {"Hello world!"};//str 是一个指针而不是数组
char str1[1] = {'a'};
printf("%d\n",long_of_str(str));
printf("%c\n",char_of_str(str,1));
printf("%d\n",long_of_str(str1));//第三个打印
printf("%c\n",char_of_str(str1,2));
}
观察注意的两个地方,第一个函数对 char_str
自增,第二个函数对 char_str
使用下标运算符。
根据函数声明的 char *char_str
知道,char_str
是一个 指向字符的指针 ,但是却可以使用下标运算符!
你是否注意到 long_of_str 是一个危险函数呢?
请注意第三个打印语句,第三个打印语句作用是打印出 str1
的长度,而对于上面的程序,打印结果永远是 13!
而 str1
是只有一个字符的数组,内容是 a
,为什么 long_of_str
打印出了 13 呢,因为
C 语言中字符串的判断依赖于 ‘\0’ 。而不是其他任何东西。
所以即使 str1
这个数组只有一个字符,但是 long_of_str
依然会在内存中去寻找 ‘\0’。此时就会发生一个严重问题:明明传递给 long_of_str
函数的是 str1 ,但是 函数却访问到非 str1 中的内容了 !
C 语言中对于字符串进行处理的库函数都存在此问题,不过由于库函数考虑了内存对齐等技术,在这种情况下结果是不能直接预测的。比如我使用 GCC 9.2.0 在 Windows 10 Build 21359 上执行 strlen(str1)
的结果是 0。
并且 13 不是一个随机的数字,他是 str
的长度 + str1
的长度。
为什么呢?因为 直接声明的变量,实际上都是在一个栈中储存的,这个栈储存数据的方向和实际上数据声明的顺序是反的 ,换句话说,str1
会储存到 str
前面,即内存中实际是 aHello world!
和一个 ‘\0’。
由于 Hello world!
后面有一个 ‘\0’,所以,调用 long_of_str(str1)
实际上算出的是 aHello world!
的长度。
这点可以由第四个打印得到,第四个打印是得到 str1 的第二个字符,而结果为 H,即 Hello 的 H。
越界访问数据的问题并非字符串处理独有,但字符串处理是一个典型。在编写 C 语言程序时需要留心此类问题,才能避免程序出错。