ANSI/ISO C++ Professional Programmer's Handbook(12)

  摘自: http://sttony.blogspot.com/search/label/C%2B%2B

12


优化你的代码


by Danny Kalev




简介


在过去30年中经常听到的观点是性能不再要紧,因为硬件的计算能力不断提高的同时价格不断下降。所以只要买一个更强的CPU或扩充机器的内存就可以很好的运行由高级语言写的迟缓的程序。换句话说,升级硬件比费力的调整代码更有费效比。这对于在标准个人电脑上执行的客户应用程序可能是对的。今天廉价的个人电脑比20年前的大型机性能更好,而且还在以摩尔定律增长。但是在有些应用用领域升级硬件不令人喜欢,因为硬件升级太贵或者更本就不能升级。在只有128k或更少RAM的嵌入式系统中,扩展RAM需要重新设计整个系统,在发展和测试新的芯片上花的时间可能需要好几年。在这种情况下,代码优化是获得满意性能的唯一手段。


但是优化并不限于象嵌入式系统或硬核心实时系统这样深奥的应用领域中。即使在主流应用领域中,比如金融和财务系统,有时候代码优化也是必须的。对于拥有$1,500,000 大型机的银行,买一台更快的机器比重写不到一千行关键代码花的钱要多的多。对于支持无数用户的服务系统,比如关系数据库管理系统和Web服务器,代码优化也是获得满意性能的主要工具。


另外的一种公有观念是代码优化意味着降低可读性,使得软件难以维护。这不一定是真的。有时候,简单的代码修改,比如改变源文件中申明的顺序或选择不同的容器类型,关系重大。但是这些改变都不会产生不可读代码,也不会带来额外的维护开销。事实上,有些优化技术甚至增强软件的可扩展性和可读性。更主动的优化不外乎使用更简单的类层次体系,以及使用内联汇编代码。这时结果确实是降低可读性,难以维护,难以移植。优化可以看作一把双刃剑;应该根据实际情况决定。


本章的范围


优化是一个巨大的主题,它可以轻易的写上厚厚的一本书。本章讨论多种的优化技术,其中大多数可以适用于C++代码而不需要你掌握特殊平台的底层硬件体系。我的目的只是想给你在选择编程策略时对性能消耗有正确的估计(你可以在你的计算机上试验后面章节讨论的的程序)。提供给你实践上的指导方针,而不是钻研性能分析、算法效率或大O表示法的理论。


在优化你的软件之前


找到程序性能的瓶颈时优化的第一步。但是,要分析发布版本而不是调试版本,因为调试版本包含有额外的代码,这很重要。调试版本的可执行文件可以比它的发布版本大40%。额外的代码是查找记号和其他调试“脚手架”所需要的。大多数编译器为调试版本和发布版本提供完全不同的运算符new和其他库函数。通常调试版本的new用独特的值初始化分配的内存并且在开始处增加头;发布版本的运算符new没有这些任务。此外,可执行文件的发布版本已经优化过了,包括消除不必要的临时对象
、空循环(参见“编译器的一些窍门”),将对象放入寄存器以及内联函数。由于这些原因,你很难从调试版本中发现性能瓶颈到底在什么地方。





编译器的窍门

编译器可以在几个方面自动的优化代码。命名返回值(named return value)循环分裂(loop unrolling)
是自动优化的两个例子。


考虑下面的代码:



int *buff = new int[3];
for (int i =0; i<3; i++)
buff[i] = 0;

这个循环是低效的:在每一步循环中,它将同一个值赋值给给数组的下一个元素。然而,宝贵的CPU时间就浪费在测试、增加计数以及跳转语句上了。为了避免这种开销,编译器将循环分裂成赋值语句的序列:



buff[0] = 0;
buff[1] = 0;
buff[2] = 0;

命名返回值是C++特殊的优化方法,用来消除构造和创建临时对象当临时对象使用拷贝构造器拷贝到其他对象时,以及两个对象都是纯数据(cv-unqualified)对象时,标准允许将两个对象当作一个对象对待,而不执行拷贝。例如

class A
{
public:
A();
~A();
A(const A&);
A operator=(const A&);
};
A f()
{
A a;
return a;
}
A a2 = f();

当函数 f()返回时对象 a并不需要拷贝。 f()的返回值可以直接由对象 a2构造,从而避免了堆栈上构造和销毁临时对象。



记住,优化和调试是两个完全不同的工作。调试版本需要抓住错误、使程序没有逻辑错误。测试发布版本需要调整和优化性能。当
然,本章出现的优化技术也可以增加调试版本的性能,但是发布版本才是需要估计的性能的版本。





