More Effective C++之效率Efficiency_中
More Effective C++之效率Efficiency
- 条款19:了解临时对象的来源
- 条款20:协助完成“返回值优化(RVO)”
- 条款21:利用重载技术(overload)避免隐式类型转换(implicit type conversions)
- 条款22:考虑以操作符复合形式(op=)取代其独身形式(op)
- 条款23: 考虑使用其他程序库
条款19:了解临时对象的来源
程序员交谈的时候,往往把一个短暂需要的变量称为“临时变量”。例如下面的swap函数:
template <class T>
void swap(T& object1, T& object2) {
T temp = object1;
object1 = object2;
objec2 = temp;
}
常有人将temp称为一个临时对象(“temporary”)。但是在C++眼中,temp并不是临时对象,它只是函数中的一个局部对象。
C++真正的所谓的临时对象是不可见的——不会在我们的源码中出现。只要我们产生一个no-heap object而没有为它命名,便诞生了一个临时对象。此匿名对象通常发生于两种情况:一是隐式类型转换(implicit type conversions)被施行起来以求函数调用能够成功;二是当函数返回对象的时候。了解这些临时对象如何(以及为什么)被产生和销毁,是很重要的,因为其伴随的构造成本和析构成本可能对我们的程序性能带来值得注意的影响。
首先考虑“为了让函数调用成功”而产生的临时对象。此乃发生于“传递某对象给一个函数,而其类型于它既定绑定上去的参数类型不同”的时候。例如,考虑一个函数,用来计算字符串中某字符出现的次数:
// 返回ch在str中出现的个数
size_t countChar(const string& str, char ch);
char buffer[MAX_STRING_LEN];
char c;
// 读入一个char和一个string,利用setw避免在读入string时产生缓存区溢出的情况
cin >> c >> setw(MAX_STRING_LEN) >> buffer;
cout << "There are " << countChar(buffer, c)
<< " occurrences of the character " << c
<< " in " << buffer << endl;
请看countChar的调用动作。其第一自变量是char数组,但是相应的函数参数类型却是const string&。当“类型不吻合”的状态消除,此函数调用才会成功;编译器很乐意消除此状态,做法是产生一个类型为string的临时对象。该对象的初始化方式是:以buffer作为自变量,调用string constructor。于是countChar的str参数会被绑定于此string临时对象上。当countChar返回,此临时对象会被自动销毁。
这样的转换很方便(虽然也很危险——见条款5),但是从效率角度观之,一个string临时对象的构造和析构,有其非必要的成本。有两个做法可消除此种转换,一是重新设计代码,使这类转换不会发生,此策略验证于条款5;另一个做法就是修改软件,使这种转换不在需要,条款21将描述这种做法。
只有当对象——by value(传值)方式传递,或是当对象被传递给一个reference-to-const参数时,这些转换才会发生。如果对象被传给一个reference-to-non-const参数,并不会产生此类转换。考虑这个函数:
void uppercasify(string &str); // 将str中的所有chars改为大写
在上一个(计算字符个数)例子中,我们可以成功地将一个字符数组传递给countChar,但是在这里,将一个字符数组交给uppercasesify会导致调用失败:
char subtleBookPlug[] = "Effective C++";
uppercasify(subtleBookPlug); // 错误!
不再有任何临时对象被产生出来以成全此函数的调用。为什么?
假设编译器为此产生一个临时对象,然后此临时对象被传递给uppercasify,以便将其中的字符串全部修改为大写。此函数的实元——subtleBookPlug——并未受到影响,只有以subtleBookPlug为本所产生的那个string临时对象受到影响。这当然不是程序员所企盼的结果。程序员将subtleBookPlug传给uppercasify,就是希望subtleBookPlug被修改。当程序员期望“非临时对象”被修改,此时如果编译器针对reference-to-non-const对象进行隐式类型转换,会允许临时对象被修改。这就是为什么C++语言禁止为non-const reference参数产生临时对象的原因。Reference-to-const参数则不需要承担此问题,因为此参数由于const之故,无法被改变。
第二种会产生临时对象的情况是当函数返回一个对象时。例如,operator+必须返回一个对象,表现其操作数的总和。假设有一个Number类型,其operator+声明如下:
const Number operator+(const Number& lhs, const Number& rhs);
此函数返回值是一个临时对象,因为它没有名称:它就是函数的返回值,如此而已。每当我们调用operator+,便得为此对象付出构造和析构成本。
一般而言我们不会想要承担这份成本。对于此特殊函数,我们可以改用一个类似的函数operator+=免掉这份成本。条款22将会详述这种做法。但是对其他“返回对象”的函数而言,大多数难以被另一个函数替代,所以没办法避免返回值构造和析构。至少,观念上没办法。但是观念和实物之间,有一个名为“优化”的黝黯地带,有时候我们可以以某种方式撰写“返回值为一个对象”的函数,使编译器得以将临时对象优化,使它不复存在。在这些优化策略中,最常见也是最有用的就是所谓的“返回值优化(return value optimization”,那正是条款20的主题。
结论是:临时对象可能很耗成本,所以我们应该尽可能消除它们。然后更重要的是,如何训练出锐利的眼力,看出可能产生临时对象的地方。任何时候只要我们看到一个reference-to-const参数,就极可能会有一个临时对象被产生出来绑定至该参数上。任何时候只要我们看到函数返回一个对象,就会产生临时对象(并于稍后销毁)。学习找出这些模式,我们对幕后成本(编译器行为)的洞察力将会有显著提升。
学习心得
本条款告诉我们临时对象为在函数调用及返回过程中的不具名对象,此临时对象在不经意间会调用对象的构造和析构函数。特别需要注意在函数参数为reference-to-const类型时,隐式转化会特别隐蔽的构造临时对象,所以需要特别注意此情形的产生;另一个情形则为返回对象为非引用,此时不可避免的会产生临时对象,此时我们的思路是如何尽力减少临时对象产生的个数。亦即条款20会介绍的返回值优化方法。
条款20:协助完成“返回值优化(RVO)”
函数如果返回对象,对“效率狂”而言是一个严重的挫败,因为——by-value方式返回对象,背后隐藏的constructor和destructor都将无法消除。问题很简单:如果为了行为正确而不得不这样,函数可返回一个对象;否则就不要那么做。如果真的决定返回对象,那就没有任何办法可以摆脱“返回一个对象”所会遭遇的命运。
考虑分数(rational numbers)的operator*函数:
class Rational {
public:
Rational(int numerator = 0, int demominator = 1);
...
int numerator() const;
int demominator() const;
};
const Rational operator*(const Rational& lhs, const Rational& rhs);
甚至不必看operator的函数代码,我们也知道它必须返回一个对象,因为它返回两个任意数的乘积,operator如何能够在不产生新对象的情况下放置该乘积呢?不可能,所以它必须产生一个新对象并将它返回。尽管如此,C++程序员却像希腊神话中赫克里斯一样,耗费巨大的努力企图寻找消除“by-value返回方式”的神奇方法。
有时候人们会返回指针,为求避免返回对象。
const Rational* operator*(const Rational& lis, const Rational& rhs);
Rational a = 10;
Rational b(1, 2);
Rational c = *(a * b); // 这看起来不太自然
此外它还引出另一个问题:调用者应该删除此函数返回的指针吗?答案通常是yes,而那通常会导致资源泄露(resource leaks)。
另一些程序员可能返回references,于是导出一个可被接受的语法形式:
const Rational& operator* (const Rational& lis, const Rational& rhs);
Rational a = 10;
Rational b(1, 2);
Rational c = a * b; // 看起来很合理自然
但是这样的函数却根本无法有正确的行为。常见的做法是:
// 一个危险(而且不正确)的做法,为避免返回对象
const Rational& operator* (const Rational& lis, const Rational& rhs) {
Rational result(lhs.numberator() * rhs.numberator(), lhs.demominator() * rhs.demominator());
return result;
}
此函数返回一个reference,指向一个不在存活的对象。更明确地说,它返回一个reference,指向局部对象result,但result却在operator*返回时自动销毁了。返回一个reference却指向一个不再存活的对象,很像一个指针指向了被销毁的对象。
请相信:有些函数(例如,operator*)硬是得返回对象。它就必须如此,别对它宣战,不会赢的。
也就是说,如果函数一定得以by-value方式返回对象,我们绝对无法消除之。这是一场错误的战争。从效率的眼光来看,我们不应该在乎函数返回了一个对象,应该在乎的是那个对象的成本几何。努力找出某种方法降低被返回对象的成本,而不是想尽办法消除对象本身。如果这样的对象不需要成本,谁在乎产生多少个呢?
我们可以用某种特殊写法来撰写函数,使它在返回对象时,能够让编译器消除临时对象的成本。我们的伎俩是:返回所谓的constructor arguments以取代对象。我们可以这么做:
// 返回一个对象:一个效率且正确的做法
const Rational operator* (const Rational& lis, const Rational& rhs) {
return Rational(lhs.numberator() * rhs.numberator(), lhs.demominator() * rhs.demominator());
}
请仔细看看被返回的表达式。看起来好像是我们调用了Rational constructor,事实上也的确是。通过此表达式,我们产生了一个Rational临时对象:
Rational(lhs.numberator() * rhs.numberator(), lhs.demominator() * rhs.demominator());
而函数复制此临时对象,当做返回值。
以constructor arguments取代局部对象,当做返回值,这笔买卖似乎不见得多么划算,因为我们还是必须为“函数内的临时对象”的构造和析构付出代价,我们还是必须为“函数返回对象”的构造和析构付出代价。但是我已经赚到了某些东西。C++允许编译器将临时对象优化,使它们不存在。于是如果这样调用operator*:
Rational a = 10;
Rational b(1, 2);
Rational c = a * b; // 这里调用了operator*
编译器得以消除“operator内的临时对象”及“被operator返回的临时对象”。它们可以将return表达式所定义的对象构造于c的内存内。如果编译器这么做,调用operator*时的临时对象总成本为0,也就是没有任何临时对象需要被产生出来。取而代之的是,只需要付出一个constructor(用以产生c)的代价。我们无法做得比这更好了,因为c是一个命名对象,而命名对象时不能被消除的。我们可以将此函数声明为inline,以消除调用operator*所花费的额外开销:
// 函数返回一个对象:最有效率的做法
inline const Rational operator* (const Rational& lis, const Rational& rhs) {
return Rational(lhs.numberator() * rhs.numberator(), lhs.demominator() * rhs.demominator());
}
此特殊的优化行为——利用函数的return点消除一个局部临时对象(并可能用函数调用端的某对象取代)——不但广为人知而且很普遍地被实现出来。它甚至有个专属名称:return value optimization。“拥有专属名称”这一事实足以反映出它是多么被广泛应用。程序员在寻找理想的C++编译器时,不妨询问厂商是否支持return value optimization。如果A厂商有,而B厂商反问“那是什么?”,A厂商有明显的竞争优势。
学习心得
此条款,告诉我们如果某函数需要返回对象,如果有办法用其他方式如指针或者引用可代替的话,优先用替代的方法;因为如果返回的是对象,不可避免的会导致临时对象的产生;对于临时对象的产生,同时也提供了return value optimization方法,在return语句后产生临时对象;如此,可以让编译器顺利的进行优化,将可能得开销降到最低(只调用唯一的一次构造函数constructor)。
条款21:利用重载技术(overload)避免隐式类型转换(implicit type conversions)
以下代码,除了看起来极为合理之外,没什么特别:
class UPInt { // 这个class用于无限精密的整数
public:
UPInt();
UPInt(int value);
...
};
const UPInt operator+(const UPInt& lhs, const UPInt& rhs);
UPInt upi1, upi2;
...
UPInt upi3 = upi1 + upi2;
没有什么值得惊讶的地方。upi1和upi2都是UPInt对象,所以只需要调用UPInt的operator+便可将它们加在一起。
现在考虑以下语句:
upi3 = upi1 + 10;
upi3 = 10 + upi2;
这些句子也能成功。它们之所以能成功,是因为产生了临时对象,并将整数10转换为UPInts。
由编译器来执行此类转换,很方便,但是此类转换所产生的临时对象带来了一些我们并不想要的成本。大部分C++程序员希望获得隐式类型转换,却不希望承受临时对象所带来的成本。
我们可以退回一步,承认我们的目标并非真正在于类型转换,而是希望一个UPInt自变量和一个int自变量调用operator+。只不过隐式类型转换是一种手段罢了,千万不要把目的和手段混淆了。如果有其他做法可以让operator+在自变量类型混杂的情况下别调用成功,那便消除了类型转换的需求。如果我们希望能够对UPInt和int进行加法,我们需要做的就是将我们的意图告诉编译器,做法是声明数个函数,每个函数有不同的参数表:
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);
UPInt upi1, upi2;
...
UPInt upi3 = upi1 + upi2;
upi3 = upi1 + 10;
upi3 = 10 + upi2;
上述代码的三次operator+调用都不会产生UPInt临时对象。
一旦开始以重载技术消除类型转换,我们可能会热情地进一步写出以下函数声明:
const UPInt operator+(int lhs, int rhs); // (4)错误!
这样的想法原是颇为合理的。毕竟面对类型UPInt和int,我们希望将operator+的所有可能组合都予以重载。上述3中组合之外,唯一遗漏的就是“获得两个int自变量”的operator+,所以我们要加上它。
不论是否合理,C++存在许多游戏规则,其中一个就是:每个“重载操作符”必须获得至少一个“用户定制类型”的自变量。int不是用户定制类型,所以我们不能够将一个只获得int自变量的操作符加以重载。(如果这个规则不存在,程序员可以改变预先定义的操作符意义,而那当然会导致天下大乱。例如,上述的operator重载版本(4),会改变ints的加法意义。难道这真是我们所希望的吗?)
“用以避免产生临时对象”的此等重载技术,并不只局限运用在操作符函数身上。例如,在大部分程序中,如果可以接受一个char*,我们可能会希望也接受一个string对象,反之依然。同样的道理,如果我们正在使用一个数值类似complex,应该会希望该数值对象可出现的任何地点,也都能够有效运用类型int和double。因此,任何函数如果接受类型为string,char*,comple等自变量,都可以借由重载技术,合理消除类型转换。
不过,请不要忘了,80-20法则(见条款16)还是很重要的。增加一大堆重载函数不见得是件好事,除非我们有好的理由相信,使用重载函数后,程序的整体效率可获得重大的改善。
学习心得
此条款,给出了一种通过重载函数(包括操作符、函数等)的方式,来消除隐式类型转换的方法。同时表明了该方法并不是银弹,因为重载函数可能会导致程序代码变大,导致其他引起性能降低的可能。所以我们需要酌情考虑在隐式类型转换确实会造成大的代价的情况下,才使用该条款进行重载处理。
条款22:考虑以操作符复合形式(op=)取代其独身形式(op)
大部分程序员都希望,如果他们能够这样写:
x = x + y; x = x - y;
他们也能够写成这样:
x += y; x -=y;
如果x和y属于定制类型,就不保证一定能够如此。到目前为止C++并不考虑在operator+,operator=和operator+=之间设立任何互动关系。所以如果我们希望这3个操作符都存在并且有着所期望的互动关系,必须自己实现。操作符-,*,/也是一样。
要确保操作符的复合形式(例如,operator+=)和其独身形式(例如,operator+)之间的自然关系能够存在,一个好方法就是以前者作为基础实现后者。这很容易做到:
class Rational {
public:
...
Rational& operator += (const Rational& rhs);
Rational& operator -= (const Rational& rhs);
};
const Rational operator+(const Rational& lhs, const Rational& rhs) {
return Rational(lhs) += rhs;
}
const Rational operator-(const Rational& lhs, const Rational& rhs) {
return Rational(lhs) -= rhs;
}
此例中的operator+=和operator-=都是从头做起的,而operator+和operator-则是调用前者以供应它们所需的机能。如果采用这种设计,那么这些操作符之中就只有复合形式才需要维护,此外,如果这些操作符的复合形式是在class的public接口内,那么就不需要让独身形式成为该class的friends。
如果不介意把所有独身形式操作符放在全局范围内,我们可以利用templates,完全消除独身形式操作符的撰写必要:
template<class T>
const T operator+(const T& lhs, const T& rhs) {
return T(lhs) += rhs;
}
template<class T>
const T operator-(const T& lhs, const T& rhs) {
return T(lhs) -= rhs;
}
...
有了这些templates之后,只要程序中针对类型T定义有一个复合操作符,对应的独身版本就会在需要的时候被自动产生出来。
这些都很好,但是到现在为止我们还没有考虑效率问题,而效率毕竟是本章的主题。3个与效率有关的情况值得注意。第一,一般而言,复合操作符比其对应的独身版本效率高,因为独身版本通常必须返回一个新对象,而我们必须因此负担一个临时对象的构造和析构成本。至于复合版本则是直接将结果写入左端自变量,所以不需要产生一个临时对象来放置返回值。
第二,如果同时提供某个操作符的复合形式和独身形式,便允许客户在效率与便利性之间做取舍(虽然那是极其困难的抉择)。也就是说,客户可以绝对是否写这样的代码:
Rational a, b, c, d, result;
...
result = a + b + c + d; // 可能会用到3个临时对象,每一个对应一次operator+的调用
或是这样的代码:
result = a;
result += b; // 不需要临时对象
result += c; // 不需要临时对象
result += d; // 不需要临时对象
前者较易撰写、调试、维护。并在80%的时间内供应足可接受的性能。后者效率较高,而且(或许)对汇编语言程序员比较直观。如果同时供应两种选择,我们便允许客户以较易理解的操作符独身版本来发展程序并调试,而同时仍保留“将独身版本用更有效率的复合版本取代”的权力。此外,借由“以复合版本作为独身版本的实现基础”,我们可以确保,当客户以某种选择改变为另一种选择时,操作语法仍保持不变。
我们把最后一个效率观察放在独身形式操作符身上。再次看看operator+实现码:
template<class T>
const T operator+(const T& lhs, const T& rhs)
{ return T(lhs) += rhs; }
表达式T(lhs)是个调用动作,调用T的copy constructor。它会产生一个临时对象,其值和lhs相同。这个临时对象然后被用来调用operator+=,并以rhs作为自变量,运算结果则被operator+返回。这段代码似乎过于晦涩,这样写难道不更好吗:
template<class T>
const T operator+(const T& lhs, const T& rhs){
T result(lhs);
return result += rhs;
}
比大部分编译器所能接受的“返回值优化”形式更为复杂。前述第一个template可能需要在函数内消耗一个临时对象,就像使用命名对象result成本一样。然而,自古以来匿名对象总是比命名对象更容易被消除,所以当面临命名对象或临时对象的抉择时,最好选择临时对象。它应该绝不会比其命名兄弟耗用更多成本。反倒是极有可能降低成本(尤其是搭配旧式编译器时)。
命名对象、匿名对象、编译器优化等相关讨论度相当有趣,但是我们不要忘了本条款的主题:操作符的“复合版本”(例如,operator+=)比其对应的“独身版本”(例如,operator+)有着更高效率的倾向。身为以为程序库设计者,我们应该两者都提供;身为以为软件开发者,如果性能是重要因素的话,我们应该考虑以“复合版本”操作符取代其“独身版本”。
学习心得
本条款,针对操作符复合形式(op=)取代其独身形式(op),从性能及其实现方式上做了阐述;建议我们首先实现复合形式(op=),然后独身形式通过调用复合形式的方式,以提升代码的可维护性(只需要维护复合形式即可);同时,为了程序库的通用性,建议将复合形式和独身形式都提供,但是自己在写效率优先的软件时,优先通过复合形式来提升系统性能。
条款23: 考虑使用其他程序库
程序库的设计,可说是一种折中态度的练习。理想的程序库应该小、快速、威力强大、富弹性、有扩展性、直观、可广泛运用、有良好支持、使用时没有束缚,而且没有bug。当然,这样的东西是不存在的。如果针对大小和速度做优化,便往往不具移植性。如果拥有丰富的机能,就不容易直观。没有bug的程序库只能在乌托邦中寻找。真实世界中几乎不可能拥有每一样东西,某些东西必须取舍。
不同的设计者面对这些规范给予不同的优先权。他们的设计各有不同的牺牲。于是,很容易出现“两个程序库提供类似机能,却有相当不同的性能表现”的情况。
举个例子。考虑iostream和stdio程序库,任何一位C++程序员对这两者应该都不陌生。iostream程序库较其C兄弟有数个优点,例如,它具有类型安全(type-safe)特性,并且可扩充。然而效率方面iostream通常表现得比stdio差,因为stdio的可执行文件通常比iostreams更小也更快。
让我们先考虑速度。想要对iostreams和stdio之间的性能差异有一些感觉,办法之一就是使用这两个程序库来执行性能评估软件(所谓benchmark)。但很重要的一点是,请记住,性能评估软件会说谎。“以一组输入数据作为程序或程序库的典型用途”不只是件困难的事,就算有这样的数据,也是没用的,除非我们有一个可信赖的方法足以决定客户的“典型”程度。尽管如此,性能评估软件还是可以再“不同做法之间的性能比较”上助我们一臂之力。所以虽然完全依赖性能评估软件是很愚蠢的行为,但完全忽略它也是一样愚蠢。
让我们检验一个极为简单的性能评估软件,它只测试最基本的I/O机能。这个程序从标准输入设备读取30000个浮点数,然后以固定格式将它们写到标准输出设备。程序就是采用iostream或是stdio,由预处理符号STDIO来决定——在编译期就做决定。如果这个符号有定义,就使用stdio程序库,否则就使用iostream程序库。
#ifedf STDIO
#include <stdio.h>
#else
#include <iostream>
#include <iomanip>
using namespace std;
#endif
const int VALUES = 30000;
int main() {
double d;
for (int n = 1; n <= VALUES; ++n) {
#ifdef STDIO
scanf("%lf", &d);
printf("%10.5f", d);
#else
cin >> d;
cout << setw(10)
<< setprecision(5)
<< setiosflags(ios::showpoint)
<< setiosflags(ios::fixed)
<< d;
#endif
if (n % 5 == 0) {
#ifdef STDIO
printf("\n");
#else
cout << "\n";
#endif
}
}
return 0;
}
iostream的以下形式:
cout << setw(10)
<< setprecision(5)
<< setiosflags(ios::showpoint)
<< setiosflags(ios::fixed)
<< d;
并不像下面这行那么易读易写:
printf("%10.5f", d);
但是operator<<不但类型安全而且可扩充,而printf两者皆否。
本例使用不同的机器、不同的操作系统、不同的编译器来测试这个程序。无论哪种组合,stdio版都比较快,有时候只是快一点(大约20%),有时候则是快很多(几乎达到200%),但从未测出iostream版比stdio版更快的情况。此外这个平淡无奇的程序的可执行文件大小,stdio版本比iostream版小(有时候小很多)。不过对于一个真正有用的程序而言,两者所造成的可执行文件大小差别应该不大。
请记住,stdio所具备的任何效率优势都和其产品高度相依,所以未来所测试的产品,或目前存在而未曾测试的产品,都有可能造成iostreams和stdio之间的性能差异小到可以忽略。事实上,我们可以合理地期望找到一个iostream产品,速度表现比stdio更快,因为iostreams在编译期就决定其操作数的类型,而stdio函数则是在运行时期才能解析其格式字符串(format string)。
然后,iostream和stdio之间的性能对比只是个例子,不是重点。重点是,不同的程序库即使提供相似的技能,也往往表现出不同的性能取舍策略,所以一旦我们找出程序的瓶颈(通过分析器),我们应该思考是否有可能因为改用另一程序库而移除那些瓶颈。如果程序有一个I/O瓶颈,可以考虑以stdio取代iostream,但如果程序花费许多时间在动态内存分配和释放方面,或许应该看看是否有其他提供了operator new和operator delete的程序库产品。由于不同的程序库将效率、扩充性、移植性、类型安全性等的不同设计具体化,有时候可以找找看是否存在另一个功能相近的程序库而其效率上有较高的设计权重。如果有,改用它,可大幅改善程序性能。
学习心得
本条款,通过给出iostream和stdio库的性能对比,向我们推荐了程序性能优化过程中,如果发现性能瓶颈点在程序库的时候,不妨试试新的库替换方案(建立在程序的分析profiler基础上)。