从头学C(2): 导言
纸上得来终觉浅,绝知此事要躬行。 —— 陆游
任何一件事,如果只是停留在思考阶段,而不亲自去尝试,终归是毫无意义的!
第一章 导言
现在,就从一个最简单的程序开始:如何打印“hello,world”?
1.1 入门
对于初学者来说,这可能会无从下手。其实简单起来就三步:
- 编写程序文本(也就是源程序);
- 成功地进行编译;
- 加载、执行;
不同的操作系统其编译、加载、执行的方式会不一样,书中以UNIX系统为例做了个示范,那我们就以Linux系统为例。所以你需要有一个Linux系统,比如说Ubuntu、Debian、CentOS、RedHat等等都可以。那么以上三步操作就是:
- 打开文本编辑器,输入代码(见下方),保存为
.c文件(比如hello.c)然后退出; - 使用命令
gcc hello.c进行编译,编译成功的话会生成a.out文件(这是一个可执行的二进制文件); - 使用命令
./a.out执行该程序,就可以看到打印出来的信息;
源代码如下:
#include <stdio.h> // 包含标准库的信息
main() // 定义名为main的函数,是主函数,代表程序的入口
{ // main函数中的语句都包含在花括号中
printf("hello, world\n"); // 调用库函数printf()来打印字符,\n代表换行符
}
一个C语言程序,无论大小,都是由最基本的两个元素组成:函数和变量:
- 函数中包含的语句,是要执行的计算操作;
- 变量用于存储计算过程中要使用到的值;
通常函数的命名没有限制,但main是个特殊的函数名——每个C语言程序都是从main函数开始执行,所以每个可执行的程序都必须有且仅有一个main函数。
main函数通常会调用其他函数(可以是自行设计的,也可以是函数库中的)来帮助完成工作,上面代码第一行#include <stdio.h>是告诉编译器在本程序中包含标准输入/输出库(Standard Input/Output)的信息,printf()函数就来自于这个.h头文件。
函数之间进行数据交换的一种方法是:调用函数向被调用函数提供一个值(称为参数)列表,函数名后面用小括号将参数列表括起来。此例中main函数不需要参数,因此用空参数表()表示。
调用函数时,只需使用函数名+参数列表即可。printf()是一个用于打印输出的库函数,在这里就是打印双引号中间的字符串。
用双引号括起来的字符串序列称为字符串或字符串常量。
\n表示换行符,在printf()进行打印时,遇到该字符会将打印的光标移至下一行开始的位置处。printf()函数永远不会自动换行。
注意:
\n只代表一个字符,还有其他一些类似字符的如\t、\b、\”、\\,这些转义字符序列为表示无法输入的字符或不可见字符提供了一种通用的、可扩充的机制,在后续还会详细介绍。
1.2 变量与算术表达式
先从一个程序入手。如果要基于公式℃=(5/9)(℉-32),打印华氏温度和摄氏温度的对照表(如下表),该如何进行呢?
0 -17
20 -6
40 4
60 15
80 26
100 37
120 48
140 60
160 71
180 82
200 93
220 104
240 115
260 126
280 137
300 148
当然,这个程序也只包含一个名为main的函数,示例如下:
#include <stdio.h>
/* 当fahr= 0,20,...,300时,分别打印华氏温度与摄氏温度对照表 */
main()
{
int fahr, celsius;
int lower, upper, step;
lower = 0; /* 温度表的下限 */
upper = 300; /* 温度表的上限 */
step = 20; /* 步长 */
fahr = lower;
while(fahr <= upper) {
celsius = 5 * (fahr-32) / 9;
printf("%d\t%d\n", fahr, celsius);
fahr = fahr + step;
}
}
注释
包含在/*和*/之间的字符序列称为注释,用于解释程序,使程序更易于理解。注释在编译时会被编译器忽略。
程序中允许出现空格、制表符或换行符的地方,都可以使用注释。
变量
注意:在C语言中,所有变量都必须先声明后使用。
变量声明通常放在函数的起始位置、程序的任何可执行语句之前。变量声明用于说明变量的属性,由一个类型名和一个变量表组成,如:
int fahr, celsius;
int lower, upper, step;
其中类型int表示其后这些变量均是整数。int类型的取值范围取决于具体的机器。在较早的机器上通常占16位,即取值范围为-32768和+32767之间,然后时至今日,在绝大部分机器上,int类型占64位。
除int类型之外,C语言还提供了其他一些基本数据类型,如:
| 类型名 | 说明 |
|---|---|
| char | 字符(一个字节) |
| short | 短整型 |
| int | 整型 |
| long | 长整型 |
| float | 浮点数 |
| double | 双精度浮点型 |
变量的赋值如上述程序第9-13行所示,以等号=来为变量设置初值,并以分号;结束各条语句。
关于while循环
while循环语句的执行方式是这样的:
- 测试圆括号中的条件,即判断(fahr <= upper)是否为真;
- 如果为真,则执行循环体内的语句,并返回到第1步,重新测试圆括号的中条件;
- 如果为假,则跳出
while循环,执行后续的语句。
while语句的循环体可以是用花括号{}括起来的一条或多条语句(如上例),也可以是不用花括号的单条语句(如下例)。
while(i < j)
i = 2 * i;
在这两种情况下,我们都应该养成一个好习惯:将由while控制的语句缩进一个制表位(或4个空格)。
关于程序设计风格
尽管C编译器并不关心程序的外观形式,但正确的缩进以及保留适当空格的程序设计风格(Coding Style)对程序的易读性非常重要。比较建议的做法是:
- 每行只书写一条语句;
- 运算符前后各加一个空格字符,以突出运算关系;
- 多个变量的列表,在“,”后面加个空格;
- 整数除法的舍位
- 把表达式写成
celsius = 5 * (fahr-32) / 9而不是celsius = (5/9) * (fahr-32),是因为在C语言中,整数除法的操作会执行舍位,即丢弃小数部分,这样一来5/9就变成0了,而这样求得的所有摄氏温度的值都将是0。
关于printf()函数
printf是一个通用输出格式化函数,后续章节还会详细介绍,这里先了解一些在这个程序中使用到的功能。
printf()函数中第一个参数是待打印的字符串,其中每个百分号(%)表示其他的参数(第二个、第三个、……个参数)之一进行替换的位置,并指定打印格式。
注意:第一个参数中的各个
%分别对应第二个、第三个、……个参数,他们在数量和类型上都必须匹配,否则将出现错误的结果(编译时可能不会报错,只是警告)。
如%d对应整型数据(int),%f对应浮点型数据(float),等等……
值得一提的是,printf函数并不是C语言本身的一部分,C语言本身并没有定义输入/输出功能。它仅仅是标准库函数中的一个有用的函数而已。
由于
ANSI标准定义了printf函数的行为,因此对每个符合该标准的编译器和库来说,printf的属性都是相同的。
隐藏的问题
上述程序其实还存在两个问题:
- 输出的数不是右对齐;
- 整型算术运算,得到的摄氏温度值不太精确。
对于第一个问题,由于printf函数输出的数默认是右对齐,所以只需在%d中指明打印的宽度即可,如:
printf("%3d %6d\n", fahr, celsius);
这样fahr的值占3个数字宽,而celsius占6个数字宽。
对于第二个问题,则需要通过浮点运算来代替上面的整型运算。下面是另一个版本的程序
#include <stdio.h>
/* 当fahr= 0,20,...,300时,打印华氏温度与摄氏温度对照表浮点数版本 */
main()
{
float fahr, celsius;
int lower, upper, step;
lower = 0; /* 温度表的下限 */
upper = 300; /* 温度表的上限 */
step = 20; /* 步长 */
fahr = lower;
while(fahr <= upper) {
celsius = (5.0/9.0) * (fahr-32.0);
printf("%3.0f %6.1f\n", fahr, celsius);
fahr = fahr + step;
}
}
前面说(5/9)因为有舍位所以会得到0,但是,常数中的小数点表明该常数是一个浮点型,所以(5.0/9.0)是两个浮点数相除,其结果不会被舍位。
注意:如果某个算术运算符的所有操作数均为整型,则执行整型运算。但如果某个运算符有一个浮点型操作数和一个整型操作数,则在开始运算前,这个整型操作数会被转换为浮点型。
如上例中,表达式fahr-32中的32在运算过程中将被自动转换为浮点数再进行运算。但为了便于阅读,最好还是为它加上一个显式的小数点以强调其浮点性质。
关于数的输出格式
目前已见过的,有如下几种,分别说明下:
| 占位符 | 说明 |
|---|---|
| %d | 按照十进制整型数打印 |
| %6d | 按照十进制整型数打印,至少占6个字符的宽度 |
| %f | 按照浮点数打印 |
| %6f | 按照浮点数打印,至少占6个字符的宽度 |
| %.2f | 按照浮点数打印,小数点后有2位小数 |
| %6.2f | 按照浮点数打印,至少占6个字符的宽度,且小数点有2位小数 |
另外还有:
%o表示八进制数;%x表示十六进制数;%s表示字符串;%%表示百分号(%)本身。
1.3 for语句
还是以华氏温度-摄氏温度对照表为例,下面的程序也可以实现温度转换的功能:
#include <stdio.h>
/* 打印华氏温度-摄氏温度对照表 */
main()
{
int fahr;
for (fahr = 0; fahr <= 300; fahr = fahr + 20)
printf("%3d %6.1f\n", fahr, (5.0/9.0)*(fahr-32));
}
可以看到相比上一节的程序,改进了很多地方:
- 去掉大部分变量,而只使用了1个
int类型的变量; - 上限、下限、步长都变成了常量;
- 计算摄氏温度的表达式变成了
printf的一个参数,而不再是一行单独的赋值语句;
其中第三点反映出了C语言中的一个通用规则:在允许使用某种类型变量值的任何场合,都可以使用该类型的更复杂的表达式。
由于第三个参数必须与%6.1f所指示的浮点数匹配,所以第三个参数可以是任何浮点表达式。
和while语句一样,for语句也是一种循环语句,甚至可以说是对while的推广。
可以看到for语句的圆括号中有三部分,其执行的过程如下:
- 执行圆括号中的第一部分 ——初始化:进入循环前的操作,仅执行一次;
- 执行圆括号中的第二部分——条件测试:如果测试语句为真,往下执行;如果为假,直接跳出循环;
- 如果第二部分为真,执行循环体内的语句;
- 执行圆括号中德第三部分——增加步长:
- 回到第2步。
和while循环一样,for语句的循环体可以是一条语句,也可以是用花括号括起来的一组语句。
在实际编程中,可以根据具体情况来选择用哪种循环语句。相对而言,
for语句比较适合需要初始化、有增加步长的情形。
1.4 符号常量
上一节的温度对照表程序中,我们直接使用了300、20这样的常量,而具体这几个数字代表什么意义呢?简单的程序还比较容易理清,复杂的程序怎么办?
不过,利用符号常量,我们就可以给这些“莫名其妙”的数字赋予特殊的意义。请看下面的程序:
#include <stdio.h>
#define LOWER 0 /* 表的下限 */
#define UPPER 300 /* 表的上限 */
#define STEP 20 /* 步长 */
/* 打印华氏温度-摄氏温度对照表 */
main()
{
int fahr;
for (fahr = LOWER; fahr <= UPPER; fahr = fahr + STEP)
printf("%3d %6.1f\n", fahr, (5.0/9.0)*(fahr-32));
}
#define指令可以把符号名(或叫符号常量)定义为一个特定的字符串:
#define 名字 替换文本
其中名字和普通变量名的形式相同(即以字母打头的字母和数字序列),而替换文本可以是任何的字符序列。
编译时,编译器会将程序中所有用#define定义了的“名字”替换为对应的“替换文本”。如上例,所有出现LOWER的地方都会被替换为0,所有出现UPPER的地方都会被替换成300。
注意:
- 符号常量是常量,不是变量,因此无需声明
#define指令行的末尾没有分号(;)- 符号常量的名字通常用全大写的字母,易于与小写字母拼写的变量名相区别。
1.5 字符输入/输出
C标准库还提供了一次读/写一个字符的函数,最简单的当属getchar和putchar两个函数了。
getchar函数负责从文本流中读取一个输入字符,并将其作为结果返回。执行完上述语句,变量c就代表了输入流中的下一个字符(一般是通过键盘输入,也可以从文件导入)。
char c = getchar();
putchar函数负责打印一个字符。执行完上述语句,变量c的内容就会以字符的形式打印出来(通常是在屏幕上直接显示)。
putchar(c);
下面通过几个示例程序来演示下。
1.5.1 文件复制
如何把输入的字符一个一个地复制到输出呢?先把流程弄清楚,比如:
读一个字符
while(该字符不是结束指示符)
{
输入刚刚读入的字符
读下一个字符
}
有了框架,转化为C语言就容易多了:
#include <stdio.h>
/* 将输入复制到输出:版本1 */
main()
{
int c;
c = getchar();
while(c != EOF) { //"!="是关系运算符,表示“不等于”
putchar(c);
c = getchar();
}
}
关于判断getchar()得到的字符是有效字符还是输入结束符,C语言采取的解决办法是:当没有输入时,getchar函数将返回一个特殊值,这个特殊值与任何实际字符都不同。它就是EOF(End Of File,文件结束)。
这也是为什么这里要将变量c声明为int类型,而不是char型,因为变量c需要能够存储任何可能的有效字符外,还要能存储EOF。
EOF的定义在头文件<stdio.h>中,是一个整型数,具体数值是什么并不重要(一般是-1),只要不与任何char类型的值不相同即可。
其实,上面的程序还能简化一下:
#include <stdio.h>
/* 将输入复制到输出:版本2 */
main()
{
int c;
while((c = getchar()) != EOF)
putchar(c);
}
while循环先读一个字符并赋值给变量c,然后测试该字符是否为EOF,如果不是EOF,则打印字符,继续while循环。
单条语句变少,程序变得更加紧凑,程序也更易于阅读。
注意:
while语句中的c = getchar()两端的圆括号不能省略。因为关系运算符的优先级比赋值运算符的优先级要高,如果省略了圆括号,就变成了c = (getchar() != EOF),c的值就会被赋为1或0(取决于getchar() != EOF是真还是假),这就偏离了我们的设计。
1.5.2 字符计数
先看程序:
#include <stdio.h>
/* 统计输入的字符数:版本1 */
main()
{
long nc;
nc = 0;
while(getchar() != EOF)
++nc;
printf("%ld\n", nc);
}
其中++nc和nc = nc + 1是相同的操作,都是执行加1的操作,不过前者更精炼,而且通常效率也更高。
++是自增运算符,对应的有——是自减运算符。这两个运算符既可以前缀(如++nc、——nc),也可以作为后缀(如nc++、nc——),前缀和后缀这两者的区别在后面的章节中会通过例子详细介绍。
这里使用long类型来声明变量,是为了保证nc能存储足够大的字符总数。而使用double(双精度浮点数)类型可以处理更大的数字。来看下一个使用for循环的版本:
#include <stdio.h>
/* 统计输入的字符数:版本2 */
main()
{
double nc;
for (nc = 0; getchar() != EOF; ++nc)
;
printf("%.0f\n", nc);
}
double类型和float类型一样,都是用%f来进行说明,%.0f强制不打印小数点和小数部分。
这里for语句的循环体是空语句,因为所有的工作都在for语句圆括号内的第二部分(条件测试)和第三部分(增加步长)完成了,但是C的语法规则要求for循环必须要有一个循环体,因此用了一个单独的分号(空语句)来代替循环体。
1.5.3 行计数
输入的文本中每一行都会以换行符结束,所以,统计行数其实就变成统计换行符个数,示例如下:
#include <stdio.h>
/* 统计输入中的行数 */
main()
{
int c, nl;
nl = 0;
while((c = getchar()) != EOF)
if(c == '\n')
++nl;
printf("%d\n", nl);
}
while的循环体是一个if语句,if语句控制自增语句++nl。if语句先测试圆括号内的条件,为真,则执行后面的语句。
应该注意到,这里用了多个缩进来体现语句之间的控制关系。
==和!=一样都是关系运算符,不过前者是“等于”,后者是“不等于”。
特别注意:千万不能将赋值运算符
=和关系运算符==搞混了,这也是新手最容易犯的错误。
单引号中的字符表示一个整型值,该值等于此字符在机器字符集中对应的数值,我们通常称其为字符常量。比如'A'是一个字符常量,在ASCII字符集中其值为65,即'A'代表着65这个值。
字符串常量中使用的转义字符序列也是合法的字符常量。'\n'就代表换行符的值,在ASCII字符集中其值是10。我们需要注意到这个差别:
'\n'是单个字符,在表达式中,它只不过是个整型数(10)而已"\n"是一个仅包含一个字符的字符串常量。
1.5.4 单词计数
和统计行数类似,统计单词数要更宽泛一些,一个单词前后无非是包含空格、制表符或者回车。示例如下:
#include <stdio.h>
#define IN 1 /* 在单词内 */
#define OUT 0 /* 在单词外 */
/* 统计输入的行数、单词书与字符数 */
main()
{
int c, nl, nw, nc, state;
state = OUT;
nl = nw = nc = 0;
while((c = getchar()) != EOF) {
++nc;
if(c == '\n')
++nl;
if(c == ' ' || c == '\n' || c == '\t')
state = OUT;
else if( state == OUT) {
state = IN;
++nw;
}
}
printf("%d %d %d\n", nl, nw, nc);
}
state用于记录当前的字符是否位于一个单词之中,通过两个符号常量IN和OUT,是程序更易阅读。
下列语句是将三个变量nl、nw、nc同时设置为0:
nl = nw = nc = 0;
根据C语言的语法规则:在兼有值与赋值两种功能的表达式中,赋值结合次序是从右到左,所以上面的语句等同于:
nl = ( nw = ( nc = 0 ) );
运算符||代表逻辑或(OR),对应的&&代表逻辑与(AND),AND要比OR高一个优先级。有一个需要注意的地方:由&&或||连接的表达式是从左到右求值,并且在求值过程中只要能判断最终结果为真或者假,求值就立即终止。比如
- 对于
a || b || c表达式,如果a已经为真了,那么b、c都不会执行; - 对于
a && b && c表达式,如果a已经为假了,那么b、c也不会执行。
这里出现了if-else的组合语句,else是指定了if为假时,所要执行的动作。一般形式是:
if(表达式)
语句1;
else
语句2;
语句1和语句2也可以是花括号括起来的多条语句。
另外if-else还可通过嵌套,判断多条分支:
if(表达式1)
语句1;
else if(表达式2)
语句2;
else if(表达式3)
语句3;
……
else
语句n;
1.6 数组
前面我们看到的都是一个个独立的变量,而数组相当于将多个相同类型的变量组合到了一起变成一个整体,共用一个变量名却不会发生冲突。
目的:编写一个程序,统计各个数字(0 – 9)、空白符(包括空格、制表符、换行符),及所有其他字符出现的次数。
解决:
#include <stdio.h>
/* 统计各个数字、空白符及其他字符出现的次数 */
main()
{
int c, i, nwhite, nother;
int ndigit[10];
nwhite = nother = 0;
for(i=0; i<10; ++i)
ndigit[i] = 0;
while((c = getchar()) != EOF) {
if(c >= '0' && c <= '9')
++ndigit[c-'0'];
else if(c == ' ' || c == '\n' || c == '\t')
++nwhite;
else
++nother;
}
printf("digits=");
for(i = 0; i < 10; ++i)
printf(" %d", ndigit[i]);
printf(", white space = %d, other = %d\n", nwhite, nother);
}
可以看到,总共应该有12种字符需要统计,但这里使用了一个数组ndigit[10]来分别存放0~9这10个数字的出现次数,整个程序就显得相当的整洁。
声明语句int ndigit[10]将变量ndigit声明为由10个整型数构成的数组。
数组中的各个元素是由数组下标来区分的,在C语言中,数组的下标都是从0开始的,因此这10个元素分别是ndigit[0]、ndigit[1]、……、ndigit[9]。
数组的下标可以是任何整型表达式,包括整型变量和整型常量。
因为在所有的字符集中,‘0’、‘1’、……、‘9’是连续递增的,所以在条件测试中使用了c >= '0' && c <= '9'这样巧妙的表达式来判断变量c是不是数字,如果是数字,那么c – '0'就恰好是该数字对应的数值。
char类型本身就是小整型,而且char类型的变量和常量在算术表达式中等价于int类型的变量和常量,所以c – '0'是一个整型表达式。
另外这里还使用了上一节提到过的 多个else if(条件)的语法,从上往下依次去测试各个条件:
- 只要满足其中任何一个
if(条件),就会去执行对应的语句,然后跳出整个语句体; - 如果不满足任何一个
if(条件),则会去执行最后一个else及其对应的语句; - 如果不满足任何一个
if(条件),而且也最后也没else,那么这整个语句体将不执行任何动作。
1.7 函数
函数为计算的封装提供了一种简便的方法,我们不需要考虑它是如何实现的,就可以直接去调用它,由它返回我们想要的结果。实际编程中也会接触到各种各样的函数调用,这些函数将很多流程封装成一个个函数,使代码段更清晰易读。
前面我们已经接触了诸如printf()、putchar()、getchar()这样的库函数,我们只需要按照ANSI C的标准去调用这些函数,这一节我们学习如何自己来定义并使用一个函数。
目的:编写一个求幂的函数power(m,n),用于计算整数m的n次幂。
解决:
#include <stdio.h>
int power(int m, int n);
/* 测试power函数 */
main()
{
int i;
for(i = 0; i < 10; ++i)
printf("%d %d %d\n", i, power(2,i), power(-3,i));
return 0;
}
/* power函数:求底数base的n次幂,其中n>=0 */
int power(int base, int n)
{
int i, p;
p = 1;
for(i = 1; i <= n; ++i)
p = p * base;
return p;
}
备注:这里只是一个简单的求幂函数,只能处理较小的整数的正整数次幂。其实标准库中提供了一个计算
x的y次幂的函数pow(x,y)。
函数定义的一般形式为:
返回值类型 函数名(0个或多个参数声明)
{
声明部分
语句序列
}
main函数在printf()中调用了两次power()函数,每次调用时,main函数就向power函数传递两个参数,power函数执行完之后,返回一个int型值,该返回值传递给printf函数,最后由printf函数打印出来。
注意:并不是所有函数的返回值都是
int型。
power函数的第一行语句:
int power(int m, int n);
声明了参数的类型、函数的名字及函数的返回值类型。表明power函数有两个int类型的参数,并返回一个int类型的值。这种声明我们称之为函数原型。它必须与函数的定义和用法一致,否则编译时会报错。
函数原型与函数定义的参数名字不要求相同,事实上,函数原型中的参数名是可选的,上面的函数原型也可以写成如下这般:
int power(int, int);
但是,恰当的参数名可以起到很好的提示作用,我们还是建议在函数原型中总是加上参数名。
函数的参数仅在该函数内部有效,因此,不同函数可以使用同名的参数。比如main函数中的i和power函数中的i之间就没有任何关系,况且我们中华泱泱大国那么多同名的人,不过也只是同名而已。
我们通常把函数定义中圆括号内列表中的变量称为形式参数(简称“形参”)。而把函数调用中与形式参数对应的值称为实际参数(简称“实参”)。比如power(2,i)的调用中,base是形参,而2才是实参。
函数调用的返回值通过return语句返回。关键字return后面可以跟任何表达式,其形式如下:
return 表达式;
当然,不是每一个函数都有返回值,不带表达式的return语句表示将控制权返回给调用者,而不返回任何值,执行到return也就表示当前这个函数“走到了尽头”。
细心一点的童鞋可能会发现,这里的main函数有了一个“小尾巴”( return 0;)。因为main函数本身也是一个函数,因此也向其调用者返回一个值,该调用者实际上就是程序的执行环境。一般来说,返回值为0表示正常终止,返回值为非0表示出现异常情况或出错结束条件。
之前的例子都省略了return语句,而在以后的例子中会把return加入进来,以强调程序还要向其执行环境返回状态。
备注:ANSI C和较早C语言之间的最大区别在于函数的声明与定义方式的不同。为避免混淆,这里就不详细列举了,感兴趣的童鞋可以去了解下。
1.8 参数——传值调用
在C语言中,所有函数的参数都是通过值进行传递的,即传递给调用函数的参数值存放在临时的变量中,而不是存放在原来的变量中。究其原因,是因为被调函数不能直接修改主调函数中的变量的值,而只能修改其私有的临时副本的值。(不像Fortran或Pascal,被调用函数必须访问原始参数,而不是访问参数的本地副本)
再看另外一个版本的power函数:
/* power函数:求底数base的n次幂,n>=0;版本2 */
int power(int base, int n)
{
int p;
for(p = 1; n >= 0; --n)
p = p * base;
return p;
}
参数n作为临时变量,其实际值是主调函数传进来时n所代表的的整型值,power函数内部对形式参数n的任何操作都不会影响到主调函数中n的原始参数值。
另外,相比上一节的版本,参数n直接作为power函数内部的局部变量使用,而不用额外的引入变量i。
这时肯定就有疑问了:那这样我岂不是不能在调用函数中修改主调函数中的变量了?
当然不是!ANSI C显然也是考虑到了这点,调用者通过向被调函数传递变量的地址(地址就是指向变量的指针)作为参数,而被调用函数也需要将相应的参数声明为指针类型,通过这个变量的地址,就可以实现在调用函数中直接对原始变量进行操作,而不再是操作变量的值副本。关于指针的内容,我们后面还会详细的学习到。
如果是数组参数,情况就不太一样了。如果把数组名作为参数,传递给函数的值其实是数组的起始元素的地址——它并不复制数组元素本身,在被调函数中,可以通过数组下标访问或修改数组元素的值。
1.9 字符数组
字符数组是C语言中最常用的数组类型,下面通过一个示例来学习字符数组的用法。
【目的】编写一个程序,使其可以读入一组文本行,并打印其中最长的文本行。
【解决】稍微想想,其实该算法的框架不算复杂:
while(还有未处理的行,则读取该行)
{
if(该行比已处理的最长行还要长)
{
保存该行
保存该行的长度
}
}
打印最长的文本行
有了这个框架,所以我们至少会需要以下两个函数:
getline():读入新的一行,同时返回该行的长度;copy():复制行,用于保存当前已知的最长行。
这里还需要考虑特殊的情况:
- 假如该行只有一个回车(注意并不是文件结束标识符),那么
getline()应该返回1,表明该行长度为1; - 假如到达文件结束符,那么
getline()应该返回0,表明该行不是有效行;
这样我们就能根据getline()的返回值来判断是否还有未处理的行了。
下面是详细代码:
#include <stdio.h>
#define MAXLINE 1000 /* 允许的单行的最大长度h */
int my_getline(char line[], int maxline);
void copy(char to[], char from[]);
/* 打印最长的行 */
main()
{
int len; /* 当前行长度 */
int max; /* 目前已处理的最长行的长度 */
char line[MAXLINE]; /* 当前的输入行 */
char longest[MAXLINE]; /* 用于保存最长的行 */
max = 0;
while((len = my_getline(line, MAXLINE)) > 0) {
if(len > max){
max = len;
copy(longest, line);
}
}
if(max > 0) /* 存在这样的行 */
printf("%s", longest);
return 0;
}
/* my_getline:读取新的一行到s中,并返回其长度 */
int my_getline(char s[], int lim)
{
int c, i;
for(i=0; i< lim-1 && (c=getchar())!=EOF && c!='\n'; ++i)
s[i] = c;
if(c == '\n') {
s[i] = c;
++i;
}
s[i] = '\0';
return i;
}
/* copy: 将from复制到to,这里假定to足够大 */
void copy(char to[], char from[])
{
int i;
i = 0;
while((to[i] = from[i]) != '\0')
++i;
}
在第4行中,我们声明了my_getline函数,并把参数s声明为数组,参数lim声明为整型,函数返回值也声明为整型(函数的返回值默认是int型,所以其实这个声明中的int可以省略)。
在my_getline()函数中,利用了一个for循环,通过getchar()依次读入各个字符,只要字符个数小于(MAXLINE – 1)并且当前字符不是回车符(即换行符'\n'),该行的字符就会依次存入字符数组s[ ]的各个单元中。
细心的童鞋会发现,当读到换行符后,会在字符数组s[ ]的末尾额外添加一个'\0'字符。这是因为在C语言中,用'\0'(也叫空字符,其值为0)来标记字符串的结束,所以当我们看到类似"hello\n"的字符串常量时,它将以字符数组的形式存储,并以'\0'标志字符串的结束,存储单元图如下所示:
而且,在printf函数中,以%s格式输出的参数必须是以这种形式表示的字符串。
my_getline()的最后,通过return返回该行的长度,注意,返回字符串长度时并没有计算'\0'在内,但是'\n'是包含在内的,即空字符不是普通文本的一部分。
第5行,将copy函数声明为无返回值的函数,是因为该函数仅仅执行复制行的动作,并不需要返回什么值。在copy函数中我们也可以注意到,它也是以遇到空字符'\0'为跳出while循环的条件。
其实,这个程序在传递参数方面还是有隐藏的问题的,童鞋们可以自己先想想看~
最后,个人觉得还有一些需要注意的地方:
- 声明一个函数的参数为数组时,不能漏掉数组的中括号(
[ ]);- 在调用参数为数组的函数时,赋给被调函数的参数只需数组名即可,不需要中括号(
[ ])。
1.10 外部变量与作用域
在上一节的示例代码中,如len、max、line[MAXLINE]、longest[MAXLINE]这几个变量是在main函数中声明的,所以其他函数不能直接通过变量名访问它们。同理,在其他函数中声明的变量,我们main函数也不能直接访问它们,这也是我们称这些变量为私有变量或局部变量的原因,这些变量只在各自的函数内有效,因此不同函数内声明的相同名字的变量是没有任何关系的。
函数中每个局部变量只在函数被调用执行的过程中才存在,函数执行完毕返回时就消失,所以其他语言中通常把这类变量称为“自动变量”。我们后面也会使用“自动变量”代替“局部变量”。(后面会学习static存储类型,这种类型的局部变量在多次函数调用之间保持值不变。)
可见,一个自动变量的作用域是声明这个变量的函数内。
除了自动变量,我们还可以定义位于所有函数外部的变量,区别在于:
- 所有函数都可以通过变量名直接访问外部变量;
- 函数间可以通过外部变量交换数据,也就不必再使用参数表了;
- 外部变量在程序执行期间一直存在,而不是在函数调用时产生、在函数返回时消失。
使用外部变量时,也有两点需要注意的:
- 外部变量必须定义在所有函数之外,而且只能定义一次,定义后编译程序会为它分配存储单元;
- 在每个需要访问外部变量的函数中,必须声明相应的外部变量,说明其类型。
还是来看看修改后的“打印最长行的文本”的程序:
#include <stdio.h>
#define MAXLINE 1000 /* maximum input line size */
int max; /* maximum length seen so far */
char line[MAXLINE]; /* current input line */
char longest[MAXLINE]; /* longest line saved here */
int my_getline(void);
void copy(void);
/* print longest input line; specialized version */
main()
{
int len;
extern int max;
extern char longest[];
max = 0;
while((len = my_getline()) > 0) {
if(len > max){
max = len;
copy();
}
}
if(max > 0) /* there was a line */
printf("%s", longest);
return 0;
}
/* my_getline: specialized version */
int my_getline(void)
{
int c, i;
extern char line[];
for( i = 0; i < MAXLINE - 1 && (c=getchar()) != EOF && c != '\n'; ++i)
line[i] = c;
if(c == '\n'){
line[i] = c;
++i;
}
line[i] = '\0';
return i;
}
/* copy: specialized version */
void copy(void)
{
int i;
extern char line[], longest[];
i = 0;
while((longest[i] = line[i]) != '\0')
++i;
}
在分析程序之前,我们要先注意下“定义”和“声明”这两个词:
- 定义(define)表示创建变量或分配存储单元;
- 声明(declaration)指的是说明变量的类型或性质,但不分配存储单元。
第5-7行定义了main、my_getline、copy函数使用的几个外部变量,声明了各外部变量的类型,这样编译程序将会为它们分配存储单元。
在函数中使用外部变量之前,必须要先知道外部变量的名字。在函数内声明一个外部变量,有两种方式:
- 通过
extern语句显式声明; - 通过上下文结构隐式声明。
在上面的例子中,用的就是第一种方式。使用关键字extern显式说明这个变量在其他地方定义了,而在这个函数内仅仅是声明一下类型。
在源文件中,如果外部变量的定义出现在使用它的函数之前,那么在那个函数中就没有必要使用extern声明,所以其实上面的例子中那几个extern的声明都是多余的。这也就是第二种方式:通过上下文结构隐式声明。一般的做法是将所有外部变量放在源文件开始的地方,这样就可以省略extern声明。
如果程序包含在多个源文件中,比如某个变量在aaa.c中定义,而在bbb.c、ccc.c文件中使用,那么在bbb.c和ccc.c中就需要使用extern声明来建立该变量与其定义之间的联系。不过,对于这种一个程序包含在多个源文件中的情况,人们习惯把变量和函数的extern声明放在一个单独的文件中(即.h后缀的头文件),而在每个源文件的开头使用#include把要用到的头文件包含进来。我们常见的标准库中的函数就是在类似<stdio.h>的头文件中声明的。
在ANSI C中,如果要声明空参数表,则必须使用关键字void进行显式声明。所以尽管my_getline()和copy()不带参数,但在第9-10行的声明中还是使用了void来声明。
外部变量带来的好处是显而易见的,函数间的数据通信非常便捷,参数表也变短了。然而过分依赖外部变量也会导致一定的风险:
- 外部变量在程序执行期间一直存在,即使已经没有函数在使用它,对存储单元来说是一种资源浪费;
- 恰恰是由于任何函数都可以直接访问外部变量,变量的值可能会被意外地修改,程序内的数据关系模糊不清,给程序的调试或修改带来极大的困难;
- 代码的复用性不佳,函数只能针对特定的变量有效。
所以这么来看,这个版本的的程序就没有上一节的版本好。