读书笔记 effective c++ Item 21 当你必须返回一个对象的时候,不要尝试返回引用
正文
1. 问题的提出:要求函数返回对象时,可以返回引用么?
一旦程序员理解了按值传递有可能存在效率问题之后(Item 20),许多人都成了十字军战士,决心清除所有隐藏的按值传递所引起的开销。对纯净的按引用传递(不需要额外的构造或者析构)的追求丝毫没有懈怠,但他们的始终如一会产生致命的错误:它们开始传递指向并不存在的对象的引用。这可不是好事情。
考虑表示有理数的一个类,它包含将两个有理数相乘的函数(Item 3):
1 class Rational {
2
3 public:
4
5 Rational(int numerator = 0, // see Item 24 for why this
6
7 int denominator = 1); // ctor isn’t declared explicit
8
9 ...
10
11 private:
12
13 int n, d; // numerator and denominator
14
15 friend
16
17 const Rational // see Item 3 for why the
18
19 operator*(const Rational& lhs, // return type is const
20
21 const Rational& rhs);
22
23 };
Operator* 的这个版本为按值返回结果,如果你没有为调用这个对象的构造函数和析构函数造成的开销而担心,你就是在逃避你的专业职责。如果这个对象不是必须的,你就不想为这样一个对象的开销去买单。所以问题是:这个对象的生成是必须的么?
2. 问题的分析(一):如返回引用,必须为返回的引用创建一个新的对象
如果你能够返回一个引用那么就不是必须为其买单。但是记住引用只是一个别名,一个已存对象的别名。每当你声明一个引用时,你应该马上问问自己它用来做谁的别名,因为它必须是某些东西的别名。对于operator*来说,如果这个函数返回一个引用,它必须返回一个指向已存在Rational对象的引用,这个对象包含了两个对象的乘积结果。
没有任何理由假设在调用operator*之前这样一个对象已经存在了。也就是说,如果你进行下面的操作:
1 Rational a(1, 2); // a = 1/2
2
3 Rational b(3, 5); // b = 3/5
4
5 Rational c = a * b; // c should be 3/10
期望已经存在一个值为3/10的有理数看上去是不合理的。如果operator*即将返回一个指向值为3/10的有理数的引用,它必须自己创建出来。
3. 问题的分析(二):创建新对象的三种错误方法
3.1 在栈上创建reference指向的对象
一个函数只可以通过两种方法来创建一个新的对象:在栈上或者在堆上。通过定义一个本地变量来完成栈上的对象创建。使用这个策略,你可以尝试使用下面的方法来实现:operator*:
1 const Rational& operator*(const Rational& lhs, // warning! bad code!
2
3 const Rational& rhs)
4
5 {
6
7 Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
8
9 return result;
10
11 }
你会立即否决这种做法,因为你的目标是避免调用构造函数,但是这里的result必须被构造出来。更加严重的问题是:这个函数返回指向result的引用,但result是一个本地对象,当函数退出的时候这个对象就会被销毁。所以这个版本的operator*并没有返回指向Rational的引用,它返回的引用指向从前的Rational对象,现在变成了一个空的,令人讨厌的,已经腐烂的Rational对象的尸体,它已经被销毁了。任何使用这个函数的返回值的调用者都将会马上进入未定义行为的范围。事实是,任何返回指向本地对象的引用的函数都是被破坏掉的函数。(返回指向本地对象的指针的函数也是如此)。
3.2 在堆上创建reference指向的对象
让我们再考虑一下下面这种用法的可能性:在堆上创建一个对象并且返回指向它的引用。堆上的对象通过使用new来创建,所以你可以像下面这样实现一个基于堆的operator*:
1 const Rational& operator*(const Rational& lhs, // warning! more bad
2
3 const Rational& rhs) // code!
4
5 {
6
7 Rational *result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
8
9 return *result;
10
11 }
这里你仍然需要为构造函数的调用买单,对new分配的内存进行初始化是通过调用一个合适的构造函数来实现的,但是现在有另外一个问题:谁在这个对象上应用new召唤出来的delete?
即使是一个认真负责的,心怀善意的调用者,对于下面这种合理的使用场景,他们也没有什么方法来避免内存泄漏:
1 Rational w, x, y, z;
2
3 w = x * y * z; // same as operator*(operator*(x, y), z)
这里,在同一个语句中调用了两次operator*,因此使用了两次new,这也需要使用两次delete来对new出来的对象进行销毁。没有什么合理的方法来让operator*的客户来进行这些调用,因为对于他们来说没有合理的方法来获取隐藏在从operator*返回回来的引用后面的指针。这么做保证会产生资源泄漏。
3.3 为reference创建 static对象
3.3.1单一static 对象
你可能注意到了,不管是在堆上还是栈上创建从operator*返回的结果,你都必须要调用一个构造函数。可能你能回忆起来我们的初衷是避免这样的构造函数调用。可能你认为你知道一种只需要调用一次构造函数,其余的构造函数被避免调用的方法。下面的这种实现突然出现了,这种方法基于另外一种operator*的实现:令其返回指向static Rational对象的引用,函数实现如下:
1 const Rational& operator*(const Rational& lhs, // warning! yet more
2
3 const Rational& rhs) // bad code!
4
5 {
6
7 static Rational result; // static object to which a
8
9 // reference will be returned
10
11 result = ... ; // multiply lhs by rhs and put the
12
13 // product inside result
14
15 return result;
16
17 }
像所有使用静态对象的设计一样,这种方法增加了对于线程安全的梳理工作,但这个缺点是比较明显的。为了看一下更深层次的缺陷,考虑一份完全合理的客户代码:
1 bool operator==(const Rational& lhs, // an operator==
2
3 const Rational& rhs); // for Rationals
4
5 Rational a, b, c, d;
6
7 ...
8
9 if ((a * b) == (c * d)) {
10
11 do whatever’s appropriate when the products are equal;
12
13 } else {
14
15 do whatever’s appropriate when they’re not;
16
17 }
你猜怎么着?表达式((a*b) == (c*d))的求值结果总为true,而不管a,b,c,d的值是什么!
将表达式用等价的函数形式进行重写,上面的不可思议的事情就能很容易明白:
1 if (operator==(operator*(a, b), operator*(c, d)))
注意当operator==被调用的时候,已经调用了两次operato*,每次调用都会返回指向operator*中的static Raitional对象的引用。因此,operator==会对operator*中的static Rational对象和operator* 中的static Rational对象进行比较。如果不相等就奇怪了。
3.3.2 Static数组
这应该足够使你相信从像operator*一样的函数中返回一个引用是在浪费时间,但是一些人现在开始想了:好,如果一个static不够,可能一个static数组能够达到目的。。。
我不能提供示例代码来让这个设计显得如此高贵,但是我能描述一下为什么这个想法会让你感到羞愧脸红。首先,你必须选择一个合适的n,也就是数组的大小。如果n太小,你可能会耗尽存储函数返回值的空间,这样对于上面的单一静态对象设计来说,我们没有获得任何好处。如果n太大,你的程序的性能会降低,因为即使这个函数仅被使用一次,在第一次被调用之前,数组中的每一个对象都会被构造出来。这会让你付出调用n个构造函数和n个析构函数的代价。如果最优化(optimization)是改善软件性能的一个过程,那么这种事情应该被叫做“最差化”(pessimization)。最后,想象一下你该如何把你所需要的值放入数组的对象中,并且这样做会付出什么代价。最直接的方法是通过赋值来对对象之间的值进行移动,但是赋值的代价是什么呢?对于许多类型来说,赋值等同于调用一个析构函数(释放旧值)和一个构造函数(拷贝新值)。但是你的目标是要避免析构和构造的开销!直面它把,这个方法没有奏效。(使用vector来代替数组也不会对问题有所改善。)
4. 问题结论:从函数中返回新对象的正确方法是——返回对象
实现一个必须返回一个新对象的函数的正确方法是让函数返回新的对象(value不是reference)。对于Rational的opertaor*函数来说,其实现如下面的代码(或者与其等价的代码):
1 inline const Rational operator*(const Rational& lhs, const Rational& rhs)
2
3 {
4
5 return Rational(lhs.n * rhs.n, lhs.d * rhs.d);
6
7 }
当然,你会从operator*的返回值中引入构造和析构的开销,但从长远来看,这是为正确的行为付出了一个小的代价。此外,让你毛骨悚然的账单再也不会到来。像许多编程语言一样,C++允许编译器实现者在不改变可视化代码行为的前提下,对代码进行优化,以达到改善生成码性能的目的。在一些情况中,我们发现,operator*返回值的构造和析构可以被安全的消除。当编译器利用了这个事实(编译器经常这么做),你的程序就会以你所期望的方式进行下去,只是比你想要的要快。
将本条款归结如下:在返回一个引用还是返回一个对象之间做决定时,你的工作是选择能够提供正确行为的那个。对于“如何使这个选择有尽可能小的开销”这个问题的解决,让编译器供应商去斗争把。
5. 总结
绝不要返回指向本地栈对象的指针或者引用,指向堆对象的引用,或者在有可能需要多个对象的时候返回指向本地静态对象的指针或者引用。(Item 4)给出了一种设计的一个例子,说明了返回指向本地静态对象的引用是合理的,至少在单线程环境中。)
作者:
HarlanC
博客地址:
http://www.cnblogs.com/harlanc/
个人博客:
http://www.harlancn.me/
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出,
原文链接