就像结构体可以嵌套一样,数组也可以嵌套,一个数组的元素可以是另外一个数组,这样就构成了多维数组(Multi-dimensional Array)。例如定义并初始化一个二维数组:
int a[3][2] = { 1, 2, 3, 4, 5 };
数组a
有3个元素,a[0]
、a[1]
、a[2]
。每个元素也是一个数组,例如a[0]
是一个数组,它有两个元素a[0][0]
、a[0][1]
,这两个元素的类型是int
,值分别是1、2,同理,数组a[1]
的两个元素是3、4,数组a[2]
的两个元素是5、0。如下图所示:
从概念模型上看,这个二维数组是三行两列的表格,元素的两个下标分别是行号和列号。从物理模型上看,这六个元素在存储器中仍然是连续存储的,就像一维数组一样,相当于把概念模型的表格一行一行接起来拼成一串,C语言的这种存储方式称为Row-major方式,而有些编程语言(例如FORTRAN)是把概念模型的表格一列一列接起来拼成一串存储的,称为Column-major方式。
多维数组也可以像嵌套结构体一样用嵌套Initializer初始化,例如上面的二维数组也可以这样初始化:
int a[][2] = { { 1, 2 }, { 3, 4 }, { 5, } };
注意,除了第一维的长度可以由编译器自动计算而不需要指定,其余各维都必须明确指定长度。利用C99的新特性也可以做Memberwise Initialization,例如:
int a[3][2] = { [0][1] = 9, [2][1] = 8 };
结构体和数组嵌套的情况也可以做Memberwise Initialization,例如:
struct complex_struct { double x, y; } a[4] = { [0].x = 8.0 }; struct { double x, y; int count[4]; } s = { .count[2] = 9 };
如果是多维字符数组,也可以嵌套使用字符串字面值做Initializer,例如:
例 8.4. 多维字符数组
#include <stdio.h> void print_day(int day) { char days[8][10] = { "", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" }; if (day < 1 || day > 7) printf("Illegal day number!\n"); printf("%s\n", days[day]); } int main(void) { print_day(2); return 0; }
这个程序中定义了一个多维字符数组char days[8][10];
,为了使1~7刚好映射到days[1]~days[7]
,我们把days[0]
空出来不用,所以第一维的长度是8,为了使最长的字符串"Wednesday"
能够保存到一行,末尾还能多出一个Null字符的位置,所以第二维的长度是10。
这个程序和例 4.1 “switch语句”的功能其实是一样的,但是代码简洁多了。简洁的代码不仅可读性强,而且维护成本也低,像例 4.1 “switch语句”那样一堆case
、printf
和break
,如果漏写一个break
就要出Bug。这个程序之所以简洁,是因为用数据代替了代码。具体来说,通过下标访问字符串组成的数组可以代替一堆case
分支判断,这样就可以把每个case
里重复的代码(printf
调用)提取出来,从而又一次达到了“提取公因式”的效果。这种方法称为数据驱动的编程(Data-driven Programming),写代码最重要的是选择正确的数据结构来组织信息,设计控制流程和算法尚在其次,只要数据结构选择得正确,其它代码自然而然就变得容易理解和维护了,就像这里的printf
自然而然就被提取出来了。[人月神话]中说过:“Show me your flowcharts and conceal your tables, and I shall continue to be mystified. Show me your tables, and I won't usually need your flowcharts; they'll be obvious.”
最后,综合本章的知识,我们来写一个最简单的小游戏--剪刀石头布:
例 8.5. 剪刀石头布
#include <stdio.h> #include <stdlib.h> #include <time.h> int main(void) { char gesture[3][10] = { "scissor", "stone", "cloth" }; int man, computer, result, ret; srand(time(NULL)); while (1) { computer = rand() % 3; printf("\nInput your gesture (0-scissor 1-stone 2-cloth):\n"); ret = scanf("%d", &man); if (ret != 1 || man < 0 || man > 2) { printf("Invalid input! Please input 0, 1 or 2.\n"); continue; } printf("Your gesture: %s\tComputer's gesture: %s\n", gesture[man], gesture[computer]); result = (man - computer + 4) % 3 - 1; if (result > 0) printf("You win!\n"); else if (result == 0) printf("Draw!\n"); else printf("You lose!\n"); } return 0; }
0、1、2三个整数分别是剪刀石头布在程序中的内部表示,用户也要求输入0、1或2,然后和计算机随机生成的0、1或2比胜负。这个程序的主体是一个死循环,需要按Ctrl-C退出程序。以往我们写的程序都只有打印输出,在这个程序中我们第一次碰到处理用户输入的情况。我们简单介绍一下scanf
函数的用法,到第 2.9 节 “格式化I/O函数”再详细解释。scanf("%d", &man)
这个调用的功能是等待用户输入一个整数并回车,这个整数会被scanf
函数保存在man
这个整型变量里。如果用户输入合法(输入的确实是数字而不是别的字符),则scanf
函数返回1,表示成功读入一个数据。但即使用户输入的是整数,我们还需要进一步检查是不是在0~2的范围内,写程序时对用户输入要格外小心,用户有可能输入任何数据,他才不管游戏规则是什么。
和printf
类似,scanf
也可以用%c
、%f
、%s
等转换说明。如果在传给scanf
的第一个参数中用%d
、%f
或%c
表示读入一个整数、浮点数或字符,则第二个参数的形式应该是&运算符加相应类型的变量名,表示读进来的数保存到这个变量中,&运算符的作用是得到一个指针类型,到第 1 节 “指针的基本概念”再详细解释;如果在第一个参数中用%s
读入一个字符串,则第二个参数应该是数组名,数组名前面不加&,因为数组类型做右值时自动转换成指针类型,在第 2 节 “断点”有scanf
读入字符串的例子。
留给读者思考的问题是:(man - computer + 4) % 3 - 1
这个神奇的表达式是如何比较出0、1、2这三个数字在“剪刀石头布”意义上的大小的?