Effective C++ 学习笔记 条款21 必须返回对象时,别妄想返回其reference

一旦程序员领悟了pass-by-value的效率牵连层面(见条款20),往往变成十字军战士,一心一意根除pass-by-value带来的种种邪恶。在坚定追求pass-by-reference的纯度中,他们一定会犯下一个致命错误:开始传递一些references指向其实并不存在的对象。这可不是件好事。

考虑一个用以表现有理数(rational numbers)的class,内含一个函数用来计算两个有理数的乘积:

class Rational
{
public:
    Rational(int numerator = 0, int denominator = 1);    // 条款24说明为什么这个构造函数不声明为explicit
    // ...

private:
    int n, d;    // 分子(numerator)和分母(denominator)
    // 条款3说明为什么返回类型是const
    friend const Rational operator*(const Rational &lhs, const Rational &rhs);    
};

这个版本的operator*系以by value方式返回其计算结果(一个对象)。如果你完全不担心该对象的构造和析构成本,你其实是明显逃避了你的专业责任。若非必要,没有人会想要为这样的对象付出太多代价(指构造和析构的代价),问题是需要付出任何代价吗?

如果可以改而使用reference,就不需付出代价。但是记住,所谓reference只是个名称,代表某个既有对象。任何时候看到一个reference声明式,你都应该立刻问自己,它的另一个名称是什么?因为它一定是某物的另一个名称。以上述operator*为例,如果它返回一个reference,后者一定指向某个既有的Rational对象,内含两个Rational对象的乘积。

我们当然不可能期望这样一个(内含乘积的)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的Rational对象”并不合理。如果operator*要返回一个reference指向如此数值,它必须自己创建那个Rational对象。

函数创建新对象的途径有二:在stack空间或在heap空间创建之。如果定义一个local变量,就是在stack空间创建对象。根据这个策略试写operator*如下:

const Rational &operator*(const Rational &lhs, const Rational &rhs)
{
    Rational result(lhs.n * rhs.n, lhs.d * rhs.d);    // 警告!糟糕的代码!
    return result;
}

你可以拒绝这种做法,因为你的目标是要避免调用构造函数,而result却必须像任何对象一样地由构造函数构造起来。更严重的是:这个函数返回一个reference指向result,但result是个local对象,而local对象在函数退出前被销毁了。因此,这个版本的operator*并未返回reference指向某个Rational,它返回的reference指向一个“从前的”Rational:一个旧时的Rational;一个曾经被当做Rational但如今已经成空、发臭、败坏的残骸,因为它已经被销毁了。任何调用者甚至只是对此函数的返回值做任何一点点运用,都将立刻坠入“无定义行为”的恶地。事情的真相是,任何函数如果返回一个reference指向某个local对象,都将一败涂地(如果函数返回指针指向一个local对象,也是一样)。

于是,让我们考虑在heap内构造一个对象,并返回reference指向它。Heap-based对象由new创建,所以你得写一个heap-based 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出来的对象实施delete?

即使调用者诚实谨慎,并且出于良好意识,他们还是不太能够在这样合情合理的用法下阻止内存泄漏:

Rational w, x, y, z;
w = x * y * z;    // 与operator*(operator*(x, y), z)相同

这里,同一个语句内调用了两次operator*,因而两次使用new,也就需要两次delete。但却没有合理的办法让operator*使用者进行那些delete调用,因为没有合理的办法让他们取得operator*返回的reference背后隐藏的那个指针。这绝对导致资源泄露。

但或许你注意到了,上述不论on-the-stack或on-the-heap做法,都因为对operator*返回的结果调用构造函数而受惩罚。也许你还记得我们的最初目标是要避免如此的构造函数调用动作。或许你认为你知道有一种办法可以避免任何构造函数被调用。或许你心里出现下面这样的实现代码,此法奠基于“让operator*返回的reference指向一个被定义于函数内部的static Rational对象”:

const Rational &operator*(const Rational &lhs, const Rational &rhs)
{    // 警告,又一堆烂代码
    static Rational result;    // static对象,此函数将返回其reference
    result = ...;    // 将lhs乘以rhs,并将结果置于result内
    return result;
}

就像所有用上static对象的设计一样,这一个也立刻造成我们对多线程安全性的疑虑。不过那还只是它显而易见的弱点。如果想看看更深层的瑕疵,考虑以下这些完全合理的客户代码:

bool operator=(const Rational &lhs, const Rational &rhs);    // 一个针对Rational而写的operator==
Rational a, b, c, d;
// ...
if ((a * b) == (c * d))
{
    // 当乘积相等时,做适当的相应动作
}
else
{
    // 当乘积不等时,做适当的相应动作
}

以上表达式((a * b) == (c * d))总是被核算为true,不论a、b、c、d的值是什么!

一旦将代码重新写为等价的函数形式,很容易就可以了解出了什么意外:

if (operator==(operator*(a, b), operator(c, d))

注意,在operator==被调用前,已有两个operator*调用式起作用,每一个都返回reference指向operator*内部定义的static Rational对象。因此operator==被要求将“operator*内的static Rational对象值”拿来和“operator*内的static Rational对象值”比较,如果比较结果不相等,那才奇怪呢(译注:这里补充说明,两次operator*调用的确各自改变了static Rational对象值,但由于它们返回的都是reference,因此调用端看到的永远是static Rational对象的“现值”)。

这应该足够说服你,欲令诸如operator*这样的函数返回reference,只是浪费时间而已,但现在或许又有些人这样想:“如果一个static不够,或许一个static array可以得分……”。

作者不打算再次写出示例来驳斥这个想法以彰显自己多么厉害,但可以简单描述为什么你该为了提出这个念头而脸红。首先你必须选择array大小n。如果n太小,你可能会耗尽“用以存储函数返回值”的空间,那么情况就回到了我们刚才讨论过的单一static设计。但如果n太大,会因此降低程序效率,因为array内的每一个对象都会在函数第一次被调用时构造完成。那么将消耗n个构造函数和n个析构函数——即使我们所讨论的函数只被调用一次。如果所谓“最优化”是改善软件效率的过程,我们现在所谈的这些应该称为“恶劣化”。最后,想一想如果将你需要的值放进array内,而那么做的成本又是多少。在对象之间搬移数值的最直接办法是通过赋值(assignment)操作,但赋值的成本几何?对许多types而言它相当于调用一个析构函数(用以销毁旧值)加上一个构造函数(用以复制新值)。但你的目标是避免构造和析构成本!面对现实吧,这个做法不会成功的。就算以vector替换array也不会让情况更好些。

一个“必须返回新对象”的函数的正确写法是:就让那个函数返回一个新对象呗。对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*返回值的构造和析构可被安全地消除(指承受不起的构造和析构成本被编译器优化所减少,从而安全地使得成本可接受)。如果编译器运用这一事实(它们也往往如此),你的程序将继续保持它们该有的行为,而执行起来又比预期快。

作者把以上讨论总结为:当你必须在“返回一个reference和返回一个object”之间抉择时,你的工作就是挑出行为正确的那个。就让编译器厂商为“尽可能降低成本”鞠躬尽瘁吧,你可以享受你的生活。

请记住:
绝不要返回pointer或reference指向一个local stack对象,或返回reference指向一个heap-allocated对象,或返回pointer或reference指向一个local static对象而有可能同时需要多个这样的对象。条款4已经为“在单线程环境中合理返回reference指向一个local static对象”提供了一份设计实例。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值