一旦程序员把注意力都转向了对象传值方式隐含的效率问题(参见第 20 条)时,许多人都变成了极端的“改革运动者”,他们对传值方法采取斩草除根的态度,在他们不屈不挠追求传递引用方式的纯粹性的同时,他们也犯下了致命的错误:有时候传递的引用所指向的对象并不存在。这决不是一件好事情。
请看下面的示例,其中的 Rational 类用来表示有理数,其中还包括一个函数来计算两个有理数的乘积:
class Rational {
public:
Rational(int numerator = 0, int denominator = 1);
// 第 24 条中解释了为什么这里的构造函数没有显性声明。
...
private:
int n, d; // 分子( n )和分母( d )
friend const Rational
operator*(const Rational& lhs, const Rational& rhs);
// 第 3 条中解释了为什么返回值是 const 的。
};
这一版本的 operator* 通过传值方式返回一个对象,如果你不去考虑这一对象在构造和析构过程中的开销,那么你就是在逃避你的专业职责。如果你并不是非得要为这样的对象付出代价,那么你大可不必那样做。现在问题就是:你必须付出这一代价吗?
好的,如果此时你可以返回一个引用作为替代品,那么就不需要了。但是请记住,一个引用仅仅是一个名字,它是一个已存在的对象的别名。当你看到一个引用的声明时,你应该立刻问一下你自己:它的另一个名字是什么,因为一个引用作指向的内容必定有它自己的名字。于是对于上面的 operator* 而言,如果它返回一个引用,那么它所引用的必须是一个已存在的 Rational 对象,这个对象中包含着需要进行乘法操作的两个对象的乘积。
如果你期望在调用 operator* 之前这一对象必须存在,那么你就太不理智了。也就是说,如果你这样做了:
Rational a(1, 2); // a = 1/2
Rational b(3, 5); // b = 3/5
Rational c = a * b; // c 的值应该为 3/10
期待存在一个值为 3/10 的有理数的做法看上去显得很不理智。其实并不是这样的,如果 operator* 返回一个指向这类数值的引用,那么它必须要自己创建这个数字。
一个函数只能以两种方式创建新的对象:在栈上或在堆上。定义一个局部变量就是在栈上创建一个新对象。应用这一策略时,你可能会以这种方式编写 operator* :
const Rational& operator*(const Rational& lhs, const Rational& rhs)
// 警告!错误的代码
{
Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
return result;
}
你大可以拒绝这样的实现方法,因为你的目标是防止对构造函数的调用,但是此时 result 会像其它对象一样被初始化。一个更严重的问题是:这个函数会返回一个指向 result 的引用,但是 result 是一个局部对象,而局部对象在函数退出时就会被销毁。那么,这一版本的 operator* ,并不会返回一个指向 Rational 的引用,它返回的引用指向一个“前期 Rational ”,它曾经是 Rational 的对象,一个“空寂的、散发着霉气的、开始腐烂的、曾是一个 Rational 的尸体”,但它现在与 Rational 已经毫无关系,因为它已经被销毁了。对于所有的调用者而言,只要稍稍触及这一函数的返回值,都会遭遇到无尽的无法预知的行为。事实上,任何返回局部对象引用的函数都是灾难性的。(任何返回指向局部对象的指针的函数也是如此。)
现在,让我们考虑下面做法的可行性:在堆上创建一个对象,然后返回一个指向它的引用。由于保存于堆上的对象由 new 来创建,因此你可能会这样编写基于堆的 operator* :
const Rational& operator*(const Rational& lhs, const Rational& rhs)
// 警告!这里有更多的错误!
{
Rational *result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
return *result;
}
好的,此时仍然需要付出调用构造函数的代 价,这是因为通过 new 分配的内存要通过调用一个合适的构造函数来初始化,但是现在你面临这另一个问题:谁来确保与 new 相对应的 delete 的执行呢?
即使调用者十分认真负责并且抱有良好的初衷,他们也无法保证:下面这样合理的使用场景下不会出现内存泄漏:
Rational w, x, y, z;
w = x * y * z; // 等价于 operator*(operator*(x, y), z)
这里,在一个语句中存在着两次对 operator* 的 调用,于是存在两次 new 操作有待于使用 delete 来清除。但是又没有任何理由要求 operator* 的客户端程序员来进行这一操作,这是因为对 operator* 的调用返回了一个引用,没有理由要求客户端程序员去取得隐藏在这一引用背后的指针。这势必会造成资源泄漏。
但是,也许你注意到了,栈方案与堆方案都面临着同一个问题:它们都需要为 operator* 的每一个返回值调用一次构造函数。也许你能够回忆起我们最初的目的就是避免像此类构造函数调用。也许你认为你知道某种方法来将此类构造函数调用的次数降低到仅有一次。也许你想到了下面的实现方法:让 operator* 返回一个指向一个静态的 Rational 对象的引用,这一静态对象在函数的内部:
const Rational& operator*(const Rational& lhs, const Rational& rhs)
// 警告!会出现更多更多的错误!
{
static Rational result; // 用来作为返回值的静态对象
result = ... ; // 将 lhs 与 rhs 相乘, 并将乘积存入 result
return result;
}
与其它引入静态对象的设计方法一样,这种方法很显著的提高了线程的安全性,但是这却带来了更明显的缺陷。下面的客户端代码是无懈可击的,但是上文中的设计会使其暴露出问题:
bool operator==(const Rational& lhs, const Rational& rhs);
// 为有理数作比较的 operator==
Rational a, b, c, d;
...
if ((a * b) == (c * d)) {
当乘积相等时,执行恰当的操作 ;
} else {
当乘积不相等时,执行恰当的操作 ;
}
猜猜会发深什么?无论 a 、 b 、 c 或 d 取什么值,表达式 ((a*b) == (c*d)) 的值永远为真。
我们为上面函数中的判断语句更换一个形式,这个问题就更加浅显了:
if (operator==(operator*(a, b), operator*(c, d)))
请注意,在调用 operator== 时,已经存在了两次活动的对 operator* 的调用,每次调用时都回返回一个指向 operator* 内部的静态 Rational 对象的引用。于是编译器将要求 operator== 去将 operator* 内部的静态 Rational 对象与自身相比较。如果结果并不总是相等的,这才是让人奇怪的事情。
上面的内容似乎已经足够让你确信:为类似于 operator* 这样的函数返回一个引用确实是在浪费时间,但是有些时候你会想:“好吧,一个静态值不够,那么用一个静态数组总可以了吧 … ”
我无法用实例来捍卫我的观点,但是我可以用非常简明的推理证明这样做会让你多羞愧:首先,你必须确定一个 n 值,也就是数组的大小。如果 n 太小了,函数返回值的存储空间可能会用完,这种情况与刚才否定的单一静态对象的方案一样糟糕。但是如果 n 的值太大,那么你的程序将面临性能问题,这是因为数组中的每个对象都应在函数在第一次调用时被构造。这会使你付出 n 次构造函数和 n 次析构函数的调用,即使我们讨论的函数只被调用一次。如果将“优化”称为改善软件性能的一个步骤,那么我们可以把这一做法称为“劣化”。最后,请考虑一下:你如何将需要的值放入数组中的对象里,在放置的过程中你又付出了多大代价呢?在两个对象之间传值的最直接的方法就是赋值,但是赋值操作又会带来多大开销呢?对于许多类型而言,赋值的开销类似于调用一次析构函数(以销毁旧数值)加上一次构造函数(以复制新数值)。但是要知道,你的原始目标本来是避免构造和析构过程所带来的开销!请面对它:这样做一定不会得到好结果。(别妄想,用 vector 来代替数组也不会改善多少。)
编写必须返回一个新对象的函数,正确的方法就是:让这个函数返回一个新对象。对于 Rational 的 operator* 来说,这就意味着下面的代码是基本符合要求的:
inline const Rational operator*(const Rational& lhs, const Rational& rhs)
{
return Rational(lhs.n * rhs.n, lhs.d * rhs.d);
}
显然地,这样做可能会招致对 operator* 的返回值的构造和析构过程的开销,但是从长远角度讲,付出这小小的代价可以获得更大的收益。而且,这一恐怖的清单可能永远不需要你来付账。就像其它编程语言一样,C++允许编译器的具体实现版本通过优化代码来提升性能,同时又不改变其固有的行为,在一些情况下,对 operator* 返回值的构造和析构过程可以被安全的排除。当编译器利用了这一事实时(编译器通常都会这样做),你的程序就可以继续按预期的行为执行,仅仅是更快了一些。
归根结底,当选择是使用引用返回,还是直接返回一个对象时,你的工作就是:做出正确的抉择,使程序拥有正确的行为。然后把优化工作留给编译器制造商,他们会使你的抉择变得尽可能的经济实用。
牢记在心
- 对于局部的 / 分配于栈上 / 分配于堆上的对象,如果你需要将其中的任意一种作为函数的返回值
在必须返回一个对象时,不要去尝试返回一个引用