计算机的函数,是一个固定的一个程序段,或称其为一个子程序,它在可以实现固定运算功能的同时,还带有一个入口和一个出口,所谓的入口,就是函数所带的各个参数,我们可以通过这个入口,把函数的参数值代入子程序,供计算机处理;所谓出口,就是指函数的函数值,在计算机求得之后,由此口带回给调用它的程序。
一个较大的程序一般应分为若干个程序块,每一个模块用来实现一个特定的功能。所有的高级语言中都有子程序这个概念,用子程序实现模块的功能。在C语言中,子程序的作用是由一个主函数和若干个函数构成。由主函数调用其他函数,其他函数也可以互相调用。同一个函数可以被一个或多个函数调用任意多次。
在程序设计中,常将一些常用的功能模块编写成函数,放在函数库中供公共选用。要善于利用函数,以减少重复编写程序段的工作量。
下面是一个编写的不好的函数
Void HandleStuff(CORP_DATA* pInputRec, int CrntQtr, EMP_DATA EmpRec,
float* pEstimRevenue, float YTDRevenue, int ScreenX,
int ScreenY,COLOR_TYPE * pNewColor, COLOR_TYPE PrevColor,
STATUS_TYPE* pStatus , int ExpenseType)
{
int i;
for( i = 1; i<= 100; i++){
pInputRec->revenue[i] = 0;
pInputRec->expense[i] = corpExpense [CrntQtr][i];
}
UpdateCorpDatabase( EmpRec ) ;
*pEstimRevenue = YTDRevenue * 4.0 /(( float )CrntQtr);
*pNewColor = PrevColor;
*pStatus = Success;
if( ExpenseType == 1){
Profit[i] =Revenue[i] - Expense.Type1[i];
}
else if( ExpenseType == 2){
Profit[i] = Revenue[i] - Expense.Type2[i];
}
else (if ExpenseType == 3){
Profit[i] = Revenue[i] - Expense.Type3[i];
}
}
此函数的缺点:
①函数的名字不好,不能说明函数是干什么的
②函数进行了全局变量的读写操作
- CorpExpense、profit都是全局变量
- 对全局变量的操作应使用专门的存取函数
③函数的功能不是单一的
- 它初始化了某些变量;对一个数据库进行写操作;又进行了某些计算工作。而这些功能之间又看不出任何联系
④函数中没有采取预防非法数据的措施
- 如果CrntQtr的值为“0”,那么,表达式YTDRevenue* 4.0/real(CrntQtr)就会出现被零除的错误
- 指针没有检查是否为空
⑤在函数中仅使用了CORP-DATA型参数的两个域,却传入了整个结构体变量
- 是否需要传递结构体变量需根据实际情况来定,比如计算某个点到自车的距离的函数,只传入必要的经纬度就可以了,不要传一个既有名称、读音等又有经纬度的PointInfo结构体变量。
- 一定要传结构体变量的话务必传递指针或引用。
⑥函数中的一些参数没有使用过 :ScreenX和ScreenY
⑦函数中的参数太多(不要多于7个)
⑧函数中使用了几个常数(神秘数字): 100,4.0,2和 3
函数设计概述
函数设计主要在详细设计中做
创建函数的理由
- 降低复杂性
- 避免重复代码段
- 限制改动带来的影响(改动一个函数的实现不会影响其它代码)
- 隐含逻辑(比如排序)
- 隐含数据结构(从DB数据中取得“版本号”)
- 改进性能(可以优化某个函数算法来改进)
- 实行集中控制(比如集中控制内存的申请)
- 代码段复用
- 改善某一代码段的可读性(min(),max())
- 改善可移植性(分隔硬件依赖和OS依赖的代码)
- 分隔复杂操作(繁琐的算法、通信协议等)
- 独立非标准语言函数的使用
- 简化复杂的布尔测试(比如IsSuccess())
大原则——内聚性
内聚性是指一个函数内的各种操作之间联系的紧密程度
可以接受的内聚性
- 功能内聚性
- 顺序内聚性
- 通讯内聚性
- 临时内聚性
不可接受的内聚性
- 过程内聚性
- 逻辑内聚性
- 偶然内聚性
函数设计的目标:强内聚
可以接受的内聚性
功能内聚性
- 函数中的所有操作都是为了完成一个单一功能。功能可以有大有小,但必须单一。“GetPersonalInfo”是一个单一的功能,“GetName”是单一的,“GetBirthday”是单一的,但“GetNameAndBirthday”就不是单一的功能了。
- 举例:Sin(),GetCustomerName(),EraseFile()等
- 设计某个函数时,功能内聚性是首选。
- 函数名字的选取很重要,比如处理某项事务有如下步骤:读取用户的确认信息;增加一条记录到数据库中;计数器加1,进行以上步骤的函数取名为ConfirmEntryAndAdjustData时,此函数仅具有偶然内聚性;取名为CompleteTransaction时,此函数具有功能内聚性。
顺序内聚性
- 特点:函数内的操作不能形成完整的功能,只是包含需要按特定顺序进行的、逐步分享数据的一些操作
- 举例:下面五个操作完成一个功能:打开文件、读文件、进行两个计算、输出结果、关闭文件。如果DoStep1()打开文件、读文件和计算操作,而DoStep2()进行输出结果和关闭文件操作。Dostep1和DoStep2只是把步骤分隔开来,并没有产生出独立的功能,所以具有顺序内聚性。
- 实际设计当中,把一个功能分成若干个步骤,需要有充足的理由。
通讯内聚性
- 特点:函数中的若干个操作只是使用相同数据,而没有其它任何联系
- 举例:GetNameandChangePhoneNumber(),它取得的Name和PhoneNumber在同一个用户记录中,GetName和ChangePhoneNumber之间没有其他的关系。
- 有比较充分的理由才可以接受此内聚性,比如GetName的同时ChangePhoneNumber可以明显改善性能,不然的话还是推荐拆成GetName和ChangePhoneNumber两个函数,这样两个函数就具有功能内聚性了。
临时内聚性
- 特点:函数的几个操作因为同时执行才被放入同一个函数
- 例如:Startup(),CompleteNewEmployee(),Shutdown()等
- 又比如到达目的地的操作(删除目的地;删除经路;停止引导等),可以在一个函数中完成,因为这些操作必须同时执行。
不可接受的内聚性
过程内聚性
- 特点:函数中的操作是按某一特定顺序进行的,却不使用相同的数据。
- 举例:按一定的顺序打印报告的函数,打印的内容包括销售收入、开支、雇员电话表。各操作之间并不共享数据。
逻辑内聚性
- 特点:函数的几个操作之间没有任何联系,只是用传进来的控制标志来决定执行哪些操作,这些操作往往包括在一个很大的if“或者case语句中。
- 举例:函数InputAll()的输入内容可能是用户名字、雇员时间卡信息或者库存数据,至于到底是其中的哪一个,则由传入函数的控制标志决定。
偶然内聚性
- 特点:函数中的各操作之间无任何联系,也叫作“无内聚性”
- 举例:本文中开头的那个不好的函数
内聚性实例
实例1:一个按给出的生日计算雇员年龄和退休时间的函数
分析:
- 如果它是利用所计算的年龄来确定雇员将要退休的时间,那么它就具有顺序内聚性;而如果它是分别计算年龄和退休时间的,但使用相同生日数据,那它就只具有通讯内聚性。
如何改进成功能内聚性呢?
- 可以分别建立两个函数,一个根据生日计算年龄,另外一个根据生日确定退休时间,确定退休时间函数将调用计算年龄的函数,这样,它们就都是功能内聚性的,而且其它函数也可以调用其中任何一个函数,或这两个都调用
实例2:一个函数将打印季度开支报告、月份开支报告和日开支报告,具体打印哪一个,将由传入的控制标志决定
分析:
- 具有逻辑内聚性
如何改进成功能内聚性呢?
- 建立三个函数:一个打印季度报告,一个打印月报告、一个打印日报告,改进原来的函数,让它根据传进去的控制标志来调用这三个函数之一。调用函数将只有调用代码而没有自己的计算代码,因而具有功能内聚性;而三个被调用函数也显然是功能内聚性的
实例3:一个函数先读取雇员的名字,然后是地址,最后是它的电话号码。这种顺序是用户要求的。另外一个函数将读取关于雇员的其它信息。
分析:
- 如果雇员信息放在一个记录中,是顺序内聚;否则是过程内聚
如何改进成功能内聚性?
- 把它分为几个部分,并把这几部分分别放入程序中。要保证调用程序的功能是单一、完善的。调用程序应该是诸如GetEmployeeData()这样的函数,而不该是像GetFirstPartOfEmployeeData()这类的函数。可能还要改动其余读取数据的函数。
实例4:比如一个函数进行某种复杂计算的前5个操作,并把中间结果返回到调用函数。由于5项操作可能要用好几个小时,因此当系统瘫痪时,函数要把中间结果存入一个文件中,函数还要检查磁盘,以确定其是否有足够空间来存储最后计算结果,并把磁盘状态和中间结果返回到调用程序
分析:
- 原来的函数是由一系列令人莫名奇妙的操作组成的,与功能内聚性相距甚远
如何改进成功能内聚性?
- 调用函数ComputeExtravagantNumber()应该调用几个独立的函数,不应该把中间结果写入一个文件,也决不该为后来的操作检查磁盘剩余空间,它所作的就仅限于调用其它具有单一功能的函数而已。改进这个设计,将至少影响到一到两个层次上的程序, 对于这项任务的较好设计,如下图所示
内聚性总结
共享数据、各个操作都是为了完成函数的功能而存在——功能内聚
共享数据、各个操作顺序进行——顺序内聚
共享数据、各个操作之间没有联系——通讯内聚
不共享数据、各个操作同时进行——临时内聚
不共享数据、根据控制标记决定做哪个操作——逻辑内聚
不共享数据、各个操作顺序进行——过程内聚
不共享数据、操作之间没有任何关系——偶然内聚
大原则——耦合性
耦合性是指两个函数之间联系的紧密程度
函数与其它函数之间的联系应该是显著、松散和灵活的,函数应该容易被其他函数调用
- 函数对外的接口包括参数、函数中使用的全局变量和数据库或文件等
耦合标准
-- 耦合规模
- 两个函数之间联系的数量越少越好:参数越少越好,使用的全局变量越少越好,使用数据库和文件的范围越少越好
-- 显著性
- 两个函数之间联系越显著越好,使用参数最显著,其次是全局变量,最后的数据库或文件
-- 灵活性
- 改变两个函数之间联系越容易越好
耦合层次
简单数据耦合
- 两个函数之间传递的数据是非结构化的,并且全部都是通过参数表进行的
- 这通常称作“正常耦合”,这也是一种最好的耦合
结构数据耦合
- 两个函数之间传递的数据是结构化的,并且是通过参数表实现传递的
- 如果使用恰当的话,这种耦合也不错
全局数据耦合
- 两个函数使用同一个全局数据
- 如果所使用的数据是只读的,那么这种耦合还是可以忍受的,但是,总的来说,全局耦合是不受欢迎的
控制耦合
- 一个函数通过传入另一个函数的数据通知它该作什么
- 控制耦合是令人不快的, 因为它往往与逻辑内聚性联在一起,并且,通常都要求调用者了解被调函数的内容与结构
耦合的例子
Tan(float degree)
- 简单数据耦合。
一个函数向另一个函数传递姓名、住址、电话号码、生日和身份证号码等五个变量
- 简单数据耦合。
一个函数向另一个函数传递变量EmpRec,,EmpRec是一个结构化的变量,包括姓名、住址、生日等等五个方面的数据
- 结构数据耦合。
- 传递结构体变量时务必传递指针或引用。
一个函数向另一个函数传递控制标志,通知它到底是打印月报表、季度报表还是年度报
- 控制耦合
一个函数把雇员识别卡传递给另一个函数,两个函数都利用这个识别卡从一个全局表中读取雇员的名字。
- 全局数据耦合
大原则——封装(信息隐藏)
封装有两层含义
- 为了隐藏一些信息,往往会形成一个或多个函数
- 函数的名字和参数应该尽量少的暴露实现的细节,这样可以在不影响用户的情况下方便地修改函数的实现方式。getNamefromDISC不太好,有可能以后从硬盘读数据了。
常见需要隐藏的信息
-- 容易被改动的区域(比如说解析数据格式,可以用函数封装起来)
- 对硬件有依赖的地方
- 输入和输出
- 非标准语言特性
- 状态变量
- 不要使用逻辑型变量作为状态变量,应使用枚举型变量
- 使用存取函数检查变量,而不要对其直接检查(比如IsOnRoute())
- 数据规模限制(某些代码因数据量的多少而不同)
- 商业规则
- 预计得到的其他改动
-- 复杂的数据
-- 复杂的逻辑
小细节——函数名
函数名要有意义,要能描述函数所做的一切
- 在函数名字中,应描述所有输出结果及其附加结果
- 如果很难为一个函数命名,就需要考虑这个函数的功能是不是有问题
实现行为的过程或函数要使用动词或动词短语
- 如PrintReport(), CheckOrder()
如果函数只有一个返回值,可以用返回值来描述
- 如Cos(), CurrentPenCount()
- 如果函数返回布尔值,可以在形容词或名词前加Is或Has,如IsPrinterReady(), HasFirstName()
命名约定
- 公开的接口函数和全局名字空间的函数选择SmallTalk风格的命名原则(一种首字母大写,字间直接相连而无分隔符的书写风格),如IsPrinterReady()
- 私有的函数使用C++ 草拟标准工作底稿使用的约定(全部为小写字符、字间以下划线分隔),如is_printer_ready()
- 编码规范中有对命名的详细规定。
小细节——参数
函数的参数的命名要有意义
- stringCopy( char* str1, char* str2); /*这样的参数命名不好,很难区分*/
函数参数的顺序要合理
- 按照输入一修改一输出的顺序排列参数,但是首先要符合使用习惯
/*这个参数顺序不符合使用习惯,很容易出错*/
StringCopy( char*strSource, char* strDestination)
因为我们习惯这样使用:stringCopy( str, “hello”)
- 如果几个函数中使用了相似的参数,应使用相同的顺序
- 如strncpy和memncpy
- 把状态和“错误”变量放在最后
参数的个数不要太多,尽量控制在7个以内
- 如果参数太多,在使用时容易将参数类型和顺序搞错
- 参数太多,有时也意味着函数功能划分不好
使用所有的参数,删除没有用到的参数
对参数的限制越少越好,但是如果对参数的取值有限制,一定要加上注释来说明
结构体如果很大,要用指针或引用传递
- 参数太大,占用的堆栈空间大,调用的效率也低
如果参数是指针,且仅作输入用,则应在类型前加const,以防止该指针在函数体内被意外修改
小细节——返回值
显式声明函数的返回值类型
- 原理:如果不显示的声明返回值类型,不同的编译器可以给与不同的解释
- 如:TestFunc()最好能显示的声明为int TestFunc()
函数名字和返回值类型在语义上不可冲突
- int getChar()就是一个不好的设计
不要将正常值和错误标志混在一起返回。正常值用输出参数获得,而错误标志用return返回
有时候函数原本不需要返回值,但为了增加灵活性(如支持链式表达),可以附加返回值
- 例如 char *strcpy(char *strDest,const char *strSrc)
函数的每种出错返回值的意义要清晰、明了、准确,防止使用者误用、理解错误或忽视错误返回码
小细节——函数内部设计
函数体的规模不要太大,否则难以理解,也容易出错
- 一个函数的规模不要超过350行。
- 如果太大了应该根据前面讲过的大原则进行拆分。
资源管理:谁申请谁释放
- 除非函数的功能就是申请资源(如malloc, openfile等函数),否则尽量不要在函数内部申请资源并返回资源的句炳或指针,如申请内存返回内存指针,打开文件返回文件指针等
Void GetData( struct Sample **ppData); //不好
Void GetData( struct Sample *pData);// OK
- 由调用者申请的资源应该由调用者释放,不要在函数内部随便释放调用者申请的资源
Void ReadData( FILE* dataFile, char* buf){
if( …){
fclose( dataFile); //关闭了调用者打开的文件,不好
}
…
}
不要修改调用者的数据
Struct Sample{
int data1;
int data2;
};
int Sum(Struct Sample* pInputRec)
{
if( pInputRec->data1 < 0)
pInputRec->data1 = 0; //这样做不好
return pInputRec->data1 + pInputRec->data2;
}
int Sum(Struct Sample* pInputRec)
{
// 修改成这样就好多了
int temp =pInputRec->data1 >=0)? pInputRec->data1:0;
return temp + pInputRec->data2;
}
合理使用全局变量
- 使用全局变量前,需要考虑是否真的有必要使用。
- 确定要使用全局变量之后,应该意识到其带来的风险并避免。
全局变量的缺点
- 全局数据的疏忽改变。你可以会在某处改变全局变量的值,而在别处又会错误地以为它仍保持着原值
TheAnswer = GetTheAnswer();——TheAnswer是一个全局变量
OtherAnswer = GetOtherAnswer();——GetOtherAnswer 改变了TheAnswer
AverageAnswer = (TheAnswer + OtherAnswer)/2—AverageAnswer错误
- 伴随全局变量的奇怪的别名问题
Void WriteGlobal (int& InputVar)
{
GlobalVar = lnputVar + 1;
cout<<“input Varinble ”<<InputVar;
cout<<“Global Varinble “<<GlobalVar;
}
GlobleVar = 1;
WriteGlobal( GlobleVar);
最后的输出结果是input Varinble 2; Global Varinble 2。
- 有全局数据的代码重入问题(重入:函数没有执行完成,由于外部因素或内部调用,又一次进入该函数执行。strtok函数的ANSI C标准版本就是非可重入的,因为其使用了静态变量来保存上一次查找的结果)
- 全局数据妨碍代码复用
- 全局变量会损害模块性和可管理性
怎样降低使用全局数据的危险
- 先使所有变量都成为局部的,然后再根据需要把其中某一些改为全局变量
- 全局变量加g_前缀,如g_theAnswer
- 不要通过把数据放入庞大的结构化变量,同时又到处传递它来掩盖你使用了全局变量的事实
- 用存取函数来代替全局数据
- 要求所有使用全局变量的函数使用存取函数
- 不要把所有的全局数据都放人同一个模块中
- 在存取函数中建立某种程度的抽象
Node = node->next; // 直接使用全局变量
Node = nextEmployee(node); // 比较抽象,容易理解
- 把对数据的所有存取保持在同一抽象水平上
AddEvent(Event)
EventCount = EventCount-l // 和上一个语句的抽象程度不同
函数检查表
总体问题
- 创建函数的理由充分吗?
- 如果把一个函数中的某些部分独立成另一个函数会更好的话,你这样做了吗?
- 函数的命名合适么?函数的名称是否描述了它做的所有工作?
- 函数的内聚性强么?它是只做一件工作并做得很好吗?
- 函数的耦合是不是松散的?两个函数之间的联系是不是小规模、密切、可见和灵活的?
- 函数的长度合适么?
参数
- 程序中参数的排列合理吗?与相似函数中的参数排列顺序匹配吗?
- 接口假设说明了吗?
- 程序中参数个数是不是7个或者更少?
- 是否只传递了结构化变量中另一个函数用得到的部分?
- 是否用到了每一个输入参数?是否用到了每一个输出参数?
全局变量
- 是否是在迫不得已的情况下,才使某些变量成为全局的?
- 命名约定是否对局部、模块和全局变量进行了区分?
- 是否给所有全局变量都加了注释?
- 程序中是否不含有伪全局变量——传往各个函数的庞大而臃肿的数据结构?
- 是否用存取函数来代替了全局数据?
- 是把存取函数和数据组织成模块而不是随意归成一堆的吗?
- 存取函数是否有一定程度的抽象?
- 所有相互有联系的存取函数,其抽象程度都是一致的吗?
参考书
Code Complete (代码大全)
The Practice of Programming (程序设计实践)
高质量C/C++编程指南