我怀疑一些人在C++软件开发人员身上进行秘密的巴甫洛夫试验,否则为什么当提到“效率”这个词时,许多程序员都会流口水。(Scott Meyers真幽默 译者注)
事实上,效率可不是一个开玩笑的事情。一个太大或太慢的程序它们的优点无论多么引人注目都不会为人们所接受。本来就应该这样。软件是用来帮助我们更好地工作,说运行速度慢才是更好的,说需要32MB内存的程序比仅仅需要16MB内存的程序好,说占用100MB磁盘空间的程序比仅仅占用50MB磁盘空间的程序好,这简直是无稽之谈。而且尽管有一些程序确是为了进行更复杂的运算才占用更多的时间和空间,但是对于许多程序来说只能归咎于其糟糕的设计和马虎的编程。
在用C++写出高效地程序之前,必须熟悉到C++本身绝对与你所碰到的任何性能上的问题无关。假如想写出一个高效的C++程序,你必须首先能写出一个高效的程序。太多的开发人员都忽视了这个简单的道理。是的,循环能够被手工展开,移位操作(shift operation)能够替换乘法,但是假如你所使用的高层算法其内在效率很低,这些微调就不会有任何作用。当线性算法可用时你是否还用二次方程式算法?你是否一遍又一遍地计算重复的数值?假如是的话,可以毫不夸张地把你的程序比喻成一个二流的观光胜地,即假如你有额外的时间,才值得去看一看。
本章的内容从两个角度阐述效率的问题。第一是从语言独立的角度,关注那些你能在任何语言里都能使用的东西。C++为它们提供了非凡吸引人的实现途径,因为它对封装的支持非常好,从而能够用更好的算法与数据结构来替代低效的类实现,同时接口可以保持不变。
第二是关注C++语言本身。高性能的算法与数据结构虽然非常好,但假如实际编程中代码实现得很粗糙,效率也会降低得相当多。潜在危害性最大的错误是既轻易犯又不轻易察觉的错误,濒繁地构造和释放大量的对象就是一种这样的错误。过多的对象构造和对象释放对于你的程序性能来说就象是在大出血,在每次建立和释放不需要的对象的过程中,宝贵的时间就这么流走了。这个问题在C++程序中很普遍,我将用四个条款来说明这些对象从哪里来的,在不影响程序代码正确性的基础上如何消除它们。
建立大量的对象不会使程序变大而只会使其运行速度变慢。还有其它一些影响性能提高的因素,包括程序库的选择和语言特性的实现(implementations of language features)。在下面的条款中我也将涉及。
在学习了本章内容以后,你将熟悉能够提高程序性能的几个原则,这些原则可以适用于你所写的任何程序。你将知道如何准确地防止在你的软件里出现不需要的对象,并且对编译器生成可执行代码的行为有着敏锐的感觉。
俗话说有备无患(forewarned is forearmed)。所以把下面的内容想成是战斗前的预备。
牢记80-20准则(80-20 rule)
80-20准则说的是大约20%的代码使用了80%的程序资源;大约20%的代码耗用了大约80%的运行时间;大约20%的代码使用了80%的内存;大约20%的代码执行80%的磁盘访问;80%的维护投入于大约20%的代码上;通过无数台机器、操作系统和应用程序上的实验这条准则已经被再三地验证过。80-20准则不只是一条好记的惯用语,它更是一条有关系统性能的指导方针,它有着广泛的适用性和坚实的实验基础。
当想到80-20准则时,不要在具体数字上纠缠不清,一些人喜欢更严格的90-10准则,而且也有一些试验证据支持它。不管准确地数字是多少,基本的观点是一样的:软件整体的性能取决于代码组成中的一小部分。
当程序员力争最大化提升软件的性能时,80-20准则既简化了你的工作又使你的工作变得复杂。一方面80-20准则表示大多数时间你能够编写性能一般的代码,因为80%的时间里这些代码的效率不会影响到整个系统的性能,这会减少一些你的工作压力。而另一方面这条准则也表示假如你的软件出现了性能问题,你将面临一个困难的工作,因为你不仅必须找到导致问题的那一小块代码的位置,还必须寻找方法提高它们的性能。这些任务中最困难的一般是找到系统瓶颈。基本上有两个不同的方法用来寻找:大多数人用的方法和正确的方法。
大多数人寻找瓶颈的方法就是猜。通过经验、直觉、算命纸牌、显灵板、传闻或者其它更荒唐的东西,一个又一个程序员一本正经地宣称程序的性能问题已被找到,因为网络的延迟,不正确的内存分配,编译器没有进行足够的优化或者一些笨蛋主管拒绝在要害的循环里使用汇编语句。这些评估总是以一种带有嘲笑的盛气凌人的架式发布出来,通常这些嘲笑者和他们的预言都是错误的。
大多数程序员在他们程序性能特征上的直觉都是错误的,因为程序性能特征往往不能靠直觉来确定。结果为提高程序各部分的效率而倾注了大量的精力,但是对程序的整体行为没有显著的影响。例如在程序里加入能够最小化计算量的奇异算法和数据结构,但是假如程序的性能限制主要在I/O上(I/O-bound)那么就丝毫起不到作用。采用I/O性能强劲的程序库代替编译器本身附加的程序库(参见条款23),假如程序的性能瓶颈主要在CPU上(CPU-bound),这种方法也不会起什么作用。
在这种情况下,面对运行速度缓慢或占用过多内存的程序,你该如何做呢?80-20准则的含义是胡乱地提高一部分程序的效率不可能有很大帮助。程序性能特征往往不能靠直觉确定,这个事实意味着试图猜出性能瓶颈不可能比胡乱地提高一部分程序的效率这种方法好到哪里去。那么会后什么结果呢?
结果是用经验识别程序20%的部分只会导致你心痛。正确的方法是用profiler程序识别出令人讨厌的程序的20%部分。不是所有的工作都让profiler去做。你想让它去直接地测量你感爱好的资源。例如假如程序太缓慢,你想让profiler告诉你程序的各个部分都耗费了多少时间。然后你关注那些局部效率能够被极大提高的地方,这也将会很大地提高整体的效率。
profiler告诉你每条语句执行了多少次或各函数被调用了多少次,这是一个作用有限的工具。从提高性能的观点来看,你不用关心一条语句或一个函数被调用了多少次。究竟很少碰到用户或程序库的调用者抱怨执行了太多的语句或调用了太多的函数。假如软件足够快,没有人关心有多少语句被执行,假如程序运行过慢,不会有人关心语句有多么的少。他们所关心的是他们厌恶等待,假如你的程序让他们等待,他们也会厌恶你。
不过知道语句执行或函数调用的频繁程度,有时能帮助你洞察软件内部的行为。例如假如你建立了100个某种类型的对象,会发现你调用该类的构造函数有上千次,这个信息无疑是有价值的。而且语句和函数的调用次数能间接地帮助你理解不能直接测量的软件行为。例如假如你不能直接测量动态内存的使用,知道内存分配函数和内存释函数的调用频率也是有帮助的。(也就是,operators new, new[], delete, and delete[]—参见条款8)
当然即使最好的profiler也是受其处理的数据所影响。假如用缺乏代表性的数据profile你的程序,你就不能抱怨profiler会导致你优化程序的那80%的部分,从而不会对程序通常的性能有什么影响。记住profiler仅能够告诉你在某一次运行(或某几次运行)时一个程序运行情况,所以假如你用不具有代表性的输入数据profile一个程序,那你所进行的profile也没有代表型。相反这样做很可能导致你去优化不常用的软件行为,而在软件的常用领域,则对软件整体的效率起相反作用(即效率下降)。
防止这种不正确的结果,最好的方法是用尽可能多的数据profile你的软件。此外,你必须确保每组数据在客户(或至少是最重要的客户)如何使用软件的方面能有代表性。通常获取有代表性的数据是很轻易的,因为许多客户都愿意让你用他们的数据进行profile。究竟你是为了他们需求而优化软件。