字符串
之前我一直对字符串避而不谈,不做详细解释,现在已经具备了必要的基础知识,可以深入讨论一下字符串了。字符串可以看做一个数组,它的每个元素都是字符型的,例如字符串"Hello,world!\n"图示如下:
注意每个字符末尾都有一个字符'\0‘做结束符,这里的\0是ASCll码的八进制表示,也就是ASCII码为0的NUII字符,在C语言中这种字符串也称为以零结尾的字符串。数组元素可以用过数组名加下标的方式访问,而字符串字面值也可以像数组名一样使用,可以加下标访问其中的字符:
char c = "Hello, world.\n"[0];
但是通过下标修改其中的字符是不允许的:
"Hello, world.\n"[0] = 'A';
这行代码会产生编译错误,说字符串字面值是只读的,不允许修改的。字符串字面值还有一点和数组明类似,做右值使用时自动转换成指向首元素的指针,在形参和实参中我们看到printf原型的第一个参数是指针类型,而printf("hello world");其实就是传一个指针参数给printf.前面讲过数组可以像结果提一样初始化,如果是字符数组,也可以用一个字符串字面值来初始化:
char str[10] = "Hello";
相当于:
char str[10] = { 'H', 'e', 'l', 'l', 'o', '\0' };
str的后四个元素没有定,自动初始化为0,即NUII字符。注意,虽然字符串字面值"Hello"是只读的,但用它初始化的数组str却是可读可写的。 数组str中保存了一串字符,以'\0’结尾,也可以叫字符串。在本书中只要是以NUII字符结尾的一串字符都叫字符串,不管是像str这样的数组,还是像"Hello"这样的字符串字面值。
如果用于初始化的字符串字面值比数组还长,比如:
char str[10] = "Hello, world.\n";
则数组str只包含字符串的前10个字符,不包含NUII字符,这种情况编译器会给出警告。如果要用一个字符串字面值准确地初始化一个字符数组,最好的办法是不指定数组长度,让编辑器自己计算:
char str[] = "Hello, world.\n";
字符串字面值的长度包括 Null 字符在内一共 15 个字符,编译器会确定数组 str 的长度为 15 。
有一种情况需要特别注意,如果用于初始化的字符串字面值比数组刚好长出一个 Null 字符的长度,
比如:
char str[14] = "Hello, world.\n";
则数组str不包含NUII字符,并且编译器不会给出警告,说这样规定是为了程序员方便,以前地很多编辑器都是这样实现的,不管它有理没理,C标准既然这么规定了我们也没办法,只能自己小心了。
补充一点,printf函数地格式化字符串中可以用%s表示字符串地占位符。在学字符数组以前,我们用%s没什么意义,因为
printf("string: %s\n", "Hello");
还不如写成
printf("string: Hello\n");
但现在字符串可以保存在一个数组里面,用%s来打印就很有必要:
printf("string: %s\n", str);
printf会从数组str的开头一直打印到NUII字符为止,NUII字符本身是Non-printable字符,不打印。这其实是一个危险的信号:如果数组str中没有NUII字符,那么printf函数就会访问数组越界,后果可能会很诡异;有时候打印出乱码,有时候看起来没错误,有时候引起程序崩溃。
多维数组
就像结构体可以嵌套一样,数组也是可以嵌套的,一个数组的元素可以是另外一个数组,这样就构成了一个多维数组。例如定义并初始化一个二维数组:
int a[3][2] = { 1, 2, 3, 4, 5 };
数组a有三个元素,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方式,而有些编程语言是把概念模型的表格一列一列的接起来拼成一串存储的,称为Column_major方式。
多维数组也可以像嵌套结构体一样用嵌套初始化,例如上面的二维数组也可以这样初始化:
int a[][2] = { { 1, 2 }, { 3, 4 }, { 5, } };
注意,除了第一维的长度可以由编译器自动计算而不需要指定,其余各维都必须明确指定长度。利用C99的新特性也可以做Memberwise Initialization,例如:
int a[3][2] = { [0][1] = 9, [2][1] = 8 };
况也可以做Memberwise Initalization,例如:
struct complex_struct { double x, y; } a[4] = { [0].x = 8.0 }; struct { double x, y; int count[4]; } s = { .count[2] = 9 };
如果是多维字符数组,也可以嵌套使用字符串字面值做Initalizer,例如:
#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。这个程序和前面switch语句的功能其实是一样的,但是代码简洁太多了。简洁的代码不仅可读性强,而且维护成本也低。像switch语句那样一堆case,printf和break,如果漏写一个break就要出现Bug。这个程序之所以简洁,是因为用数据代替了代码。具体来说,通过下标访问字符串组成的数组可以代替一堆case分支判断,这样就可以把每个case里重复的代码(printf调用)提取出来,从而又一次达到了"提取公因式"的效果。这种方法称为数据驱动的编程,写代码最重要的是选择正确的数据结构来组织信息,设计控制流程和算法尚在其次,只要数据结构选择的正确,其他代码自然而然就变得容易理解和维护了,就像这里的printf自然而然就被提取出来了。
最后,综合本章知识,写一个简单的小游戏--剪刀石头布:
#include<stdio.h> #include<stdlib.h> #include<time.h> int main() { char gesture[3][10] = { "scissor","stone","cloth" }; int man, computer, result, ret; srand((unsigned)time(NULL)); while (1) { computer = rand() % 3; printf("Input 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,1or2\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函数的用法,到格式化I/O函数中再详细解释。scanf("%d",&man)这个调用的功能是等待用户输入一个整数并回车,这个整数会被scanf函数保存在man这个整型变量里。如果用户输入合法(输入的确实是数字而不是别的字符),而scanf函数返回1.表示成功读入一个数据。但即使用户输入的是整数,我们还需要进一步检查是不是在0-2的范围内,写程序时对用户输入要格外小心,用户有可能输入任何数据,他才不管游戏规则是什么.
和print类似,scanf也可以用%c,%f,%s等转换说明。如果在传给scanf的第一个参数中用%d,%f或%c表示读入一个整数,浮点数或字符,则第二个参数的形式应该是&运算符加相应类型的变量名,表示读进来的数保存到这个变量中,&运算符的作用是得到一个指针类型,到后面指针的基本概念再详细解释;如果在第一个参数中用%s读入一个字符串,则第二个参数应该是数组名,数组名前面不加&,因为数组类型做右值自动转换成指针类型,在端点章节中有scanf读入字符串的例子。
思考:(man-computer+4)%3-1这个神奇的表达式是如何比较出0,1,2这三个数字在"剪刀石头布"意义上的大小?
胜 负 平 胜 负
man-computer -2 -1 0 1 2
man-computer+4 2 3 4 5 6
(man-computer+4)%3 2 0 1 2 0
(man-computer+4)%3-1 1 -1 0 1 -1
刀石头布相生相克,形成一个环,凡是具有环的特性的数学模型都可以考虑用取模运算,首先确定了man-computer和%3,然后再调整其它常数得到normalized的结果。