洛译小筑

别来无恙,我的老友…
随笔 - 45, 文章 - 0, 评论 - 172, 引用 - 0
数据加载中……

[ECPP读书笔记 条目21] 在必须返回一个对象时,不要去尝试返回一个引用

一旦程序员把注意力都转向了对象传值方式隐含的效率问题(参见条目20)时,许多人都变成了极端的“改革运动者”,他们对传值方法采取斩草除根的态度,不屈不挠的追随着一个纯粹的传递引用的世界,与此同时,他们也无可避免的犯下了一个致命的错误:有时候传递的引用所指向的对象并不存在。这决不是一件好事情。

请看下面的示例,其中的Rational类用来表示有理数,还有一个函数用来计算两个有理数的乘积:

class Rational {

public:

  Rational(int numerator = 0, int denominator = 1);

                                   // 条目24中解释了为什么这里的构造函数

                                   // 没有声明为explicit

  ...

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已经毫无关系,因为它已经被销毁了。对于所有的调用者而言,只要稍稍触及这一函数的返回值,都会遭遇到无尽的未定义行为。事实上,任何返回局部对象引用的函数都是灾难性的。(任何返回指向局部对象的指针的函数也是如此。)

现在,让我们考虑下面做法的可行性:在堆上创建一个对象,然后返回一个指向它的引用。由于保存于堆上的对象由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 = ... ;                   // lhsrhs相乘,

                                   // 并将乘积存入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))的值永远为true

我们为上面代码中的判断语句更换为函数的形式,这个问题就更加浅显了:

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

请注意,在调用operator==时,已经存在了两次活动的operator*调用,每次调用时都回返回一个指向operator*内部的静态Rational对象的引用。于是编译器将要求operator==去将operator*内部的静态Rational对象与自身相比较。如果结果不相等,才是让人吃惊的事情。

上面的内容似乎已经足够让你确信:为类似于operator*这样的函数返回一个引用确实是在浪费时间,但是有些时候你会想:“好吧,一个静态值不够,那么用一个静态数组总可以了吧

我无法用实例来捍卫我的观点,但是我可以用非常简明的推理证明这样做会让你多羞愧:首先,你必须确定一个n值,也就是数组的大小。如果n太小了,函数返回值的存储空间可能会用完,这种情况与刚才否定的单一静态对象的方案一样糟糕。但是如果n的值太大,那么你的程序将面临性能问题,这是因为数组中的每个对象都应在函数在第一次调用时被构造。这会使你付出n次构造函数和n次析构函数调用的代价,即使我们讨论的函数只被调用一次。如果将“优化”称为改善软件性能的一个步骤,那么我们可以把这一做法称为“劣化”。最后,请考虑一下:你如何将需要的值放入数组中的对象里,在放置的过程中你又付出了多大代价呢?在两个对象之间传值的最直接的方法就是赋值,但是赋值操作又会带来多大开销呢?对于许多类型而言,赋值的开销类似于调用一次析构函数(以销毁旧数值)加上一次构造函数(以复制新数值)。但是要知道,你的原始目标本来是避免构造和析构过程所带来的开销!请面对它:这样做一定不会得到好结果。(别妄想,用vector来代替数组也不会改善多少。)

在编写必须返回一个新对象的函数时,正确的方法就是让这个函数返回一个新对象。对于Rationaloperator*来说,这就意味着下面的代码是基本符合要求的:

inline const Rational operator*(const Rational& lhs, const Rational& rhs)

{

  return Rational(lhs.n * rhs.n, lhs.d * rhs.d);

}

显然地,这样做可能会招致对operator*的返回值的构造和析构过程的开销,但是从长远角度讲,付出这小小的代价可以获得更大的收益。而且,这一可能会吓到你的清单也许永远不需要你来付账。就像其它编程语言一样,C++允许编译器的具体实现版本在不改变其固有行为的同时通过优化代码来提升性能,在某些情况下,对operator*返回值的构造和析构过程可以被安全的排除。当编译器利用了这一事实(编译器通常都会这样做),你的程序就可以继续按预期的行为执行,同时还会比你预期的快一些。

归根结底,是使用引用返回,还是直接返回一个对象?你的工作就是:做出正确的抉择,使程序拥有正确的行为。然后把优化工作留给编译器制造商,他们会致力于让你的选择执行起来更高效。

时刻牢记

对于局部的/分配于栈上/分配于堆上的对象,如果你需要将其中的任意一种作为函数的返回值,请确保做到以下几点:不要返回一个指向局部的、分配于栈上的对象;不要返回一个引用去指向分配于堆上的对象;不要返回一个指向局部静态对象的指针或引用。(另有条目4中包含一个示例告诉我们:至少在在单线程环境下,返回一个指向局部静态对象的指针还是有意义的。)

posted on 2007-06-02 21:13 ★ROY★ 阅读(1220) 评论(2)  编辑 收藏 引用 所属分类: Effective C++

评论

# re: 【翻译】[Effective C++中文版第3版][第21条]在必须返回一个对象时,不要去尝试返回一个引用  回复  更多评论   

分析的不错
2007-06-04 13:57 | picasa

# re: 【翻译】[Effective C++中文版第3版][第21条]在必须返回一个对象时,不要去尝试返回一个引用  回复  更多评论   

其实我感觉返回一个指向堆对象的指针是可行的,只要那个堆对象不是在函数内部生成的就好。
2007-08-28 15:29 | LJW

只有注册用户登录后才能发表评论。
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理