读书笔记 effective c++ Item 21 当你必须返回一个对象的时候,不要尝试返回引用

简介: 1. 问题的提出:要求函数返回对象时,可以返回引用么? 一旦程序员理解了按值传递有可能存在效率问题之后(Item 20),许多人都成了十字军战士,决心清除所有隐藏的按值传递所引起的开销。对纯净的按引用传递(不需要额外的构造或者析构)的追求丝毫没有懈怠,但他们的始终如一会产生致命的错误:它们开始传递指向并不存在的对象的引用。

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/
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出, 原文链接

如果觉的博主写的可以,收到您的赞会是很大的动力,如果您觉的不好,您可以投反对票,但麻烦您留言写下问题在哪里,这样才能共同进步。谢谢!

目录
相关文章
|
1天前
|
编译器 C++ 开发者
【C++篇】深度解析类与对象(下)
在上一篇博客中,我们学习了C++的基础类与对象概念,包括类的定义、对象的使用和构造函数的作用。在这一篇,我们将深入探讨C++类的一些重要特性,如构造函数的高级用法、类型转换、static成员、友元、内部类、匿名对象,以及对象拷贝优化等。这些内容可以帮助你更好地理解和应用面向对象编程的核心理念,提升代码的健壮性、灵活性和可维护性。
|
1天前
|
安全 编译器 C语言
【C++篇】深度解析类与对象(中)
在上一篇博客中,我们学习了C++类与对象的基础内容。这一次,我们将深入探讨C++类的关键特性,包括构造函数、析构函数、拷贝构造函数、赋值运算符重载、以及取地址运算符的重载。这些内容是理解面向对象编程的关键,也帮助我们更好地掌握C++内存管理的细节和编码的高级技巧。
|
1天前
|
存储 程序员 C语言
【C++篇】深度解析类与对象(上)
在C++中,类和对象是面向对象编程的基础组成部分。通过类,程序员可以对现实世界的实体进行模拟和抽象。类的基本概念包括成员变量、成员函数、访问控制等。本篇博客将介绍C++类与对象的基础知识,为后续学习打下良好的基础。
|
4天前
|
编译器 C语言 C++
类和对象的简述(c++篇)
类和对象的简述(c++篇)
|
1月前
|
C++ 芯片
【C++面向对象——类与对象】Computer类(头歌实践教学平台习题)【合集】
声明一个简单的Computer类,含有数据成员芯片(cpu)、内存(ram)、光驱(cdrom)等等,以及两个公有成员函数run、stop。只能在类的内部访问。这是一种数据隐藏的机制,用于保护类的数据不被外部随意修改。根据提示,在右侧编辑器补充代码,平台会对你编写的代码进行测试。成员可以在派生类(继承该类的子类)中访问。成员,在类的外部不能直接访问。可以在类的外部直接访问。为了完成本关任务,你需要掌握。
70 19
|
1月前
|
存储 编译器 数据安全/隐私保护
【C++面向对象——类与对象】CPU类(头歌实践教学平台习题)【合集】
声明一个CPU类,包含等级(rank)、频率(frequency)、电压(voltage)等属性,以及两个公有成员函数run、stop。根据提示,在右侧编辑器补充代码,平台会对你编写的代码进行测试。​ 相关知识 类的声明和使用。 类的声明和对象的声明。 构造函数和析构函数的执行。 一、类的声明和使用 1.类的声明基础 在C++中,类是创建对象的蓝图。类的声明定义了类的成员,包括数据成员(变量)和成员函数(方法)。一个简单的类声明示例如下: classMyClass{ public: int
51 13
|
3月前
|
存储 编译器 C++
【c++】类和对象(下)(取地址运算符重载、深究构造函数、类型转换、static修饰成员、友元、内部类、匿名对象)
本文介绍了C++中类和对象的高级特性,包括取地址运算符重载、构造函数的初始化列表、类型转换、static修饰成员、友元、内部类及匿名对象等内容。文章详细解释了每个概念的使用方法和注意事项,帮助读者深入了解C++面向对象编程的核心机制。
156 5
|
1月前
|
编译器 数据安全/隐私保护 C++
【C++面向对象——继承与派生】派生类的应用(头歌实践教学平台习题)【合集】
本实验旨在学习类的继承关系、不同继承方式下的访问控制及利用虚基类解决二义性问题。主要内容包括: 1. **类的继承关系基础概念**:介绍继承的定义及声明派生类的语法。 2. **不同继承方式下对基类成员的访问控制**:详细说明`public`、`private`和`protected`继承方式对基类成员的访问权限影响。 3. **利用虚基类解决二义性问题**:解释多继承中可能出现的二义性及其解决方案——虚基类。 实验任务要求从`people`类派生出`student`、`teacher`、`graduate`和`TA`类,添加特定属性并测试这些类的功能。最终通过创建教师和助教实例,验证代码
53 5
|
1月前
|
存储 算法 搜索推荐
【C++面向对象——群体类和群体数据的组织】实现含排序功能的数组类(头歌实践教学平台习题)【合集】
1. **相关排序和查找算法的原理**:介绍直接插入排序、直接选择排序、冒泡排序和顺序查找的基本原理及其实现代码。 2. **C++ 类与成员函数的定义**:讲解如何定义`Array`类,包括类的声明和实现,以及成员函数的定义与调用。 3. **数组作为类的成员变量的处理**:探讨内存管理和正确访问数组元素的方法,确保在类中正确使用动态分配的数组。 4. **函数参数传递与返回值处理**:解释排序和查找函数的参数传递方式及返回值处理,确保函数功能正确实现。 通过掌握这些知识,可以顺利地将排序和查找算法封装到`Array`类中,并进行测试验证。编程要求是在右侧编辑器补充代码以实现三种排序算法
41 5
|
1月前
|
Serverless 编译器 C++
【C++面向对象——类的多态性与虚函数】计算图像面积(头歌实践教学平台习题)【合集】
本任务要求设计一个矩形类、圆形类和图形基类,计算并输出相应图形面积。相关知识点包括纯虚函数和抽象类的使用。 **目录:** - 任务描述 - 相关知识 - 纯虚函数 - 特点 - 使用场景 - 作用 - 注意事项 - 相关概念对比 - 抽象类的使用 - 定义与概念 - 使用场景 - 编程要求 - 测试说明 - 通关代码 - 测试结果 **任务概述:** 1. **图形基类(Shape)**:包含纯虚函数 `void PrintArea()`。 2. **矩形类(Rectangle)**:继承 Shape 类,重写 `Print
48 4