注意:在调试版本中找到“幻影瓶颈(phantom bottleneck)”很平常,有些程序员常会费力的修补他们,而在最终的发布版本中都会消失。Andrew Koenig 写过一篇关于这些模糊瓶颈在发布版本中消失的极好的文章(“An Example of Hidden Library Overhead” C++ Report vol. 10:2, February 1998, page 11)。从这篇文章中可学到的教训适用于每一个优化代码的人。



申明的布局


程序中变量和对象申明的位置对性能有重大影响。同样的选择前缀还是后缀运算符也影响性能。本节集中讨论四个问题:初始化VS.赋值、在程序中重新放置申明、构造器的成员初始化列表、前缀和后缀运算符。


初始化比赋值好


C 仅允许在块开始所有语句之前申明。例如



void f();
void g()
{
int i;
double d;
char * p;
f();
}

在C++,申明是语句;同样的它可以出现在程序的任何地方。例如



void f();
void g()
{
int i;
f();
double d;
char * p;
}

在C++ 中作这个改动的动机是允许在对象使用之前申明他们。这个习惯有两个好处。第一,这个习惯保证在对象使用之前不被程序的其他部分修改。当对象在块的开始处申明而在20或50行使用时,就没有这个保证。例如,指向在堆中分配对象的指针就有可能在使用它之前被意外的删除。在使用它之前在申明就减少了这种错误的可能性。


第二个好处是立刻用所需的值初始化对象的能力。例如



#include <string>
using namespace std;
void func(const string& s)
{
bool emp = s.empty(); //局域申明使得可以直接初始化
}

对于基本类型,初始化仅仅比赋值高效一点;或者初始化和稍后赋值的性能是一样的。考虑下面的版本的函数func(),使用赋值而不是初始化:



void func2() //不如func()高效?不一定
{
string s;
bool emp;
emp = s.empty(); //后赋值
}

我的编译器产生了和初始化版本一样的汇编代码。但是对于用户定义类型,初始化和赋值的差别就十分明显了。下面的例子示范了这种情况下的性能增长(通过修改前面的例子)。代替bool变量,使用一个完全类,它的四个特殊成员函数都定义了:



int constructor, assignment_op, copy, destr; //全局容器
class C
{
public:
C();
C& operator = (const C&);
C(const C&);
~C();
};
C::C()
{
++constructor;
}
C& C::operator = (const C& other)
{
++assignment_op;
return *this;
}
C::C(const C& other)
{
++copy;
}
C::~C()
{
++destr;
}

于前面的例子一样,比较同一函数的两个版本;第一个使用赋值,第二个使用初始化:



void assign(const C& c1)
{
C c2;
c2 = c1;
}
void initialize(const C& c1)
{
C c2 = c1;
}

调用assign()导致了三次成员函数调用:构造器、赋值运算运算和销毁器。initialize()仅导致两次成员函数调用:拷贝构造器和销毁器。初始化节省了一次函数调用。对于象C这样的无意义类,多余的构造器调用产生的附加运行期开销可能是无关紧要的。但是,现实世界中对象的构造器还要调用它基类和内部对象的构造器。如果有选择初始化和赋值的机会,总是选择初始化。


重新放置申明


初始化对象比赋值要好是局域申明的一个方面。在有些场合,通过移动申明带来的性能提升更明显。考虑下面的例子:



bool is_C_Needed();
void use()
{
C c1;
if (is_C_Needed() == false)
{
return; //不需要c1
}
//在这里使用c1
return;
}

局域对象c1无条件的在use()中构造和销毁,即使它根本没有使用。编译器将函数 use()变成:



void use()
{
C c1;
c1.C::C(); //1. 编译器增加的构造器调用
if (is_C_Needed() == false)
{
c1.C::~C(); //2. 编译器增加的销毁器调用
return; //c1 不需要,但是仍然被构造和销毁
}
//在这里使用c1
c1.C::~C(); //3. 编译器增加的销毁器调用
return;
}

就像你看到的,当is_C_Needed()返回false时,不必要的c1的构造和销毁仍然没有避免。聪明的编译器总会优化这些不必要的构造和销毁吗?标准允许编译器取消对象的创建(当然也有销毁),如果不需要并且对象的构造器和销毁器都没有任何副作用的话。然而咱这个例子中,编译器由于两个原因不能执行这个优化。首先,c1的构造器和销毁器都有副作用——他们增加计数器。其次,is_C_Needed()的结果在编译期是未知的;因此就不能保证在运行期是否真的需要c1。不过程序员稍微帮助一下,不必要的构造和销毁就可以消除。艘游这些只需要改变一下c1的申明的位置,在需要时再申明:



