C语言之数组
——TechZone(Harris)
学完了前面几种基础语法之后,你可能会渐渐发现,现有的数据的记录方式,已经无法很好地解决我们接下来要解决的问题。比如有一天,老师找你计算一下全班同学的平均成绩。那么你就会开始思考如何存储全班的成绩。按照之前学习的知识,我们可以定义a1 a2 a3 ……。但是,这样未免也太麻烦了,如果要记录全省人民的身高数据呢?如果下个学期班里学生人数有变化呢?
你会发现,种种原因,导致了我们编写的程序很繁杂,不够灵活。那么这样,编程也就没有太大的必要了。好在,C语言提供了一种存储数据的方式,叫做数组。
数组,就是存放一堆同类型的数据的容器。比如刚刚的例子,我们要存储学生的成绩,这时候数组就可以大显神威了。
定义一维数组
定义一维数组的方法很简单,只需要指定元素的类型和存放的数量即可。
类型 数组名[元素个数]
比如:
int Score1[50];//定义一个叫Score1的整型数组,有50个元素
float Score2[30];//定义一个叫Score2的浮点型数组,有30个元素
double Score3[20];//定义一个叫Score3的双精度浮点型数组,有20个元素
char Str[10];//定义一个叫Str的字符型数组,有10个元素
数组一旦被定义,在其生命周期内,就不可能被改变(其实是在内存中开辟了一段连续的空间了)。
访问数组
访问数组的方法和定义有点类似,但是如果混淆了的话,那可就不是什么好事了。
数组名[下标]
方括号里面的,实际上是指的数组的下标,也可以叫索引。需要注意的是,下标的计数是从0开始的,最大的下标是(元素个数-1)。也就是说,如果我int Score[10]之后,那么我想访问第一个元素,就是这样Score[0],如果要访问最后一个元素,就是Score[9]。
之前看到过一个段子,大概意思是说程序员数数都喜欢从0开始数。如果你是刚刚接触编程,那么你也要开始习惯从0开始计数的这种思路。
其实,并不是C语言才开始有数组,FORTRAN语言就有数组了。但是下标从0开始计数这种方式,是从C语言才开始有的。当时开发C语言编译器的人们就想让编译器能够更加简单,如果从0开始,那么编译器实际上能够少做很多事情,于是就多了这么一个设定。随着计算机科学的发展,后面出现的优秀的语言也越来越多。但是我们所说的"C-Like”语言,也就是参照C语言来开发的语言,也都继承了C语言这一“优良传统”,因此就有了程序员数数是从0开始的这么一种说法。
讲到这里,想必大家也就会明白之前为什么我们在循环的时候,初始值都是设定为0的了。像这样:
for (i = 0; i < 10; i++)
{
...
}
而不是像这样写(当然也没错):
for (i = 1; i <= 10; i++)
{
...
}
这就是因为,我们在使用循环的时候,经常会配合数组一起来使用,那么我们循环设置成和数组下标的计数方法一样,有利于我们使用数组。
还是回到我们最初的那个问题,存储班里面学生的成绩,然后计算出平均值:
//Example 01
#include
int main(void)
{
int s[10];//假定我们班上有10个人
int i;
double sum = 0;
for (i = 0; i < 10; i++)
{
printf("请输入第 %d 位同学的成绩:", i + 1);
scanf("%d", &s[i]);
sum += s[i];
}
printf("成绩录入完毕,该次考试的平均分是:%.2f\n", sum/10);
return 0;
}
程序实现如下:
//Consequence 01
请输入第 1 位同学的成绩:80
请输入第 2 位同学的成绩:90
请输入第 3 位同学的成绩:70
请输入第 4 位同学的成绩:66
请输入第 5 位同学的成绩:77
请输入第 6 位同学的成绩:54
请输入第 7 位同学的成绩:67
请输入第 8 位同学的成绩:86
请输入第 9 位同学的成绩:78
请输入第 10 位同学的成绩:65
成绩录入完毕,该次考试的平均分是:73.30
数组的初始化
在定义数组的时候同时对其各个元素进行赋值,称为数组的初始化。在刚刚的代码中,我们定义了数组,但却没有在定义的时候就初始化,而是在循环中进行赋值。那么初始化数组一般有下面几种方法:
将数组中所有的元素初始化为0,可以这么写:
int a[10] = {0};
如果要赋予不同的值,用逗号分开即可:
int a[5] = {1, 2, 3, 4, 5};
给部分元素赋值,剩下的自动初始化为0:
int a[10] = {1, 2 ,3};//剩下的全部为0
也可以偷懒只给出每个元素的值,让编译器自己判断数组长度:
int a[] = {1, 2, 3, 4, 5};
C99中增加了一种特性,指定元素进行赋值,剩下的自动初始化为0。也就是说,可以针对不连续的几个元素赋值:
int a[10] = { [3] = 3, [5] = 5, [8] = 8 };//编译的时候记得加上-std=c99选项
那可能你会说了,”你刚刚提到的一个问题还没解决呢!要是班里的人数变了怎么办呢?“
没错,我们现在就来解决下这个问题。
可变长数组
在C99标准推出之前,要求定义数组的时候,数组的维度必须是常量表达式或者const常量,但是C99标准中,支持了变量定义数组,那么,我们就可以将第一次的代码改成这样:
//Example 02
#include
int main(void)
{
int Member;
printf("请输入班级人数:");
scanf("%d", &Member);
int s[Member];//使用用户输入的值来确定数组的大小
int i;
float sum = 0;
for (i = 0; i < Member; i++)
{
printf("请输入第 %d 位同学的成绩:", i + 1);
scanf("%d", &s[i]);
sum += s[i];
}
printf("成绩录入完毕,该次考试的平均分是:%.2f\n", sum/Menber);
return 0;
}
这样,在开始存储成绩之前,先让使用者告诉程序班里有多少学生,该开辟多大的数组,然后就完美解决了人数变动的问题。
注意,这里的”可变长数组“是指的数组在程序运行的时候才确定长度,也就是说每一次运行都不一定一样。但是数组一旦被创建,在其生命周期内就不会再改变了,这是数组的根本特性。
但是,如果有的同学使用的是Visual Studio的话,是不支持C99的这个特性的(我也不知道为什么巨硬不支持,明明这么好的特性),那么就只能使用动态分配的方法来创建数组。放在这里来讲的话有些超纲,后面会讲到。
字符型数组
还记得之前说过,C语言是没有字符串这种类型的。那么C语言处理字符串有两种方法:字符串常量和字符型数组。字符串常量是指用双引号括起来的字符串,一旦确定下来就无法改变。一般我们会更多地倾向于使用更加灵活的字符型数组。这样,数组中的每一个元素表示一个字符,当然还要多一位来表示\0。
那么接下来就讲讲字符串的一些方法,因为字符串实在是太重要了。
获取字符串的长度
计算字符串的长度使用strlen函数(这是长度,不是尺寸),这个函数包含在string.h中
#include
...
size_t strlen ( const char * str );
这个方法是不包含字符串末尾的\0的。且看下面的例子:
//Example 03
#include
#include
int main(void)
{
char str[] = "I love Clang!";
printf("sizeof str = %d\n", sizeof(str));
printf("strlen str = %u\n", strlen(str));
return 0;
}
运行结果如下:
//Consequence 03
sizeof str = 14
strlen str = 13
除了验证不包含\0以外,我们还可以看到,strlen函数返回的是size_t而不是int。size_t被定义在stddef.h中,实际上就是无符号整型。
复制字符串
估计在第一次见到这个词的时候,你的大脑浮现出来的就是使用赋值符号=,但是,这是错的……
字符串的复制应该使用strcpy和strncpy来实现。
#include
...
char *strcpy (char *dest, const char *src);
char *strncpy (char *dest, const char *src, size_t n);
不多废话,且看下面的例子:
//Example 04
#include
#include
int main(void)
{
char str1[] = "Original String";
char str2[] = "New String";
char str3[100];
strcpy(str1, str2);
strcpy(str3, "Successfully Copied");
printf("\
str1: %s\n\
str2: %s\n\
str3: %s\n", \
str1, str2, str3);
return 0;
}
运行结果如下:
//Consequence 04
str1: New String
str2: New String
str3: Successfully Copied
但是其实这个程序是有缺陷的。
我们可以看到,两个数组的长度其实不一样,我们现在是把短的复制到长的里面,那么不会有问题。如果上面的str1和str2对调一下,那么就极有可能出问题,这就是我们等会儿要讲的数组越界问题。
那么如何解决复制时的这个隐式bug呢?
使用strncpy方法来复制
如果超出的字符不是很多,那么程序有可能能够成功地运行。但是如果两者悬殊的话,那编译运行之后,程序会报Segmentation fault。
因此在复制的时候,我们应该确保不越界,在复制之后不溢出。那么使用strncpy函数,由于增加了一个参数来指定复制的字符个数,我们在编写代码的时候就可以规避这样的问题。
举个例子:
//Example 05
#include
#include
int main(void)
{
char str1[] = "TechZone was made by HarrisWilde";
char str2[40];
strncpy(str2, str1, 8);
str2[8] = '\0';
printf("%s\n", str2);
return 0;
}
结果如下:
//Consequence 05
TechZone
有一个地方要格外小心,strncpy函数并不会在字符串的末尾添加\0,因此在使用的时候要注意加上。
连接字符串
如果你想把一个字符串拼接到另一个后面的话,就可以使用strcat和strncat两个函数来实现。
#include
...
char *strcat (char *dest, const char *src);
char *strncat (char *dest, const char *src, size_t n);
可以看到,这个函数的用法和上面复制字符串的用法完全相同,strncat也就是比strcat多了一个指定复制长度的参数罢了。
需要注意的是,这个函数会自动在末尾追加一个\0,这和复制不一样,要特别注意区分。
比较字符串
比较两个字符串,也和上面的一样,有两个类似的函数,strcmp和strncmp。
#include
...
char *strcmp (char *dest, const char *src);
char *strncmp (char *dest, const char *src, size_t n);
采用这套函数来比较两个字符串是否相同的时候,如果两个字符串完全一致,那么返回的值为0。这个函数的原理是,从第一个字符开始,依次对比两个字符串中每个字符的ASCII,如果第一个字符串的ASCII小于第二个字符串对应的字符,那么返回一个小于0的数值(通常是-1),如果大于,那就会返回一个大于0的值(通常是1)。
strncmp则是增加了一个参数,可以用来仅比较前面n个元素。
//Example 06
#include
#include
int main(void)
{
char str1[10] = "TechZone";
char str2[20] = "TechZone";
if (!strcmp(str1, str2))
{
printf("Same!\n");
}
else
{
printf("Different!\n");
}
return 0;
}
运行结果为:
//Consequence 06
Same!
多维数组
有时候,使用数组来存储还是不够方便,比如,老师让你做一个全班全部科目的成绩的分析。如果利用我们刚刚所学习的数组知识,你可能会这么写:
//Example 07
#include
int main(void)
{
int chinese[50];
int math[50];
int English[50];
int science[50];
...
}
但是如果我们使用二维数组的话,那么只需要定义一次就行了。
假设我们有6科。
那么就这样:
//Example 07 V2
#include
int main(void)
{
int score[6][50];
...
}
这其实就像一个表格一样,二维数组通常也被称为矩阵(matrix),将二维数组写成行和列的表示形式,可以形象地帮我们解决一些问题。
访问二维数组也和普通的数组一样,也是从0开始计数的,只不过下标随着维度的变化会增加罢了(比如二维数组就有2个下标)。
二维数组的初始化
二维数组在内存中是线性存放的,因此可以将所有的数据写在一个大括号内:
int a[2][3] = {1, 2, 3, 4, 5, 6};
这样就是先将第一行的三个元素初始化,然后再初始化第二行的元素。
为了更直观地表达我们可以这么写:
int a[2][3] = {
{1, 2, 3},
{4, 5, 6}
};
二维数组也可以仅对部分元素赋值:
int a[2][3] = {{1}, {4}};
这样写只是对各行的第一列元素赋值,其余的全部为0.
如果希望全部为0,那么可以这么写:
int a[2][3] = {0};
C99中增加的指定赋值的特性,这里也可以适用。其余未被操作的元素为0。
int a[2][3] = {[0][0] = 1, [1][2] = 6};
二维数组也可以偷懒,但是只有第一维度的元素个数可以不写,其他的都要写上:
int a[][3] = {
{1, 2, 3},
{4, 5, 6}
};
数组越界
我们刚刚说过了,我们在写程序的时候,尽量要把越界的情况通过代码的努力来规避。那么,可能有的小伙伴比较感兴趣,如果越界了,会发生什么呢?
那好,咱们就来试试。
//Example 08
#include
void f();
int main(void)
{
f();
return 0;
}
void f()
{
int a[10];
a[10] = 0;//这里我们写到了一个不存在的下标里面
}
我们来跑一下这个程序。
笔者使用的Visual Studio 2019给出了以下的错误提示:
//Consequence 08
Run-Time Check Failure #2 - Stack around the variable 'a' was corrupted.
它发现了我在写入一个错误的地址。并且还给了我两个warning:
警告 C6201 索引“10”超出了“0”至“9”的有效范围(对于可能在堆栈中分配的缓冲区“a”)。
警告 C6386 写入到“a”时缓冲区溢出: 可写大小为“40”个字节,但可能写入了“44”个字节。
如果我们像普通程序员一样,不管代码warning,直接强制执行,试试会发生什么。
为了更直观体现,我们把代码改成这样:
//Example 08
#include
void f();
int main(void)
{
f();
printf("Here\n");//我们加了这句,如果函数正常执行完毕了,就可以看到这个语句的输出
return 0;
}
void f()
{
int a[10];
a[10] = 0;
}
还是出现了这句:
Run-Time Check Failure #2 - Stack around the variable 'a' was corrupted.
控制台上面没有看到Here的输出,说明函数还没有执行完,程序就已经崩溃了,根本没办法执行到输出。
但是,为什么编译器没有给我error,而是给了我warning呢?
有的编译器可能连warning都没有。
实际上,我们在对a[10]写入的时候,其实是成功了的。只不过我们把a[10]写在了一个不该写的地方(实际上就是这段数组内存的后面),干扰到了其他东西的运行,程序就有可能会崩溃。如果后面的内存为空或者是没有被回收的垃圾内存,那么就没关系,但是如果是有用的内存,出问题就很正常了。
有时候我们写了一个程序,可能这次运行没问题,下一次运行就出错,或者是在我的电脑上可以,在你的电脑上就不行了等等,都有可能是数组越界,或者是我们后面要学的指针出错了。我们作为创造代码的人,有责任通过代码上的设计,来规避这样的问题,避免程序的崩溃。
长度为0的数组?
有的同学可能会异想天开,说,我可不可以定义一个长度为0的数组呢?
类似于这样:
int a[0];
答案是,完全没问题!
不信的话可以去试试,编译可以通过的,只不过这样的数组不存在任何意义,因为没有符合要求的下标。我们说,最大的下标就是元素个数-1,那么
,-1显然不是一个合法的下标。所以这样的操作可行,但是没有任何意义。
好了,本节内容就到这里了,希望你能够从中有所收获哦!