C语言常规优化策略
4 参数传递、宏定义、全局变量与汇编
按照结构化程序设计的原则,一种语言,如果具有赋值、选择与循环三种结构,并严格按照这三种结构
来组织程序,避免使用象goto语句这类使程序控制发生跳转的语言成分,在每一个程序块(如选择块、循
环块)中保持单向的输入流和输出流,写出的程序就算是结构化的程序,因此,前面三节有关赋值语句、
条件语句和循环语句的优化策略对于采用其它结构程序语言,如PASCAL,进行程序设计的程序员来说,
同样具有指导价值。
本节所讨论的话题比较杂乱,不过基本思想却是想将C语言中特有的,而在上面三节中没有介绍过的一些
程序优化的思想在这里集中讨论一下。这些话题包括参数传递的有效方式,宏定义与全局变量的使用,
以及使用汇编语言来优化代码等方面。其中,掌握C语言的参数传递的原理并加以正确使用,是每一个老
练的C程序员必须掌握的,其它三个话题多少都带有一定的争议性,因为这些程序设计技巧与软件工程的
某些原则是相违背的,但其中的奥秒却是:尽管违背原则,可总是有些程序员在使用。原因在于有些技
巧,如将简短的函数调用改为宏扩展往往可以大幅提高程序效率。正如结构化程序设计运动并没有从根
本上驱除goto语句一样,这些有争议的程序设计技巧可能还会长时间地在程序员中间流传下去。审慎的
做法是:继续使用它们,但不要过量。
4.1 C语言中函数的参数传递
C语言中函数的参数传递方式只有一种规则:传值规则。所谓传值,就是形参与实参之间只发生值的传递
。例如我们有一个计算绝对值的程序:
int MyAbs(int x)
{
if (x<0)
x=-x;
return x;
}
在函数的参数说明中出现的就是形参,如果我们要实际计算一些整数的绝对值时,就将这些整数(实参)
值代入函数的形参中,从而可以得到相应的返回绝对值,如MgAbs(-3)和MgAbs(0)等。
C编译器在处理函数调用时,通常是这样完成的:与函数的实际代码相关的有一片堆栈区域用来保存参数
值。如MyAbs(x)函数的代码必有一个整型栈单元用于保存整型变量x的值,当函数调用发生时,如调用
MyAbs(-3),将实参按次序和类型放入这一片堆栈单元,激活函数,函数执行过程中就会在取相应形参的
值时,从堆栈中指定的地方去取值,而取得的值就是传入的实参值。在这一过程中,实参本身将值传入
后不会受影响。例如,在下面的代码中:
y=-3;
z=MyAbs(-y);
y的值在MyAbs函数调用结束后仍为-3。
C语言的这种单一传值规则非常简单,相对于PASCAL语言中传值、传名等多种参数传递规则而言更统一,
更易理解。但这也在一些C的初学者中造成误会。例如,他们常问的一个问题是:如果函数要返回两个值
时怎么办呢?例如,若要求设计一个函数求一个数组中元素的最小值和最大值时怎么办呢?在前面已经给
出了这一问题的正确方法,为了给初学者一个好的答覆,我们这里再罗嗦几句。
因为函数的参数只能传值,而函数返回值只有一个,这时为了返回两个值,可以设计一种结构,这个结
构中包含有要返回的两个值。例如求一个数组的最小最大值的程序设计问题,我在刚开始学习C语言时就
采用过这种方法:
typedef struct tagTwoInt
{
int minV,maxV;
} TwoInt;
//下面是主程序
intt a[100], n=100;
TwoInt M;
int minV, maxV;
......
M=MinMax(a, n);
minV=M.minV;
maxV=M.maxV;
......
// 下面是函数
TwoInt MinMax(int *a, int n)
{
int minV, maxV;
TwoInt M;
// 下面是计算最小值minV和最大值maxV的程序
......
M.minV=minV;
M.maxV=maxV;
return M;
}
这段程序非常笨拙,且有着明显生造的痕迹,但的确是我曾经“构思”出的有关多个返回值问题的一种
解决方案。而且虽然笨,其思想却可适用于多个返回值的情况。
从今天的角度来看,这当然是一个错误的故事(It's a wrong story! 可译成“不那么回事”)。如果C语
言是这样的,十年前我就不会再使用它了。正确的方法是怎样的呢?我们在前面已经给出过了,即使用指
针,具体地说,使用指向最小最大两个整型量的指针作为参数传递,在用指针作为参数传递时,尽管指
针本身的值是不会改变的,但指针所指向的具体地址单元中的值却可以改变,这样,我们不仅仅可以返
回两个值,返回更多个值都有了一个好办法。例如,为了利用函数FindMinMaxElems求数组x[100]的最小
最大值,在主程序中可以使用下面的调用:
int x[100];
int nMin,nMax;
……
FindMinMaxElems(&nMin, &nMax, a, 100);
其中&nMin, &nMax为取变量nMin, nMax的地址,结果实际上是得到了指向这两个变量的指针。
如果理解了C函数参数传递的方式,那么也就不难理解为什么高水平的C程序员只是以值的方式来传递标
准的C变量类型(如整数、字节、浮点数等),而复杂的结构均以指针的方式来传递了。因为以整个结构作
为形参,会导致在函数调用点上大量的复制工作,而以结构的指针作为参数传递,结构中的所有内容均
可以引用到,而需要复制的仅仅是一个指针(一般是一个长整数)。
理解了C语言传值规则,也就不难理解为什么有些程序中需要使用指针的指针类型了,例如,我们要删去
一个表List的头元素,下面程序将是无能为力的:
void RmvHead(List *p, List *head)
{
head=p;
p=p->next;
head->next=null;
}
假设我们已构造出一个如图所示的由3个元素构成的表,要求调用上述函数后结果如图所示
p-->1-->2-->3-->null
head-->1-->null
p-->2-->3-->null
而调用上述函数RmvHead(p, head)后的实际结果怎么样呢?实际结果却是
p-->1-->null
2-->3-->null
head指针原来是什么值,现在还是什么值,为什么会这样呢?我们分析一下函数的调用过程就会明白。
RmvHead(p,head)的完成步骤如下:
(1) 将p的值拷入函数入口参数的堆栈中,设相应的单元为p’
p’--+
|
V
p-->1-->2-->3-->null
(2) 将head的值拷贝到函数入口参数的堆栈中,设相应的单元为h’,这时函数中的p实际上指p’,head
实际上是指h’
(3) 执行函数的结果,各指针的情况如图所示:
h’--+
|
V
p-->1-->null
p’-->2-->3-->null
可以看到p和h的值根本没发生变化,因此,上面的程序完全是错误的,正确的做法为:
void RmvHead(List **pp, List **phead)
{
List *p, *head;
p=*pp;
head=p;
p=p->next;
head->next=null;
*pp=p;
*phead=head;
}
这样,在调用时就可以得到所要求的结果了。
List *head ,*p;
……
RmvHead(&p, &head);
所以,无论是考虑到参数传递的效率还是程序的正确性,理解C的参数传值规则都是最重要的。
4.2 全局变量
由于函数调用中参数传递需要花费时间,即使将复杂结构参数的传递改为结构指针的传递,指针的拷贝
仍需耗费一定的代价,因此,有人建议采用一种“根本性”的解决办法:对程序中需要传递参数的函数
重新改写,将参数作为全局变量申明在外,在所有的函数中都可以自由的使用,从而不需要参数的说明
。很遗憾这是一种不合软件工程的做法。因为按软件工程规范,恰恰应当尽可能避免使用全局变量,因
为在一个大的软件工程项目中,多个人同时从事软件开发工作,如果全局变量满天飞,甲、乙两人就不
可避免因为某一个全局变量的定义或使用而发生冲突,而且,即使有办法暂时消解冲突,大量引用全局
变量也会导致系统资源的紧缺,导致程序的难以理解。有多少C程序员为了找到有关全局变量的定义所在
,并把握这些变量的语义,而耗费了大量宝贵的时间。我曾经从事过一件让人沮丧的程序移植工作,任
务是将一个石油方面的Dos应用程序移到Windows 3.1上。因为不涉及领域模型的修改,界面的移植工作
应该是比较容易完成的。可是问题不是这样,移植后的程序永远发生资源紧缺的错误。究其原因,乃是
在Dos版本中所有的程序变量几乎全部是全局变量,除了没有任何涵义的i,j,k之外,而且其中还包括一
系列大大小小的数组。为了彻底根除这一情况,我们课题组不得不对该应用的各种计算模型所使用的变
量进行仔细地推敲,从而差不多是以重写所有代码的方式完成了这项移植工作,因为几乎每个函数原型
都发生了改动,而凑合改动一下又非常危险;谁知哪天又会发生资源紧缺的错误呢?
这里,我们并不想对全局变量的使用进行指责,我们真正想表达的意思是:要克制住使用全局变量的冲
动,只有当真正需要时才使用它,比喻说你的函数中有90%需要同一参数。由于全局变量真正能改进程序
的效率,而且也真正能给我们造成大大小小、明显或隐蔽的麻烦,因此一定要慎重。
顺使需要指出的是,为什么Dos上能跑得很好的应用程序移到Windows 3.1上就发生麻烦了呢?因为
Windows 3.1上所使用的资源都基于一个64K的堆栈,而Windows的界面程序中所用的各种资源占去其中相
当一部分,因此,老的Dos程序就遇到了困难。要突破64K的限制也有许多巧妙的方法,但考虑到Windows
95全32位编程已差不多取代了Windows 3.1的16编程,因此多说无益,我们还是就此打住,请大家直接在
32位编程中一试身手。
4.3 宏定义
宏定义除了一些大家所熟知的好处外,如可以提高程序的清晰性、可读性,使于修改移植等,还有一个
很妙的地方:利用宏定义来代替函数可以提高程序设计的效率。
这种效率的提升,其涵义是多方面的,一方面可以节省程序的空间上的篇幅,如关于求两个数的最大值
的宏
#define max ((x),(y)) ((x)<(y) ? (y) : (x))
既可用于两个整型变量,也可用于两个浮点型变量,节省语句不说,还节省了函数量。另一方面,恰当
地使用宏定义可提高程序的时间效率。因为宏定义仅仅是一种予编译技术,意思是说,在形成真正的代
码进行编译之前,程序中所有出现宏的地方都必须由予编译器用宏定义加以展开,这样,如果我们将函
数改成宏定义的话,根本就不存在参数的传递问题,甚至函数调用本身都不存在了,当然程序时间效率
会提升。可以说,宏定义比全局变量的使用更彻底。
使用宏定义同使用全局变量一样需要克制,需要慎重对待,否则就会象吸毒一样容易上瘾(原谅我使用这
么恶毒的比喻。)宏定义的使用实际上也存在副作用,大量的使用会破坏程序的可读性,并给程序的调试
带来麻烦,一般来说,如果一个函数非常大,一般不宜采用宏定义来进行改造,仅仅是那些小的函数,
而且非常影响效率的函数才值得这样去做。
4.4 汇编
汇编是提高程序效率的又一个神话,曾经有一位学者对我说:”真正的程序员应该用汇编写一切需要的
应用代码”,如果您是一个程序设计天才,我不反对您这样去做,但也决不鼓励。对于普通的程序员,
除非不得已(如写单板机控制程序),否则想都不要去想。我是83年学习的VAX 11汇编语言,14年了,从
来没有真正需要写过一个实际的汇编应用程序。
现在的C语言中一般都增加了内嵌汇编的成分,这为喜欢汇编的程序员大开了方便之门,他们可以随其所
好在任何需要的时间、地点开始这一个工作,的确非常方便。可是说到底,汇编只是一种手工优化的方
法,在IBM的“深蓝”已经战胜人类棋王卡斯帕洛夫的今天,你真的相信你手工优化的代码一定比机器编
译优化的代码效率要高吗?所以,对于汇编只略知皮毛的程序员不要去想汇编这件事,只需要知道这是奶
奶的奶奶们常使用的纺车就可以了。
有专家对汇编和C语言完成同一任务的程序作过有趣的对比实验,开始,他采用极朴素的汇编写出的代码
,其效率比C代码低,他运用自己所了解的各种方法对汇编代码进行优化,代码越来越长,终于,在汇编
代码数均为C代码数10-20倍时,其效率开始占上风。到此为止吧!这样下去真的值得吗?何况,现在有几
个人能理解一个2万行的汇编程序呢?且不说编写和调试了。
5 代码优化中的注意事项
代码优化不能仅仅停留在局部、细节上来考虑,而是应该将其视为整个软件工程的一个阶段,从整个工
程的全局高度来考虑。这个工程除了要求保证效率外,更重要的是保证其安全可靠,可以为以后的工程
提供借鉴,即软件的可重用性等方面。这样说,似乎是否定了本书的意义,其实不然。因为优化毕竟是
任何软件工程必不可少的一个步骤,我们要说的只是不要把局部的工作夸张到极至,从而看不到其它工
作的存在。
以下是代码优化需要注意的一些事项:
(1) 程序的优化以不破坏程序的可读性(可理解性)为原则。
软件技术的发展对软件开发的工程化要求日益提高。以现在的标准来衡量,一个好的程序决不仅仅是执
行效率高的程序,象计算菲波那契数时采用计算的方法来交换两个变量值的方法在50、60年代也许称得
上是一种好的技巧,但在今天,程序的可读性和可维护性要比这类“雕虫小技”更加重要。
(2) 如果将程序的执行效率纳入软件的整个生命周期来考虑,为提高单个程序的效率而花费大量的开发
时间往往得不偿失,只有在下列情况下,程序的优化才是有意义的:
(i) 首先保证程序的正确性和强壮性,然后才考虑优化;
(ii) 严重影响效率的程序才值得优化。例如系统反复调用的核心函数。程序中各函数的执行时间可以利
用编译器中相应的工具来统计,从中可以找出函数优化的线索,无关大局的函数没有优化的价值。
时刻需要记住的是:程序员必须考虑程序的全局效率而非局部效率。以下是需要注意的一些方面:
(1) 如果程序的使用次数不多,那么程序的编写时间和调试时间可能是主要的时间耗费。为了追求问题
完美的解,往往需要付出很多的时间和精力来编写精巧的程序,因而机器的实际运行时间对于总的时间
耗费影响不大。这种情况下,应当选用最易于正确实现的算法。一般来说,最简单的方法往往是最有效
的方法。一个程序,若其控制与逻辑都非常复杂,难以掌握,则我们宁可选用简单的方法,这样可以保
证程序的安全性。
(2) 程序的执行效率受到机器时间和空间两方面的限制,好的程序应当平衡两者之间的关系。有些程序
看起来虽然有效,但由于需要占用过多存储空间,可能并不实用,甚至难于实现,例如,若使用的空间
大到超过机器内存的限制,而需要使用硬盘等外存储设备时,程序的效率将大打折扣。
在一个应用程序中,需要优化的代码往往只是有限的核心模块,对系统进行全盘优化往往是不切实际的
。但作为一种技术的储备,代码优化是任何一个程序员都必须具备的基本功,在关键的时刻,代码优化
的思想将有助于你对严重影响系统效率的代码加以改造。这也正是本书对代码优化所持的基本观点,同
时,也是本文意义所在。