void use()
{
if (is_C_Needed() == false)
{
return; //不需要c1
}
C c1; //从开始移到这里
//使用c1
return;
}

因此,仅当实际需要c1的时候才被构造——就是is_C_Needed()返回true。另一方面,如果is_C_Needed()返回falsec1即不会构造也不会销毁。因此简单的移动c1申明的位置,就消除了两次不必要的成员函数调用!它是怎么工作的呢?编译器将use()变成了下列形式:



void use()
{
if (is_C_Needed() == false)
{
return; //不需要c1
}
C c1; //从开始移到这里
c1.C::C(); //1 编译器增加的构造器调用
//use c1 here
c1.C::~C(); //2 编译器增加的销毁器调用
return;
}

为了了解优化的效果,改变use()。不用单一对象,而是1000个元素的C数组:



void use()
{
if (is_C_Needed() == false)
{
return; //不需要c1
}
C c1[1000];
//在这里使用 c1
return;
}

另外,定义is_C_Needed()返回false



bool is_C_Needed()
{
return false;
}

最后,main()如下:



int main()
{
for (int j = 0; j<100000; j++)
use();
return 0;
}

两个版本的use()在性能上有巨大差异。在Pentium II, 233MHz的机器上比较他们。为了使结果可信,测试5次。当使用优化版本时,main()中的for循环平均耗时少于0.02秒,而不优化版本用了16秒。巨大的差异并不是很令人惊讶;毕竟不优化版本产生了100,000,000 次构造器调用和100,000,000 次销毁器调用,而优化版本没有。这个结果也暗示为容器对象预分配足够空间而不是允许容器重分配就可以简单的获得性能提升(参见第十章“STL和泛型程序设计”)。


成员初始化列表


就象你在你在第四章“特殊成员函数:默认构造器,拷贝构造器,销毁器和分配运算符特殊成员函数:默认构造器,拷贝构造器,销毁器和赋值运算符”读到的,成员初始化列表用来初始化const和引用数据成员,以及向基类获内部对象的构造器传递参数。另外,数据成员可以在构造器中赋值也可以在成员初始化列表中初始化。例如



class Date //成员初始化版本
{
private:
int day;
int month;
int year;
//构造器和销毁器
public:
Date(int d = 0, int m = 0, int y = 0) : day , month(m), year(y) {}
};

你也可以这样定义构造器:



Date::Date(int d, int m, int y) //在构造器中赋值
{
day = d;
month = m;
year = y;
}

两个构造器在性能上有什么区别吗?在这个例子中是没有的。Date所有的数据成员都是基本类型。所以在成员初始化列表中初始化他们和在构造器中赋值在性能上是一样的。但是对于用户定义类型,两种形式有显著的区别。为了示范,回到成员函数计数类C,并且一个另外的类包含两个C



class Person
{
private:
C c_1;
C c_2;
public:
Person(const C& c1, const C& c2 ): c_1(c1), c_2(c2) {}
};

另一个可选的Person构造器的版本如下:



Person::Person(const C& c1, const C& c2)
{
c_1 = c1;
c_2 = c2;
}

最后,改动一下main()



int main()
{
C c; //仅创建一次,作为Person构造器的哑元参数
for (int j = 0; j<30000000; j++)
{
Person p(c, c);
}
return 0;
}

在Pentium II,233MHz的机器上比较两个版本。为了准确,测试5次。成员初始化版本平均耗时12秒。不优化版本平均耗时15秒。也就是说,在构造器中赋值比成员初始化列表要慢25%。成员函数计数器可以给你差别的原因。表12.1展示了类C的成员函数在两个版本中被调用的次数。


表12.1 成员初始化列表和在构造器中赋值调用类Person成员函数的次数比较
























初始化方法



默认构造器



赋值运算符



拷贝构造器



销毁器



成员初始化列表



0



0



60,000,000



60,000,000



在构造器中赋值



60,000,000



60,000,000



0



60,000,000



当使用初始化列表时,只调用了内部对象的拷贝构造器和销毁器(注意Person有两个内部对象),而在构造器中赋值增加了内部对象默认构造器的调用)。在第四章。你学到了编译器是如何在构造器的用户代码之前添加额外代码的。附加代码调用基类和内部对象的构造器。在多态类中特初始化vptr。类Person的赋值构造器被转换成下列形式:



