Post

《C程序设计语言》笔记 第1章 导言

本章将重点介绍一些基本概念,比如常量与变量、算术运算、控制流、函数、基本输入/输出等。本章是后续各章详细讲解的内容的框架。

1.1 入门

所有语言初学者编写的第一个程序都是打印”hello, world”。在C语言中,可以用下列程序打印出”hello, world”:

1
2
3
4
5
#include <stdio.h>

int main() {
    printf("hello, world\n");
}

代码:Hello world

如何运行这个程序取决于所使用的系统:对于Windows系统,最简单的方法是将代码复制到Visual Studio、Code::Blocks或其他IDE中运行;对于Linux系统,在命令行中使用gcc命令编译:

1
2
3
$ gcc hello_world.c
$ ./a.out
hello, world

注:书中的cc命令是C编译器命令(例如gcc)的别名,Windows上没有这个命令

C语言程序都是由函数(function)和变量(variable)组成的。函数中包含一些语句(statement),以指定要执行的计算操作;变量则用于存储计算过程中使用的值。在本例中,函数的名字为mainmain是一个特殊的函数名——每个程序都从main函数的起点开始执行。main函数通常会调用其他函数来帮助完成某些工作,被调用的函数可以是自己编写的,也可以来自于函数库。

上述程序的第一行语句#include <stdio.h>用于告诉编译器在本程序中包含标准输入/输出库。

函数之间进行数据交换的一种方法是传递参数(argument)。函数定义格式:返回类型 函数名(参数列表)。在本例中,main函数不需要任何参数,因此用空参数列表()表示。函数中的语句用一对花括号{}括起来。本例中main函数仅包含一条语句:printf("hello, world\n");

函数调用格式:函数名(参数列表)。上面这条语句将"hello, world\n"作为参数调用printf函数。printf是一个用于打印输出的函数,它打印字双引号中间的字符串。

用双引号括起来的字符序列称为字符串(string)或字符串常量。在C语言中,\n表示换行符,输出换行符将从下一行的行首开始。

printf函数永远不会自动换行,因此下面的程序与前面的程序的输出相同:

1
2
3
4
5
6
7
#include <stdio.h>

int main() {
    printf("hello, ");
    printf("world");
    printf("\n");
}

注意:\n只代表一个字符,称为转义字符序列。除此之外,C语言提供的转义字符序列还包括:\t表示制表符,\"表示双引号,\\表示反斜杠本身。

1.2 变量与算术表达式

下面的程序使用公式°C=5/9(°F-32)打印华氏温度与摄氏温度对照表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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

代码:打印华氏温度与摄氏温度对照表

这个程序引入了一些新概念,包括注释、声明、变量、算术表达式、循环以及格式化输出。

/**/之间的字符序列称为注释。注释用于解释程序的用途,使得程序更易于理解。注释将被编译器忽略。(即注释是给人看的,不是给机器看的)

在C语言中,所有变量都必须先声明后使用。声明用于说明变量的属性,由一个类型名和一个变量表组成,例如:int fahr, celsius;

其中,类型int表示整数,另外float表示浮点数(小数),至少有6位有效数字。

C语言提供了一些基本数据类型,例如:

  • int:整型
  • float:浮点型
  • char:字符
  • short:短整型
  • long:长整型
  • double:双精度浮点型

