5.4 函数设计的基本规则
函数是C++程序的基本功能单元,就像一块块砖头可以有规则地垒成一座房子,而一个个函数也可以有规则地组织成一个程序。我们在大量使用他人设计好的函数的同时,也在设计大量的函数供自己或他人使用。一个设计良好的函数,概念清晰职责明确,使用起来将非常容易,可以很大程度地提高我们的开发效率。反过来,一个设计糟糕的函数,概念不清职责不明,不仅难以使用,有时甚至会导致严重的错误。函数设计的好坏,成为评价一个程序员水平高低的重要标准。关于函数的设计,业界已经积累了相当多的经验规则。这些经验规则是每个新入行的程序员都应当了解和遵循的,并且需要在开发活动中将这些规则加以灵活应用。函数的两个基本要素是函数的声明和定义,下面分别从这两方面来谈一谈相关的最佳实践经验。
5.4.1 函数声明的设计规则
函数的声明,也称为函数的接口,它是函数跟外界打交道的界面。它就像函数箱子上的标签一样,可通过这个标签了解箱子中封装的是什么功能,需要什么样的输入数据,以及能够返回什么样的结果。换句话说,只要知道了一个函数的声明,也就知道了该如何使用这个函数。大量实践表明,一个函数是否好用,往往由其接口设计的好坏决定。在设计实现函数时,不仅要让函数的功能正确,还要让函数的接口清晰明了,有较高的可读性。只有这样,在使用这个函数时,才会清楚函数的功能及函数的输入/输出参数等,从而正确使用这个函数。如果函数的接口不清楚,则很容易造成函数的错误使用。
在函数接口的设计上,通常应当遵循如下几条规则。
1. 使用“动词+名词”的形式给函数命名
函数是对某个相对独立的功能的封装,而功能往往表现为某个动作和相应的作用对象。比如“拷贝字符串”这个功能,就是由“拷贝”这个动作以及动作的对象“字符串”共同构成。所以为了更好地表达函数的功能,在给函数命名时,最好使用函数的主要动作和作用对象组合而成的动宾短语,这样就可以做到望文生义,让函数的功能一目了然。例如:
// 计算面积 // Get是动作,Area是动作的对象 int GetArea(int nW, int nH); // 拷贝字符串 // cpy是动作,str是动作的对象 char* strcpy(char* strDest,const char* strSrc);
2. 使用完整清晰的形式参数名,表达参数的含义
参数表示函数所作用的对象及需要处理的数据,也就是函数所表示的动作的宾语。所以,最好能够使用完整清晰的参数名来明确这个宾语的具体意义。如果某个函数没有参数,也最好使用void填充,表示这个函数不需要任何参数。同样是函数,不同的形式参数名可以带来不同的效果,例如:
// 参数的含义明确, // 可以清楚地知道第一个参数表示宽度,第二个参数表示高度, // 整个函数的功能就是设置宽度和高度的值 void SetValue(int nWidth, int nHeight); // 参数的含义不明确, // 只能去猜测这两个参数的含义,从而也就无法理解整个函数的功能 void SetValue(int a, int b); // 值得推荐的接口设计,没有参数就使用void填充 int GetWidth(void);
3. 参数的顺序要合理
在某些情况下,表示特定意义的多个参数的顺序已经具有了业界普遍遵循的规则,比如复制字符串函数,总是把目标字符串作为第一个参数,而把源字符串作为第二个参数。这些规则我们应当逐渐熟悉并遵守,而不应该标新立异自行其事地打破这种规则。例如,写一个设置矩形参数的函数:
// 不遵循参数顺序规则的接口设计 void SetRect( int nRight, int nBottom, int nTop, int nLeft);
SetRect()函数的函数名很好地表达了这个函数的功能,形式参数名也清楚地表达了各个参数的意义,但它并不是一个好的接口设计,因为其中参数的顺序不符合业界的普遍规则。如果该函数写好了让他人使用,而他人是按照业界的普遍规则来调用该函数的,则很可能因为参数顺序的问题而导致函数被错误使用。这里,规范的参数顺序应该是:
// 规范的接口顺序——先左上角的X和Y,后右下角的X和Y void SetRect(int nLeft, int nTop, int nRight, int nBottom);
4. 避免函数有太多的参数
虽然C++对函数参数的个数没有限制,但参数个数不宜过多,应该尽量控制在5个以内。如果参数太多,在使用时容易将参数类型或顺序搞错,给函数的使用带来困难。如果确实需要向函数传递多个数据,可以使用结构体来将多个数据打包,然后以传递整个结构体来代替传递多个参数数据。例如:
// 创建字体函数 HFONT CreateFontIndirect( const LOGFONT *lplf );
这里,LOGFONT结构体就打包了创建字体所需要的多个数据,比如字体名字,字号等等。通过传递一个LOGFONT结构体指针,在函数内就可以通过这个指针访问它所包装的多个数据,也就相当于向CreateFontIndirect()函数传递了创建字体所需的多个参数。
5. 使用合适的返回值
函数的返回值代表了从函数返回的结果数据的类型。如果函数有结果数据通过返回值返回,则使用结果数据的类型作为返回值的类型。而有时候函数没有结果数据通过返回值返回,不需要返回值,则可以使用void关键字作为返回值类型。但为了增加函数的可用性,我们有时也会给函数附加一个bool类型的返回值,用来表示函数执行成功与否。如果函数返回值用于出错处理,这样的返回值一定要清楚、准确,采用一些比较特殊的值,比如0、-1或nullptr,也可以是自己定义的错误类型编号等。
好的函数遵循的规则可以用图5-9来表述。
图5-9 优秀函数的五项修炼
5.4.2 函数体的设计规则
函数接口设计的好坏,决定了这个函数是否好用,而至于这个函数到底是否能用,则取决于我们对函数主体的设计与实现。虽然各个函数实现的功能各不相同,函数主体也大不一样,但还是有一些普遍适用的经验规则供我们学习和参考,从而设计出优秀的函数体。
1. 在函数体的“入口处”,对参数的有效性进行检查
某些函数对参数是有特定要求的,例如,设置年龄的函数,其表示年龄的参数当然不能为负数;函数的参数为指针的,大多数时候这个指针参数也不能为nullptr。如果我们无法确保函数的使用者每次都能以正确的参数调用该函数,那就需要在函数的“入口处”对函数参数的有效性进行检查,避免因无效的参数而导致更大错误的发生,增强程序的健壮性。如果需要对无效的参数进行处理,则可以采用条件语句,根据参数的有效性对用户进行提示或者直接返回函数执行失败信息等。例如:
// 设置年龄 bool SetAge( int nAge ) { // 在函数入口处对参数的有效性进行检查 // 如果参数不合法,则提示用户重新设置 if( nAge < 0 ) { cout<<"设置的年龄不能为负数,请重新设置。"<<endl; // 返回false,表示函数执行失败 return false; } // 如果参数合法,则继续进行处理... }
这里,我们首先用if条件语句在函数入口处对参数的有效性进行了检查。如果参数不合法,则提示用户重新进行设置,并返回false表示函数执行失败;如果参数合法,就继续进行处理。通过对参数的有效性进行检查,可以很大程度上提高函数功能的正确性,避免了年龄为负数这种不合逻辑的错误的发生。
如果只需要对参数的有效性进行检查,而无需对无效的参数进行处理,还可以简单地使用断言(assert)来对参数的有效性进行判断检查,防止函数被错误地调用。断言可以接受一个逻辑判断表达式为参数,如果整个表达式的值为true,则断言不起任何作用,函数继续向下执行。如果表达式的值为false,断言就会提示我们断言条件不成立,函数的参数不合法,需要我们进行处理。例如,要设计一个除法函数Divide(),为了避免表示除数的参数为0这种错误的发生,我们就可以在函数入口处使用断言来对该参数进行判断,并提示函数是否被错误地调用。
#include <assert.h> // 引入断言头文件 using namespace std; double Divide( int nDividend, int nDivisor ) { // 使用断言判断表示除数的参数nDivisor是否为0 // 如果不为0,“0 != nDivisor”表达式的值为true // 断言通过,程序继续往下执行 assert( 0 != nDivisor ); return (double)nDividend/nDivisor; } int main() { // 除数为0,Divide()函数被错误地调用 double fRes = Divide( 3, 0 ); return 0; }
如果我们在主函数中以0为除数错误地调用Divide()函数,当函数执行到断言处时,断言中的条件表达式“0 != nDivisor”的值为false,则会触发断言,系统会终止程序的执行并提示断言发生的位置,以便于我们找到错误并对其进行修复。直到最后所有断言都得到通过,使参数的有效性得到保证。
值得注意的是,在函数入口处的参数合法性检查,虽然可以在一定程度上增加程序的健壮性,但“天下没有免费的午餐”,它是以消耗一定的程序性能为代价的。所以在使用的时候,我们需要在程序的健壮性和性能之间进行权衡,以做出符合需要的选择。如果程序对健壮性的要求更高,那么我们尽可能地进行参数合法性检查;反之,如果程序对性能的要求更高,或者是这个函数会被反复多次执行,那么我们将尽量避免在函数入口处进行参数合法性检查,而是将检查工作前移到函数的调用处,在调用函数之前对参数合法性进行检查,以期在保证程序健壮性的同时不过分地损失程序性能。
知道更多:静态(编译期)断言 -- static_assert
除了可以使用assert断言在运行时期对参数的有效性进行检查之外,我们还可以使用静态(编译期)断言static_assert对某些编译时期的条件进行检查。一个静态断言可以接受一个常量条件表达式和一个字符串作为参数:
static_assert(常量条件表达式, 字符串);
在编译时期,编译器会对静态断言中的常量条件表达式进行求值,如果表达式的值为false,亦即断言失败时,静态断言会将字符串作为错误提示消息输出。例如:
static_assert(sizeof(long) >= 8, "编译需要64位平台支持");
一个静态断言在判断某种假设是否成立(比如,判断当前平台是否是64位平台)并提供相应的解决方法时十分有用,程序员可以根据静态断言输出的提示信息快速找到问题所在并进行修复。必须注意的是,由于静态断言是在编译期进行求值,所以它不能用于依赖于运行时变量值的假设检验。例如:
double Divide( int nDividend, int nDivisor ) { // 错误:nDivisor是一个运行时的变量,无法用静态断言对其进行检查 static_assert( 0 != nDivisor, "除数为0"); return (double)nDividend/nDivisor; }
静态断言中的条件表达式必须是一个常量表达式,能够在编译时期对其进行求值。如果我们需要对运行时期的某些条件进行检验,则需要使用运行时assert断言。
2. 谨慎处理函数返回值
如果函数的返回值是指针类型,则不可返回一个指向函数体内部定义的局部变量的“指针”。因为这些局部变量会在函数执行结束时被自动销毁,这些指针所指向的内存位置成了无效内存,而这些指针也就成了“野指针”(所谓的“野指针”,就是指向某个无效内存区域的指针。一开始,这个指针可能指向的是某个变量,或者某个申请得到内存资源,当这个变量被销毁或者内存资源被释放以后,这一区域就成为了无效内存区域,而仍旧指向这一无效内存区域的指针也就成了“野指针”)。当我们在函数返回后再次尝试通过这些指针访问它所指向的数据时,其内容可能保持原样也可能已经被修改,不再是它原来在函数返回前的数据,而这些指向不确定内容的“指针”,会给程序带来极大的安全隐患。例如:
// 错误地返回了指向局部变量的指针 int* GetVal() { int nVal = 5; // 局部变量 // 取得局部变量的地址并返回 return &nVal; }
当在函数之外得到这个指针并继续使用时,什么事情都可能发生,例如:
// 得到一个从函数返回的指向其局部变量的指针 int* pVal = GetVal(); // 没人会预料这个动作会产生什么样的结果,也许地球会因此毁灭 *pVal = 0;
除了上面两个在函数入口和返回值上一进一出的规则外,在函数体的设计和实现上,我们还应当遵守下面这四项基本原则:
1. 函数的职责应当明确而单一
恋爱中的女孩,总是喜欢听对方说“我只爱你一个,你是我的唯一”。而C++中的函数也像个恋爱中的女孩一样,有着同样的喜好。在C++中,我们总是将一个大问题逐渐分解成多个小问题,而函数,往往就是专门用于解决某一个小问题的。这也就决定了它的职责应该做到明确而单一。
明确,表示这个函数就是专门用来解决某一个小问题的。这一点往往反映在函数名上,我们总是用一个动词或动名词来表示函数的职责。比如,print()函数表示这个函数是负责打印输出的,而strcmp()函数则是用于字符串比较的。函数名应当能够准确地反映一个函数的职责。如果我们发现无法用某个简单的动词或动名词来给某个函数命名,这往往就意味着这个函数的职责还不够明确,也许我们还需要对其进行进一步的细化分解。
单一,意味着整个函数只做函数名所指明的那件事情——print()函数就只是打印输出,不会去比较字符串,而strcmp()函数也只是比较字符串,而不会去把字符串输出。一旦我们发现函数做了它自己不应该做的事情,这时最好的解决办法是,将这个函数分解成更小的两个函数,从而各司其职,互不干扰。比如,在一个查找最好成绩的函数中,我们同时也画蛇添足地查找了最差的成绩,虽然表面上看起来一个函数作了两件事情,起到了事半功倍的效果。但是,如果我们只需要最好成绩,那么这个同时查找的最坏成绩会无端地消耗性能做了无用功,最后的结果往往是事倍功半。面对这种情况,我们应该将这个函数分解成更小的两个函数,一个专门负责查找最好成绩,而另一个专门负责查找最差成绩。这样,两个函数各司其职,我们需要什么功能就单独调用某个函数就好了,两个功能不要混在同一个函数中。
函数职责明确而单一是函数定义中最重要的一条规则,如果我们违反了这条规则,无异于大声宣布自己脚踏两只船,其下场自然是可想而知了。
2. 函数的代码应当短小而精干
函数职责的明确而单一,也就决定了函数的代码应当短小而精干。反过来,如果发现某个函数太过繁琐而冗长,那么就该考虑考虑它的职责是否做到了明确而单一了。有人担心短小的函数无法实现强大的功能,而实际上,经过良好的分层设计,函数借由调用下一层函数,将一个复杂的功能分解成多个小功能交由下一级函数实现,短小的函数同样可以实现非常强大的功能。又有人担心,函数都是短小而精干的,那样就会让函数的数量增多,会导致程序代码量的增加。可事实是,让函数保持短小而精干,不但没有增加程序的代码量,反而是减少了代码量。因为这个过程往往将程序中重复的代码提取成了独立的函数,避免了许多代码的重复,自然会减少代码量。而且,这个过程也将我们的思路整理得更加清晰,增加了程序代码的可读性。
按照一般的实践经验,一个屏幕页面应该能够完整地显示一个函数的所有代码,这样我们在查看编辑这个函数的时候就不需要翻页,让代码阅读起来更容易,同时也减少错误的发生。如果我们在这个代码量范围内,无法实现整个函数的功能,那么我们就应该考虑这个函数的职责是否足够明确而单一,是否还可以继续细分成多个更小的函数。时刻牢记,无论是谁,都同样讨厌像裹脚布一样又臭又长的函数。
3. 函数应当避免嵌套层次太多
在那些肥皂泡沫剧中,常常会出现“我爱你,你爱他,他爱我”的三角恋关系,而在函数的实现中,如果不留意,也容易出现这种三角的调用关系。在C++程序中,我们总是通过函数的嵌套调用来将某个比较复杂的问题逐渐细化分解,这可以很好地将复杂的问题简单化,便于我们个个击破。但是,我们也应当注意分解的层次。如果分解的原则不明确,分解的层次太深太混乱,就有可能会出现“我调用你,你调用他,而他又调用我”的“三角恋”式调用关系,最终让整个程序陷入嵌套调用的无限循环中。例如:
// 函数的嵌套调用 // 函数的前向声明 int GetArea(); int GetWidth(); int GetHeight(); // 函数的嵌套调用形成了无限循环 int GetWidth() { return GetArea()/GetHeight(); } int GetHeight() { return GetArea()/GetWidth(); } int GetArea() { int w = GetWidth(); int h = GetHeight(); return w*h; }
在这里,GetArea()函数调用了GetWidth()函数,而GetWidth()函数又反过来调用了GetArea()函数,这样就形成了一个嵌套调用循环,这个循环会不断地进行下去,直到最后资源耗尽程序崩溃为止。更让人绝望的是,编译器并不会发现这种逻辑上的错误,因而并不会给出任何的提示信息,使得这种错误有着极强的隐蔽性,更难以发现。
如果函数的嵌套层次太多,会让整个程序的结构思路混乱,让无论是写代码的人还是看代码的人都陷入一团乱麻而无法自拔。每次让我们迷路的地方,不是曲折的乡间小道,而往往是那些设计巧妙的立交桥。因为它们纵横交错,遮挡了我们的视线。而嵌套分层太多的函数就相当于互相交错的立交桥,在搞清楚它们干了什么之前还得记住它们谁调用了谁。所以,不要让我们的函数嵌套的层次太多,那样只会让我们的程序陷入混乱,而不会收到任何好的效果。
4. 函数应当避免重复代码
如果两个函数有部分相似的功能,初学者的做法往往是,将一个函数中已经写好的代码直接复制过来,粘贴到另一个函数中。代码的重复是程序员痛苦的根源。这一复制粘贴的过程,看似节省了我们编写代码的时间,实际上却可能隐藏着各种各样的错误:复制过来的代码,只是实现了相似功能而已,我们往往还需要对其进行修改才能满足新的需求,而这一过程可能会在复制过来的代码中引入新的混乱;另外一方面,如果我们发现被复制的代码有错误需要进行更新,因为代码的重复,我们不得不修改多个地方的相同代码,有时甚至会遗忘修改重复代码中的错误而将错误遗留在代码中,从而导致更多更大的错误。而正是因为这样,我们为了修改这些可能出现的错误所花费的时间,往往远超过我们复制粘贴代码所节省的那一点点时间,这显然是得不偿失的。
那么,如果遇到了函数中有相似功能的情况,我们该如何避免函数中代码的重复呢?一条最简单的规则是:每当我们想要复制大段代码的时候,就想想是不是应该把这段代码提取成一个单独的函数。这样,两个拥有相似功能的函数就都可以调用这同一个函数来实现其中相似的功能。例如,在打印进货单(PrintIn()函数)和出货单(PrintOut()函数)的时候,我们都需要在页眉部分打印公司名字等内容:
// 打印进货单 void PrintIn(int nCount) { // 打印页眉 cout<<"ABC有限公司"<<endl; cout<<"进货单"<<endl; // 打印内容 cout<<"今日进货"<<nCount<<"件"<<endl; } // 打印出货单 void PrintOut(int nCount,int nSale) { // 打印页眉 cout<<"ABC有限公司"<<endl; cout<<"出货单"<<endl; // 打印内容 cout<<"今日出货"<<nCount<<"件"<<endl; cout<<"销售额"<<nSale<<"元"<<endl; }
对比这两段代码,我们可以发现在两个函数中,负责打印页眉的代码几乎完全一致。面对这种情况,我们应该将这段相似的代码提取成一个独立的函数(PrintHeader()函数)。这个函数会完成两段代码中相同的功能(打印公司名),而对于稍有差异的功能(打印不同的单名),则可以使用参数来加以区分:
// 提取得到的专门负责打印页眉的PrintHeader()函数 void PrintHeader(string strType) { // 打印页眉 cout<<"ABC有限公司"<<endl; // 用参数对函数的行为进行自定义,使函数更具通用性 cout<<strType<<endl; } // 在PrintIn()和PrintOut()函数中调用 // PrintHeader()函数实现页眉的打印 void PrintIn(int nCount) { // 打印页眉 PrintHeader("进货单"); //… } void PrintOut(int nCount,int nSale) { // 打印页眉 PrintHeader("出货单"); // … }
通过这样的函数提取,不仅避免了直接复制粘贴所可能带来的诸多问题,同时使得代码的结构更加清晰,易于我们的编码实现。更重要的是,提取函数后的代码更加易于后期的维护,如果将来公司的名称发生了变化,或者是需要在页眉部分增加新的内容,我们只需要修改PrintHeader()一个函数就行了,而PrintIn()和PrintOut()函数无需做任何修改就可以实现新的功能。这种方式,比直接复制粘贴更省时省力。
以上这四项基本原则,都是来自实践的经验总结,无数前辈的代码验证了这几条经验规则的正确性。简单地讲,它们可以总结成这样一副简单的对联:“应明确单一,宜短小精干;忌嵌套太多,勿重复代码”。如果能够把这副对联高挂于厅堂之上并时时念叨,必能保函数平安,程序兴旺。
图5-10 “函数倒了”