Person::Person(const C& c1, const C& c2) //在构造器中赋值
{
//编译器在用户代码之前插入的伪C++代码
c_1.C::C(); //调用内部对象c_1的默认构造器
c_2.C::C(); //调用内部对象c_2的默认构造器
//用户代码:
c_1 = c1;
c_2 = c2;
}

内部对象的默认构造器是不需要的,因为他们马上被赋了新的值。另一方面,成员初始化列表在用户代码之前出现。因为这种情况下构造器不包含用户代码,被转换成如下形式:



Person::Person(const C& c1, const C& c2) //成员初始化ctor
{
//编译器在用户代码之前插入的伪C++代码
c_1.C::C(c1); //调用内部对象c_1的拷贝构造器
c_2.C::C(c2); //调用内部对象c_2的拷贝构造器
//用户代码在这里(但是没有用户代码)
}

从这个例子中你可以终结出,对于有内部对象的类,成员初始化列表比在构造器中赋值要好。由于这个原因,许多程序员总是使用成员初始化列表,即使是基本类型数据成员。


前缀运算符VS后缀运算符


前缀运算符++--比他们的后缀版本更有效率。因为使用后缀运算符时,产生了一个 临时对象来保存操作数改变之前的值。对于基本类型,编译器可以消除额外的拷贝。但是,对于用户定义类型,消除拷贝基本上时不可能的。一个典型重载前缀和后缀运算符的实现示范了两者的不同:



class Date
{
private:
//...
int AddDays(int d);
public:
Date operator++(int unused);
Date& operator++();
};
Date Date::operator++(int unused) //后缀
{
Date temp(*this); //创建一个当前对象的拷贝
this->AddDays(1); //增加当前对象
return temp; //在它增加之前值返回一个对象的拷贝
}
Date& Date::operator++() //前缀
{
this->AddDays(1); //增加当前对象
return *this; //返回当前对象的引用
}

重载后缀++比前缀++明显的低效,有两个原因:它需要创建一个临时对象,它要值返回对象。因此,当你可以选择前缀和后缀时,要选择前缀版本。


内联函数


内联函数可以消除函数调用产生的开销而且提供普通函数的优点。但是内联不是万能药。在有些环境下,他们也可以降低程序的性能。明智的使用这个特性是很重要的。


函数调用的开销


普通函数调用的精确开销是依赖于具体编译器和环境的。通常包括存储当前堆栈状态,函数实参压栈并初始化,跳转到函数的入口——然后才是执行函数。当函数返回时,执行一系列相反的操作。在其他语言中(比如Pascal和COBOL),函数调用的开销是十分值得注意的,因为在函数调用之前和之后编译器还要执行其他操作。对于只是返回数据成员值的成员函数,这样的开销是不能忍受的。C++增加了内联函数,以有效的实现访问器(accessor)和改变器(mutator )(分别是getterssetters)。非成员函数也可以申明为inline


内联函数的好处


内联函数的好处是十分明显的:从用户的角度看,内联函数和普通函数一样。它可以有参数和返回值;此外,它也有自己的范围,但是它不会招来函数调用的开销。另外,它明显的比宏要安全和易于调试。而且还有更多的好处。当函数被内联时,编译器还对生成的代码使用符合上下文的关联的优化方法,而不是单纯的执行函数代码。


所有在类中实现的函数都隐含的申明为inline。另外,编译器合成的构造器、拷贝构造器、赋值运算符和销毁器都隐含申明为inline。例如



class A
{
private:
int a;
public:
int Get_a() { return a; } //隐含申明内联
virtual void Set_a(int aa) { a = aa; } //隐含申明内联
//编译器合成的成员函数也申明为内联
};

但是一定要注意,inline说明符只是推荐编译器内联函数。编译器也可以不理睬这个推荐而不内联它们;编译器也可以内联没有显式申明为inline的函数。幸运的是,C++保证函数的语义不能仅仅因为内联或不内联就被被编译器改变。例如,不可能得到申明为inline函数的地址,不管它是否被编译器内联(但是结果将是创建函数的非内联拷贝)。编译器如何决定函数是否应该被内联呢?他们被设计成有权依据不同的标准来选择最适合内联的人。这些标准包括函数体的大小、是否申明了局域变量、它的复杂性(例如,递归和循环经常使丧失内联的资格),以及其他编译器和函数使用环境的因素。


被申明为内联的函数不被内联时发生什么?


理论上当编译器拒绝内联函数时,函数被当作普通函数:编译器为它产生目标代码,将函数调用转换成跳转到内存地址的代码。不幸的是,隐含成为非内联的函数比这要复杂的多。一个普遍的习惯是在类申明中定义内联函数。例如



