一个 C 程序由一个主函数(main 函数)与多个函数构成。其中,主函数 main() 可以调用任何函数,各函数之间也可以相互调用,但是一般函数不能调用主函数。所有函数都是平行、独立的,不能嵌套定义,但可以嵌套调用。本章将重点论述函数设计的一些常用建议,其中包括函数的规划、内部实现、参数与返回值等。
理解函数声明
谈到函数声明,就不得不说这样一个例子:有一段程序存储在起始地址为 0 的一段内存上,要调用这段程序,该如何去做?答案如下:
(*(void(*) ()) 0)();
恐怕像这样的表达式,无论是新程序员,还是经验丰富的老程序员,都会感到不寒而栗。然而,构造这类表达式其实只有一条简单的规则:按照使用的方式来声明。
接下来看如下两个简单的声明示例:
float f(); float *pf;
在上面的代码中,很显然,f 是一个返回值为浮点类型的函数;而 pf 则是一个指向浮点数的指针。如果将它们简单地组合起来,就可以得到如下两种声明方式:
float *f1(); float (*f2)();
上面两者的区别在于:因为“()”结合优先级高于“*”,也就是说“*f1()”等价于“*(f1())”,即f1是一个函数,它返回值类型为指向浮点数的指针;同理,f2 是一个函数指针,它所指向的函数的返回值为浮点类型。
在这里需要特别注意的是,一旦知道了如何声明一个给定类型的变量,该类型的类型转换符就很容易得到:只需要去掉声明中变量名和声明末尾的分号,再将剩余的部分用一个括号整个“封装”起来,即:
float (*f2)();
因为 f2 是一个指向返回值为浮点类型的函数的指针,因此,该类型的类型转换符如下:
(float (*)())
即它表示一个“指向返回值为浮点类型的函数的指针”的类型转换符。
好了,现在继续分析上面的例子:
(*(void(*) ())0)();
在这里假定变量 fp 是一个函数指针,显然“*fp”就是该指针所指向的函数。当然,“(*fp)()”就是调用该函数的方式(在 ANSI C 标准中,允许程序员简写为“fp()”这种形式)。在“(*fp)()”中,“*fp”两侧的括号非常重要,因为“()”结合优先级高于“*”。如果“*fp”两侧没有括号,那么“*fp()”实际上与“*(fp())”的含义完全一致,ANSI C 把它作为“*((*fp)())”的简写形式。
根据问题描述,可以知道 0 是这个函数的入口地址,也就是说,0 是一个函数的指针。结合上面的“(*fp)()”,问题中的函数调用可以写成如下形式:
(*0)();
大家都知道,函数指针变量不能是一个常数,很显然上式并不能生效。因此,上式中的0必须被转化为函数指针,一个指向返回值为 void 类型的函数的指针。也就是说,需要将 fp 的声明修改成如下形式:
void (*fp)();
这样,就可以得到该类型的类型转换符:
(void (*)())
现在将常数 0 转型为“指向返回值为 void 的函数的指针”类型就可以写成如下形式:
(void (*)())0
最后,使用“(void(*)())0”来替换“(*fp)(0”中的fp或“(*0)()”中的0,就可以很简单地得到下面的表达式:
(*(void (*)())0)();
为了便于大家理解,在这里继续对“(*(void(*)())0)()”做如下 4 点说明:
- 对于“void(*)()”,可以很简单地看出这是一个函数指针类型,这个函数没有参数(参数为空),并且也没有返回值(返回值为 void);
- 对于“(void(*)())0”,这里将 0 强制转换为函数指针类型,其中 0 是一个地址,也就是说,一个函数存在首地址为 0 的一段区域内;
- 对于“(*(void(*)())0)”,这里取 0 地址开始的一段内存中的内容,其内容就是保存在首地址为 0 的一段区域内的函数;
- 对于“(*(void(*)())0)()”,很简单,这当然就是函数调用了。
理解函数原型
在 ANSI C 标准中,允许采用函数原型方式对被调用的函数进行说明,其主要作用就是利用它在程序的编译阶段对调用函数的合法性进行全面检查。
函数原型能告诉编译器函数的名称,函数有多少个参数,每个参数分别是什么类型,函数的返回类型又是什么等。当函数被调用时,编译器可以根据这些信息判断实参个数与类型是否正确,函数的返回类型是否正确等。函数原型能让编译器及时发现函数调用时存在的语法错误。示例代码如下:
/*函数原型*/ char *Memcopy(char *dest, const char *src, size_t size); /*函数定义*/ char *Memcopy(char *dest, const char *src, size_t size) { assert((dest != NULL) && (src != NULL)); char *retAddr = dest; while (size --> 0) { *(dest++) = *(src++); } return retAddr; }
在上面的代码中,当调用 Memcopy() 函数时,编译器就会检查调用函数的实参是不是 3 个?每个参数的类型是否匹配?函数的返回类型是否正确?如果编译程序发现函数的调用或定义与函数原型不匹配,编译程序就会报告出错或发出警告消息。
在一般情况下,当被调用函数的定义出现在主调用函数之后时,应必须在调用语句之前给出函数原型。如果在被调用之前没有给出函数原型,则编译器会将第一次遇到的该函数定义作为函数的声明,并将函数返回值类型默认为int型。
如果这样,当函数返回值类型为整型时,是否无须给出函数原型呢?很显然,这种偷懒的方法将使得编译器无法对实参和形参进行匹配检查。若调用函数时参数使用不当,编译器也不会再给出善意的提醒,你也许会得意于程序的安全通过,但你很可能将面临类型不匹配所带来的系统崩溃的危险。
总之,在源文件中说明函数原型提供了一种检查函数是否被正确引用的机制。同时,目前许多流行的编译程序都会检查被引用的函数的原型是否已在源文件中说明过,如果没有,就会发出警告消息。
尽量使函数的功能单一
在程序的函数设计中,我们所要遵循的首要设计原则就是“函数功能单一”。也就是说,一个函数应该只能够完成一件事情,并且只能够完成它自己的任务。函数功能应该越简单越好,尽量避免设计多用途、面面俱到、多功能集于一身的复杂函数。
当然,如果你有一个概念上简单的函数(这里所谓的“简单”是 simple 而不是 easy),它恰恰包含着一个很长的 case 语句,这样你就不得不为这些不同的情况准备不同的处理,那么这样的长函数是允许的。但是,如果你有一个复杂的函数,并且一般程序员在没有详细文档的情况下很难读懂这个函数,那么应该努力简化这个函数的功能与代码,适当拆分这个函数的功能,使用一些辅助函数,给它们取描述性的名字。
与此同时,人类的大脑一般能够同时记住 7 个不同的东西,超过这个数目就会犯糊涂。因此,对于函数的局部变量的数目也应该尽量减少,一般情况下最多 5~10 个。
下面我们来看一个函数的设计示例。
#include <stdio.h> #include <stdlib.h> typedef int ElementType; typedef struct node { ElementType data; struct node *next; }StackNode, *LinkStack; void InvertedSequence(int num) { int i=0; int result=0; LinkStack ls; // 初始化 ls = (LinkStack)malloc(sizeof(StackNode)); ls->next = NULL; printf("数据输入为:/n"); for(i=0; i<num; i++) { // 入栈 StackNode *temp; temp = (StackNode *)malloc(sizeof(StackNode)); if(temp != NULL) { temp->data = i; temp->next = ls->next; ls->next = temp; printf("%d ",i); } } printf("/n数据输出为:/n"); while (ls->next != NULL) { // 出栈 StackNode *temp = ls->next; result = temp->data; ls->next = temp->next; free(temp); printf("%d ",result); } printf("/n"); } int main(void) { InvertedSequence(20); return 0; }
在 InvertedSequence(int num) 方法中,我们实现了链栈的常用操作,即包括初始化、入栈与出栈等操作,其运行结果为:
数据输入为:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
数据输出为:
19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
很显然,代码中的 InvertedSequence(int num) 多功能函数不仅很可能使理解、测试、维护函数等变得困难,并且也会使函数不具有好的可复用性,如果在后续的代码中遇到类似的功能需求,又将需要重写此功能代码。由此可见,虽然这里的 InvertedSequence(int num) 函数满足了程序功能的需要,但是它却违反了函数的功能单一原则。
因此,根据函数的功能单一原则,我们应该将该函数的相关链栈操作独立进行设计,这样不仅能够使函数的功能变得简单,而且能够很好地保证函数有良好的扇入和扇出比例,特别是公用模块或底层模块中的函数一定要具有较大的扇入才能有效提高代码的可复用性。改正后的示例如代码为:
typedef int BOOL; #define TRUE 1 #define FALSE 0 #define STACK_SIZE 100 typedef int ElementType; typedef struct node { ElementType data; struct node *next; }StackNode,*LinkStack; // 初始化 void InitStack(LinkStack ls) { ls->next = NULL; } // 是否为空 BOOL IsEmpty(LinkStack ls) { if(ls->next == NULL) { return TRUE; } else { return FALSE; } } // 入栈 BOOL Push(LinkStack ls, ElementType element) { StackNode *temp; temp = (StackNode *)malloc(sizeof(StackNode)); if(temp == NULL) { return FALSE; } temp->data = element; temp->next = ls->next; ls->next = temp; return TRUE; } // 出栈 BOOL Pop(LinkStack ls, ElementType *element) { if(IsEmpty(ls)) { return FALSE; } else { StackNode *temp = ls->next; *element = temp->data; ls->next = temp->next; free(temp); return TRUE; } } void InvertedSequence(int num) { int i=0; int result=0; LinkStack ls; ls = (LinkStack)malloc(sizeof(StackNode)); // 初始化 InitStack(ls); printf("数据输入为:/n"); for(i=0; i<num; i++) { // 入栈 Push(ls,i); printf("%d ",i); } printf("/n数据输出为:/n"); while (!IsEmpty(ls)) { // 出栈 Pop(ls,&result); printf("%d ",result); } printf("/n"); }
现在,通过对比以上两端代码,可以很容易看出,后段代码不仅简单易读、易维护,而且其函数也具有更好的可复用性。严格遵循函数的功能单一原则,这样不但能够让你更好地命名函数,也使理解和阅读代码变得更加容易。
如果遇到一个特殊的情况不得不打破这个原则,可以停下来,思考一下是不是自己对这个“特殊情况”的理解还不够深。函数应该很精确地执行一件事且只执行这一件事,明确函数功能(一个函数仅完成一件事情),精确(而不是近似)地实现函数设计。
避免把没有关联的语句放在一个函数中
在代码编写中,我们时常会为了提高代码的可复用性而刻意地将不同函数中使用的相同代码语句提出来,抽象成一个新的函数。当然,如果这些代码的关联度较高,并且完成同一个功能,那么这种抽象是合理的。但是,如果仅仅是为了提高代码的可复用性,把没有任何关联的语句放在一起,就会给以后的代码维护、测试及升级等造成很大的不便,同时也使函数的功能不明确。示例代码如下:
void Init (void) { /* 初始化矩形的长与宽 */ Rect.length = 0; Rect.width = 0; /* 初始化“点”的坐标 */ Point.x = 0; Point.y = 0; }
很显然,上面的函数 Init(void) 设计是不合理的,因为矩形的长、宽与点的坐标基本没有任何关联。因此,我们应该将其抽象为如下两个函数:
/* 初始化矩形的长与宽 */ void InitRect(void) { Rect.length = 0; Rect.width = 0; } /* 初始化“点”的坐标 */ void InitPoint(void) { Point.x = 0; Point.y = 0; }
函数的抽象级别应该在同一层次
先来看下面一段示例代码:
void Init( void ) { /*本地初始化*/ ... InitRemote(); } void InitRemote(void) { /*远程初始化*/ ... }
从表面上看,上面的 Init(void) 函数主要完成本地初始化与远程初始化工作,在其功能实现上没什么不妥之处。但从设计观点看,却存在着一定的缺陷。从 Init(void) 函数中,我们可以看出,本地初始化与远程初始化的地方是相当的。因此,如果远程初始化作为独立的函数存在,那么本地初始化也应该作为独立的函数存在。
很显然,上面的 Init(void) 函数将本地初始化直接运行在本函数内部,而将远程初始化封装在一个独立的函数内,并在这里进行调用。这种设计是不妥的,两个函数的抽象级别应该在同一层次,如下面的示例代码所示:
void Init(void) { InitLocal(); InitRemote(); } void InitLocal(void) { /*本地初始化*/ ... } void InitRemote(void) { /*远程初始化*/ ... }
尽可能为简单功能编写函数
有时候,我们需要用函数去封装仅用一两行代码就可完成的功能。对于这样的函数,单从代码量上看,好像没有什么封装的必要。但是,用函数可使其功能明确化、具体化,从而增加程序可读性,并且也方便代码的维护与测试。示例代码如下:
int Max(int x,int y) { return(x>y?x:y); } int Min(int x,int y) { return(x<y?x:y); }
当然,也可以使用宏来代替上面的函数,代码如下:
#define MAX(x,y) (((x) > (y)) ? (x) : (y)) #define MIN(x,y) (((x) < (y)) ? (x) : (y))
在 C 程序中,我们可以适当地用宏代码来提高执行效率。宏代码本身不是函数,但使用起来与函数相似。预处理器用复制宏代码的方式代替函数调用,省去了参数压栈、生成汇编语言的 CALL 调用、返回参数、执行 return 等过程,从而提高了运行速度。
但是,使用宏代码最大的缺点就是容易出错,预处理器在复制宏代码时常常产生意想不到的边际效应。因此,尽管看起来宏要比函数简单得多,但还是建议使用函数的形式来封装这些简单功能的代码。
避免多段代码重复做同一件事情
在源文件中,如果存在着多段代码重复做同一件事情,那么很可能在函数的划分上存在着一定的问题。若此段代码各语句之间有实质性关联并且是完成同一项功能的,那么可以考虑把此段代码抽象成一个新的函数。
示例代码如下:
/*希尔排序法*/ void ShellSort(int v[],int n) { int i,j,gap,temp; for(gap=n/2;gap>0;gap /= 2) { for(i=gap;i<n;i++) { for(j=i-gap;(j >= 0) && (v[j] > v[j+gap]);j -= gap ) { temp=v[j]; v[j]=v[j+gap]; v[j+gap]=temp; } } } } /* 冒泡排序法 */ void BubbleSort (int v[],int n) { int i,j,temp; for(j=0;j<n;j++) { for(i=0;i<(n-(j+1));i++) { if(v[i]>v[i+1]) { temp=v[i]; v[i]=v[i+1]; v[i+1]=temp; } } } }
在上面的示例代码中,函数 ShellSort(int v[],int n) 与函数 BubbleSort(int v[],int n) 分别实现了希尔排序与冒泡排序的功能。仔细观察这两个简单的排序函数,不难发现,无论是 ShellSort(int v[],int n) 函数,还是 BubbleSort(int v[],int n) 函数,都会执行交换操作。因此,我们可以将它们的交换操作代码抽取出来,独立成一个新的函数,示例代码如下:
/*交换*/ void Swap(int *i, int *j) { int temp; temp=*i; *i=*j; *j=temp; }
这样,抽取出 Swap(int*i,int*j) 函数之后,不仅能够避免不必要的代码重复,便于以后维护与升级代码,而且能够使我们的代码具有更大的复用价值。
现在,我们可以直接在上面的排序函数中或其他任何需要交换操作的函数中调用这个交换函数 Swap(int*i,int*j),示例代码如下:
/*希尔排序法*/ void ShellSort(int v[],int n) { int i,j,gap; for(gap=n/2;gap>0;gap /= 2) { for(i=gap;i<n;i++) { for(j=i-gap;(j >= 0) && (v[j] > v[j+gap]);j -= gap ) { Swap(&v[j],&v[j+gap]); } } } } /* 冒泡排序法 */ void BubbleSort (int v[],int n) { int i,j; for(j=0;j<n;j++) { for(i=0;i<(n-(j+1));i++) { if(v[i]>v[i+1]) { Swap(&v[i],&v[i+1]); } } } }
在调用函数时,必须对返回值进行判断
在程序设计中,调用一个函数之后,必须检查函数的返回值,以决定程序是继续应用逻辑处理还是进行出错处理。同时,这也带来了一系列设计问题:如果出错了怎么办?错误如何表达?该如何定义出错处理的准则或机制?
很显然,如果一个项目没有一种有效的方法表达一个错误,就会出现对于出错处理的混乱状况。与此同时,在大型项目中,如果出现错误,仅仅通过C库中已经定义的那么几个错误码并不能有效表达应用错误。因此,我们需要针对不同的错误采用完全不同的出错处理方法,设计出适合自己的出错处理机制。
尽量减少函数本身或者函数间的递归调用
递归作为一种算法在程序设计语言中被广泛应用,简单地讲,递归就是函数调用自己,或者在自己函数调用的下级函数中调用自己。示例代码如下:
long fab(const int index) { if(index == 1 || index == 2) { return 1; } else { return fab(index-1)+fab(index-2); } }
递归之所以能实现,是因为函数的每个执行过程都在堆栈中有自己的形参和局部变量的副本,而这些副本和函数的其他执行过程毫不相干。所以递归函数有一个最大的缺陷,那就是增加了系统的开销。因为每调用一个函数,系统就需要为函数准备堆栈空间用于存储参数信息,如果频繁进行递归调用,系统需要为其开辟大量的堆栈空间。
与此同时,递归调用特别是函数间的递归调用,会大大影响程序的可理解性。因此,我们在程序设计中应该尽量使用其他算法来替代递归算法。下面的示例演示了如何使用迭代算法来替代递归算法:
long fab(const int index) { if(index == 1 || index == 20) { return 1; } else { long l1 = 1L; long l2 = 1L; long l3 = 0; /*迭代求值*/ for(int i = 0;i < index-2;i ++) { l3 = l1 + l2; l1 = l2; l2 = l3; } return l3; } }
在很多时候,因为递归需要系统堆栈,所以空间消耗要远比非递归代码大很多。而且,如果递归深度太大,可能会导致系统资源不够用。因此大家都有这样一个观点:能不用递归算法就不用递归算法,递归算法都可以用迭代算法来代替。
从某种程度上讲,递归算法确实是方便了程序员,而难为了机器。递归可以通过数学公式很方便地转换为程序,其优点就是易理解,容易编程。但递归是用堆栈机制实现的,每深入一层,都要占去一块堆栈数据区域。因此,对嵌套层数深的一些算法,递归就会显得力不从心,空间上也会以内存崩溃而告终。同时,因为递归带来了大量的函数调用,这增加了许多额外的时间开销。
在理论上,虽然递归算法和迭代算法在时间复杂度方面是等价的(在不考虑函数调用开销和函数调用产生的堆栈开销)。但在实际开发环境中上,递归算法确实要比迭代算法效率低许多。但是不得不注意的是,虽然迭代算法比递归算法在效率上要高一些(它的运行时间只会因循环次数增加而增加,没什么额外开销,空间上也没有什么额外的增加),但我们又不得不承认,将递归算法转换为迭代算法的代价通常都是比较高的,而且,并不是所有的递归算法都可以转换为迭代算法。同时,迭代算法也存在着不容易理解,编写复杂问题时困难等问题。
因此,“能不用递归算法就不用递归算法,递归算法都可以用迭代算法来代替”这样的理解,还是应该辩证看待,切不能一概而论。一般而言,采用递归算法需要的前提条件是当且仅当一个存在预期的收敛时,才可采用递归算法;否则,就不能使用递归算法。
原创文章,作者:奋斗,如若转载,请注明出处:https://blog.ytso.com/21603.html