980 likes | 1.08k Views
从问题到程序. 裘宗燕 北京大学数学学院 2005 年. 第五章 C 程序结构. 本章讨论与 C 程序整体结构有关的问题。 对正确理解 C 语言 / 正确书写 C 程序都很重要。是学习用 C 程序设计时应了解的“深层问题”。 有些叙述性内容,有些例子要推迟到后面章节。 建议在学习后面章节之后重读这章。. 1 )基本数值类型的全面介绍 2 )函数与 C 程序结构,函数原型 3 )变量类,作用域与存在期 4 )预处理命令 5 )字位运算符(特殊问题,只需初步了解). 5.1 数值类型. 实数类型: float , double , long double.
E N D
从问题到程序 裘宗燕 北京大学数学学院 2005年
本章讨论与C程序整体结构有关的问题。 对正确理解C语言/正确书写C程序都很重要。是学习用C程序设计时应了解的“深层问题”。 有些叙述性内容,有些例子要推迟到后面章节。 建议在学习后面章节之后重读这章。 1)基本数值类型的全面介绍 2)函数与C程序结构,函数原型 3)变量类,作用域与存在期 4)预处理命令 5)字位运算符(特殊问题,只需初步了解)
5.1 数值类型 实数类型:float,double,long double 实数类型外的算术类型都是整数类型。字符类型也看作整数类型,可以作为整数参加运算。 每一基本整数类型都有带符号与无符号两种类型,用限定词signed或unsigned说明。 无符号类型的值大于等于0。 同一基本类型的带符号/无符号类型用同样长度编码。 signed可省略,如signed long简写为long。
字符类型:char,signed char,unsigned char 用一个字节表示,其中存字符编码。字符作为整数参加运算时直接用编码,依赖于编码方式。 ASCII字符集里数字(字母)字符的编码连续排列。 char等同于signed char或unsigned char(不同系统可能不同)。简单程序只用char,不需要关心有无符号。只有用char参加整数运算时才需要考虑。 有符号/无符号字符不好理解。就是:参加整数运算时看作有符号整数还是无符号整数? 若字符编码在0-127内也不会有问题。 基本ASCII字符集的编码范围是0-127。
整数类型基本:int,short int,long int 总共六个: int unsigned int short unsigned short long unsigned long short int简写为short,long int简写long, unsigned int简写为unsigned short表示范围不大于int,long不小于int。无符号/有符号类型位数相同。具体表示由C语言系统确定。 基本类型是int,一般用计算机的字长表示。 16位机器的C系统中,int通常用16位表示; 32位机器的C系统中,int通常用32位表示。
微机C系统情况复杂: 老系统16位int,8086字长,32位long,16位short 许多新系统用32位int和long,short用16位。 整数字面量可用十、八或十六进制写(加后缀)。 short无字面量写法。无符号数加后缀u或U:例: 123U,2987654LU,327LU,32014U 无符号整数的算术运算按取模方式做(不会溢出),超出表示范围时取模作为计算结果。 例:若16位unsignd范围为0~65535。计算超范围时取模65536。234U+65500U的计算结果是198U。
混合类型运算前把小类型转换到大类型的值。 整数提升: 小整数(short、char等)先转为int值再运算。若转换结果超出int范围(unsigned short提升可能出问题),就提升为unsigned。 基本类型相同时,认为无符号类型是比同样的有符号类型更大的类型。例如做下面计算: 2365U + 18764 将先由18764(整型值)转换生成unsigned对应值,再用此新值参与运算。 若转换结果在相应类型里无法表示,结果没有定义。
基本数据类型的选择 C 语言提供多个浮点类型和整数类型,是供编程者选择使用。对于初学,这方面的选择不太重要。 • 如无特殊需要,浮点数总用double,因其精度和范围能满足一般要求(float精度过低,long double可能低效,一般不用它们)。 • 如无特殊需要,整数总用int。int是C语言里最基本的类型,能得到硬件基本支持,其效率不会低于任何其他整数类型。有时用long类型。 • 如无特殊需要,字符总采用char。 • 尽量少用unsigned类型,除非服务于特殊目的。
5.2 函数和标准函数库 问题复杂使程序变长。大程序难开发/难阅读理解/难修改;修改时容易破坏完整性,难保证不引进新错误。 程序中常出现许多相同/类似片段。使程序更长,增加不同部分间的联系,损害可修改性。 • 处理复杂问题的基本方法: • 将复杂事物分解为 较简单的部分,分别处理。 • 由各部分的解构造出整个问题的解。 • 复杂概念在简单的基本概念的基础上定义。 • 证明复杂数学定理,常先证明一些引理。
为支持复杂程序开发,各种程序抽象机制被引进程序语言。使人能把程序片段抽象出来作为整体对待处理。为支持复杂程序开发,各种程序抽象机制被引进程序语言。使人能把程序片段抽象出来作为整体对待处理。 借助这些机制可控制复杂的程序结构,完成复杂的程序或软件系统。抽象机制也是程序结构的组织手段。 C只提供了计算过程的抽象机制,函数。其他语言把这类抽象机制分为 “函数”和“过程”两类。C统称为函数。 后有许多发展,如模块、类型定义、面向对象等。 编写大程序时应特别注意函数分解。 没有合理函数分解不可能完成复杂工作/多花时间/程序更难理解/错误更难发现和纠正。 开始就应强调好的编程习惯(包括函数分解)。
函数的作用:把一段计算抽象出来,封装(包装)成独立实体。这种封装程序段称为函数定义。函数的作用:把一段计算抽象出来,封装(包装)成独立实体。这种封装程序段称为函数定义。 定义后通过函数名就可以用简便方式要求执行该函数所封装的计算。这种描述片段称为函数调用。 • 函数抽象机制的意义: • 重复片段可用唯一的函数定义和一些形式简单的函数调用取代,使程序更简短清晰 • 同样计算片段只描述一次,易于修改 • 函数定义和使用形成对复杂程序的分解。可独立考虑函数定义与使用,大大提高工作效率 • 具有独立逻辑意义的函数可看作高层基本操作,使人可站在合适的抽象层次上观察把握程序的意义
C语言的库函数 • 基本C语言很小,ANSI C定义了标准库,其中提供最常用的与平台无关的功能。 • 每个符合标准的C系统都提供了标准库,通常还提供一些扩充库,以便使用特定硬件/特定系统的功能: • DOS 上的C系统提供与DOS有关的功能函数 • Windows上的系统提供与 Windows有关的函数 • UNIX系统上的系统提供与UNIX接口的函数 • 扩充库不标准,使用扩充库的程序依赖于具体系统。 库函数实现常用计算,可按规定方式调用,不必自己实现/不必关心怎样实现。开发一次使所有用户受益。
标准库功能包括输入输出、文件操作、存储管理,其他如数学函数、数据转换函数等。有关介绍散布在各章,后面有详细介绍。标准库功能包括输入输出、文件操作、存储管理,其他如数学函数、数据转换函数等。有关介绍散布在各章,后面有详细介绍。 对具体C系统的扩充库可查阅系统联机帮助,或查阅有关手册、参考书籍。需要学习如何阅读。 下面介绍两组常用标准函数。
字符分类(<ctype.h>) isalpha(c) c是字母字符 isdigit(c) c是数字字符 isalnum(c) c是字母或数字字符 isspace(c) c是空格、制表符、换行符 isupper(c) c是大写字母 islower(c) c是小写字母 iscntrl(c) c是控制字符 isprint(c) c是可打印字符,包括空格 isgraph(c) c是可打印字符,不包括空格 isxdigit(c) c是十六进制数字字符 ispunct(c) c是标点符号 int tolower(int c) 转为对应小写字母 int toupper(int c) 转为对应大写字母
#include <stdio.h> #include <ctype.h> int main() { int c, cd = 0, cu = 0, cl = 0; while ((c = getchar()) != EOF) { if (isdigit(c)) ++cd; if (isupper(c)) ++cu; if (islower(c)) ++cl; } printf ("digits: %d\n", cd); printf ("uppers: %d\n", cu); printf ("lowers: %d\n", cl); return 0; } 用标准库函数完成判断比自己写条件更合适。
统计单词程序: #include <stdio.h> #include <ctype.h> int main (){ int count = 0, c; while ((c = getchar()) != EOF) { if (isspace(c)) continue; ++count; while ((c = getchar()) != EOF && !isspace(c) ) ; } printf("Words: %d\n", count); return 0; }
随机数生成函数应用有时需要带随机性的计算:随机数生成函数应用有时需要带随机性的计算: • 程序调试:用数据做运行试验,随机数据非常合适。 • 计算机模拟:模拟实际情况/过程,探索规律性。客观事物变化有随机性。 • 为此需要生成随机数。 • 计算机只能生成 “伪随机数”。 常用方法是定义某种递推关系,设法使序列中的数比较具有随机性。最常用的是除余定义: a0 = A an = (B × an-1 + C) mod D A,B,D,C为常数,0≤A<D。适当选择B、C可产生0 到 D-1 的较好随机数列。 可以根据这种关系定义随机数生成函数(后面讨论)。
使用标准库的随机数功能需要包含文件<stdlib.h>。使用标准库的随机数功能需要包含文件<stdlib.h>。 随机数生成函数:int rand(void) 无参数,得到0和符号常量RAND_MAX间的随机整数。 不同系统的RAND_MAX可能不同,为至少32767。 TURBO-C里的RAND_MAX是32767。 函数srand用seed值设种子值: void srand(unsigned seed) 默认初始种子值是1。rand根据当时种子值生成下一随机数并修改种子值。
下面程序先打印出由默认种子值出发的10个随机数,而后打印设定了新的种子值11后生成的10个随机数:下面程序先打印出由默认种子值出发的10个随机数,而后打印设定了新的种子值11后生成的10个随机数: #include <stdio.h> #include <stdlib.h> int main() { int i; for (i = 0; i < 10; ++i) printf ("%d ", rand()); putchar('\n'); srand(11); for (i = 0; i < 10; ++i) printf ("%d ", rand()); putchar('\n'); return 0; }
5.3 函数定义和程序的函数分解 库函数有限,编写复杂程序时需要自己定义函数。 一个C程序主要是一系列的函数定义。函数定义形式: 返回值类型 函数名 参数表 函数体 函数头部:描述函数外部与函数内部的联系。 无返回值类型表明函数返回int值(新标准不允许)。应该总写返回类型。无返回值用void说明。 允许任意多个参数。无参函数用(void)(早期C语言对ANSI C的影响),定义函数时可用()。
主函数main • main的返回值规定为int。这个返回值在程序结束时送给外部。程序外部(如操作系统)可用这个值 • 一般用返回0表示程序正常结束,其他值表示出错 • main以外的函数只有被调用时才会执行 • 不允许调用main int main () { /*一般写法*/ ... ... return 0; }
程序的函数分解 • 什么样的程序片段应该定义为函数: • 重复出现的相同/相似计算片段,可设法抽取性定义为函数。可能缩短程序,提高可读性和易修改性。 • 长计算过程中有逻辑独立性的片段,即使出现一次也可定义为函数,以分解复杂性。 经验原则:可以定义为函数的东西,就应该定义为函数;一个函数一般不超过一页。 往往存在很多可行的分解,寻找合理有效的分解是需要学习的东西。
实例研究:字符图形 写程序打印下面这类字符图形 最“简单”的方式:用一系列 putchar 调用打印星号和空格(数清字符个数,就可以写出程序)。
方法二:程序里写一系列printf调用。例: int main () { /* 打印菱形 */ printf(" * "); printf(" * * "); printf(" * * *); ... ... /* 下略 */ } 不好!每个程序需要写一组输出语句,很罗嗦,没意思 • 若要修改图形大小或形式,程序需全部重写 • 如果需要画出新图形,已做工作对新工作毫无价值 应考虑画这类图形的“基本动作”,定义为函数。
一种分析(存在许多合理分析): 需要画的图形比较规范,一行有两种情况:1)一段连续字符;2)在两个特定位置的字符。可考虑定义两个函数,其头部分别为: void line(int begin, int end) void points(int fst, int snd) 分别从begin到end输出*,或者在fst和snd输出*。 字符图形未必用星号,可推广,引进一个字符参数 void line(char c, int begin, int end) void points(char c, int fst, int snd)
虽然函数还没定义,由于其功能很清楚,已经可以用它们输出图形了。例如画菱形:虽然函数还没定义,由于其功能很清楚,已经可以用它们输出图形了。例如画菱形: for (i = 10, j = 10; i > 4; --i, ++j) points('*', i, j); for (i+=2, j-=2; i<=j; ++i,--j) points('*', i, j); /* 10和5是常量,确定菱形的位置和大小 */ 画其他空心或实心的规范图形也不难。 函数使用者只需考虑函数的使用形式和功能,如何基于它们完成工作,无需考虑函数实现(也不应该考虑)。 下面考虑函数实现。 两个函数就是输出一些空格和字符后换行:
void line(char c, int begin, int end) { int i; for (i = 0; i < begin; ++i) putchar(' '); for ( ; i <= end; ++i) putchar(c); putchar('\n'); } void points(char c, int first, int second) { int i; for (i = 0; i < first; ++i) putchar(' '); putchar(c); for (++i ; i < second; ++i) putchar(' '); if (first < second) putchar(c); putchar('\n'); } 做好了一个图形后,修改其大小或形状也比较容易。
也可引入附加参数,将两个函数合而为一: void line(char c, int begin, int end, int fill) 在fill为0值时在两端点输出,非0时连续输出字符。 还可基于它们定义画各种基本几何图形的函数。设计函数时需要选定合适参数。例如画矩形的函数: void rect(char c, int begin, int len, int high) { ... ... } void rect_fill(char c, int begin, int len, int high) { ... ... } 这些函数又形成另一层抽象层次。定义好这样一组函数后,就可以在新的层次上写字符图形程序了。 上述分解有优点也有缺点。完全可以考虑其他分解
对函数的两种观点 封装把函数内外隔成两个世界。不同世界形成了对函数的两种观点。函数头规定了两个世界的交流方式。
函数是独立的逻辑实体。定义后可以调用执行。由此形成对函数的两种观察方式:函数是独立的逻辑实体。定义后可以调用执行。由此形成对函数的两种观察方式: 1)从函数外(以函数使用者的角度)看函数; 2)在函数内(以函数定义者的角度)看函数。 计划函数时,要同时从两个观点看:需要什么函数/参数/返回值?分析确定函数头部,定好公共规范。 写函数定义时应站在内部观点思考/解决问题; 使用函数时应站在外部立场上思考/解决问题。 功能描述清楚,接口定义好以后,函数定义和使用可由不同人做。要求双方遵循共同规范,对函数功能有一致理解。自己写函数时也要保证两种观点的一致性。
函数定义 解决实现函数的算法问题和语言问题。关注函数调用时应该做什么,如何描述有关的计算过程。 参数是局部变量,调用时用对应实际参数赋初值,然后开始执行函数体。参数像其他局部变量一样可以赋值。 return语句的执行导致函数结束。 return语句的两种形式: return; return 表达式; 函数体里可有多个return语句。在带不带表达式/所带表达式的类型方面必须与函数头部一致。 执行到函数体末端时函数结束(无返回值的函数)。
函数调用 调用函数时必须提供数目正确/类型合适的实参。调用无参函数时也需要写圆括号。 无返回值函数用在独立的函数调用语句里: pc_area(x + 3); 有返回值的函数通常用在表达式里。返回值也可不用。 例:printf返回int。工作正常时返回实际输出的字符数,出错时返回负值。前面例子都没用。
C函数的参数是值参数。函数调用时先计算实参表达式的值,把值复制给对应形参,而后执行函数体。函数内对形参的操作与实参无关。这种参数意义清晰。C函数的参数是值参数。函数调用时先计算实参表达式的值,把值复制给对应形参,而后执行函数体。函数内对形参的操作与实参无关。这种参数意义清晰。 值传递与赋值和初始化类似,可能出现转换。要求实参值可转换到形参类型,否则是类型错。 语言没有规定调用时的实参求值顺序,对求值顺序敏感的函数调用是错误(与二元运算类似)。例: n = ...; m = gcd(n += 15, n); 另一错误调用: printf("%d, %d", n++, n);
函数原型 命名对象(变量/函数等)有一个定义点,以及可能多个使用点。基本规则是先定义后使用。 保证正确编译的基本原则:从每个对象的每个使用点向前看,能得到使用该对象的完备信息。 局部变量:变量定义在语句前,保证了先定义后使用。后面变量定义可引用前面已经定义的变量。 函数:使用点所需信息就是函数的类型特征,包括函数名/参数个数和类型/返回值类型等。 调用位置要检查参数个数和类型,是否需要以及能否转换。对返回值也有类似问题。不知道函数的类型特征就无法保证正确完成这些检查和处理。
将主函数写在最后就是为保证函数定义与使用的关系。将主函数写在最后就是为保证函数定义与使用的关系。 递归定义的函数也没有问题: long fact(long n) { return n<=1 ? 1 : n * fact(n-1); } 有些情况无法通过安排函数定义位置解决。典型例子: int h(int x) { int g(int y) { .... .... .. g(...) .. .. h(...) .. .... .... } } 这是引进函数原型说明的一个原因。更重要的是使人能自由安排函数定义位置,支持大型程序开发。
原型说明的形式与函数头部类似,加分号。参数名可省略,可与函数定义用的名字不同。原型说明的形式与函数头部类似,加分号。参数名可省略,可与函数定义用的名字不同。 原型的参数名最好用有意义的名字,有利于写注释。 任何可以写定义的地方都可以写原型说明。提倡把原型说明都放在程序文件最前面: 1)使文件里所有调用能看到同一个原型说明。 2)使函数的定义点也能看到同一个原型说明。 原型说明是保证函数定义/使用间一致性的媒介。 为保证原型能起作用,必须给出完整的类型特征 void line(char c, int begin, int end); void points(char c, int fst, int snd);
过时的函数定义形式/原型形式 C标准化时保留了一些过时东西,以保证老程序合法。这里介绍老形式只是为能读别人写的老程序。 过时函数定义形式。例: double f1(x, y, n) double x, y; int n; { /* 函数体 */ } /*早期高级程序语言中常见的形式 */ 不要使用过时函数定义形式!
过时原型形式 形式:不给出参数类型,常与变量定义写在一起。这种“简写”原型说明是许多C程序中隐藏错误的根源。 例,设有两个函数在某处定义,完整原型分别是: double f(double); int g(double, int); 程序: int main (void) { int g(), n; double f(), x; .... x = f(3); n = g(n); .... } /* 大错,编译程序不会发现! */ 危害极大,绝不要使用!
不写函数原型 为宽容老程序,C语言容许不写原型。 遇到调用 f(...),若此前无f定义或原型就做默认处理,方式是假设有一个原型说明: int f(); 出现在包含该函数调用的最内层复合语句的最前面,按此假设对函数调用进行编译和处理。 只有f确实返回int,调用的实参个数和类型都与f的要求完全相同(不需转换)时才能产生正确结果。 不写原型可能引起许多问题。是极危险的编程习惯!
错误的例子: 下例编译连接都不会出错。运行时无法保证输出什么: #include <stdio.h> int main(){ double x, y; float u = 1.57; x = sin(1); y = sin(u); printf("Strange: %f, %f\n", x, y); return 0; } 用C++系统编译这种C程序可能给出警告。
为避免函数使用的隐含错误,应坚持正确编程原则:为避免函数使用的隐含错误,应坚持正确编程原则: • 如果使用标准库,必须#include必要的库文件 • 所有使用前未给出定义的函数(无论实际上在哪里定义),都必须给出正确完整的原型说明 • 应把原型说明写在源文件最前面 • 基本原则:使函数的定义点和所有使用点都能“看到”同一个原型说明。 • 坚持这些原则,就能避免函数调用与定义不一致的错误(常常是编译程序检查不到的隐含致命错误)。 简单地说“用C语言编程序容易出错”并不合理。许多错误是由于人们没按正确地方式做事情。
5.4 程序结构与变量 • C源程序由一系列外部定义和外部说明构成。 • 外部定义/说明:写在程序表层(而不是函数或其他结构内部)的定义/说明。 • 函数只能定义在外部。 • 函数定义是外部定义,写在表层的原型说明是外部说明。外部定义/说明从出现开始起作用直至文件结束。 • #开始的行不是基本语言的东西,下节讨论。 本节讨论由定义/说明位置引起的语义问题。以变量为例。函数的情况简单(不允许在函数内部定义函数) 重要概念:作用域和存在期。
外部变量 在函数之外定义的变量称为外部变量/全局变量。 外部变量原则上可在程序中任何地方用。由于变量定义从出现处开始起作用,通常把外部变量定义写在源文件最前面,使文件里的函数都可以访问它们。 外部变量可以后定义先使用,或在一个源文件里定义在其他文件使用。为此,使用前应给出外部变量说明。形式与变量定义类似,前面加关键词extern。例: extern int n, m; 外部说明通常放在源文件最前面,供整个文件参照, 。 更重要的:保证整个程序参照同一说明,保证一致性。
书上例。后面会看到有更有意义的例子。 新情况:如果一个函数定义里用到外部变量,它就依赖这些变量,不再独立了。 • 两点注意: • 定义和说明不同。定义要求创建被定义的对象;说明只指明其存在,必须另有定义,否则该说明无效。有关变量定义与说明的差别下面还要讨论 • 外部变量可在整个程序用,在一个完整程序里不能有重名外部变量。否则连接时会出问题 • 不要与库里东西重名(标准库定义了一批外部名字)。
作用域与生存期 • 变量定义:1)定义特定类型的变量;2)为变量命名。 • 还确定了: • 变量定义起作用的范围,变量定义的作用域。由定义位置确定,在此范围可通过该名字使用该变量 • 确定变量建立和销毁时间,变量的存在期。各种变量的存在期可能不同。变量实现的基础是内存单元,存在期就是变量被分配内存存储到撤消的期间 作用域/存在期是重要概念。有联系但又不同。弄清它们,许多问题就容易理解了。
一个定义的作用域是一段源程序,是静态概念。如在函数体开始定义的变量,作用域是整个函数体。一个定义的作用域是一段源程序,是静态概念。如在函数体开始定义的变量,作用域是整个函数体。 存在期是动态概念(程序执行的一段时间)。变量在存在期中保持其存储单元,不经赋值那里的值就不变。 在作用域和存在期方面,外部变量和函数内的普通局部变量(称为自动变量)性质截然不同。 外部变量定义的作用域是整个程序(全局的),这样定义的变量可以在程序中任何地方使用。 自动变量定义的作用域是定义所在的复合语句。在该复合语句之外无效(局部的)。 形参看作函数体的局部变量,作用域是整个函数体。
从作用域角度看,main也是普通函数,其中的定义是局部定义,作用域是main函数的体。从作用域角度看,main也是普通函数,其中的定义是局部定义,作用域是main函数的体。 外部变量的存在期是程序整个执行期间。程序开始时建立所有外部变量,一直保续到程序结束。 复合语句里定义的变量,存在期是该复合语句的一次执行。复合语句开始时创建,结束时撤消。复合语句再次执行时重建,新变量与撤消的变量无关。自动变量。 for (n = 0; n < 10; n++) { int m; if (n == 0) m = 2; /* 循环第二次到这里时m的值未定 */ }