// filename Time.h
#include<ctime>
#include<iostream>
using namespace std;
class Time
{
public:
inline void Show() { for (int i = 0; i<10; i++) cout<<time(0)<<endl;}
};
// filename Time.h

因为成员函数Time::Show()包含一个局域变量和一个for循环,编译器很有可能会忽略inline要求并将它作为普通函数对待。但是类申明被#included在一个独立的translation units中:



// filename f1.cpp
#include "Time.h"
void f1()
{
Time t1;
t1.Show();
}
// f1.cpp
// filename f2.cpp
#include "Time.h"
void f2()
{
Time t2;
t2.Show();
}
// f2.cpp

结果编译器为同一程序产生了同一成员函数的两个一样的拷贝:



void f1();
void f2();
int main()
{
f1();
f2();
return 0;
}

当程序连接,连接器面对Time::Show()的两个拷贝。一般的,函数重定义导致连接期错误。但是非内联函数是特殊情况。早期的C++的编译器通过将非内联函数申明为static来处理这种情况。因此,每一个编译过的函数拷贝仅在它申明的translation unit内可见。这中方法通过为同一函数产生两份局域对象来解决命名冲突。这时,inline申明不会提高性能;相反的,每一次调用非内联函数都有普通函数调用的开销。更糟的是,多个函数的拷贝增加了编译和连接时间,增大了可执行文件的大小。讽刺的是,申明Time::Show()inline可能性能更佳!记住程序员一般不清楚这样作的实际消耗——编译器平静的尽力,连接器悄悄的叹息,可执行程序更膨胀更慢。但是它仍然工作,用户抓着头皮说“这个面向对象程序实在是糟糕!如果我用C来写代码一定会快的多!”。


幸运的是,标准的关于非内联函数的规范最近更改了。遵循标准的编译器不管有多少translation units定义了函数,都保证只有一份函数的拷贝。换句话说,非内联函数当作普通函数一样对待。但是,编译器厂商采用新标准可能需要一些时间。


另外的被关注的问题


关于内联函数还有两个难题。提一个关于维护。一个函数以苗条的内联函数开始它的生命,提供前面提到的好处。在系统生命期的后期,函数被扩展以容纳附加的功能。突然的,内联条件变的无效或不可能了。因此重新考虑是否因该移去函数的inline限制是很重要的。对于在类中定义的成员函数,修改是复杂的,因为函数定义必须移的单独的源文件中。


当在代码库中使用内联函数时,还可能产生其他问题。如果内联函数改变了,维护二进制兼容性是不可能的。在这种情况下,用户必须重新编译器他们的代码来反映修改。对于非内联函数,用户只需要重新连接他们的代码,这比重新编译要好的多。


内联的Do's 和Don'ts


这里的教训是inline并不能不可思议的提供性能。对于每一个短函数——例如accessors、mutators和函数包装(参见第十三章“与C语言的兼容性问题”)——inline修饰符可能会在执行速度和程序大小上都有利可图。如果内联函数不是非常短,但是调用频繁,结果可能是增大程序大小。此外,许多处理器缓存程序经常使用的机器指令序列;过多内联可能影响指令缓存的命中率,大大影响性能。即使申明inline也被编译器拒绝实在是一件讨厌的事。在老的编译器,结果是十分痛苦的。在遵循标准的编译器中,被编译器拒绝内联的危害较小,但是仍然是不受欢迎的。有些编译器聪明的可以计算出被标为内联的函数是否内联。但是大多数编译器不能,所以最好是以经验为主的测试内联申明的效果。如果内联申明不能提高性能,避免它。


优化内存的使用


优化有几个方面:更快的执行速度,更有效率的使用系统资源和使用最小的内存。一般而言,代码优化试图改善所有的方面。前面示范的重新放置申明的技术消除了不必要的对象的创建和销毁,因此减少了程序的执行时间并提高它的运行速度。但是,其他的优化技术总是偏向一个方面——更快的执行速度或占用更小的内存。虽然有时这些目标是互斥的;就是说将定内存使用就会降低速度,反之提高速度意味着占用更大的内存。本节展示不同的程序需要的优化或压缩内存的技术。


位域


在C 和C++中都可以直接存取数据最小单元:一个比特。因为一个位不是C/C++编译器的自然存储单元,所以使用位域可能增加可执行程序的大小,处理器处理一个或多个位的序列需要额外的动作。这是一个牺牲速度来换取最小的内存使用的很典型的例子。





