C语言之数组

学完了前面几种基础语法之后,你可能会渐渐发现,现有的数据的记录方式,已经无法很好地解决我们接下来要解决的问题。比如有一天,老师找你计算一下全班同学的平均成绩。那么你就会开始思考如何存储全班的成绩。按照之前学习的知识,我们可以定义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 <stdio.h>
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

数组的初始化

在定义数组的时候同时对其各个元素进行赋值,称为数组的初始化。在刚刚的代码中,我们定义了数组,但却没有在定义的时候就初始化,而是在循环中进行赋值。那么初始化数组一般有下面几种方法:

  1. 将数组中所有的元素初始化为0,可以这么写:

    int a[10] = {0};
    
  2. 如果要赋予不同的值,用逗号分开即可:

    int a[5] = {1, 2, 3, 4, 5};
    
  3. 给部分元素赋值,剩下的自动初始化为0:

    int a[10] = {1, 2 ,3};//剩下的全部为0
    
  4. 也可以偷懒只给出每个元素的值,让编译器自己判断数组长度:

    int a[] = {1, 2, 3, 4, 5};
    
  5. C99中增加了一种特性,指定元素进行赋值,剩下的自动初始化为0。也就是说,可以针对不连续的几个元素赋值:

    int a[10] = { [3] = 3, [5] = 5, [8] = 8 };//编译的时候记得加上-std=c99选项
    

其实,在C语言界,还有一个所谓“口耳相传”的书写习惯,就是:

int a[] = {1, 2, 3, 4, 5,};

在最后一个元素后面加一个逗号。其实这对最终的编译没有任何影响,只不过一些老教材或者说计算机界的先辈们认为,这可以方便后人维护的时候添加元素。加不加逗号都无所谓,只不过习惯于加逗号的,都是在七八十年代就接触过C语言的人,某种意义上来说,这种习惯可以成为一个标签,拿出去 装装逼 也是可以的。

那可能你会说了,”你刚刚提到的一个问题还没解决呢!要是班里的人数变了怎么办呢?“

没错,我们现在就来解决下这个问题。

可变长数组

在C99标准推出之前,要求定义数组的时候,数组的维度必须是常量表达式或者const常量,但是C99标准中,支持了变量定义数组,那么,我们就可以将第一次的代码改成这样:

//Example 02
#include <stdio.h>
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 <string.h>
...
size_t strlen ( const char * str );

这个方法是不包含字符串末尾的\0的。且看下面的例子:

//Example 03
#include <stdio.h>
#include <string.h>
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而不是intsize_t被定义在stddef.h中,实际上就是无符号整型

复制字符串

估计在第一次见到这个词的时候,你的大脑浮现出来的就是使用赋值符号=,但是,这是错的……

字符串的复制应该使用strcpystrncpy来实现。

#include <string.h>
...
char *strcpy (char *dest, const char *src);
char *strncpy (char *dest, const char *src, size_t n);

不多废话,且看下面的例子:

//Example 04
#include <stdio.h>
#include <string.h>
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

但是其实这个程序是有缺陷的。

我们可以看到,两个数组的长度其实不一样,我们现在是把短的复制到长的里面,那么不会有问题。如果上面的str1str2对调一下,那么就极有可能出问题,这就是我们等会儿要讲的数组越界问题。

那么如何解决复制时的这个隐式bug呢?

使用strncpy方法来复制

如果超出的字符不是很多,那么程序有可能能够成功地运行。但是如果两者悬殊的话,那编译运行之后,程序会报Segmentation fault

因此在复制的时候,我们应该确保不越界,在复制之后不溢出。那么使用strncpy函数,由于增加了一个参数来指定复制的字符个数,我们在编写代码的时候就可以规避这样的问题。

举个例子:

//Example 05
#include <stdio.h>
#include <string.h>
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,因此在使用的时候要注意加上。

连接字符串

如果你想把一个字符串拼接到另一个后面的话,就可以使用strcatstrncat两个函数来实现。

#include <string.h>
...
char *strcat (char *dest, const char *src);
char *strncat (char *dest, const char *src, size_t n);

可以看到,这个函数的用法和上面复制字符串的用法完全相同,strncat也就是比strcat多了一个指定复制长度的参数罢了。

需要注意的是,这个函数自动在末尾追加一个\0,这和复制不一样,要特别注意区分。

比较字符串

比较两个字符串,也和上面的一样,有两个类似的函数,strcmpstrncmp

#include <string.h>
...
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 <stdio.h>
#include <string.h>
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 <stdio.h>
int main(void)
{
    int chinese[50];
    int math[50];
    int English[50];
    int science[50];
    ...
}

但是如果我们使用二维数组的话,那么只需要定义一次就行了。

假设我们有6科。

那么就这样:

//Example 07 V2
#include <stdio.h>
int main(void)
{
    int score[6][50];
    ...
}

这其实就像一个表格一样,二维数组通常也被称为矩阵(matrix),将二维数组写成行和列的表示形式,可以形象地帮我们解决一些问题。

访问二维数组也和普通的数组一样,也是从0开始计数的,只不过下标随着维度的变化会增加罢了(比如二维数组就有2个下标)。

二维数组的初始化

  1. 二维数组在内存中是线性存放的,因此可以将所有的数据写在一个大括号内:

    int a[2][3] = {1, 2, 3, 4, 5, 6};
    

    这样就是先将第一行的三个元素初始化,然后再初始化第二行的元素。

  2. 为了更直观地表达我们可以这么写:

    int a[2][3] = {
        {1, 2, 3},
        {4, 5, 6}
    };
    
  3. 二维数组也可以仅对部分元素赋值:

    int a[2][3] = {{1}, {4}};
    

    这样写只是对各行的第一列元素赋值,其余的全部为0.

  4. 如果希望全部为0,那么可以这么写:

    int a[2][3] = {0};
    
  5. C99中增加的指定赋值的特性,这里也可以适用。其余未被操作的元素为0

    int a[2][3] = {[0][0] = 1, [1][2] = 6};
    
  6. 二维数组也可以偷懒,但是只有第一维度的元素个数可以不写,其他的都要写上:

    int a[][3] = {
        {1, 2, 3},
        {4, 5, 6}
    };
    

数组越界

我们刚刚说过了,我们在写程序的时候,尽量要把越界的情况通过代码的努力来规避。那么,可能有的小伙伴比较感兴趣,如果越界了,会发生什么呢?

那好,咱们就来试试。

//Example 08
#include <stdio.h>

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 <stdio.h>

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,那么01=10-1=-1,-1显然不是一个合法的下标。所以这样的操作可行,但是没有任何意义。

好了,本节内容就到这里了,希望你能够从中有所收获哦!