C语言浅学-数组和指针
在结束了关于函数的学习之后并且对于指针有了初步的了解了之后,这一篇就来进一步学习数组和指针的相关知识。
数组
在之前已经对数组有过介绍,数组是由数据类型相同的一系列元素组成,需要使用数组的时候,通过声明数组告诉编译器数组中内含多少元素和元素的类型。编译器可以根据这些创建数组,普通变量可以使用的类型,数组都可以使用。
1 | int main(void) |
上述中的方括号([])表明candy、code、states都是数组,方括号中的数字表明了数组中的元素个数。若是要访问数组的元素,通过数组下标数(索引)表示数组的各元素,下标从0 开始,这些是比较熟悉的。
初始化数组
数组一般是被用来储存程序所需要的数据。例如,一个内含12个整数元素的数组就可以储存12个月的天数。这种情况下,在一开始就初始化比较好。
只储存单个值的变量有时也称为标量变量,这是比较常规的定义,数组的常规定义:
1 | int main(void) |
如上所示,用逗号分隔的值列表(用花括号括起来)来初始化数组,每个值之间使用逗号分隔,在逗号和值之间可以使用空格。根据上面的初始化,会把1赋值给数组的首元素以此类推。若是这个形式的初始化识别为语法错误,可以在数组声明前加上关键字static可以解决。
实例:
1 |
|
这个程序的输出就是输出12个月的每个月的天数,但是这个程序还不够完善,每四年就会把2月份的天数给打错,这是因为程序用初始化列表初始化days[],列表中用逗号分隔各值。这里还使用了符号常量 MONTHS 表示数组大小,也是推荐的做法,只需修改这行代码即可。
Tip:使用 const 声明数组
有些时候需要把数组设置为只读。这样程序只能从数组中检索值,不能把新值写入数组,若是要创建只读数组,使用 const 声明和初始化数组。因此,程序中初始化数组应改成:
1 | const int days[MONTHS] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; |
这样修改之后,程序在运行的过程中就不能修改数组的内容,和普通变量一样使用声明来初始化 const 数据,声明了就不能再进行赋值。下面展示了一个初始化数组失败的实例:
实例:
1 |
|
运行结果:
这个程序输出的时候在每个系统不同时也不同。在使用数组之前必须初始化它,与普通变量类似,在使用数组元素之前一定要先给它们赋予初值,编译器使用的值是内存相应位置上的现有值。
Tip:存储类别警告
数组和其他变量类似,可以把数组创建成不同的存储类别,后续会进行相关介绍。我们只用知道这篇章的数组属于自动存储类别,意思是这些数组是在函数内部声明,且声明时没有使用 static 关键字。
不同的存储类别有不同的属性,所以不能把此篇的内容推广到其他的存储类别,对于一些其他存储类别的变量和数组,若是声明未初始化,编译器会自动把它们的值设置为0。
初始化列表的项数应当与数组的大小一致,若是不一致,剩下的元素会自动初始化未0。这是编译器的自主行为,部分初始化数组,剩余的元素就会被初始化为0。
实例:
1 |
|
运行结果:
这个运行结果也印证了上面的说法。
但若是初始化列表的项数多于数组元素的个数,会将其视为错误,其实我们可以省略方括号中的数字,让编译器自动匹配数组大小和项数。
实例:
1 |
|
运行结果:
这个程序中我们需要注意两点:
- 方括号里省略的数字,编译器会根据初始化列表中的项数来确定大小。
- 在 for 循环中的测试条件,运用 sizeof 运算符给出它的运算对象的大小,整个数组大小除以一个元素的大小即为数组元素的个数。
但也有可能会出现别的情况,若是为了防止初始化值的个数超过数组的大小,让程序找出数组的大小。初始化用了10个值,结果就只打印了10个值,这就是自动计数的弊端:无法察觉初始化列表的项数有误。还有一种初始化数组的方法,但仅限于字符数组。
指定初始化器(C99)
C99增加了一个新特性:指定初始化器。利用该特性可以初始化指定的数组元素,若是只初始化数组的最后一个元素。按照传统语法,需要初始化最后一个元素之前的所有元素:
1 | int arr[6] = {0, 0, 0, 0, 0, 212}; //传统语法 |
对于一般的初始化,初始化一个元素,未初始化的元素都会被设置为0。
实例:
1 |
|
运行结果:
这个程序充分了展示了指定初始化器的使用和结果,以及包括初始化之后剩余元素均为 0 。若是在指定初始化器中未给出数组元素的个数就会向后扩展。
给数组元素赋值
声明数组之后,可以借助数组下标(索引)给数组元素赋值,下面这个程序段给数组的元素赋值:
1 |
|
这段代码中使用循环依次给数组元素赋值,C不允许把数组作为一个单元赋给另一个数组。错误示例:
1 |
|
oxen数组的最后一个元素是 oxen [SIZE - 1],所以数组下标越界。
数组边界
在使用数组时是要防止数组下标超出边界,必须确保下标是有效值,假设以下的声明:
1 | int doofi[20]; |
那么在使用该数组时,必须确保下标在0~19的范围内,编译器不会检查这种错误,下面实例展示了错误使用了下标:
实例:
1 |
|
运行结果:
编译器不会检查数组下标是否使用得当,在C中使用越界下标的结果是未定义的,程序可以运行,但是运行结果比较奇怪,这个编译器似乎把value2储存在数组的前一个位置,把value1储存在数组的后一个位置。但是 arr[-1] 和value2的内存地址并不相同,但有些情况会导致程序异常终止。
C 语言因为信任程序员的原则,并不会检查边界会使得 C 程序运行得更加快速,编译器没必要捕获所有的下标错误,在程序运行之前数组的下标值可能尚未确定。一般来说,C 是相信程序员能写出正确的代码会使得运行速度更快,所以会出现下标越界的问题。
声明数组时最好使用符号常量来表示数组的大小。因为这样的做法可以确保数组大小始终一致。
指定数组的大小
1 |
|
在 C99 标准前,声明数组只能在方括号中使用整型常量或者表达式。所谓整型常量表达式,是由整型常量构成的表达式,sizeof 表达式被视为整型常量,但是需要注意const值,表达式值必须大于0。
1 | int n = 5; |
上面这些注释表明,以前支持C90标准的编译器不会允许最后两种声明方式,但是C99标准允许这样表明,创建了一个新型数组,称为变长数组或简称VLA(但在C11中放弃变为可选)。
但是VLA有一些限制:声明VLA的时候不能初始化 。
多维数组
若是我们希望分析5年内每个月的降水量数据,按照一般来说就创建一个包含60个元素的数组,但若是将每年的数据分开储存会更加好,创建5个数组,每个数组12个元素,但若是创建50年又会非常麻烦,处理这种情况就应该使用数组的数组。主数组有5个元素,每个元素是内含12个元素的数组,下面是一个数组的声明:
1 | float rain[5][12]; //内含5个数组元素的数组,每个数组元素内含12个float元素。 |
其实对于多维数组,可以分开理解,上面的声明说明数组rain有5个元素,那么每一个元素的情况,需要查看声明剩余的部分,第二个数字12说明每一个元素是内含12个元素的数组。每个元素的类型是 float[12] 。
据上述分析,rain的首元素 rain[0] 是一个内含12个float类型值的数组,剩下的元素也是如此,rain[0] 是一个数组,那么它的首元素就是rain[0] [0],以此类推。其实就是数组 rain 有5个元素,每一个元素是一个内含12个 float 类型元素的数组。
下面就有一个实例可以用到二维数组:
实例:
1 |
|
运行结果:
这个程序是数组初始化和计算方案,初始化二维数组有些许复杂,还是先来看较为简单的计算部分。
计算的部分主要使用的是两个嵌套 for 循环:
第一个嵌套 for 循环的内层循环,在year 不变的情况下,遍历 month 计算某年的总降水量,而外层循环,改变 year 的值,重复遍历 month ,计算5年的总降水量。这种嵌套结构常用于处理二维数组,一个循环处理数组的第一个下标,另一个处理数组的第二个下标。
第二个嵌套循环和第一个嵌套循环结构是相同的,但是内层循环遍历 year ,外层循环遍历 month 。重点在于每执行一次外层循环就会完整遍历一次内层循环。
初始化二维数组
初始化二维数组是建立在初始化一维数组的基础上。初始化一维数组如下:
1 | sometype ar1[5] = {val1, val2, val3, val4, val5}; |
但是 rain 是一个内含5个元素的数组,对于 rain 来说,val1 包含12个值,用于初始化内含12个float类型元素的一维数组,所以初始化二维数组如下:
1 | const float rain[YEARS][MONTHS] = |
要注意初始化二维数组时要用逗号分隔每个一维数组。若是想要初始化数组的数据不能修改,可以使用 const 关键字。
其他多维数组
上面关于二维数组的知识同样适用于多维数组,一维数组是一行数据,二维数组是数据表,那么三维数组就是一叠数据表。
一般来说,处理三维数组需要使用三重的嵌套循环,处理 n 维数组要使用 n 重循环,但是一般来说我们最常使用的还是二维数组。
指针和数组
在函数那篇博客中对于指针有过一次简单的介绍,指针提供了一种以符号形式使用地址的方法,因为计算机的硬件指令非常依赖地址,指针在某些程度上把程序员想要传达的指令更接近机器的表达方式,因此使用指针的程序会更加有效率,而且指针可以更加有效地处理数组。
首先要知道的一点就是数组名就是数组首元素的地址。如下:
1 | flizny == &flizny[0]; //数组名是数组元素的地址 |
这两个都表示数组首元素的内存地址。两者都是常量,在运行过程中,不会改变。但是,可以把它们赋值给指针变量,然后可以修改指针变量的值,注意指针加上一个数的时候,会变成下一个指针。(地址按照数据类型增加数值)。
实例:
1 |
|
运行结果:
第二行打印的是两个数组的开始地址,下一行打印的是指针加1之后的地址,以此类推。地址是十六进制的,在我们的系统中,地址是按照字节编址的,short 类型占用2个字节,double 类型占用8个字节。在C中,指针加1指的是增加一个存储单元,对于数组来说就意味着加一之后是下一个元素的地址而不是下一个字节的地址。这也是为什么必须声明指针所指向的对象类型的原因。
指针的值是它所指向对象的地址,地址的表达方式依赖于计算机内部的硬件,大部分的计算机都是按字节编址,也就是内存中的每个字节都是按照顺序编号,若是像 double 类型的变量,一般是其第一个字节的地址代表它的地址。
在指针前面使用 * 运算符可以得到该指针指向的值。指针加1会递增指向类型的大小(以字节为单位)。下面的等式就体现了C的灵活性:
1 | dates + 2 = &dates[2]; |
从等式也可以看出数组和指针的密切关系,可以使用指针标识数组的元素和获得元素的值,从本质上看,同一个对象有两种表示方法,实际上C语言标准在描述数组表示法时也确实借助了指针。理解了数组和指针的关系就可以在程序中使用数组表示法和指针表示法。
实例:
1 |
|
运行结果:
在这个程序里 days 是数组首元素的地址,days + index 是元素 days[index] 的地址,而给其加上 * 号之后就是这个地址指向的元素的值,相当于是days{index}。指针表示法和数组表示法是两种等效的方法。在使用以数组为参数的函数时需要需要注意这点。
函数、数组和指针
假设要编写一个处理数组的函数,该函数返回数组中所有元素之和,待处理的是名为marbles的 int 类型数组,可能的函数调用:
1 | total = sum(marbles); //可能的函数调用 |
从上面可以看出sum()应该从该参数获得的是一个地址,其实就是数组首元素的地址,并且在这个位置上找到一个整数,一般函数定义:
1 | int sum(int *ar) |
但是上述函数定义仍然还有缺陷,就是只能计算10 整型元素,那么另外一个方法就是把数组的大小作为参数传入函数,这个方法的函数定义:
1 | int sum(int *ar, int n) |
在这一段函数定义里,第一个形参是告诉函数该数组的地址和数据类型,第二个形参告诉函数数组中元素的个数。关于函数形参,还有一点需要注意,只有在函数原型和函数定义中,才可以使用 int ar[] 代替 int *ar。
1 | int sum(int ar[], int n); //函数原型 |
其实 int *ar 和 int ar[] 形式都是表示 ar 是一个指向 int 的指针,但是 int ar[] 只能用于声明形式参数,这种形式旨在说明指针 ar 指向的不仅仅是一个int类型的值,还是一个int类型的数组的元素。
声明数组形参尤其需要注意,因为数组名是该数组首元素的地址,那么作为实参的数组名就会要求形参是一个与之相匹配的指针,也只有在这种情况下,C才会把 int *ar 和 int ar[] 解释成一样,也就是 ar 是指向 int 的指针,由于函数原型可以省略参数名,下面四种声明是等价:
1 | int sum(int *ar, int n); |
但是在函数定义中不能省略参数名,下面两种形式的函数定义等价:
1 | int sum(int *ar, int n) |
下面有一个实例就演示了程序打印原始数组的大小和表示该数组的函数形参的大小:
实例:
1 |
|
运行结果:
简单来说,marbles是一个数组,ar是一个指向marbles数组首元素的指针,利用C中数组和指针的特殊关系,可以使用数组表示法表示指针。
指针形参的使用
函数处理数组必须要知道从哪里开始、哪里结束。上面的sum()函数使用一个指针形参标识数组的开始,用一个整数形参表明待处理数组的元素个数。但其实这不是唯一的方法,还有一个方法就是传递两个指针指明数组的开始和结尾,下面的实例进行展示:
实例:
1 |
|
运行结果:
这个程序中指针 start 开始指向 marbles 数组的首元素,所以赋值表达式会把首元素加给 total 。然后表达式 start++ 会将其指向下一个元素,在这两个程序中关于结束循环是不同的,在程序1中是用形参 n 来结束for循环,到了程序2中就是使用while 循环对 start 和 end 进行大小比较来结束循环。
在这个程序中还有一个值得注意的是有一个“越界”指针就是其中的 marbles + SIZE其实是指向最后一个元素的下一个位置,若是我们希望 end 指向数组的最后一个元素,则是要把 marbles + SIZE 改为 marbles +SIZE -1,但是这样的写法既不简洁也不好记,不过虽然 marbles[SIZE] 在C中是有效的,但是对该位置的值未作保证,所以程序不能访问这个位置。
对于循环体内部,还可以进行压缩:
1 | total += *start++; |
该式子中的一元运算符 * 和 ++ 的优先级是一样的,按照结合律的话是从右往左,先解引用然后递增指针,而且递增符号使用的是后缀而不是前缀,或者是别的情况也可能出现,下面是一个优先级的实例:
实例:
1 |
|
运行结果:
可以看到只有(*p3)++ 才改变了元素的值,其余的两个操作都是把指针指向了数组的下一个元素。
指针表示法和数组表示法
从上面的分析中可以看到,处理数组的函数是以指针作为形参,但是在编写具体的函数内容的时候,可以选择使用其中任意一个方法,无论对于形参是什么,在C中,ar[i] 和 *(ar + 1)这两个表达式都是等价的,不管ar是数组名还是指针变量,但是只有当 ar 是指针变量的时候才能使用 ar ++ 的表达式。
指针操作
C提供了一些基本的指针操作,下面的实例展示了8种不同的指针操作:
实例:
1 |
|
运行结果:
下面就来说一下指针变量的基本操作:
1.赋值:可以把地址赋给指针,可以使用数组名、带地址运算符的变量名、另一个指针进行赋值,上面这个例子中,urn数组的首地址赋给了ptr1 ,变量ptr2 获得了第三个元素的地址,但是要注意地址应该和指针类型兼容,不能将double类型的地址赋给int类型的指针。
2.解引用:* 运算符给出指针指向地址上存储的值,所以*ptr1 的值为100。
3.取址:和其他变量一样,指针变量也有自己的地址和值,在实例中,&ptr1是ptr1的地址,而ptr1是指向urn[0] 的指针。
4.指针和整数相加:可以使用 + 运算符把指针和整数相加,或整数与指针相加,但不管是那个情况,整数都会和指针所指类型大小(字节数)相乘,将结果和初始地址相加。相加若是超过了初始指针所指的数组范围,那么结果是未定义的,若正好是最后一个元素之后的第一个位置,C会保证其有效性。
5.递增指针:递增指向数组元素的指针可以让该指针移动至数组的下一个元素,因此ptr1++ 相当于把ptr1的值加上4,使得其指向urn[1],但是ptr1本身的地址并不会改变。
6.指针减去一个整数:可以使用减号运算符从一个指针中减去一个整数,而且指针必须是第一个运算对象,整数是第二个运算对象,这个运算会使用初始地址减去整数和指向的数据类型的乘积。若是 ptr3 指向的 urn[4],那么 ptr3 - 2 和&urn[2] 就是一样的。
7.递减指针:对应上面的递增指针当然还有递减指针,在上面这个实例中,递减 ptr3 使其指向数组的第二个元素,前缀还是后缀的递增和递减运算符都可以使用,要知道的是在重置 ptr1 和 ptr2 前,这两个都是指向相同的元素 urn[1] 。
8.指针求差:可以计算两个指针的差值,一般来说求差的指针分别指向同一个数组的不同元素,通过计算求出两个元素之间的距离,差值的单位和数组类型的单位相同。
9.比较:使用关系运算符可以比较两个指针的值,前提是要指向相同类型的对象。
值得注意的是,这里的减法有两种:第一可以用一个指针减去另外一个指针得到一个整数;第二用一个指针减去一个整数得到另一个指针。
在递增和递减指针时需要注意一些问题:编译器是不会检查指针是否仍然指向数组元素,C只能保证指向数组任意元素的指针和指向数组后面的第一个位置的指针是有效的,一旦递增或者递减超出了这个范围就是未定义的。此外,可以解引用指向数组任意元素的指针,但即使指针指向的数组后面一个位置是有效的,也不能解引用。
实例:
1 | int *pt; //未初始化的指针 |
这个例子告诉我们一定不能解引用未初始化的指针,第二行的意思是把 5 储存在 pt 所指向的位置,但是 pt 没有被初始化,其值是一个随机值,所以 5 就不知道被存在那,可能会导致某些数据被擦除。
Tip:切记创建一个指针时,系统只会分配储存指针本身的内存,并没有分配储存数据的内存。
在我们使用指针之前必须先用已经分配的地址初始化它,简单来说就像 int x ;一样,如果没给 x 赋初始值,想用printf()函数输出 x 的值也是做不到的,上面的 pt 只是指针变量的名字,它也有自己的地址,但是 pt 没有初始值,而 *pt 就像是 printf()一样,随机值的 pt 怎么实现解引用的功能呢。还有另外一种情况就是可以使用后面会介绍到的 malloc 函数先分配内存。
基于有效的操作,程序员创建了指针数组,函数指针,指向指针的指针数组,指向函数的指针数组等。指针主要有两个用法,首先第一个用法是在函数之间传递信息,也就是说希望在被调函数改变主函数中的变量,必须使用指针;第二个用法就是用在处理数组的函数里。
保护数组中的数据
我们在编写一个处理基本类型(例如 int 类型)的函数时,一般来说会直接传递其值,只有程序需要在函数中改变这个值的时候才需要传递指针;但对于数组来说则是必须要传递指针,因为这样做效率会更高,若是一个函数按照值来传递数组,就需要有足够的空间来储存副本,但若是传递地址就大大提高了效率。
有些函数是需要使用地址的,但是有些函数使用地址的话会造成很多的麻烦,至于是否需要使用指针需要程序员自己来判断。
对形参使用const
在C语言的早期,这种情况是需要我们自己来提高警惕的,但是ANSI C 提供给我们一种预防手段,若是函数的意图不是修改数组的数据内容,可以在声明函数原型和函数定义的时候使用 const 关键字:
1 | int sum(const int ar[], int n); //函数原型 |
若是使用 const 关键字,那么即使在代码出现改变数组元素的式子也不会生效,而且会返回错误警告。需要知道的是,函数的意思并不是要求传入的参数是一个常量,而是保护传入的数据不被修改,就像传入普通类型值一样不会被修改。
实例:
1 |
|
上面这个程序就给我们演示了const 关键字在两个不同需求下的用法,第一个函数我们只需要把数组的值进行展示,并不需要我们对数组的值进行更改,所以使用了 const 关键字,而第二个函数则是需要将数组的每个元素乘以一个特定数,所以就不加 const 关键字了。
Tip:采用指针最方便的一点就是不用使用 return机制 就可以改变数组中的值。
const 的其他内容
在前面的章节中我们也使用过 const 创建过变量,虽然使用 #define 指令可以创建类似功能的符号常量,但是 const 的用法更加的灵活,可以创建 const 数组、const 指针以及指向 const 的指针。
1 |
|
上面代码中,days数组是不允许被改变的,若是更改则会报错。
指向 const 的指针也是不能用于更改值,考虑下面的代码:
1 | double rates[5] = {88.99, 100.12, 59.45, 183.11, 340.5}; |
从上面我们可以看到不管使用指针表示法还是数组表示法,都是不能通过 p 来更改数组的值,但是 rates 是可以的,且 p 也可以指向他处。
指向 const 的指针一般使用于函数的形参当中,表明该函数不会使用指针改变数据,就像是上面这个实例中的 show_array 函数一样。关于指针赋值和 const 需要注意的一些规则,把 const 数据或者是非 const 数据的地址初始化为指向 const 的指针或为其赋值是允许的。
1 | double ar[5] = {10.11, 21.98, 30.02, 50.12, 90.32}; |
但是,只能把非 const 数据的地址赋给普通指针:
1 | double ar[5] = {10.11, 21.98, 30.02, 50.12, 90.32}; |
这个规则就是为了防止通过指针可以改变 const 数组的值。在这个规则之下,show _array 函数就可以接受普通数组名和 const 数组名作为参数了,所以对函数的形参使用 const 不仅可以保护数据,还可以让函数处理 const 数组。
此外,我们还需要注意不能把 const 数组名作为实参传递给 mult_array 这样的函数,因为这个函数规定使用非 const 标识符去修改 const 数据会导致结果是未定义的。
const 还有别的用法,例如可以声明并初始化一个不能指向别处的指针,关键是 const 位置:
1 | double rates[5] = {88.99, 100.12, 59.45, 183.11, 340.5}; |
这个就是可以修改指针指向的值,但是不能修改初始化的地址(也就是指针的值)。当然还有一种就是常指针指向常量,那么地址也不能变,地址下的值也不能变。
1 | double rates[5] = {88.99, 100.12, 59.45, 183.11, 340.5}; |
指针和多维数组
指针和多维数组是什么关系,又该怎么使用两者之间的关系,还需要深入的学习指针,假定以下声明:
1 | int zippo[4][2]; //内含int数组的数组 |
这个声明中 zippo 是该数组的首元素的地址,在这个例子中,zippo 的首元素是一个内含两个 int 值的数组,所以 zippo 是这个内含两个 int 值的数组的地址,从指针的属性来看,因为 zippo 是数组首元素的地址,所以 zippo == &zippo[0] ,而zippo[0] 本身就是一个内含两个整数元素的数组,所以 zippo[0] 的值和其首元素的地址相同,就是 &zippo [0] [0] ,简单来说zippo [0] 是一个占用一个 int 类型的地址,而 zippo 是一个占用两个 int 大小对象的地址,但是由于这个整数和数组都开始于一个地址,所以这两个值一样。
给指针或者地址加 1 ,值会增加对应类型的大小,在这一块 zippo 和zippo[0] 就不一样了,因为指向对象一个是占用了两个 int 大小,一个是占用了一个 int 大小。
在我们解引用一个指针时,或者在数组名后面使用带下标的 [] 运算符,得到引用对象代表的值,下面实例可以得到结论:
1 | int zippo[4][2]; //设定一个二维数组 |
从上面,我们可以简单的看出 zippo 就是二维数组第一个元素地址的地址,必须解引用两次才可以得到原始值,来看看下面这个实例。
实例:
1 |
|
运行结果:
从运行结果我们可以看到,地址之间的关系和理论是一致的,zippo 和 zippo[0] 的地址值虽然一样,但是包含的对象却是不一样的,这一点从地址加 1 之后所增加的数值就能看的出来。程序中还展示了 zippo[0] 和 *zippo 是完全相同的,事实也是如此。尤为值得注意是程序最后分别使用指针表示法和数组表示法来表达数组中元素,这也告诉我们若是有一个指向二维数组的指针,最好是用数组表示法获得这个值,而不是指针表示法。
*Tip:这里还有一个值得注意的地方,那就是 (间接运算符)和方括号 [] 的作用都是一样的。
指向多维数组的指针
从上面我们可以知道指针和多维数组的关系,那么又该怎么声明指针变量指向一个二维数组,当我们编写处理类似于 zippo 这样的二维数组时就会用到这样一个指针,但同时也就不能够仅仅只声明指针类型了,因为 zippo 指向的是一个内含两个 int 类型的数组,所以我们声明的指针就是指向含两个 int 类型的数组:
1 | int (*pz)[2]; //这就是一个指向二维数组的指针 |
有一个值得注意的点,那就是圆括号,因为 [] 的优先级是要高于 * 的,所以需要添加,若是去掉圆括号:
1 | int *pz[2]; //这是一个内含两个指针的数组 |
那么由于优先级的判定,[] 会先与 pz 结合,使得 pz 成为一个内含两个元素的数组,然后 * 则表示两个元素是指针,最后才是和 int 结合表示指针是指向整型。
实例:
1 |
|
运行结果:
上面的程序就是展示了通过指针来获取二维数组的信息,可以看到地址之间的关系和实际是一样的,虽然 pz 只是一个指针,不是数组名,但也是可以使用 pz[2] [1] 这样的写法,如下:
1 | zippo[m][n] = *(*(zippo + m) + n); |
指针的兼容性
指针的赋值比数值的赋值更要严格,在不经过类型转换就可以将 int 类型的值赋给 double 类型的变量,但是两个类型的指针却不能这样进行赋值。
1 | int n = 5; |
上面都是简单的,但是原理一致,那类型再复杂的指针也是如此。
1 | int *pt; |
除了指针的兼容性之外,多重解引用也是一个难点,如下:
1 | int x = 20; |
从这段代码中,可以知道指针中许多操作是不安全的,尽管非 const 指针赋给 const 指针有效,但也仅仅局限在一级解引用的基础上。
1 | const int **p2; |
这一段里面就是展示了一个非 const 指针影响 const 二级指针和 const 整数值的过程。C标准中规定了通过非 const 指针更改 const 数据是未定义的,对上述程序进行编译,都会给出指针类型不兼容的警告。
C const 和 C++ const
C和C++中 const 的用法都很相似,但是并不完全相同:
区别1:C++是允许声明数组大小时使用 const 整数,但 C 不允许。
区别2:C++指针赋值检查会更加严格,不会出现将 const 指针赋给非 const 指针的情况。
上述那些情况在C++中是不会出现的。
函数和多维数组
若是希望函数处理二维数组,首先我们必须正确的声明函数的指针形参,但在函数体中一般使用的是数组表示法进行操作。其实函数的形参主要看我们最终处理的是什么类型的值。
实例:
1 |
|
这个实例给我们展示的就是使用函数来处理二维数组,通过函数形参的传递,可以得到二维数组的列数,再通过传递额外的一个参数来使得函数得到一个行数,虽然传递的是指针,但在函数体内部却是可以使用数组表示法来进行运算。
依然有需要注意的地方:
1 | int sum2(int ar[][], int rows); //错误的声明 |
若是在第一个中括号中加上数字也是有效声明,但是会被忽略,在这里我们可以回想到一个关键字:typedef。
typdef 的作用是给一个数据类型取一个别名:
1 | typedef int zhen; //将zhen变成int的别名 |
那么还有另外一种定义二维数组的方法就是用 typedef ,但是会显得些许麻烦。
1 | typedef int ar4[4]; //ar4是含四个int的数组 |
一般来说,声明一个指向N维数组的指针时,只能省略最左边的括号的值:
1 | int sum4d(int ar[][10][20][30], int n); //第一对方括号只用于表明这是一个指针,其余的则是说明指向的数据类型 |
声明中的形参 ar 指向的是一个三维数组。
变长数组(VLA)
在上面的学习中,对处理二维数组的函数中为什么要把数组的行数做为单独形参,列数却置于函数体内,但是这样就限制可以处理不同长短的二维数组了,只能对特定的数组进行处理,若是想使用一个函数就处理任意大小的二维数组,过程就比较繁琐了,为了解决这个问题,C99新增了变长数组(VLA),允许使用变量来表示数组的维度:
1 | int quarters = 4; |
但是变长数组是有一些限制的,其必须为自动存储类别,无论其是在函数中声明还是在函数形参中声明,都不能使用 static 或是 extern 存储类别说明符,且不能在声明中初始化他们。
Tip:变长数组不能改变大小,变长数组的 “变” 不是指可以修改数组的大小,而是创建数组时可以使用变量。
变长数组作为C语言的新特性,在现在的编译器中还是较为常见的,可以看看以下的声明:
1 | int sum2d(int m, int n, int ar1[m][n]); //ar1是一个VLA |
我们可以看到函数声明中的前两个形参 m ,n 是作为第三个形参 ar1 的两个维度,因为ar1的声明需要使用 m 和 n ,所以在参数列表中声明 ar1 之前必须要先声明这两个形参。
在C99和C11标准中规定,可以省略原型中的形参名,但是必须使用星号来代替省略的维度如上述的第三种声明。下面是这个函数的定义:
1 | int sum2d(int m, int n, int ar1[m][n]) |
该函数现在可以处理任意大小的二维 int 数组,且用VLA作为形参的函数既可以处理传统数组也可以处理变长数组,下面来个实例进行演示:
1 |
|
运行结果:
我们需要注意的是在函数定义的形参中声明的VLA并未实际创建一个数组,和传统的语法类似,VLA实际上是一个指针,这也说明带VLA形参的函数实际上是在原始数组中处理数组,因此可以修改传入的数组。
1 | { |
ar1 和 thing 都是指向 thing[1] 的指针,所以 ar1 [0] [0] 和 thing [0] [0] 访问的数据位置相同。
const 和 数组大小
那么 const 变量是否可以在声明数组时使用:
1 | const int SZ = 80; |
在前面我们说过数组的大小必须是给定的整型常量表达式或是整型常量的组合,最好的情况是不要使用 const 来给数组声明,这种是无法移植的。C99标准中允许VLA在声明时使用 const 变量,所以该数组的定义必须是声明在块中的 auto 存储类别的数组。
复合字面量
假设一个带 int 类型的函数传递一个值,既可以传递 int 类型的变量也可以传递 int 类型的常量,在C99标准之前,对于带数组形参的函数情况不太一样,可以传递数组,但是没有等价的数组常量,C99中新增了复合字面量的概念:
1 | 5 //int类型字面量 |
对于数组,复合字面量类似数组初始化列表,前面可以使用括号括起来的类型名:
1 | int ar1[2] = {10, 20}; |
Tip:去掉声明中的数组名,留下的 (int[2])即是复合字面量的类型名。
类似于初始化有数组名的数组时可以省略数组的大小,复合字面量也可以省略大小, 而且复合字面量是匿名的,所以不能先创建再使用而是在创建的时候就使用它,下面就演示了一个使用指针记录地址的用法。
1 | (int[]){10, 20, 30} //内含3个元素的复合字面量 |
我们还可以把复合字面量作为实际参数传递给匹配形式的函数:
1 | int sum(const int ar[], int n); |
其中第一个实参是内含6个 int 类型值的数组,这样的好处是把信息传入函数前不用先创建数组,是复合字面量的典型用法,这样的用法也可以推广到多维数组:
实例:
1 |
|
运行结果:
虽然复合字面量很好用,但是其也只是提供一种临时需要值的手段,其具有块作用域,这也说明一旦离开定义的域就无法保证其存在了。
关键概念
数组用于存储相同类型的数据,C把数组看作是派生类型,因为数组是建立在其他类型之上的,如 int 、float ,或是其他类型,若是类型是数组类型,那么创建的就是二维数组。
一般来说我们会编写一个函数来处理数组,这样可以在特定的函数解决特定的问题,常规来说会传递给函数的是数组名,这样我们传递的不是整个数组,而是数组的首地址,数组可以将地址和元素个数分开成两个参数进行传递,这样可以处理大小不同的数组。
数组和指针的关系相当的密切,同一个操作可以使用数组表示法也可以使用指针表示法,这也就意味着在处理数组的函数中,即使函数的形参是一个指针,也可以使用数组表示法。
本章小结
大部分的内容上述概念中都有介绍,但是数组中关于字符串还是有一些特殊的规则,这是由于其末尾的空字符所导致的,因为有了空字符,不用传递数组的大小,通过监测空字符也能知道在何处停止,在后续的博客会详细介绍。