Function and Program Structure
函数可以把大的计算任务分解成若干个较小的任务,程序设计人员可以基于函数进一步构造程序,而不需要重新编写一些代码。一个设计得当的函数可以把程序中不需要了解的具体操作细节隐藏起来,从而使整个程序结构更加清晰,并降低修改程序的难度。
C语言在设计中考虑了函数的高效性与易用性这两个因素。C语言程序一般都由许多小的函数组成,而不是由少量较大的函数组成。一个程序可以保存在一个或者多个源文件中。各个文件可以单独编译,并可以与库中已编译过的函数一起加载。
ANSI标准对C语言所做的最明显的修改是函数声明与函数定义这两方面。目前C语言已经允许在声明函数时声明参数的类型。为了使函数的声明与定义相适应,ANSI标准对函数定义的语法也做了修改。基于该原因,编译器就有可能检测出比以前的C语言版本更多的错误。并且,如果参数声明得当,程序可以自动地进行适当的强制类型转换。
ANSI标准进一步明确了名字的作用域规则,特别要求每个外部对象只能有一个定义。初始化的适用范围也更加广泛了,自动数组与结构都可以进行初始化。
C语言预处理器的功能也得到了增强。新的预处理器包含一组更完整的条件编译指令(一种通过宏参数创建带引号的字符串的方法),对宏扩展过程的控制更严格。
1. 函数的基本知识
尽管我们可以把所有的代码都放在主程序main
中,但更好的做法是,利用其结构把每一部分设计成一个独立的函数。分别处理3个小的部分比处理一个大的整体更容易,因为这样可以把不相关的细节隐藏在函数中,从而减少了不必要的相互影响的机会,并且,这些函数也可以在其他程序中使用。
函数的定义形式如下:
1 | 返回值类型 函数名(参数声明表) |
函数定义中的各构成部分都可以省略。最简单的函数如下所示:
1 | dummy() {} |
该函数不执行任何操作也不返回任何值。这种不执行任何操作的函数有时很有用,它可以在程序开发期间用以保留位置(留待以后填充代码)。如果函数定义中省略了返回值类型,则默认为int
类型。
程序可以看成是变量定义和函数定义的集合。函数之间的通信可以通过参数、函数返回值以及外部变量进行。函数在源文件中出现的次序可以是任意的。只要保证每一个函数不被分离到多个文件中,源程序就可以分成多个文件。
被调用函数通过return
语句向调用者返回值,return
语句的后面可以跟任何表达式:
1 | return expression; |
在必要时,表达式将被转换为函数的返回值类型。表达式两边通常加一对圆括号,此处的括号是可选的。
调用函数可以忽略返回值。并且,return
语句的后面也不一定需要表达式。当return
语句的后面没有表达式时,函数将不向调用者返回值。当被调用函数执行到最后的右花括号而结束执行时,控制同样也会返回给调用者(不返回值)。如果某个函数从一个地方返回时有返回值,而从另一个地方返回时没有返回值,该函数并不非法,但可能是一种出问题的征兆。在任何情况下,如果函数没有成功地返回一个值,则它的“值”肯定是无用的。
…主程序main
返回了一个状态。该返回值可以在该程序的环境中使用。
在不同的系统中,保存在多个源文件中的C语言程序的编译与加载机制是不同的。
2. 返回非整型值的函数
首先,由于函数的返回值类型不是int
,因此函数必须声明返回值的类型。返回值的类型名应放在函数名字之前。
其次,调用函数必须知道函数返回的是非整型值,这一点也是很重要的。为了达到该目的,一种方法是在调用函数中显式声明函数。
函数的声明与定义必须一致。如果函数与调用它的主函数main
放在同一源文件中,并且类型不一致,编译器就会检测到该错误。但是,如果函数是单独编译的(这种可能性更大),这种不匹配的错误就无法检测出来,函数将返回double
类型的值,而main
函数却将返回值按照int
类型处理,最后的结果值毫无意义。
根据前面有关函数的声明如何与定义保持一致的讨论,发生不匹配现象似乎很令人吃惊。其中的一个原因是,如果没有函数原型,则函数将在第一次出现的表达式中被隐式声明。如果先前没有声明过的一个名字出现在某个表达式中,并且其后紧跟一个左圆括号,那么上下文就会认为该名字是一个函数名字,该函数的返回值将被假定为int
类型,但上下文并不对其参数作任何假设。并且,如果函数声明中不包含参数,那么编译程序也不会对函数的参数作任何假设,并会关闭所有的参数检查。对空参数表的这种特殊处理是为了使新的编译器能编译比较老的C语言程序。不过,在新编写的程序中这么做是不提倡的。如果函数带有参数,则要声明它们;如果没有参数,则使用void
进行声明。
在下列形式的return
语句中:
1 | return (expression); |
其中,表达式的值在返回之前将被转换为函数的类型。这种操作可能会丢失信息,某些编译器可能会对此给出警告信息。在该函数中,由于采用了类型转换的方法显式表明了所要执行的转换操作,因此可以防止有关的警告信息。
3. 外部变量
C语言程序可以看成由一系列的外部对象构成,这些外部对象可能是变量或函数。形容词 external 与 internal 是相对的,internal 用于描述定义在函数内部的函数参数及变量。外部变量定义在函数之外,因此可以在许多函数中使用。由于C语言不允许在一个函数中定义其他函数,因此函数本身是“外部的”。默认情况下,外部变量与函数具有下列性质:通过同一个名字对外部变量的所有引用(即使这种引用来自于单独编译的不同函数)实际上都是引用同一个对象(标准中把这一性质称为外部连接)。
因为外部变量可以在全局范围内访问,这就为函数之间的数据交换提供了一种可以代替函数参数与返回值的方式。任何函数都可以通过名字访问一个外部变量,当然这个名字需要通过某种方式进行声明。
如果函数之间需要共享大量的变量,使用外部变量要比使用一个很长的参数表更方便、有效。但是,这样做必须非常谨慎,因为这种方式可能对程序结构产生不良的影响,而且可能会导致程序中各个函数之间具有太多的数据联系。
外部变量的用途还表现在它们与内部变量相比具有更大的作用域和更长的生存期。自动变量只能在函数内部使用,从其所在的函数被调用时变量开始存在,在函数退出时变量也将消失。而外部变量是永久存在的,它们的值在一次函数调用到下一次函数调用之间保持不变。因此,如果两个函数必须共享某些数据,而这两个函数互不调用对方,这种情况下最方便的方式便是把这些共享数据定义为外部变量,而不是作为函数参数传递。
4. 作用域规则
构成C语言程序的函数与外部变量可以分开进行编译。一个程序可以存放在几个文件中,原先已编译过的函数可以从库中进行加载。
名字的作用域指的是程序中可以使用该名字的部分。对于在函数开头声明的自动变量来说,其作用域是声明该变量名的函数。不同函数中声明的具有相同名字的各个局部变量之间没有任何关系。函数的参数也是这样的,实际上可以将它看作是局部变量。
外部变量或函数的作用域从声明它的地方开始,到其所在的(待编译的)文件的末尾结束。
另一方面,如果要在外部变量的定义之前使用该变量,或者外部变量的定义与变量的使用不在同一个源文件中,则必须在相应的变量声明中强制性地使用关键字extern
。
将外部变量的声明与定义严格区分开来很重要。变量声明用于说明变量的属性(主要是变量的类型),而变量定义除此以外还将引起存储器的分配。
在一个源程序的所有源文件中,一个外部变量只能在某个文件中定义一次,而其他文件可以通过extern
声明来访问它(定义外部变量的源文件中也可以包含对该外部变量的extern
声明)。外部变量的定义中必须指定数组的长度,但extern
声明则不一定要指定数组的长度。
外部变量的初始化只能出现在其定义中。
5. 头文件
…把程序分割到若干个源文件中的情况,如果该程序的各组成部分很长,那么这么做还是有必要的。…主要是考虑在实际的程序中,它们分别来自于单独编译的库。
此外,还必须考虑定义和声明在这些文件之间的共享问题。我们尽可能把共享的部分集中在一起,这样就只需要一个副本,改进程序时也容易保证程序的正确性。我们把这些公共部分放在头文件中,在需要使用该头文件时通过#include
指令将它包含进来。
我们对下面两个因素进行了折衷:一方面是我们期望每个文件只能访问它完成任务所需的信息;另一方面是现实中维护较多的头文件比较困难。我们可以得出这样一个结论:对于某些中等规模的程序,最好只用一个头文件存放程序中各部分共享的对象。较大的程序需要使用更多的头文件,我们需要精心地组织它们。
6. 静态变量
某些变量,它们仅供其所在的源文件中的函数使用,其他函数不能访问。用static
声明限定外部变量与函数,可以将其后声明的对象的作用域限定为被编译源文件的剩余部分。通过static
限定外部对象,可以达到隐藏外部对象的目的。
要将对象指定为静态存储,可以在正常的对象声明之前加上关键字static
作为前缀。…因此名字不会和同一程序中的其他文件中的相同的名字相冲突。
外部的static
声明通常多用于变量,当然,它也可用于声明函数。通常情况下,函数名字是全局可访问的,对整个程序的各个部分而言都可见。但是,如果把函数声明为static
类型,则该函数名除了对该函数声明所在的文件可见外,其他文件都无法访问。
static
也可用于声明内部变量。static
类型的内部变量同自动变量一样,是某个特定函数的局部变量,只能在该函数中使用,但它与自动变量不同的是,不管其所在函数是否被调用,它一直存在,而不像自动变量那样,随着所在函数的被调用和退出而存在和消失。换句话说,static
类型的内部变量是一种只能在某个特定函数中使用但一直占据存储空间的变量。
7. 寄存器变量
register
声明告诉编译器,它所声明的变量在程序中使用频率较高。其思想是,将register
变量放在机器的寄存器中,这样可以使程序更小、执行速度更快。但编译器可以忽略此选项。
register
声明只适用于自动变量以及函数的形式参数。
实际使用时,底层硬件环境的实际情况对寄存器变量的使用会有一些限制。每个函数中只有很少的变量可以保存在寄存器中,且只允许某些类型的变量。但是,过量的寄存器声明并没有什么害处,这是因为编译器可以忽略过量的或不支持的寄存器变量声明。另外,无论寄存器变量实际上是不是存放在寄存器中,它的地址都是不能访问的。在不同的机器中,对寄存器变量的数目和类型的具体限制也是不同的。
8. 程序块结构
C语言并不是程序块结构的语言,它不允许在函数中定义函数。但是,在函数中可以以程序块结构的形式定义变量。变量的声明(包括初始化)除了可以紧跟在函数开始的花括号之后,还可以紧跟在任何其他标识复合语句开始的左花括号之后。以这种方式声明的变量可以隐藏程序块外与之同名的变量,它们之间没有任何关系,并在与左花括号匹配的右花括号出现之前一直存在。
每次进入程序块时,在程序块内声明以及初始化的自动变量都将被初始化。静态变量只在第一次进入程序块时被初始化一次。
自动变量(包括形式参数)也可以隐藏同名的外部变量与函数。
在一个好的程序设计风格中,应该避免出现变量名隐藏外部作用域中相同名字的情况,否则,很可能引起混乱和错误。
9. 初始化
在不进行显式初始化的情况下,外部变量和静态变量都将被初始化为0,而自动变量和寄存器变量的初值则没有意义(即初值为无用的信息)。
定义标量变量时,可以在变量名后紧跟一个等号和一个表达式来初始化变量。对于外部变量与静态变量来说,初始化表达式必须是常量表达式,且只初始化一次(从概念上讲是在程序开始执行前进行初始化)。对于自动变量与寄存器变量,则在每次进入函数或程序块时都将被初始化。
对于自动变量与寄存器变量来说,初始化表达式可以不是常量表达式:表达式中可以包含任意在此表达式之前已经定义的值,包括函数调用。实际上,自动变量的初始化等效于简写的赋值语句。究竟采用哪一种形式,还得看个人的习惯。考虑到变量声明中的初始化表达式容易被人忽略,且距使用的位置较远,我们一般使用显式的赋值语句。
数组的初始化可以在声明的后面紧跟一个初始化表达式列表,初始化表达式列表用花括号括起来,各初始化表达式之间通过逗号分隔。当省略数组的长度时,编译器将把花括号中初始化表达式的个数作为数组的长度。
如果初始化表达式的个数比数组元素数少,则对外部变量、静态变量和自动变量来说,没有初始化表达式的元素将被初始化为0。如果初始化表达式的个数比数组元素数多,则是错误的。不能一次将一个初始化表达式指定给多个数组,也不能跳过前面的数组元素而直接初始化后面的数组元素。
字符数组的初始化比较特殊:可以用一个字符串来代替用花括号括起来并用逗号分隔的初始化表达式序列。
10. 递归
C语言中的函数可以递归调用,即函数可以直接或间接调用自身。
函数递归调用自身时,每次调用都会得到一个与以前的自动变量集合不同的新的自动变量集合。
递归并不节省存储器的开销,因为递归调用过程中必须在某个地方维护一个存储处理值的栈。递归的执行速度并不快,但递归代码比较紧凑,并且比相应的非递归代码更易于编写与理解。在描述树等递归定义的数据结构时使用递归尤其方便。
11. C预处理器
C语言通过预处理器提供了一些语言功能。从概念上讲,预处理器是编译过程中单独执行的第一个步骤。两个最常用的预处理器指令是:#include
指令(用于在编译期间把指定文件的内容包含进当前文件中)和#define
指令(用任意字符序列替代一个标记)。…预处理器的其他一些特性,如条件编译与带参数的宏。
11.1. 文件包含
文件包含指令(即#include
指令)使得处理大量的#define
指令以及声明更加方便。在源文件中,任何形如:
#include "文件名"
或
#include <文件名>
的行都将被替换为由文件名指定的文件的内容。如果文件名用引号引起来,则在源文件所在的位置查找该文件;如果在该位置没有找到文件,或者如果文件名是用尖括号<
与>
括起来的,则将根据相应的规则查找该文件,这个规则同具体的实现有关。被包含的文件本身也可包含#include
指令。
源文件的开始处通常都会有多个#include
指令,它们用以包含常见的#define
语句和extern
声明,或从头文件中访问库函数的函数原型声明。
在大的程序中,#include
指令是将所有声明捆绑在一起的较好的方法。它保证所有的源文件都具有相同的定义与变量声明,这样可以避免出现一些不必要的错误。很自然,如果某个包含文件的内容发生了变化,那么所有依赖于该包含文件的源文件都必须重新编译。
11.2. 宏替换
宏定义的形式如下:
#define 名字 替换文本
这是一种最简单的宏替换——后续所有出现名字记号的地方都将被替换为替换文本。#define
指令中的名字与变量名的命名方式相同,替换文本可以是任意字符串。通常情况下,#define
指令占一行,替换文本是#define
指令行尾部的所有剩余部分内容,但也可以把一个较长的宏定义分成若干行,这时需要在待续的行末尾加上一个反斜杠符\
。#define
指令定义的名字的作用域从其定义点开始,到被翻译的源文件的末尾处结束。宏定义中也可以使用前面出现的宏定义。替换只对记号进行,对括在引号中的字符串不起作用。
替换文本可以是任意的。
宏定义也可以带参数,这样可以对不同的宏调用使用不同的替换文本。使用宏看起来很像是函数调用,但宏调用直接将替换文本插入到代码中。形式参数的每次出现都将被替换成对应的实际参数。
如果对各种类型的参数的处理是一直的,则可以将同一个宏定义应用于任何数据类型,而无需针对不同的数据类型需要定义不同的函数。
…存在一些缺陷。其中,作为参数的表达式要重复计算两次,如果表达式存在副作用(比如含有自增运算符或输入/输出),则会出现不正确的情况。同时还必须注意,要适当使用圆括号以保证计算次序的正确性。
但是,宏还是很有价值的。…这样可以避免调用函数所需的运行时开销。
可以通过#undef
指令取消名字的宏定义。
形式参数不能用带引号的字符串替换。但是,如果在替换文本中,参数名以#
作为前缀则结果将被扩展为由实际参数替换成该参数的带引号的字符串。在实际参数中,每个双引号"
将被替换为\"
,反斜杠\
将被替换为\\
,因此替换后的字符串是合法的字符串常量。
预处理器运算符##
为宏扩展提供了一种连接实际参数的手段。如果替换文本中的参数与##
相邻,则该参数将被实际参数替换,##
与前后的空白符将被删除,并对替换后的结果重新扫描。
11.3. 条件包含
还可以使用条件语句对预处理本身进行控制,这种条件语句的值是在预处理执行的过程中进行计算。这种方式为在编译过程中根据计算所得的条件值选择性地包含不同代码提供了一种手段。
#if
语句对其中的常量整形表达式(其中不能包含sizeof
、类型转换运算符或enum
常量)进行求值,若该表达式的值不等于0,则包含其后的各行,直到遇到#endif
、#elif
或#else
语句为止。在#if
语句中可以使用表达式defined(名字)
,该表达式的值遵循下列规则:当名字已经定义时,其值为1;否则,其值为0。
C语言专门定义了两预处理语句#ifdef
与#ifndef
,它们用来测试某个名字是否已经定义。