注意:但是注意,有些硬件体系提供特殊的处理器指令来存取位。因此,不管位域是否影响速度,它非常依赖平台。



一般的,你不应使用位域来存储超过一字节的数据。但是对于有些应用程序折衷执行速度和存储空间后还是会选择位域。例如,通常国际电话公司的记帐系统将每一个电话呼叫作为一个记录存储在关系数据库中。这些记录被周期性的处理以计算用户的月帐单。数据库每天存储上百万新记录,而且它必须保存用户至少一年的帐单。完整的数据库在给定的时间包含大约一百万记录。因为数据库定时备份,因为数据库是分部式数据库,每一条记录存储在多个物理位置。事实上在给定时间,可能有2000万记录存储在不同的备份中和数据库分部位。最小的帐单包含用户ID、一个时间戳、指示呼叫类型的代号(例如,本地还是长途)和价目(off peak, peak time)。在这里每一个bit都要计算在内——一个多余的bit意味着2.5GB的浪费!


没有空余空间的帐单记录的定义可能是这样的:



struct BillingRec
{
long cust_id;
long timestamp;
enum CallType
{
toll_free,
local,
regional,
long_distance,
international,
cellular
} type;
enum CallTariff
{
off_peak,
medium_rate,
peak_time
} tariff;
};

在我的32位机器上一个BillingRec不止占用bytes的内存。显然,空间浪费了。头两个域都占用了四个比特,与期望的一样。但是两个enum变量占用了另外的8字节,即使他们都可以安全的用一个字节来表示。紧缩版本的BillingRec使用两个位域来代替enum



struct BillingRec
{
long cust_id;
long timestamp;
enum CallType
{
toll_free,
local,
regional,
long_distance,
international,
cellular
};
enum CallTariff
{
off_peak,
medium_rate,
peak_time
};
unsigned call: 3; //三个 bits
unsigned tariff: 2; //两个bits
};

现在BillingRec大小是12字节。节约的4字节等于每天节约存储兆字节。尽管如此,还可以进一步的缩小。两个位域占用5比特,不到以字节。可能期望BillingRec只占用9字节而不是12字节。问题是编译器在位域后面插入3个附加对齐字节来使BillingRec对齐一个字。(更多参见第十一章“内存管理”)。附加对齐字节来保证存取速度——浪费了三个字节。又两个方法可以避免这个问题:你可以改变编译器的设置来允许对齐字节边界或改变其他成员的大小以使总量刚好达到8字节。





注意:注意,两种解决方案都是不好移植的,而且在有些硬件体系上编译器可能坚持对齐字边界。检查你的编译器的关于成员对齐设置的规格。



改变成员的大小有些狡猾,因为头两个成员也改成位域:



struct BillingRec
{
int cust_id: 24; // 23 bits + 1 符号位
int timestamp: 24;
enum CallType
{//...
};
enum CallTariff
{//...
};
unsigned call: 3;
unsigned tariff: 2;
};

这时,BillingRec总共占用8字节,只是原来大小的一半。在前面的例子中可以每年节省10GB的空间。考虑今天磁介质存储器的便宜价格,节约不到一千美元的成本好像是强迫人——但是用更小的存储空间有其他原因:数字通信的费用。分部式数据库在多个地点有同步的备份。同步过程经常依靠中央数据库向它的同步拷贝传输数字数据来完成,或反过来。在租赁的线路上传输上百万记录也是非常昂贵的。但是对于那些自己有线路的电话公司,这不是需要特别关注的问题;但是假定公司是要为每小时上百美元的帐目传输数据的国际银行。在这种情况下,减半的数据容量肯定是有利可图的。另一个要注意的是Web;如果电话公司有Web站点,在站点上上用户可以在线查询他们帐单的情况,通过这个方法从模拟拨号线路下载的时间将减半。


共用体


通过将两个或两个以上数据成员放在同一地址,共用体将内存浪费降到最低,在同一时候只有一个变量是激活的。 共用体的大小刚好可以保存最大数据元素的大小。 共用体可以有成员函数,包括构造器和销毁器,但是它不能有虚成员函数。一个共用体即不能作基类,也不能继承其他类。另外,共用体不能存储有特殊成员函数的对象。C++也支持匿名共用体(anonymous unions)。匿名共用体是一个未命名类型的未命名对象(匿名共用体也在第十一章讨论)。例如



union { long n; void * p}; //匿名的
n = 1000L; // 直接存取数据成员
p = 0; // 现在n 也是0

与命名共用体不同,匿名共用体不能有成员函数和非公用数据成员。