(每种类型占用的字节数及取值范围见2.2节

另外,还有这些基本数据类型的数组结构联合,指向这些类型的指针以及返回这些类型值的函数

在上面的程序中,最开始执行的是4个赋值语句,用于为变量赋值。各条语句均以分号结束。

接下来是使用while循环语句重复输出各行。while循环语句的格式:

1
2
3
while (条件) {
    语句
}

执行方式:首先测试圆括号中的条件,如果条件为真则执行循环体(花括号中的语句);然后重新测试圆括号中的条件,如果为真则再次执行循环体;当圆括号中的条件为假时,循环结束,并继续执行跟在while语句之后的下一条语句。

如果循环体只有一条语句则可以省略花括号。习惯上将循环体缩进1个制表符(tab)或4个空格,这种缩进方式突出了程序的逻辑结构。尽管编译器并不关心程序的外形,但正确的缩进以及保留适当的空格对程序的易读性非常重要。建议:每行只书写一条语句,并在运算符两边各加上一个空格,使得运算的结合关系更清楚明了。左花括号的位置可以放在单独一行(书中的风格),也可以放在上一行末尾。

循环体中的赋值语句celsius = 5 * (fahr - 32) / 9;用于计算指定华氏温度对应的摄氏温度值。在该语句中,之所以把表达式写成5 * (fahr - 32) / 9而不是5 / 9 * (fahr - 32),是因为C语言的除法是整数除法,结果中的小数部分会被舍弃。因此5 / 9的结果为0,5 / 9 * (fahr - 32)求得的所有摄氏温度都将为0。

printf函数是一个通用输出格式化函数,第一个参数是待打印的字符串,其中的每个百分号表示使用其他参数之一进行替换的位置,并指定打印格式。例如,%d指定一个整型参数。printf函数的第一个参数中的各个%分别对应第二个、第三个……参数,它们在数目和类型上必须匹配,否则将出现错误的结果。

上述的温度转换程序存在两个问题:(1)输出的数不是右对齐的;(2)由于使用的是整型运算,因此得到的摄氏温度值不精确。

对于第一个问题,可以在printf函数的第一个参数的%d中指明打印宽度,则打印的数字会在打印区域内右对齐。例如,用语句printf("%3d %6d\n", fahr, celsius);打印,则fahr的值占3个字符宽,celsius的值占6字符宽,且右对齐:

1
2
3
4
5
6
7
  0    -17
 20     -6
 40      4
 60     15
 80     26
100     37
...

对于第二个问题,应该用浮点运算代替整型运算。

代码:打印华氏温度与摄氏温度对照表(浮点数版本)

该程序将fahrcelsius声明为float类型,并将常数改为浮点数,因此5.0 / 9.0是两个浮点数相除,结果将不被舍位。

如果算术运算符的两个操作数均为整型,则执行整型运算;如果有一个浮点型操作数和一个整型操作数,则整型操作数会被转换为浮点型(隐式类型转换)。赋值语句fahr = lower和条件测试语句while (fahr <= upper)也是隐式类型转换。

printf中的转换说明可以指定宽度与精度:

转换说明打印格式
%d十进制整数
%6d十进制整数,至少6个字符宽
%f浮点数
%6f浮点数,至少6个字符宽
%.2f浮点数,小数点后有两位小数
%6.2f浮点数,至少6个字符宽,小数点后有两位小数
%o八进制整数
%x十六进制整数
%c字符
%s字符串
%%%本身

练习1-3 修改温度转换程序,使之能在转换表的顶部打印一个标题。

练习1-4 编写一个程序打印摄氏温度转换为相应华氏温度的转换表。

1.3 for语句

下面的代码使用for循环实现前面的温度转换程序的功能:打印华氏温度与摄氏温度对照表(for循环)

主要改进:

  • 去掉了大部分变量,只是用了一个int类型的变量fahr
  • 改用for循环语句,温度的上限、下限和步长都是常量
  • 计算摄氏温度的表达式编程了printf函数的第三个参数,而不再是一个单独的赋值语句

其中最后一点是C与原中一个通用规则的实例:在允许使用某种类型变量值的地方都可以使用该类型的表达式。

for循环语句的格式:

1
2
3
for (初始化; 条件; 增加步长) {
    语句
}

初始化部分仅在进入循环前执行一次;条件部分控制循环的执行条件,如果条件为真则执行循环体,如果为假则终止;增加步长部分将循环变量增加一个步长,并进入下一次循环。

while循环相比,for循环比较适合初始化和增加步长都是单条语句并且逻辑相关的情形,因为它将循环控制语句集中放在一起,且比while语句更紧凑。

练习1-5 修改温度转换程序,要求以逆序(即按照从300读到0度的顺序)打印温度转换表。

1.4 符号常量

在程序中使用300、20之类的“魔数”(magic number)并不是一个好习惯,它们无法提供有意义的信息,而且使得程序的修改更加困难(当多次出现时)。处理魔数的一种方法是赋予它们有意义的名字。

#define指令可以把符号名(或称为符号常量)定义为一个特定的字符串:

1
#define 名字 替换文本

在该定义之后,程序中出现的所有“名字”都将用“替换文本”替换。其中,“名字”与普通变量名的形式相同:字母开头的字母和数字序列;“替换文本”可以是任何字符序列。

代码:打印华氏温度与摄氏温度对照表(符号常量)

符号常量名通常用大写字母+下划线

注意,#define指令行的末尾没有分号

1.5 字符输入/输出

标准库提供的输入/输出模型:文本流。文本流是由多行字符构成的字符序列,每行以换行符结尾。

标准库提供了一次读/写一个字符的函数getcharputchargetchar函数从文本流读取下一个输入字符(通常是通过键盘输入的),并将其作为返回值;putchar(c)整型变量c以字符的形式打印出来(通常是显示在屏幕上)。

1.5.1 文件复制

该示例把输入一次一个字符地复制到输出,其基本思想如下:

1
2
3
4
读一个字符
while (该字符不是文件结束符)
    输出刚读入的字符
    读下一个字符

代码:将输入复制到输出(版本1)

其中,关系运算符!=表示“不等于”。

字符在机器内部是以ASCII码的形式存储的,因此可以用整数表示(例如 '0'=48, 'A'=65, 'a'=97)。char类型专门用于存储这种字符型数据,这里使用int类型是为了处理输入结束符的问题。

C语言采取的解决方法是:输入结束时,getchar函数返回一个特殊值EOF(end of file,文件结束)。EOF定义在头文件<stdio.h>中,是一个整型数,与任何char类型的值都不相同(实际值为-1)。因为c必须能够存储EOF,因此不声明为char类型。(getchar的返回类型以及putchar的参数类型也都是int

注:在命令行窗口中如何输入结束符取决于操作系统,Windows系统是Ctrl+Z,Linux系统是Ctrl+D。注意要在单独的一行按Ctrl+Z(或Ctrl+D)再按回车才能输入EOF。

注意:对于从键盘输入的字符,在输入过程中并不是每输入一个字符就立即调用getchar,而是每次按回车之后才会调用。此时输入的字符(包括回车输入的换行符)被放入缓冲区,之后调用getchar时直接从缓冲区中读取字符。当缓冲区中的字符被消耗完后(包括换行符),再调用getchar则会再次等待输入。例如,输入abc<回车> ,会直接打印出abc<换行> ,这个过程中调用了4次getcharpurchar,第5次调用getchar时缓冲区中没有字符,因此等待输入。

在C语言中,类似于c = getchar()的赋值操作是一个表达式,并且具有一个值,即赋值后左边变量保存的值,因此赋值表达式可以作为其他表达式的一部分。上述程序可以该写成以下形式:

将输入复制到输出(版本2)

这段程序将输入集中化,整个程序看起来更紧凑,更易阅读。

注意:while语句的条件部分中,赋值表达式两边的圆括号不能省略,因为不等于运算符!=的优先级比赋值运算符=的优先级要高。因此c = getchar() != EOF等价于c = (getchar() != EOF),执行该语句后,c的值将被置为0或1(取决于getchar是否碰到文件结束符),这并不是期望的结果。

1.5.2 字符计数

下面的程序用于对输入的字符进行计数。

统计输入的字符数(版本1)

其中自增运算符++的功能是执行加1操作,++nc等价于nc = nc + 1。与其对应的是自减运算符--

该程序使用long类型的变量存放计数值。长整型占4字节(32位),最大值是231-1(在自己电脑上与int类型的长度相同)。转换说明%ld告诉printf函数其对应的参数是long类型。

该程序的另一种写法使用for循环:

统计输入的字符数(版本2)

使用double(双精度浮点数)类型可以处理更大的数字。对于floatdouble类型,printf函数都使用%f转换说明。%.0f表示不打印小数点和小数部分。

该程序中,for语句的循环体部分是空的,因为所有工作都在条件部分和增加步长部分完成了,因此用单独的分号代替。单独的分号称为空语句。把它单独放在一行是为了更加醒目。

边界条件:如果输入中不包含字符(即直接输入一个EOF),那么while语句或for语句的条件测试从一开始就为假,程序的执行结果将为0,这是正确的结果。

1.5.3 行计数

下面的程序用于统计输入中的行数。标准库保证输入文本流每行均以换行符结束,因此统计行数等价于统计换行符的个数。

统计输入中的行数

if 语句先测试圆括号中的条件,如果为真则执行后面的语句。

==是C语言中表示“等于”的关系运算符,注意与赋值运算符=区分。

单引号中的字符是字符常量,表示一个整数值,其值等于该字符在字符集中对应的数值(ASCII码)。例如,'0'的值是48,'A'的值是65,'@'的值是64。

字符常量中可以使用转义字符序列。例如,'\n'表示换行符,其值是10。注意:'\n'是单个字符,"\n"是仅包含一个字符的字符串常量。

练习1-8 编写一个统计空格、制表符与换行符个数的程序。

练习1-9 编写一个将输入复制到输出的程序,并将其中连续的多个空格用一个空格代替。

练习1-10 编写一个将输入复制到输出的程序,并将其中的制表符替换为\t,把回退符替换为\b,把反斜杠替换为\\

1.5.4 单词计数

下面的程序用于统计行数、单词数与字符数。这里对单词的定义是任何不包含空格、制表符或换行符的字符序列。(例如,"Hello, how are you?"包含4个单词"Hello,", "how", "are", "you?"

单词计数

state变量表示程序当前是否正位于一个单词之中。如果遇到空白符(空格、制表符或换行符)则表示单词结束(也可能本身就在单词外),将其置为OUT;如果遇到其他字符且当前位于单词外,则表示下一个单词开始,将其置为IN,并将nw加1。

语句nl = nw = nc = 0;将3个变量都设置为0,这种用法很常见。这是因为赋值运算符的结合次序是从右至左,因此这条语句等价于nl = (nw = (nc = 0));

运算符||代表逻辑或,运算符&&代表逻辑与。由&&||连接的表达式由左到右求值,并保证在求值过程中只要能够判断最终的结果为真或假,求值就立即终止(短路求值)。

例如,a的值为1,则表达式a == 1 || b == 2判断完a的值后即可确定整个表达式的值为真,不会再判断b的值。

注:C语言没有布尔类型,关系表达式的值用0或1表示,0为假,1为真。

这段程序还包括一个else部分,它指定当if语句的条件部分为假时所要执行的动作。其格式为:

1
2
3
4
if (条件)
    语句1
else
    语句2

如果条件为真则执行语句1,否则执行语句2。这两条语句都既可以是单条语句,也可以是括在花括号内的语句序列。在单词计数程序中,else之后的语句仍是一个if语句(即所谓的else if):

1
2
3
4
5
6
if (条件1)
    语句1
else if (条件2)
    语句2
else
    语句3

等价于

1
2
3
4
5
6
7
8
if (条件1)
    语句1
else {
    if (条件2)
        语句2
    else
        语句3
}

(第一种形式可以扩展到任意多个else if,见下一节)

练习1-12 编写一个程序,以每行一个单词的形式打印输入。

1.6 数组

下面的程序用于统计输入中各个数字、空白符(空格、制表符和换行符)以及其他字符的出现次数。对于10个数字,使用数组存放各个数字的出现次数比使用10个变量更方便。

统计字符的出现次数

该程序中的声明语句int ndigit[10];将变量ndigit声明为由10个整数构成的数组(array)。数组是一个固定大小的、相同类型元素的顺序集合,通过下标(subscription)来访问其中的元素,如下图所示。

数组

在C语言中,数组下标总是从0开始,因此数组ndigit的10个元素分别为ndigit[0], ndigit[1], ..., ndigit[9]

数组下标可以是任何整型表达式,包括整型变量以及整型常量。

前面提到,字符常量的值是ASCII码。由于'0', '1', ..., '9'具有连续递增的值(48~57),因此可以用c >= '0' && c <= '9'判断字符c是否为数字。如果是数字,那么对应的数值是c - '0'(例如:'8' - '0' = 56 - 48 = 8)。由于char类型本质上就是小整数,因此可以进行算术运算。

C语言使用if语句表示多路判定:

1
2
3
4
5
6
7
if (条件1)
    语句1
else if (条件2)
    语句2
...
else
    语句n

各条件从前往后依次求值,直到某个条件为真,然后执行对应的语句部分,之后整个语句执行结束。(其中每个“语句”都可以是括在花括号中的语句序列)如果所有条件都不满足,则执行最后一个else之后的语句(如果有)。

注:如1.5.4节最后所述,else if本质上是else后面的跟了另一个if语句。由于if语句可以视为单条语句,因此习惯上省略上一个else后的花括号,而不是将每个嵌套的if语句都缩进一级。

第3章的switch语句提供了编写多路分支程序的另一种方式。

练习1-13 编写一个程序,打印输入中单词长度的直方图。水平方向的直方图比较容易,垂直方向的直方图则要困难些。(注:所谓打印直方图就是用字符来模拟,例如频数为5则打印5个*;另外,需要规定单词的最大长度才能定义数组)

单词长度直方图

练习1-14 编写一个程序,打印输入中各个字符出现频度的直方图。

1.7 函数

函数(function)是一种计算的封装,使用函数时只需知道做什么,不需要知道怎么做。(例如标准库提供的printfgetcharputchar等)

C语言没有提供求幂运算符(不是**也不是^),现在通过编写一个求幂的函数power来说明定义函数的方法。函数power(m, n)用于计算整数m的n次幂,其中n是正整数。(标准库提供了一个可用于浮点数的版本pow(x, y)

下面是函数power(m, n)的定义及调用它的主程序:

测试power函数

函数定义的一般形式为:

1
2
3
返回值类型 函数名(参数声明) {
    语句序列
}

注:目前的程序都是单个源文件(.c文件),实际上C语言代码可以分散在多个源文件中,见4.5节。

main函数中调用了两次power函数。每次调用时,main函数向power函数传递两个参数;调用完成时,power函数向main函数返回一个整数,因此power(2, i)是一个整型值。

power函数的第一行int power(int m, int n)声明了参数的类型、名字以及函数的返回值类型。函数定义中圆括号内的变量称为形式参数(parameter或formal argument),函数调用中与形式参数对应的值称为实际参数(argument或actual argument)。例如,power函数定义中的mn是形式参数,main函数调用power函数时的2i是实际参数。

形式参数的名字只在函数内部有效,对其他任何函数都是不可见的,因此其他函数可以使用与之相同的参数名字而不会引起冲突。

关键字return用于函数返回值,形式为:

1
return 表达式;

函数不一定都有返回值,不带表达式的return语句将把控制权返回给调用者。

在该程序中,main函数的末尾有一个return语句。由于main本身也是函数,因此也可以向其调用者返回一个值,该调用者实际上就是程序的执行环境(IDE或命令行)。一般来说,程序(即main函数)返回值为0表示正常终止,返回值为非0表示出现异常情况。(前面的main函数都省略了return语句,之后的main函数将包含return语句)

出现在main函数之前的声明语句int power(int m, int n);用于声明(而不是定义)power函数,从而main函数可以在power函数定义之前调用它。这种声明称为函数原型(function prototype)。函数原型必须与函数的定义一致,否则编译器将报错(但函数原型与函数定义中的参数名不要求相同)。

即:函数声明(declaration)用于声明函数原型,函数定义(definition)给出函数的实现。有了函数声明即可在其他函数中调用该函数,但如果只有声明没有定义,编译器将报错。在该程序中,也可以将power函数的定义移至main函数之前,这样就不需要声明。但是更好的程序结构是先声明自己的函数,再写main函数,最后写自己的函数的定义。

练习1-15 重新编写1.2节中的温度转换程序,使用函数实现温度转换计算。

1.8 参数——传值调用

在C语言中,所有函数参数都是通过值传递的,即传值调用(call by value)。也就是说,传递给被调用函数的参数值从实际参数拷贝到形式参数,因此被调用函数不能直接修改主调函数中变量的值,而只能修改其私有的临时副本的值。

传值调用

上图是传值调用的示意图。在调用函数f时,实际参数x的值被拷贝到形式参数x中。这两个x不是同一个变量,形式参数x相当于函数f内部的一个局部变量。

在被调函数中,参数可以看作便于初始化的局部变量,因此额外使用的变量更少,程序更紧凑简洁。下面的power函数利用了这一性质:

测试power函数(版本2)

其中,参数n用作临时变量,并通过for循环递减,直到其值为0,这样就不需要额外引入变量ipower函数内部对n的任何操作不会影响到调用函数中n的原始参数值。

必要时,也可以让函数能够修改主调函数中的变量。此时主调函数需要向被调函数提供变量的内存地址(address)(地址就是指向变量的指针(pointer)),而被调函数则需要将对应的参数声明为指针类型,并通过它间接访问变量。第5章将介绍指针。

如果是数组参数,情况就不同了。当把数组名用作参数时,传递给函数的值是数组起始元素的地址,它并不复制数组元素本身。在被调函数中,可以通过数组下标访问或修改数组元素的值。这是下一节将要讨论的问题。

1.9 字符数组

字符数组是C语言中最常用的数组类型。下面通过编写一个程序来说明字符数组以及操作字符数组的函数的用法。该程序读入一组文本行,并把最长的文本行打印出来,基本框架如下:

1
2
3
4
5
6
while (还有未处理的行)
    读入新行
    if (该行比已处理的最长的行还要长)
        保存该行
        保存该行的长度
打印最长的行

程序很自然地分成了几部分,分别用于读入新行、测试读入的行、保存该行,其余部分则控制这一过程。

读入行和保存行可以分别用一个独立的函数实现。getline函数用于读取输入的下一行,并返回该行的长度,遇到文件结束符时返回0。如果某个新读入的行比以前读入的最长行还要长,就需要把该行保存起来,copy函数用于将新行复制到一个安全的位置。最后,在main函数中控制getlinecopy这两个函数。

打印最长的输入行

getline函数的声明int getline(char line[], int maxline);中,把第一个参数line声明为字符数组,在这里没有必要指明数组line的长度,因为该数组的大小是在main函数中设置的。(C语言的数组不包含长度信息,因此getline需要第2个参数来指定数组line的长度)

有些函数返回有用的值,而有些函数仅用于执行一些动作,并不返回值(例如copy)。返回值类型为void显式说明该函数不返回任何值。

在C语言中,字符串常量以字符数组的形式存储,数组的各元素分别存储字符串的各个字符,并'\0'标志字符串结束(与'\n'一样,'\0'也是转移字符序列,表示空字符,其值为0)。例如,字符串"hello\n"的存储形式如下图所示:

字符串的存储形式

printf函数的转换说明%s对应的参数必须是以这种形式表示的字符串。字符串的长度是不包括'\0'的字符个数,因此字符串"hello\n"的长度为6。

copy函数的实现正是依赖于输入参数由'\0'结束这一事实。其中while循环的判断条件(to[i] = from[i]) != '\0'每次将from的一个元素拷贝到to的对应元素,并判断所赋的值是否等于'\0'(赋值表达式的值等于赋值后左边变量的值),如果等于'\0'则结束,否则继续拷贝下一个元素。由于是先赋值后判断,因此'\0'也将被拷贝到to,从而to是一个完整的字符串。

getline函数以行为单位读取输入,结束条件有三种:

  • 达到最大行长度(由于要给'\0'留一个位置,因此可以读取的最大字符数是maxlen - 1
  • 遇到换行符(由于在for循环中遇到换行符就直接结束了,因此又使用一个if语句将换行符加上)
  • 遇到EOF

由于每次向line中写入一个字符后都会将i加一(除了最后的'\0'),因此最终i的值恰好就是读入的行长度(包括换行符)。例如,输入hello<回车>,则最终的line数组如下:

line数组

注意:在getlinecopy函数中可以通过形参数组lineto修改main函数中数组元素的值。这与1.8节所说的传值调用并不冲突。因为C语言中数组名表示的是数组起始元素的内存地址,通过内存地址即可修改其中的值。在传递数组参数时,传递的也是这个地址的值,如下图所示:

传递数组参数

注:内存地址就是一块内存区域的编号,是一个整数,但是一般用十六进制表示(例如0x1dcfee20)。任何变量都有一个内存地址(每次运行程序同一个变量的内存地址都不相同),数组元素的内存地址是连续的。用于保存内存地址的变量就是指针。

在这里为了简化程序没有考虑的问题:

  • 读入的行长度超过最大值:getline函数的执行是安全的,而main函数会将一行拆成两行或多行来读取。例如,将MAXLINE改为4,并输入”a”、”aa”、”aaa”三行,程序将输出”aa”而不是”aaa”(这是因为除去'\0',实际的最大行长度为3,第三行被拆分为”aaa”和”\n”两部分来读取,而”aaa”的长度并没有超过第2行”aa\n”)。如果main函数对这种情况要做其他处理,可以通过返回的行长度或者读取的最后一个字符是否是换行符来判断。
  • 数组下标越界:copy函数的注释说明“假定to足够大”,这里的意思是假定数组to的长度足以存放from中的字符串。但是,即使数组to不够大,编译器也不会报错,因为编译器无法得知数组to的长度。此时如果from中的字符串长度超过了数组to的长度,就确实会发生下标越界!这里的正确性只能由函数的调用者来保证。类似地,getline函数的maxline参数表示数组line的长度,而如果maxline的值大于数组line的长度就会发生下标越界,二者的一致性也只能由函数的调用者来保证。

练习1-16 修改打印最长文本行的程序的主程序main,使其可以打印任意长度的输入行的长度,并尽可能多地打印文本。

练习1-17 编写一个程序,打印长度大于80个字符的所有输入行。

练习1-18 编写一个程序,删除每个输入行末尾的空格及制表符,并删除完全是空格的行。

练习1-19 编写函数reverse(s),将字符串s中的字符顺序颠倒过来。使用该函数编写一个程序,每次颠倒一个输入行中的字符顺序。

1.10 外部变量与作用域

函数中的局部变量只在函数被调用时存在,在函数执行完毕退出时消失,其他函数不能直接访问它们。因此局部变量也称为自动变量(automatic variable)(可以在类型前添加auto说明符,也可以省略)。例如,getline函数不能直接访问main函数中的数组line,必须通过参数传递;getline函数中声明的变量icopy函数中声明的变量i没有关系。

由于自动变量只在函数调用执行期间存在,因此,在函数的两次调用之间,自动变量不保留前次调用时的赋值,在每次进入函数时都要显式为其赋值。如果自动变量没有赋值,则其中存放的是无效值。

外部变量(external variable)(也称为全局变量)定义在所有函数的外部,在所有函数中都可以通过变量名访问。因此函数间可以通过全局变量交换数据,而不必使用参数。另外,全局变量在程序执行期间一直存在,即使在对其赋值的函数返回后,这些变量仍然保持原来的值不变。

外部变量必须定义(define)在所有函数之外,需要访问外部变量的函数中必须声明(declare)相应的外部变量。可以使用extern语句显式声明,也可以通过上下文隐式声明。外部变量只能定义一次,但可以声明多次。

下面改写上述打印最长文本行的程序,把linelongestmax改为外部变量:

打印最长的输入行(外部变量)

从语法角度看,外部变量的定义与局部变量相同,只是位于函数的外部。函数在使用外部变量之前必须知道外部变量的名字,因此使用extern语句进行声明。这种声明除了在前面加了一个关键字extern外,其他方面与普通变量的声明相同。

注:在同一个源文件中,如果外部变量的定义出现在使用它的函数之前,则该函数中可以省略extern声明。因此,该例子中的几个extern声明都是多余的。但是,如果程序包含在多个源文件中,就需要使用extern声明来建立外部变量与其定义之间的联系。通常把外部变量和函数的声明放在一个单独的文件中(称为头文件(header)),并在每个源文件的开头使用#include语句把要用的头文件包含进来。后缀名.h约定为头文件的扩展名。

在该例子中,getlinecopy函数都不带参数,ANSI C规定使用关键字void显式声明空参数表。

注意:这一节区分了“定义”与“声明”这两个词。“定义”是指创建变量并分配存储单元;“声明”是指仅说明变量的类型,但不分配存储单元。

使用外部变量似乎可以简化数据的通信,使参数表变短。但过分依赖外部变量会使程序中的数据关系模糊不清。一方面,外部变量的值可能会被意外地修改;另一方面,将函数所使用的变量名直接写入函数,使函数失去了通用性。

到目前为止,已经对C语言的核心部分进行了介绍。借助这些少量的语言元素,已经能够编写出相当规模的有用的程序。下面的几个练习比本章前面编写的程序要复杂一些(亿些)。

练习1-20 编写程序detab,将输入中的制表符替换成适当数目的空格,使空格充满到下一个制表符终止位的位置,假设制表符终止位的位置是固定的,比如每隔n列就会出现一个制表符终止位。n应该作为变量还是符号常量呢?

注:制表符(tab)即C语言中的'\t',可以视为一个长度可变的空格。假设制表符的宽度是4,则一个制表符的作用是使下一个字符出现在列数是4的整数倍的位置(即制表符终止位)。例如,下面的空白部分都是一个制表符,制表符后边的字符出现在第4列或第8列:

1
2
3
4
5
0123456789
a	aaaaaa
aa	aaaaaa
aaa	aaaaaa
aaaa	aa

练习1-21 编写程序entab,将空格串替换为最少数量的制表符和空格,但要保持单词之间的间隔不变。假设制表符终止位的位置与练习1-20的情况相同。当使用一个制表符或者一个空格都可以到达下一个制表符终止位时,选用哪一种替换字符比较好?

练习1-22 编写一个程序,把较长的输入行“折”成短一些的两行或多行,折行的位置在输入行的第n列之前的最后一个非空格之后。要保证程序能够智能地处理输入行很长以及在指定的列前没有空格或制表符时的情况。

练习1-23 编写一个程序,删除C语言程序中所有注释。要正确处理带引号的字符串与字符常量。在C语言中,注释不允许嵌套。

练习1-24 编写一个程序,查找C语言程序中的基本语法错误,如圆括号、方括号、花括号不配对等。要正确处理引号(包括单引号和双引号)、转义字符序列与注释。(如果读者想把该程序编写成完全通用的程序,难度会比较大)

This post is licensed under CC BY 4.0 by the author.