More Effective C++ 读书摘要(四、效率)Item16 - 24

来源:互联网 发布:绿色版的软件 编辑:程序博客网 时间:2024/06/08 20:07

Item16. 记住80-20准则:


80-20准则说的是大约20%的代码使用了80%的程序资源;大约20%的代码耗用了大约80%的运行时间;大约20%的代码使用了80%的内存;大约20%的代码执行80%的磁盘访问;80%的维护投入于大约20%的代码上;通过无数台机器、操作系统和应用程序上的实验这条准则已经被再三地验证过。80-20准则不只是一条好记的惯用语,它更是一条有关系统性能的指导方针,它有着广泛的适用性和坚实的实验基础。

 

软件整体的性能通常取决于程序中的一小部分代码。

 

正确的方法是用profiler程序识别出令人讨厌的程序的20%部分。不是所有的工作都让profiler去做。你想让它去直接地测量你感兴趣的资源。请记住profiler仅能够告诉你在某一次运行(或某几次运行)时一个程序运行情况,所以如果你用不具有代表性的输入数据profile一个程序,那你所进行的profile也没有代表型。相反这样做很可能导致你去优化不常用的软件行为,而在软件的常用领域,则对软件整体的效率起相反作用(即效率下降)。

 

要防止这种不正确的结果,最好的方法是用尽可能多的数据profile你的软件。

 

Item17. 考虑使用延迟计算:

  

关键是要懒惰Lazy一些。
以下为四个应用场景:

 
引用计数(Reference Counting)
lazy evaluation:除非你却是需要,不要一上来就制作一个拷贝。我们应该是懒惰的,只要可能就共享使用别人的值。

 

区别对待读取和写入operator[]
在Item30中利用代理类可以实现这一点

 

延迟读取(Lazy Fetching)
假设你的程序使用了一些包含许多字段的大型对象。这些对象的生存期超越了程序运行期,所以它们必须被存储在数据库里。每一个对象都有一个唯一的对象标识符,用来从数据库中重新获得对象:因为LargeObject对象实例很大,为这样的对象获取所有的数据,数据库的操作的开销将非常大,特别是如果从远程数据库中获取数据和通过网络发送数据时。而在这种情况下,不需要读去所有数据。

当LargeObject对象被建立时,不从磁盘上读取所有的数据,而是仅仅建立一个对象“壳“,当需要某个数据时,这个数据才被从数据库中取回。这种“demand-paged”对象初始化的实现方法是

延迟表达式计算(Lazy Expression Evaluation)

应该建立一个数据结构来表示m3的值是m1与m2的和,在用一个enum表示它们间是加法操作。很明显,建立这个数据结构比m1与m2相加要快许多,也能够节省大量的内存。考虑程序后面这部分内容,在使用m3之前,代码执行如下:
Matrix<int> m4(1000, 1000);
...                                           // 赋给m4一些值
m3 = m4 * m1;
现在我们可以忘掉m3是m1与m2的和(因此节省了计算的开销),在这里应该记住m3是m4与m1运算的结果。不用说,我们不会马上进行这个乘法。

一个更常见的应用领域是当我们仅仅需要计算结果的一部分时。例如假设我们初始化m3的值为m1和m2的和,然后象这样使用m3:
cout << m3[4];                                // 打印m3的第四行
很明显,我们不能再懒惰了,应该计算m3的第四行值。但是我们也不需要费太大的劲,没有理由计算m3第四行以外的结果;m3其余的部分仍旧保持未计算的状态直到确实需要它们的值。

 

Item18. 分期摊还预期的计算开销:


即时计算(eager evaluation)是在函数被调用时计算。而提前计算(over-eager evaluation)是预计某个计算会被频繁调用时就可以设计一个数据结构高效地处理这些计算需求,这样可以降低每次计算需求的开销。

 

以下程序使用了标准模板库(STL)里的map对象作为本地缓存,这样就不需要每次从数据库中查询。

(有一个代码细节需要解释一下,最后一个语句返回的是(*it).second,而不是常用的it->second。为什么?答案是这是为了遵守STL的规则。简单地说,iterator是一个对象,不是指针,所以不能保证”->”被正确应用到它上面。但是STL明确要求”.”和”*”在iterator上是合法的,所以(*it).second在语法上虽然比较繁琐,但是保证能运行。)

(thy:即空间换时间。如TSP问题中可以提前计算出城市距离矩阵)

 

Item19. 了解临时对象的来源:
在C++中真正的临时对象是看不见的,它们不出现在你的源代码中。一个无命名的非堆(non-heap)对象就是临时对象。这种未命名的对象通常在两种条件下产生:为了使函数成功调用而进行隐式类型转换和函数返回对象时。

仅当通过传值(by value)方式传递对象或传递常量引用(reference-to-const)参数时,才会发生这些类型转换。当传递一个非常量引用(reference-to-non-const)参数对象,就不会发生。

在任何时候只要见到常量引用(reference-to-const)参数,就存在建立临时对象而绑定在参数上的可能性。在任何时候只要见到函数返回对象,就会有一个临时对象被建立(以后被释放)。

 

Item20. 协助编译器实现返回值优化:
相信我:一些函数(operator*也在其中)必须要返回对象。这就是它们的运行方法。不要与其对抗,你不会赢的。
你所应该关心的是把你的努力引导到寻找减少返回对象的开销上来,而不是去消除对象本身。

诀窍就是返回带有参数的构造函数而不是直接返回对象:

通过这个表达式建立一个临时的Rational对象,
Rational(lhs.numerator() * rhs.numerator(),

         lhs.denominator() * rhs.denominator());
函数的返回值正是这人上临时对象的拷贝。C++规则允许编译器优化超出生存周期的临时对象(temporary objects out of existence)。