什么时候使用共用体?下面的类从数据库中获得个人的数据。关键字可以是唯一的ID号码或姓,但是不会两个都是:



class PersonalDetails
{
private:
char * name;
long ID;
//...
public:
PersonalDetails(const char *nm); //使用类型为char * 的关键字
PersonalDetails(long id) : ID(id) {} //使用数字作关键字
};

内存浪费了,因为只有一个关键字会被用到。使用一个匿名共用体可以使用的内存最小。例如



class PersonalDetails
{
private:
union //anonymous
{
char * name;
long ID;
};
public:
PersonalDetails(const char *nm);
PersonalDetails(long id) : ID(id) {/**/} //直接存取成员
//...
};

通过使用共用体,类PersonalDetails的大小减半了。不需要太多的麻烦又节约了四个字节,如果这个类作为数据库上百万条记录的模型或者必须在慢速的通讯线路上传输时,又可以大大提高性能。注意共用体不会产生运行期开销,所以使用共用体不会降低速度。匿名共用体比命名共用体好的地方是它的成员可以直接存取。


优化速度


在临界时间应用程序中,每一个CPU周期都必须考虑。本节展示了一些简单的速度优化的方针。有些从C就有了;有些时C++特有的。


使用类包装长的参数表


当函数有一个长的参数表时调用函数的开销增加。运行期系统必须用实参的值初始化堆栈;自然的,当有许多实参时这个操作变的很长。例如,在我的机器上执行下面的函数100,000,000 次耗时8.5秒:



void retrieve(const string& title, //5 个参数
const string& author,
int ISBN,
int year,
bool& inStore)
{}

将实参表包装在一个类中,再传递类的引用使得结果减少到5秒。当然,对于要执行很长时间的函数,初始化堆栈的开销是可以忽略的。但是,对于短和快的而且经常被调用的函数,将长的参数表包装再一个类中再传递它的引用可以提高性能。


寄存器变量


存储修饰符register可以告诉编译器一个对象对程序十分重要。例如



void f()
{
int *p = new int[3000000];
register int *p2 = p; //将地址存储再寄存器中
for (register int j = 0; j<3000000; j++)
{
*p2++ = 0;
}
//...使用 p
delete [] p;

}

循环计数器是一个可以申明为寄存器变量好的人选。当他们没有存储在寄存器中时,可观的循环执行时间浪费在从内存中去变量、赋新的值给它和再把它放回内存上了。将它存储在机器的寄存器中可以避免这些开销。但是注意register只是一个给编译器的推荐。就像函数内联一样,编译器可以拒绝将对象存储在机器的寄存器中。此外,现代编译器总是优化循环计数器,并将他们存储到机器的寄存器中。register存储修饰符不限于基本类型。它也可以用于任何类型的对象。如果对象大的不能放到寄存器中,编译器可以将对象放在快速内存区域中,比如cache(cache一般比主存储器要快10倍以上)。





注意:有些编译器完全忽略 register修饰符,并自动按内部优化规则存储程序员的变量。请参考你的编译器厂商的关于register申明的具体处理细节。



register存储修饰符申明函数形参是推荐通过机器的寄存器而不是堆栈来传递参数。例如



void f(register int j, register Date d);

申明常量对象为const


将常量对象申明为const还还有其他好处,优化的编译器也可以从这种申明获得好处,将这种对象存储在机器的寄存器而不是普通内存中。注意同样的优化适用与申明为const的函数形参。另一方面,volatile修饰符关闭这样的优化(参见Appendix A“程序设计风格手册”),所以仅在不可避免时用它。


虚函数的运行期开销


当通过指针或对象的引用来调用虚函数时,这个调用增加了不必要的运行期开销。如果编译器可以静态的决定调用,就没有额外的开销产生了。此外,这种情况下,非常短的虚函数可以内联。在下面的例子中,聪明的编译器可以静态的解决虚成员函数的调用:



#include <iostream>
using namespace std;
class V
{
public:
virtual void show() const { cout<<"I'm V"<<endl; }
};
class W : public V
{
public:
void show() const { cout<<"I'm W"<<endl; }
};
void f(V & v, V *pV)
{
v.show();
pV->show();
}
void g()
{
V v;
f(v, &v);
}
int main()
{
g();
return 0;
}

如果整个程序出现在单个translation unit,编译器可以用内联来替换main()中的函数g()的调用。因为传递给f()的参数的动态类型在编译期就可以决定,编译器可以静态的插入f()来解决虚函数的调用。不能保证每一个编译器实际中都会内联所以的函数调用;但是有的编译器的确可以从中得到好处,因为f()的实参类型在编译期就决定了,这样就可以避免动态绑定的开销。


