【高质量C/C++编程】—— 6. 函数设计
函数是C/C++程序的基本功能单元,其重要性不言而喻。函数设计的细微缺点很容易导致该函数被错用,所以光使用函数的功能是远远不够的。
函数接口的两个要素是参数和返回值。C语言中参数和返回值的传递方式有两种:值传递 和 指针传递。C++中多了一个引用传递,由于引用传递的性质和指针传递很像,但是使用方式和值传递很像,初学者容易对其混淆,不懂的同学们可以先阅读本章“第6段引用与指针的比较”。
一、参数的规则
规则:
- 在函数声明中,参数的书写要完整,不要贪图省事只写参数类型而省略参数名字
- 如果函数没有参数,则用
void
填充 - 参数名字要有意义,顺序要恰当,一般遵从程序员的习惯
- 如果参数是指针,且仅作输入作用,则应在类型前加
const
,防止指针在函数体中被意外修改 - 如果参数是以值传递的方式传递对象,则使用
const &
方式传递,这样可以省去临时对象的构造和析构过程,从而提高效率
void SetValue(int width, int height); // 良好的风格
void SetValue(int, int); // 不良的风格
float GetValue(void); // 良好的风格
float GetValue(); //不良的风格
void StringCopy(char* str1, char* str2); // 不良的风格,参数名意义不足
void StringCopy(char* destination, char* source); // 不良风格,用来输入的指针source没有用const修饰
void StringCopy(char* destination, const char* source); // 良好的风格
// 类ClassA
class ClassA {... ...}; // 不良的风格
void CopyObject(const ClassA& a); // 良好的风格
二、返回值的规则
规则:
- 不要省略返回值的类型
- C语言中不加返回值类型的函数,统一按整型处理。容易让用户错误的认为是
void
类型- C++中有严格的类型检查,理论上不用担心无返回值的函数,但是C++中可以使用C语言,这就产生了很多危险
void test(void); // 良好的风格,返回类型是void
test(void); // 不良的风格,返回值类型实际上是int
- 函数名字与返回值类型在语义上不可冲突
- 违反这条规则的是C标准库函数
int getchar(void);
,按名字的意思返回的是char
类型,但实际上返回的是int
类型。- 在我们实现函数时,不要使用这种与返回值类型冲突的命名方式。
int GetInt(void); // 良好的风格
double GetInt(void); // 不良的风格,命名与实际返回值不同
- 不要将正常值和错误标志混在一起返回。正常值用输出参数获得,而错误标志用
return
返回
- 在
getchar
函数的正常值是char
类型字符的ASCII码值,而发生错误时会返回EOF(-1)
,它将正常值和错误标志放在一起返回。- 在我们实现函数时,应将正常值放在参数中带回,而返回值只返回错误标志
int GetChar(void); // 不良的风格,错误标志与正常值一起返回
int GetChar(char* ch); // 良好的风格,错误标志由返回值返回,正常值由参数ch指针带回
建议:
- 有些函数不需要返回值,但是为了增加灵活性,如支持链式表达,可以增加返回值
- 如果函数的返回值不是局部变量,可以使用引用类型返回以提高效率,若是局部变量就不可以使用引用类型返回
char* strcpy(char* dest, const char* src); // 拷贝函数原型
// 引用类型返回非局部变量
int& GetInt(int* num)
{
*num += 20;
return *num;
}
// 错误的返回
int& GetInt(void)
{
int num = 10;
return num; // 错误,返回值为局部变量
}
三、函数体实现规则
不同功能的函数其内部实现各不相同,但是我们可以在函数的入口处和出口处严格把关,从而提高函数质量
规则:
- 在函数的入口处对参数的有效性进行检查
很多程序错误是由非法参数引起的,我们应该正确理解
assert
断言(本章第5段有详细描述)
// 对数组进行处理
int test(int* arr, int size)
{
// 断言对arr数组和size数组长度进行有效性检查
assert(arr != NULL && size >= 0);
// 对数组长度为0进行检查
if (size == 0)
{
return 0;
}
... ...
return 1;
}
- 在函数的出口处对return语句的正确性和效率进行检查
- 要搞清楚返回值究竟是值、指针还是引用
return
语句不可返回指向栈内存的指针或引用,在函数结束时内存空间会自动销毁- 如果返回值是一个对象,要考虑return语句的效率
int* test1(void)
{
int num = 10;
return # // 返回指向栈内存的指针
}
class ClassA {... ...};
int& GetClassA(ClassA& a)
{
return a; // 引用类型返回对象,提高效率
}
四、其他建议
建议:
- 函数功能要单一,不要设计多用途的函数
- 函数体规模要小,尽量控制在50行代码之内
- 尽量避免函数带有记忆功能,相同输入参数应当产出相同的输出,带有记忆功能的函数其行为可能是难以预测的,不便于理解和维护。
在C/C++语言中,带有记忆功能的函数是由
static
修饰的局部变量来存储记忆值。我们尽量减少使用static
局部变量
// 带有记忆功能的函数
int GetInt(int num)
{
static int temp = 1; // 静态局部变量存储记忆值
temp += num;
num = temp;
return num;
}
- 不仅要检查输入参数的有效性,还要检查通过其他途径进入函数体内的变量的有效性,如全局变量,文件句柄等
- 用于处理错误的返回值一定要清除,让使用者不容易忽视或误解错误情况
五、使用断言
程序一般分为Debug版本和Release版本,Debug版本用于内部调试,Release版本发行给用户使用。
断言assert
是仅在Debug版本起作用的宏,它用于检查不应该发生的情况,如果运行过程中assert
的参数为假,则程序中断(一般地还会出现提示对话,说明在什么地方引发assert
)
// 不重叠内层的复制函数
void *memcpy(void* pvTo, const void* pvFrom, size_t size)
{
assert((pvTo != NULL) && (pvFrom != NULL)); // 使用断言
byte* pbTo = (byte*)pvTo; // 防止改变pvTo的地址
byte* pbFrom = (byte*)pvFrom; // 防止改变pvFrom的地址
while (size-- > 0)
*pbTo++ = *pbFrom++;
return pvTo;
}
assert
不是一个仓促拼凑起来的宏,为了不在程序的Debug版本和Release版本引起差别,assert
不应该产生任何副作用。所以assert
不是函数,而是宏。程序员可以把assert
看成一个在任何系统状态下都可以安全使用的无害测试手段。如果程序在assert
处终止了,并不是说该assert的函数有错误,而是调用者出现了差错,assert
可以帮助我们找到发生错误的原因。
很少有比跟踪到程序的断言,却不知道该断言的作用更让人更沮丧的事情了。你花了很多时间,不是为了排除错误,而只是为了弄清楚这个错误到底是什么。有时候,程序员偶尔还会设计出错误的断言。所以如果搞不清楚断言检查的是什么,就很难判断错误是出现在程序中,还是出现在断言中。幸运的是这个问题很好解决,只要加上清晰的注释即可。这本是显而易见的事情,可是很少有程序员这样做。难以理解的断言常常被程序员忽略,甚至删除,这是非常危险的。
规则:
- 使用断言捕捉不应该发生的非法情况。不要混淆非法情况与错误情况之间的区别,后者是必然存在且一定要处理的
- 在函数的入口处检查参数的合法性
// 对数组进行处理
int test(int* arr, int size)
{
// 数组arr为NULL或数组长度为负数是非法情况,需要断言处理
assert(arr != NULL && size >= 0);
// 数组长度为0属于错误情况,需要处理
if (size == 0)
{
return 0;
}
... ...
return 1;
}
建议:
- 在编写函数时需要进行反复考察,自问“我打算做哪些假定?”,一旦确定了假定,就使用断言对假定进行处理
- 一般教科书都鼓励程序员进行防错设计,但要记住这种编程风格可能会隐瞒错误。当进行防错设计时,如果不可能发生的事情发生了,需要进行断言处理进行报警。
六、引用与指针的比较
在C++的概念中,初学者容易把指针和引用混淆在一起。
- 指针:指针变量的创建需要开辟内存,需要显示解引用才能访问指向内存的内容
- 引用:引用类型的底层使用了指针实现,实际也要开辟内存。在使用时,可以直接通过变量访问内存内容,相当于对变量起了别名。
引用的一些规则:
- 引用被创建的同时必须初始化,而指针任何时候都可以初始化
- 没有对
NULL
或nullptr
的引用,必须引用合法的存储单元- 一旦引用被初始化,就不能改变引用的关系
void test(void)
{
int a = 0;
int b = 0;
int& ra = a; // 引用
int* pb = &b; // 指针
std::cout << ra << std::endl; // 引用类型变量可以当作变量本身使用
std::cout << *pb << std::endl; // 指针类型必须显示解引用使用
int& raa = ra; // 没有二级引用,引用直接当变量名使用
int** ppb = &pb; // 有二级指针,指针有严格的内外层之分
}
// 指针参数
int AddOfPionter(int* a, int* b)
{
return *a + *b;
}
// 引用参数
int AddOfLead(int& a, int& b)
{
return a + b;
}
int main(void)
{
int a = 10;
int b = 100;
AddOfPionter(&a, &b); // 传递指针
AddOfLead(a, b); // 传递引用
return 0;
}