C语言类型的本质(二):数组

C语言中的数组是一种最简单的复合类型,同一基本类型紧密按序排列就是一个数组,如下表达式:

code c
int a[5] = { 10, 11, 12, 13, 14 };

编译器会分配int->空间占用 * 5共20个字节的空间给a,然后将10放在前4个字节,剩余的部分同理每4个字节放一个数字,在编译器中会对应这样的结构:

code c
 a = {
     空间占用: int->空间占用 * 5,  // 20
     基本格式: int,
     存储格式:,
     读取格式: 指针->读取格式,
     存储空间指针: 指向分配的20字节空间的首地址,
 }

有心的人会注意结构中的存储格式为,这是因为a是一个不可修改的左值,例如下面的代码是无法通过编译的:

code c
int x[2] = { 1, 2 };
int y[2] = { 3, 4 };
x = y;    // Error: 表达式必须是可以修改的左值。

左值和右值就是操作符左边和右边的值,这里的操作符不是所有的操作符,必须是以修改为目的(即会调用存储格式)的操作符,例如=++--等,而+-*/等仅调用了读取格式,也就没有左右值一说了。编译器没有办法为数组类型设定一个存储格式,因为数组定义的时候可以指定任意长度,不同的长度对应的存储格式不尽相同。

其实看到这里会发现,格式概念和文件的概念太像了,格式的读取器和存储器就像是读写权限,就像上面的变量a因为没有存储器,所以没有写权限,后文还会提到函数格式的执行器,相当于文件的执行权限。

区别于基本类型的读取格式,数组类型的读取格式为基本类型指针的读取格式,即数组变量的值总是等于首元素的地址:

code c
a == &a[0]    // true
*a == a[0]     // true

也许看到这里有人觉得变量a的格式应该是int*,回想文章开头提到过的格式三要素,三要素都相同才是同一种类型,a的空间占用和int*不相同,所以它们并不是一种类型,它的类型应该为int[10]。

取址操作符&,取址符就是取出变量的存储地址,即返回变量->存储空间指针。内存是线性分布的,从0开始每隔一个字节就加1,一直到内存结束。假设变量a是从0开始分配内存的,它一共占据20个字节,那么0 - 19就是为a准备的,此时a的存储空间指针会指向该内存的首地址,也就是0

解地址操作符*,解址符和取值符互为逆操作,两个操作符行为完全相反,取址符是读取变量的地址,解址符是根据地址访问目标变量。使用地址访问变量时,根据变量->存储空间指针变量->空间占用计算出内存块的边界地址,边界地址就是首地址和尾地址,首尾地址唯一标识了一个内存块,首地址即为变量->存储空间指针,尾地址为首地址 + 变量->基本格式->空间占用,如果a的首地址为0,那么尾地址就为0 + 4 = 4,所以*aa[0]等价,*a并不会返回整个数组。

内存偏移符[],可以把它理解成内存偏移操作符,a[2]执行的操作就是调用a->基本格式->读取格式去读取a->存储空间指针 + a->基本格式->空间占用 * 2的值,也就是用int->读取格式读取 0 + 4 * 2 = 8位置的数字,上文初始化时说道0 - 3存储着10,4 - 7存储着11,8 - 11存储着12,所以a[2]最终的结果是12。

回到最初的表达式,&a[0]即取出a[0]的地址,结果是0,自然和a相等了,类比着可以得出这样的结论:

复合类型的首地址与复合类型首元素的地址相等。

这个特性可以帮助我们完成很多神奇的事情,后文会涉及到。

内存偏移和类型转换

前文说道[]是执行内存偏移的操作符,它可以将指针基于当前地址以基本格式的空间占用为单位进行偏移,最常见的就是读取数组中的元素。但是操作数组的时候经常会遇到数组越界的问题,这一点C语言并没有严格规定不允许越界,这也是我觉得C语言灵活的体现,编译器完全信任你的指令,相信你清楚自己在做什么。由于数组变量是不可修改的,所以数组的偏移单位是固定的,例如int a[5]中,对变量a只能以4个字节为单位进行偏移,如果a[0]的地址是0,那么能取到的地址只有4、8、12等,无法取到2、3等地址,因为每次偏移最少加4。但是在C语言中没有什么是不可能的,回顾上文可知偏移单位是由数组的基本类型->空间占用决定的,如果可以改变空间占用的值,就可以改变偏移单位,C语言提供了类型转换机制,可以帮助我们解决这个问题:

code c
int a[4] = { 1, 2, 3, 4 };
char * p = (char *)a;
a[0];  // 地址0
a[1];  // 地址4
p[0];  // 地址0
p[1];  // 地址1
p[4];  // 地址4,和a[1]的地址相同

因为char格式的空间占用为1,所以在对p进行内存偏移时的单位就为1,这样就可以取到任意一个字节的地址。举个简单的应用例子,假设要存储一组二维坐标(x, y),坐标值格式为int:

code c
// 一共有4个坐标(0, 0), (1, 1), (2, 2), (3, 3)
int coordinate[8] = { 0, 0, 1, 1, 2, 2, 3, 3 };

// double格式的空间占用为8字节,对它进行内存偏移单位就会变成8
double * temp = (double *)coordinate;

for (int i = 0; i < 4; i ++) {
    int * p = (int * )(&temp[i]);
    printf("(%d, %d)", p[0], p[1]);
}
// 输出
// (0, 0)(1, 1)(2, 2)(3, 3)

上面的写法没有什么实际意义,可读性也差,可以采用下面的写法:

code c
// 结构体一共占用8个字节,属性x对应[0, 3],属性y对应[4, 7],同数组的分布格式一致
typedef struct {
    int x;
    int y;
}Coordinate;

// 一共有4个坐标(0, 0), (1, 1), (2, 2), (3, 3)
int coordinate[8] = { 0, 0, 1, 1, 2, 2, 3, 3 };

 // Coordinate结构体的空间占用也为8字节
 Coordinate* temp = (Coordinate*)coordinate;
 for (int i = 0; i < 4; i++) {
    // 第一次循环中,temp和coordinate指向的首地址相同,temp->x等价访问coordinate[0],temp->y等价访问coordinate[1]
    printf("(%d, %d)", temp->x, temp->y);
    temp++;
}
// 输出
// (0, 0)(1, 1)(2, 2)(3, 3)

还记得前面说过的例子吗?

code c
int arr[] = { 100, 350 };
printf("arr: %s = %f\n", getFractionStr(arr, result), getFraction(arr));
// getFractionStr(arr, result)这里发生了强制类型转换,arr由int*转换为fraction,内存分布和上面的例子相同。

内存偏移和类型转换远远没有表面那么简单,涉及到结构体部分后文会讲到,此处先卖个关子,C语言的魅力无处不在。

C语言中除了使用[]可以进行内存偏移,+-也可以内存偏移,但是只有操作元素是指针类型时才可以,可以认为指针类型重载了+-运算符。p[4] == p + 4p[-2] == p - 2要慎用减号进行偏移,负方向的偏移很容易发生越界。