函数对象VS. 函数指针


使用函数对象来代替函数指针的好处(函数对象在第十章和第三章“运算符重载”中讨论过)不仅限于泛型和更简单的维护。而且,编译器可以内联函数对象的调用,因此可以进一步的提高性能(内联函数指针的调用几乎时不可能的)。


最后的手段


迄今为止介绍的优化技术都没有影响设计的安全或降低可读性。事实上,他们中的有些技术改善软件的活力,减轻了维护的费用。将长参数表包装在一个对象中,const申明和使用函数对象而不是函数指针等等为进一步的提高了性能。但是在严格时间和内存限制的程序中,这些技术可能是不够的;有时需要另外的方法,这些方法就要影响软件的可移植性和可扩展性。在本节展示的技术仅仅在最后才使用,在别的优化技术都用完的时候。


关闭RTTI 和异常处理的支持


但你移植旧的C代码到C++编译器时,你可以发现轻微的性能下降。这不是程序语言或编译器的毛病,而是C++造成的。所有你要作的只是关闭编译器的RTTI和异常处理支持,就可以获得和C编译器一样的性能。为什么呢?为了支持RTTI和异常处理,一个C++编译器插入了附加的“脚手架”代码到了原来的源文件中。这增加了可执行文件的大小,并增加了轻微的运行期开销(异常处理和RTTI的性能开销分别在第六章“异常处理”和第七章“运行期类型识别”中讨论)。当使用旧的C代码时,这些附加代码是不需要的。但是请注意,这种方法不能适用于C++代码或使用了任何C++构造比如new和虚函数的C代码。


嵌入汇编


C++的时间临界片断可以用汇编代码重写。T结果可以显著的增加速度。但是注意这个措施并不轻松,因为它可能使将来的修改变的十分困难。维护代码的程序员可能不熟悉所使用的汇编语言的细节,或者他们可能更本没有汇编语言的经验。此外,移植到其他平台时需要重写汇编代码的部分(在有些例子中,升级处理器可能也需要重写)。另外,开发和测试汇编代码是一个比用高级语言开发测试要艰辛的工作。


一般,用汇编语言编码的操作是底层库函数。例如在大多数编译器中,标准库函数memset()strcpy()是用本地汇编代码编写的。C和C++准许程序员在asm块中嵌入汇编。例如



asm
{
mov a, ecx
//...
}

直接与操作系统作用


API函数和类使你能够与操作系统结合。但是有时,直接执行系统命令可能更快。为了这个目的,你可以使用标准函数system()来向Shell发送一个const char *类型的命令。例如在DOS/Windows系统中,你可以通过如下方式显示当前目录下的文件。



#include <cstdlib>
using namespace std;
int main()
{
system("dir"); //执行"dir"命令
}

在这里也是,速度和可移植性、可扩展性的折衷。


总结


在完美情况下,软件设计者和开发者可能注意他们在强壮、可扩展性和可读性代码方面的努力。幸运的是,在软件世界中已经比15、30或50年前更接近这个目标了。尽管如此,性能需求和优化在相当长一段时间内仍是一个要求。硬件越来越快,更多的软件需要更高的执行环境。语言识别,在线自然语言翻译,神经网络和复杂数学计算仅仅是资源饥渴的几个例子,这些都是需要发展,需要仔细优化的程序。


教科书经常推荐你将优化放到测试的最后一步去考虑。实际上,主要的目的是使系统正确的工作。虽然如此,这里展示的有些技术——比如申明对象为局域,尽可能的用前缀运算符代替后缀运算符和使用成员初始化列表代替赋值——应该成为一种自然的习惯。众所周知,通常程序10%的代码会消耗90%的执行时间(数字可能会变,但他们在80% - 20% 到 95% - 5%)。因此,优化的第一步就是识别出那10%的代码,优化他们。许多自动化的压缩和优化工具可以帮助你识别出这些优化关键代码。有些工具也可以提出提供性能的解决方案。 尽管如此,许多优化技术是依赖编译器,并总是需要人的经验。以经验为主检验你怀疑的代码,测试改变代码之后系统性能提升的情况来确定优化代码的效果,这是重要的优化方针。程序员关于特定操作花费情况的直觉经常是不真实的。例如,短的代码不一定是快的代码。同样的,写费解的代码避免来简单的if语句的消耗也是不值得的,因为它仅节省两个CPU周期。







HEIGHT="28" ALIGN="BOTTOM" ALT="Contents" BORDER="0">



  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值