admin管理员组

文章数量:1530235

Effective C++条款21:必须返回对象时,别妄想返回其reference(Don't try to return a reference when you must return an object)

  • 条款21:必须返回对象时,别妄想返回其reference
    • 1、值传递影响效率的原因
    • 2、返回引用,必须为返回的引用创建一个新的对象
    • 3、创建新对象的三种错误方法
      • 3.1 错误方法:在栈上创建reference指向的对象
      • 3.2 错误方法:在堆上创建reference指向的对象
      • 3.3 错误方法:为reference创建 static对象
        • 3.3.1 单一static 对象
        • 3.3.2 Static数组
    • 4、从函数中返回新对象的正确方法是——返回对象
    • 5、牢记
  • 总结


《Effective C++》是一本轻薄短小的高密度的“专家经验积累”。本系列就是对Effective C++进行通读:


条款21:必须返回对象时,别妄想返回其reference

1、值传递影响效率的原因

  一旦程序员理解了传值有可能存在效率问题之后(见条款20),往往变成了十字军战士,一心根除传值所带来的开销。对纯净的按引用传递(不需要额外的构造或者析构)的追求丝毫没有懈怠,但他们的一定会会犯下一个致命的错误:开始传递指向并不存在的对象的引用。这可不是好事情。

  考虑表示有理数的一个类,它包含将两个有理数相乘的函数:

class Rational
{
public:
    Rational(int numerator = 0, int denominator = 1);
private:
    int n, d; //分子和分母
    friend const Rational 
        operator*(const Rational& lhs, const Rational& rhs);
};

  Operator* 的这个版本为值传递方式返回结果,如果你完全不担心该对象的构造函数和析构函数造成的开销,你就是在逃避你的专业职责。若非必要,没人会想要为这样的对象付出太多代价。所以问题是:需要付出任何代价吗?

2、返回引用,必须为返回的引用创建一个新的对象

  如果传递引用,就不需要付出代价。但是记住引用只是一个别名,一个已存对象的别名。每当你声明一个引用时,你应该马上问问自己它是谁的别名,因为它一定是某物的另一个名称。对于operator*来说,如果这个函数返回一个引用,它必须返回一个指向已存在Rational对象的引用,这个对象包含了两个对象的乘积结果。

  我们不可能期望这样一个(内含乘积的)Rational 对象在调用operator*之前就已经存在了。也就是说,如果你进行下面的操作:

Rational a(1, 2);
Rational b(3, 5);
Rational c = a * b;

  期望已经存在一个值为3/10的有理数看上去是不合理的。如果operator*即将返回一个指向值为3/10的有理数的引用,它必须自己创建出来。

3、创建新对象的三种错误方法

3.1 错误方法:在栈上创建reference指向的对象

  函数有两种方法来创建一个新的对象:在栈上或者在堆上。通过定义一个本地变量来完成栈上的对象创建。使用这个策略,你可以尝试使用下面的方法来实现:operator*:

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

  你应该拒绝这种做法,因为你的目标是避免调用构造函数,但是这里的result必须被构造出来。更加严重的问题是:这个函数返回指向result的引用,但result是一个本地对象,当函数退出的时候这个对象就会被销毁。所以这个版本的operator*并没有返回指向Rational的引用,它返回的引用指向从前的Rational对象,现在变成了一个空的,令人讨厌的,已经腐烂的Rational对象的尸体,它已经被销毁了。任何使用这个函数的返回值的调用者都将会马上进入未定义行为的范围。事实是,任何返回指向本地对象的引用的函数都是被破坏掉的函数。(返回指向本地对象的指针的函数也是如此)。

3.2 错误方法:在堆上创建reference指向的对象

  让我们再考虑在堆内构造一个对象,并返回引用指向它。堆上的对象通过使用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来对new出来的对象进行销毁。没有什么合理的方法来让 operator* 的客户来进行这些调用,因为对于他们来说没有合理的方法来获取隐藏在从 operator* 返回回来的引用后面的指针。这么做保证会产生资源泄漏。

3.3 错误方法:为reference创建 static对象

3.3.1 单一static 对象

  你可能注意到了,不管是在堆上还是栈上创建从 operator* 返回的结果,你都必须要调用一个构造函数。可能你能回忆起来我们的初衷是避免这样的构造函数调用。可能你认为你知道一种只需要调用一次构造函数,其余的构造函数被避免调用的方法。下面的这种实现突然出现了,这种方法基于另外一种 operator* 的实现:令其返回指向static Rational对象的引用,函数实现如下:

const Rational& operator*(const Rational& lhs, 
						  const Rational& rhs)
{	// warning, 又一堆烂代码
    static Rational result; //静态局部变量
 	result=...; //将lhs乘以rhs,然后将结果保存在result中
    return *result; //虽然编译器不报错,但是逻辑上是错误的
}

  像所有使用静态对象的设计一样,这种方法增加了对于线程安全的梳理工作,但这个缺点是比较明显的。为了看一下更深层次的缺陷,考虑下面这些完全合理的客户代码:

bool operator==(const Rational& lhs, 
				const Rational& rhs); // for Rationals
Rational a, b, c, d;
...
if ((a * b) == (c * d)) {
	//乘积相等,做相应动作
} else {
	//不相等,做相应动作
}

  猜想怎么着?表达式((ab) == (cd))的求值结果总为true,而不管a,b,c,d的值是什么!

  将表达式用等价的函数形式进行重写,上面的不可思议的事情就能很容易明白:

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

  注意当 operator== 被调用的时候,已经调用了两次operato*,每次调用都会返回指向 operator* 中的static Raitional对象的引用。因此,operator==会对 operator* 中的static Rational对象和 operator* 中的static Rational对象进行比较。如果不相等就奇怪了。

两次operator*调用的确各自改变了static Raitional对象值,但由于它们返回的都是reference,因此调用端看到的永远是static Raitional对象的“现值”。

3.3.2 Static数组

  这应该足够说服你,诸如operator*这样的函数中返回一个引用,就是在浪费时间,但是一些人现在开始想了:好,如果一个static不够,可能一个static数组能够达到目的……。

  这当然也是错误的想法,首先,你必须选择一个合适的n,也就是数组的大小。如果n太小,你可能会耗尽存储函数返回值的空间,这样对于上面的单一静态对象设计来说,我们没有获得任何好处。如果n太大,你的程序的性能会降低,因为即使这个函数仅被使用一次,在第一次被调用之前,数组中的每一个对象都会被构造出来。这会让你付出调用n个构造函数和n个析构函数的代价。如果最优化(optimization)是改善软件性能的一个过程,那么这种事情应该被叫做“最差化”(pessimization)。最后,想象一下你该如何把你所需要的值放入数组的对象中,并且这样做会付出什么代价。最直接的方法是通过赋值来对对象之间的值进行移动,但是赋值的代价是什么呢?对于许多类型来说,赋值等同于调用一个析构函数(释放旧值)和一个构造函数(拷贝新值)。但是你的目标是要避免析构和构造的开销!直面它把,这个方法没有奏效。(使用vector来代替数组也不会更好。)

4、从函数中返回新对象的正确方法是——返回对象

  一个“必须返回新对象”的函数的正确写法是:让函数返回新的对象。对Rational的opertaor*函数来说,其实现如下面的代码(或者与其等价的代码):

inline const Rational operator*(const Rational& lhs, const Rational& rhs)
{
	return Rational(lhs.n * rhs.n, lhs.d * rhs.d);
}

  当然,你会从 operator* 的返回值中引入构造和析构的开销,但从长远来看,这是为正确的行为付出了一个小的代价。此外,让你恐惧的账单再也不会到来。像许多编程语言一样,C++允许编译器实现者在不改变可视化代码行为的前提下,对代码进行优化,以达到改善生成码性能的目的。在一些情况中,我们发现, operator* 返回值的构造和析构可以被安全的消除。当编译器利用了这个事实(编译器经常这么做),你的程序就会以你所期望的方式进行下去,只是比你想要的要快。

我们将以上总结如下:在返回一个引用还是返回一个对象之间做决定时,你的工作是选择能够提供正确行为的那个。对于“如何使这个选择有尽可能小的开销”这个问题的解决,让编译器供应商去努力把。

5、牢记

  • 绝不要返回指针/引用指向一个局部stack对象,或返回一个引用指向heap-allocated对象,或者返回一个引用/指针指向一个静态局部变量。

  • 条款4已经为“在单线程中合理返回引用指向一个静态局部变量”提供了一份设计实例。

总结

期待大家和我交流,留言或者私信,一起学习,一起进步!

本文标签: 妄想条款对象Effectivereference