C语言中的数组是一种最简单的复合类型,同一基本类型紧密按序排列就是一个数组,如下表达式:
int a[5] = { 10, 11, 12, 13, 14 };
编译器会分配int->空间占用 * 5
共20个字节的空间给a,然后将10放在前4个字节,剩余的部分同理每4个字节放一个数字,在编译器中会对应这样的结构:
a = { 空间占用: int->空间占用 * 5, // 20 基本格式: int, 存储格式: 无, 读取格式: 指针->读取格式, 存储空间指针: 指向分配的20字节空间的首地址, }
有心的人会注意结构中的存储格式为无,这是因为a是一个不可修改的左值,例如下面的代码是无法通过编译的:
int x[2] = { 1, 2 }; int y[2] = { 3, 4 }; x = y; // Error: 表达式必须是可以修改的左值。
左值和右值就是操作符左边和右边的值,这里的操作符不是所有的操作符,必须是以修改为目的(即会调用存储格式)的操作符,例如=
、++
和--
等,而+
、-
、*
、/
等仅调用了读取格式,也就没有左右值一说了。编译器没有办法为数组类型设定一个存储格式,因为数组定义的时候可以指定任意长度,不同的长度对应的存储格式不尽相同。
其实看到这里会发现,格式概念和文件的概念太像了,格式的读取器和存储器就像是读写权限,就像上面的变量a
因为没有存储器,所以没有写权限,后文还会提到函数格式的执行器,相当于文件的执行权限。
区别于基本类型的读取格式,数组类型的读取格式为基本类型指针的读取格式,即数组变量的值总是等于首元素的地址:
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
,所以*a
和a[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语言提供了类型转换机制,可以帮助我们解决这个问题:
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:
// 一共有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)
上面的写法没有什么实际意义,可读性也差,可以采用下面的写法:
// 结构体一共占用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)
还记得前面说过的例子吗?
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 + 4
,p[-2] == p - 2
要慎用减号进行偏移,负方向的偏移很容易发生越界。