Rational c = a * b;                          // 在这里调用operator*
编译器就会被允许消除在operator*内的临时变量和operator*返回的临时变量。它们能在为目标c分配的内存里构造return表达式定义的对象。如果你的编译器这样去做,调用operator*的临时对象的开销就是零:没有建立临时对象。

 

Item21. 通过函数重载避免隐式类型转换:

现在考虑下面这些语句:
upi3 = upi1 + 10;
upi3 = 10 + upi2;

这些语句也能够成功运行。方法是通过建立临时对象把整形数10转换为UPInts。还有一种方法可以成功进行operator的混合类型调用,它将消除隐式类型转换的需要。如果我们想要把UPInt和int对象相加,通过声明如下几个函数达到这个目的,每一个函数有不同的参数类型集。

一旦你开始用函数重载来消除类型转换,你就有可能这样声明函数,把自己陷入危险之中:
const UPInt operator+(int lhs, int rhs);           // 错误!

在C++中有一条规则是每一个重载的operator必须带有一个用户定义类型(user-defined type)的参数。
不过,必须谨记80-20规则(参见条款16)。没有必要实现大量的重载函数,除非你有理由确信程序使用重载函数以后其整体效率会有显著的提高。

 

Item22. 考虑使用op=来取代单独的op运算符:

第一、总的来说operator的赋值形式比其单独形式效率更高,因为单独形式要返回一个新对象,从而在临时对象的构造和释放上有一些开销。operator的赋值形式把结果写到左边的参数里,因此不需要生成临时对象来容纳operator的返回值。

 

第二、提供operator的赋值形式以及其标准形式,可以允许类的客户端在便利与效率上做出折衷选择。也就是说,客户端可以决定是这样编写:
Rational a, b, c, d, result;
...
result = a + b + c + d;            // 可能用了3个临时对象,每个operator+ 调用使用1个

 

还是这样编写:
result = a;                                  //不用临时对象
result += b;                                 // 不用临时对象
result += c;                                 //不用临时对象
result += d;                                 //不用临时对象

前者比较容易编写、debug和维护,并且在80%的时间里它的性能是可以被接受的。后者具有更高的效率,估计这对于汇编语言程序员来说会更直观一些。

最后一点,涉及到operator单独形式的实现。由于历史的原因,无名字的对象比有名字的对象更容易清除,因此当我们面对在命名对象和临时对象间进行选择时,用临时对象更好一些。它使你耗费的开销不会比命名的对象更多,特别是使用老编译器时,它的耗费会更少。


Item23. 考虑使用其他等价的程序库:

当把正整数的自然对数传给这个程序,它会这样输出:
0.00000   0.69315   1.09861   1.38629   1.60944
1.79176   1.94591   2.07944   2.19722   2.30259
2.39790   2.48491   2.56495   2.63906   2.70805
2.77259   2.83321   2.89037   2.94444   2.99573
3.04452   3.09104   3.13549   3.17805   3.21888

 

使用iostreams也能这种也能产生fixed-format I/O。当然,远不如  printf("%10.5f", d); 输入方便。
但是操作符<<既是类型安全(type-safe)又可以扩展,而printf则不具有这两种优点。

本节的主旨是:提供类似功能的不同的程序库在性能上采取不同的权衡措施,所以一旦你找到软件的瓶颈(通过进行 profile 参见条款16),你应该知道是否可能通过替换程序库来消除瓶颈。

 

Item24. 理解虚函数、多重继承、虚基类以及RTTI所带来的开销


当调用一个虚拟函数时,被执行的代码必须与调用函数的对象的动态类型相一致;指向对象的指针或引用的类型是不重要的。编译器如何能够高效地提供这种行为呢?大多数编译器是使用virtual table和virtual table pointers。virtual table和virtual table pointers通常被分别地称为vtbl和vptr。


虚函数所需的第一个开销:你必须为每个包含虚函数的类的virtual talbe留出空间。

 

虚函数所需的第二个开销是:在每个包含虚函数的类的对象里,你必须为它的对象付出一定的开销来存放一个额外的指针。

 

为了找到一个虚函数的地址编译器生成的代码会做如下这些事情:
通过对象的vptr找到类的vtbl。找到对应vtbl内的指向被调用函数的指针。
调用第二步找到的的指针所指向的函数。这与非虚函数的调用在效率上相差无几。

 

虚函数在运行时刻的真正开销与内联函数有关。实际上虚函数不能是内联的。这是因为“内联”是指“在编译期间用被调用的函数体本身来代替函数调用的指令,”但是虚函数的“虚”是指“直到运行时才能知道要调用的是哪一个函数。”如果编译器在某个函数的调用点不知道具体是哪个函数被调用,你就能理解为什么它不会内联该函数的调用。

 

这是虚函数所需的第三个开销:你实际上必须放弃内联函数。(当通过对象调用的虚函数时,它可以被内联,但是大多数虚函数是通过对象的指针或引用被调用的,这种调用不能被内联。因为这种调用是标准的调用方式,所以虚函数实际上不能被内联。)
实际上现在的编译器一般总是忽略虚函数的的inline指令。

多继承的引入,使得事情将会变得更加复杂。

下面这个表是对虚函数、多继承、虚基类以及RTTI所需主要开销的总结:

Feature

Increases
Size of Objects

Increases
Per-Class Data

Reduces
Inlining

Virtual Functions

Yes

Yes

Yes

Multiple Inheritance

Yes

Yes

No

Virtual Base Classes

Often

Sometimes

No

RTTI

No

Yes

No

请记住如果没有这些特性所提供的功能,你必须手工编码来实现。在多数情况下,你的手工模拟可能比编译器生成的代码效率更低,稳定性更差。

原创粉丝点击