4. 效率
条款 16:谨记 80-20 法则
80-20 法则说:一个程序 80% 的资源用于 20% 的代码身上。80% 的执行时间花在大约 20% 的代码身上,80% 的内存被大约 20% 的代码使用,80% 的磁盘访问动作由 20% 的代码执行,80% 的维护力气花在 20% 的代码上面。
不必拘泥于法则的数字,其基本重点是:软件的整体性能几乎总是由其构成要素(代码)的一小部分决定。
条款 17:考虑使用 lazy evaluation(缓式评估)
拖延战术在计算机科学中被称作,lazy evaluation(缓式评估)。一旦你采用 lazy evaluation,就是以某种方式撰写你的 class,使他们延缓运算,直到那些运算结果被需要时,如果运算结果一直不需要,运算也就一直不执行。
reference counting(引用计数)
考虑这样的代码:
class String { ... };
String s1 = "Hello";
String s2 = s1; // 调用 String 的拷贝构造函数
String 拷贝构造函数的一个常见做法是,让 s2 以 s1 为初值,这会导致 s1 和 s2 各有自己的一份 Hello 副本。只因 Sting 的拷贝构造函数被调用,就为 s1 做一个副本并放进 s2 内。但其实此时 s2 尚未真正需要实际内容,因为 s2 尚未被使用。
lazy 做法可以省下很多工作。我们让 s2 分享 s1 的值,不再给予 s2 一个 s1 的副本。唯一需要做的就是一些记录工作,使我们直到谁共享了什么东西。这种做法节省了调用 new 以及复制东西的成本。s1 和 s2 共享同一个数据结构。
事实上,数据共享所引起的唯一危机就是在其中某个字符串被修改时发生的。此时应该只有一个(而非两个)字符串被修改。此时,我们就不能够在做任何拖延了:我们必须将 s2(被共享的)的内容做一个副本,给 s2 私人使用。另一方面,如果 s2 从没有被更改,我们就不需要为其做一个私有副本,该内容可以继续被共享。
区分读和写
考虑下面代码:
String = "Homer's Iliad"; // 假设 s 是个 reference-counted 字符串
...
cout << s[3]; // 调用 operator[] 读取数据 s[3]
s[3] = 'x'; // 调用 operator[] 将数据写入 s[3]
上述代码要求我们必须在 operator[] 内做不同的事情(视它用于读取功能还是写入功能)。但是我们没有办法做到,除非我们运用 lazy evaluation 和条款 30 所描述人 proxy class,我们可以延缓决定“究竟是读还是写”,直到能够确定其答案为止。
lazy fetching(缓式取出)
想象你的程序使用大型对象,其中内含愈多字段。如果此对象必须在程序每次执行时保持与前次执行的一执行与连贯性,索引它们必须存储与一个数据库中。每个对象有一个对象识别码,可以用来从数据库中取回对象:
class LargeObject {
public:
LargeObject(ObjectID id); // 从磁盘中回存对象
const string& field1() const; // 字段 1 的值
int field2() const; // 字段 2 的值
...
};
现在考虑从磁盘中回复一个 LargeObject 对象所需的成本:
void restoreAndProcessObject(ObjectID id) {
LargeObject object(id);
}
由于 LargeObject 的体积很大,所以取回此类对象的所有数据,数据库相关操作程序可能成本极高,特别是如果这些数据必须从远程数据库跨越网络而来。
此问题的缓式做法是,我们在产生一个 LargeObject 对象时,只产生该对象的外壳,不从磁盘读取任何字段数据。当对象内的某个字段被需要了,程序才从数据库中取回对应的数据。其行为类似:
class LargeObject {
public:
LargeObject(ObjectID id); // 从磁盘中回存对象
const string& field1() const; // 字段 1 的值
...
private:
ObjectID oid
mutable string *field1Value;
...
};
LargeObject::LargeObject(ObjectID id) : oid(id), field1Value(0), ... { }
const string& LargeObject::field1() const {
if (field1Value == 0) {
read the data for field1 from thd database and make field1Value point to it
}
return *field1Value;
}
对象内的每个字段都是指针,指向必要的数据。如果指针时 null,标识对应的数据必须先从数据库读入,然后才能操作。
这个方法有一个问题:null 指针可能会在任何 member function 内被赋值,然而当你企图在 const member function 内修改 data memebr 时,编译器不会同意,所以你才需要将指针字段声明为 mutable,意思时这样的字段可以在任何 memeber function 内修改,甚至是 const member function 内。
lazy expression evaluation(表达式缓评估)
考虑下面的例子:
template<class T>
class Matrix { ... };
Matrix<int> m1(1000, 1000); // 1000 * 1000 的矩阵
Matrix<int> m2(1000, 1000);
...
Matrix<int> m3 = m1 + m2;
operator+ 通常采用急式评估,此例会计算并返回 m1 和 m2 的总和。这是一个大规模运算,还有大量内存分配成本。
lazy evaluation 策略就是,设置一个数据结构在 m3 中,表示 m3 的值是 m1 和 m2 的综合。如此的数据结构可能指示由两个指针和一个 enum 构成,前者指向 m1 和 m2,后者用来指示运算动作是加法。
假设在此之后,在 m3 被使用之前,程序执行了以下操作:
Matrix<int> m4(1000, 1000);
m3 = m4 * m1;
现在就可以忘记 m3 是 m1 和 m2 的总和(于是省下了计算成本),现在就可以记录:m3 是 m4 和 m1 的乘积了,而且我们也不会立刻执行这个乘法。
假设我们将 m3 初始化为 m1 和 m2 的总和之后,这样使用 m3:
cout << m3[4];
尽管如此,我们只需计算 m3 第四行的值即可,没有理由在此刻计算 m3 第四行以外的任何值。
如果 m3 被这样使用:
cout << m3; // 输出 m3 所有内容
m3 = m1 + m2; // 记录 m3 是 m1 和 m2 的总和
m1 = m4; // 现在 m3 是 m2 和 m1 旧值的总和
这样我们就必须计算 m3 的完整内容。
总结
lazy evaluation 在很多领域都可能有用途:可避免非必要的对象复制,可区别 operator[] 的读取和写动作,可避免非必要的数据库读取动作,可避免非必要的数值计算动作。但,lazy evaluation 并非永远都是好方法,如果你的计算是必要的,lazy evaluation 甚至可能使程序更缓慢,并增加内存用量。
条款 18:分期摊还预期的计算成本
本条款的背后哲可以被称为,超急评估:在被要求之前就先把事情做下去。
over-eager evaluation 背后的观念是,如果你预期程序常常会用到某个值,你可以降低每次计算的平均成本,办法就是设计一份数据结构以便能够极有效率地处理需求。
其中最简单的一个做法就是将“已经计算好而有可能再被需要”的数值保留下来(所谓的 caching)。该策略就是使用一个局部缓存,将相对昂贵的数据库查询动作以相对廉价的内存内数据结构查找动作代替。
caching 是分期摊还预期的计算成本的一种做法,prefetching(预先取出)则是另一种做法。例如,磁盘控制器,当它们从磁盘中读取数据时,读的是整个数据块或 sector —— 即使程序只需其中少量数据。那是因为一次读一大块数据比分成两三次每次读小块数据,速度上快得多。如果某处的数据被需要,通常其临近的数据也会被需要,这就是有名的 locality of reference 现象。
参考下面的例子:
template<class T>
class DynArray { ... };
DynArray<double> a; // 此时只有 a[0] 有效
a[22] = 3.5; // a 扩张 a[0]~a[22] 都有效
a[32] = 0; // a 再次扩张 a[0]~a[32] 都有效
此时我们可以使用超急评估策略,如果我们刺客必须增加数组大小以接纳索引 i,那么有可能未来或许还要再增加大小,以接纳比 i 稍大的索引。为了避免第二次扩张所需要的内存分配成本,我们把 DynArray 的大小调整到比它目前所需的大小再大一些,使得未来可能的扩张落入我们此刻所增加的弹性范围内。
条款 19:了解临时对象的来源
参考下面代码:
template<class T>
void swap(T& object1, T& object2) {
T temp = object1;
object1 = object2;
object2 = temp;
}
常有人将 temp 称为一个临时对象。但是在 C++ 眼中,temp 并不是临时对象,它指示函数中的一个局部对象。
C++ 中真正的临时对象是不可见的 —— 不会再你的源代码中出现。只要你产生一个 non-heap object 而没有为它命名,便产生了一个临时对象。这种匿名对象通常发生于两种情况:第一是,当隐式类型转换被施行起来以求函数调用能够成功;第二是,当函数返回对象时。
考虑这个函数:
void uppercasify(string& str); // 将 str 中所有 char 改为大写
char subleBookPlug[] = "Effective C++";
uppercasify(subleBookPlug);
面对上述情况,不会有任何临时对象被产生出来。假设编译器会产生一个临时对象,然后此临时对象被传递给 uppercasify,但是 subleBookPlug 并没有受到影响,只有以 subleBookPlug 产生的 string 临时对象收到了影响。此时如果编译器针对 reference-to-non-const 对象进行隐式转换,会允许临时对象被修改。也就是为什么 C++ 语言禁止为 non-const reference 参数产生临时对象的原因。
参考下面代码:
const Number operator+(const Number& lhs, const Number& rhs);
此函数的返回值是个临时对象,因为它没有名称。但是依旧要为此对象付出构造和析构成本。
临时对象可能很耗成本,所以你应该尽可能消除它们。
条款 20:协助完成“返回值优化(RVO)”
如果函数一定得以 by-value 方式返回对象,你绝对无法消除。这时候你应该做的事,努力找出某种方法以降低被返回对象的成本,而不是想尽办法消除对象本身。
我们可以用某种特殊写法来撰写函数,使它在返回对象时,能够让编译器消除临时对象的成本。我们的方法是:返回所谓的 constructor argument 以取代对象。例如:
// 返回对象:一个有效率且正确的做法
const Rational operator*(const Rational& lhs, const Rational& rhs) {
return Rational(lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());
}
以 constructor argument 取代局部对象,当作返回值。
C++ 允许编译器将临时对象优化,使他们不存在。于是如果你这样调用 operator*:
Rational a = 10;
Rational b(1, 2);
Rational c = a * b;
你的编译器得以消除 operator* 内的临时对象以及被 operator* 返回的临时对象。它们可以将 return 表达式所定义的对象构造于 c 的内存内。如果编译器这么做,你调用 operator* 时临时对象的成本为 0,也就是说没有任何临时对象需要被产生出来,你只需要付出一个 construtor 的代价。你可以将此函数声明为 inline,以消除调用 operator* 所花费的额外开销。
这就是 return value optimization(返回值优化)。
条款 21:利用重载技术避免隐式类型转换
考虑以下代码:
class UPInt {
public:
UPInt();
UPInt(int value);
...
};
const UPInt operator+(const UPInt& lhs, const UPInt& rhs);
UPInt upi1, upi2;
现在考虑以下语句:
upi3 = upi1 + 10;
upi3 = 10 + upi2;
这些句子之所以可以成功,是因为产生了临时对象,将整数 10 转换成了 UPInt 对象。
如果希望获得隐式类型转换,却不希望承受临时对象所带来的任何成本该怎么办?我们可以退回一步,我们的目标并非在于类型转换,而是希望能够以一个 UPInt 自变量和一个 int 自变量调用 operator+。隐式类型转换指示一种手段而已,如果是这样,我们需要做的就是将我们的意图告诉编译器,做法就是声明数个函数:
const UPInt operator+(const UPInt& lhs, const UPInt& rhs);
const UPInt operator+(const UPInt& lhs, int rhs);
const UPInt operator+(int lhs, const UPInt& rhs);
注意不能两个参数都是 int 类型,C++ 规定每个重载操作符必须获得知道一个用户定制类型的自变量。
用以避免产生临时对象的重载技术,并不只局限运用在操作符函数身上。例如,在大部分程序中,如果接受一个 char*,那么可能也会希望接受一个 string 对象。不过请不要忘了,80-20 法则(见条款 16),增加一大堆重载函数不一定是好事,除非你相信使用重载函数后,程序的整体效率可获得重大改善。
条款 22:考虑以操作符复合形式(op=)取代其独身形式(op)
要确保操作符的复合形式(op=)和其独身形式(op)之间的自然关系能够存在,一个好方法就是以前者为基础实现后者。
class Rational {
public:
Rational& operator+=(const Rational& rhs);
Rational& operator-=(const Rational& rhs);
};
// 以 operator+= 实现 operator+
const Rational operator+(const Rational& lhs, const Rational& rhs) {
return Rational(lhs) += ths;
}
// 以 operator-= 实现 operator-
const Rational operator+(const Rational& lhs, const Rational& rhs) {
return Rational(lhs) -= ths;
}
如果采用这种设计,那么这些操作符之中就只有复合形式才需要维护,此外,如果这些操作符的复合形式实在 class 的 public 接口内,那么就不许哟让独身形式称为该 class 的 friend。
效率
现在我们从效率来看。
第一,一般而言,复合操作符比其对应的独身版本效率高,因为独身版本通常返回一个新对象,而我们必须因此附带一个临时对象的构造和析构成本。
第二,如果同时提供某个操作符的复合形式和独身形式,便允许你的客户在效率与便利性之间做取舍。也就是是否允许你的客户写:
Rational a, b, c, d, result;
result = a + b + c + d;
result = a;
result += b;
前者容易撰写、调试、维护,后者效率较高。总之,操作符的复合版本比起对应的独身版本有着更高效率的倾向。身为一位程序库设计者,你应该两者都提供,身为一位应用软件开发者,如果性能是重要因素的花,你应该考虑以符合版本操作符取代其独身版本。
条款 23:考虑使用其他程序库
程序库的设计,是一种这种态度的联系。理想的程序库应该小、快速、威力强大、富有弹性、有扩展性、直观、可广泛运用、有良好支持、使用时没有舒服,没有 bug。
不同的设计者面对相同的规范给予不同的优先权。他们的设计各有不同的牺牲。于是,很容易出现两个程序库提供类似的机能,却有相当不同的性能表现的情况。
比如说,iostream 和 stdio 程序库,在效率方面,iostream 通常表现比 stdio 差,因为 stdio 的可执行文件通常比 iostream 更小也更快。
不同的程序库即使提供相似的机能,也往往表现出不同的性能取舍策略,所以一旦你找出程序的瓶颈,你应该思考是否有可能因为改用另一个程序库而移除了那些瓶颈。由于不同的程序库将效率、扩充性、移植性、类型安全性等的不同设计具体化,有时候你可以找找看是否存在另一个功能相近的程序库而其在效率上有较高的设计权重。如果有,改用它,可大幅度改善程序性能。
条款 24:了解 virtual function、multiple inheritance、virtual base class、runtime type identification 成本
虚函数
当一个虚函数被调用,执行的代码必须对应于调用者(对象)的动态类型。大部分编译器使用的 virtual table 和 virtual table pointer 通常被简写为 vtbl 和 bptr。
vtbl
vtbl 通常是由一个函数指针架构而成的数组。某些编译器会以链表取代数组,但其基本策略相同。程序中的每一个 class 凡声明(或继承)虚函数者,都会有一个自己的 vtbl,其中的条目就是该 calss 的各个虚函数实现体的指针(非虚函数并不在表格中,constructor 也一样)。vtbl 内的条目会指向对应于对象类型的各个适当函数,以及没有被派生类重新定义的基类虚函数。
虚函数的第一个成本就是:你必须为每个拥有虚函数的 class 建立一个 vrbl 空间,其大小视虚函数个数(包括继承而来的)而定。每个 class 应该只有一个 vtbl,但是编译器需要决定,把它放在哪里。
第一种策略是,对于提供整合环境(包含编译器和连接器)的厂商而言,一种暴力式的做法就是在每一个需要 vtbl 的目标文件内都产生一个 vtbl 副本。最后再由连接器剥除重复的副本,使最终的可执行文件或程序库内,只留下每个 vtbl 的单一实体。
第二种策略是,以一种探勘式做法,决定哪一个目标文件应该内含某个 class 的 vtbl。大制做法如下:class 的 vtbl 被产生于内含其第一个 non-inline、non-pure 虚函数定义式的目标文件中。
vtpr
一旦有某种方法可以指示出每个对象相应于哪一个 vtbl,vtbl 才真的有用。而这正是 virtual table pointer 的任务。凡声明虚函数的 calss,其对象都含有一个隐藏的 data member(即,vtpr)用来指向该 calss 的 vtbl。
此时可以注意到虚函数的第二个成本:你必须在每一个拥有虚函数的对象内付出一个额外指针的代价。
假设 C2 继承于 C1,f1 是虚函数,考虑这样的程序片段:
void makeACall (C1 *pC1) {
pC1->f1();
}
其中指针 pC1 调用虚函数 f1。如果只看这个片段,无法知道哪一个 f1(C1::f1 或 C2::f1) 函数会被调用,因为 pC1 可能指向一个 C1 对象,也可能指向一个 C2 对象(多态)。但是不论 pC1 指向谁,编译器都必须产生代码,完成以下动作:
- 根据对象的 vptr 找出其 vtbl。成本只有一个偏移调整(以便获得 vptr)和一个指针间接动作(以便获得 vtbl)。
- 找出被调用函数在 vtbl 内的对应指针。成本是一个偏移量以求进入 vtbl 数组。
- 调用步骤 2 所得指针指向的函数。
如果我们想象每个对象都有一个隐藏 data member 称为 vptr,而函数 f1 的 vtbl 索引是 i,那么先前语句产生出来的代码将是:
(*pC1->vptr[i])(pC1); // 调用 pC1->vptr 所指的 vtbl 中的第 i 个条目所指函数。pC1 被传给该函数作为 this 指针之用
这和一个非虚函数的效率相当,因为在大部分机器上这只需数个指令就可以执行。因此,调用一个虚函数的成本,基本上和通过一个函数指针来调用函数相同。
虚函数真正的运行期成本是在和 inline 互动时,虚函数不应该被设置为 inline。inline 意味着在编译期,将调用端的调用动作被调用函数本体替代。virtual 意味着等待,直到运行期才知道哪个函数被调用。
虚函数的第三个成本就是:无法使用 inline。
多重继承
上述事实也适用于多重继承,但是在多重继承会比较复杂。此时一个对象内会有多个 vptr(每个 base class 各对应一个);而且除了我们所讨论的 vtbl 之外,针对 base class 而形成的特殊 vtbl 也被产生出来。
虚基类
多重继承往往会导致虚基类的需求。在 non-virtual base class 情况下,如果派生类在其基类有多条继承路径,则此基类的成员会在每一个派生类对象复制(多个相同的数据成员),每一个副本对应派生类和基类之间的一条继承路线。
虚基类可以消除这样的复制现象,但是会导致另一个成本,因为其实现做法往往利用指针,指向 virtual base class 成分,以消除复制行为,而你的对象内可能出现一个(或多个)这样的指针(也就是导致隐藏指针增加)。
运行时期类型辨识(RTTI)
RTTI 让我们在运行期获得 object 和 class 的相关信息,它们被存放在类型为 type_info 的对象内(可以通过 typeid 操作符获取)。
RTTI 的设计理念是:根据 class 的 vtbl 来实现。举个例子,vtbl 数组中,索引为 0 的条目可能内涵一个指针,指向该 vtbl 所对应的 class 相应的 type_info 对象。这样 RTTI 的空间成本就只需在每一个 class vtbl 内增加一个条目,再加上每个 class 所需的一份 type_info 对象空间。
总结表
性质 | 对象大小增加 | class 数据量增加 | inline 几率降低 |
---|---|---|---|
虚函数 | 是 | 是 | 是 |
多重继承 | 是 | 是 | 否 |
虚基类 | 往往如此 | 有时候 | 否 |
RTTI | 否 | 是 | 否 |