C++中不要随便返回对象的引用

简介: C++中不要随便返回对象的引用

1.问题的引入


正如前面提到的那样,我们领悟了传值方式在效率层面上的缺点。因此,我们就会一心一意根除传值方式带来的种种问题。在坚定一味追求传引用方式的过程中,我们也一定会犯一个致命错误:开始传递一些绑定在其实不存在的对象身上的引用。下面的代码段中有一个有理数类Rational,它内部含有一个函数用于计算两个有理数的乘积。如下所示:


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


上面的代码段中,opeator*按值返回一个常量,于是就必定会在调用处生成本地拷贝,那么我们可不可以通过返回一个引用来避免拷贝带来的高成本呢? 就像下面这样:


friend const Rational& operator* (const Rational& lhs, const Rational& rhs);


想法很好,可是在应用中是不现实的。既然引用必须指向一个已存在的对象,那么我们就必须自己创建一个对象,让我们返回的引用来指向这个对象。创建对象有两种方式:从堆(heap)上创建和从栈(stack)上创建


2.从栈上创建对象


先,返回引用是希望节省拷贝的成本。可是,下面的函数oprerator*第一行就生成了拷贝。其次,这个引用指向result,但result是函数oprerator*的局部变量(local 变量)。局部变量会在函数返回时自动销毁,那么这个返回的引用就成了空引用,对其执行操作就会导致程序运行错误,这种错误同样适用于返回指向本地变量的指针。


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


这样的代码会在编译时发出警告:


38.png


3.在堆上创建对象


下面,我们考虑在堆内构造一个对象,并返回引用指向它。堆内创建对象由new创建,如下面的代码段所示:


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


首先,还是不可避免要调用一次构造函数。其次,虽然可以成功编译,结果也是正确的。如下图所示:

37.png

但这样写一定会发生资源泄漏,因为没有代码负责删除掉这个指针。就算使用了智能指针或者别的资源管理策略来防止泄露,所获得数据也是坏数据,因为动态分配的局部对象会在离开函数后立即被销毁,甚至还来不及到达调用operator*代码的地方。


下面是使用智能指针实现的代码:


const Rational &operator*(const Rational &lhs, const Rational &rhs)
{
    shared_ptr<Rational> result(new Rational(lhs.n * rhs.n, lhs.d * rhs.d));
    return *result;
}


虽然成功通过编译,可是执行时输出如下结果,可以看到明显是错误的:


36.png


4.使用static对象


既然不管是堆还是栈上创建对象,每次访问这个函数都要造成一次构造函数调用,而我们的目标是节省这次调用,想一劳永逸,是不是想起来了静态变量?


const Rational &operator(const Rational &lhs, const Rational &rhs)
{
    static Rational result; // 创建一个静态对象
    result = .....;
    return result;
}


静态对象可能会带来多线程安全问题。可除此之外,我们再来看如下一段完全合理的代码:


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


结果如何? if条件语句的每次都会被计算为true,不管abcd取什么值。这是因为该条件语句等价于operator==(operator*(a,b),operator*(c,d))。所以当operator==被执行时,会产生两个对operator*的调用。但是,operator*只能返回指向同一个静态对象的引用,因此两边一定会相等。


5.正确的做法


正确的做法还是要让函数返回对象而非引用,因此我们的运算符定义应该至少等价于如下:


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


在传入参数时,我们可以使用引用传递。可是,在返回时除非确定真的要一个引用。例如在前面的文章确定对象使用前已先被初始化中用来解决不同编译单元的静态对象初始化问题或者重载输出运算符<<,还是要老老实实返回对象。虽然,这样当然会产生构造函数和析构函数的额外开销。可是从长期来看,这些开销对于一个程序的稳定运行可以说非常微小,倒不如在别的方面给程序优化一下。


同时许多C++编译器自带优化功能,生成的机器代码会在保证不影响可观测范围内结果的前提下提升效率,有时使用了优化,这些成本便可以忽略不计了。


6.总结


(1) 绝不要返回指向一个本地对象的指针或者引用,否则会造成资源泄漏和程序崩溃。

(2) 在面临返回引用还是对象的选择时,你的目标是要让程序能工作正常,因此除非明确希望返回引用,还是老老实实返回对象。至于其带来的成本,交给编译器来优化。


7.参考资料


[1]  https://zhuanlan.zhihu.com/p/79502926

相关文章
|
1月前
|
编译器 C++
C++之类与对象(完结撒花篇)(上)
C++之类与对象(完结撒花篇)(上)
36 0
|
14天前
|
存储 编译器 C++
【c++】类和对象(中)(构造函数、析构函数、拷贝构造、赋值重载)
本文深入探讨了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值重载。构造函数用于对象的初始化,析构函数用于对象销毁时的资源清理,拷贝构造函数用于对象的拷贝,赋值重载用于已存在对象的赋值。文章详细介绍了每个函数的特点、使用方法及注意事项,并提供了代码示例。这些默认成员函数确保了资源的正确管理和对象状态的维护。
46 4
|
15天前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
43 4
|
1月前
|
存储 编译器 对象存储
【C++打怪之路Lv5】-- 类和对象(下)
【C++打怪之路Lv5】-- 类和对象(下)
28 4
|
1月前
|
编译器 C语言 C++
【C++打怪之路Lv4】-- 类和对象(中)
【C++打怪之路Lv4】-- 类和对象(中)
25 4
|
1月前
|
存储 编译器 C++
【C++类和对象(下)】——我与C++的不解之缘(五)
【C++类和对象(下)】——我与C++的不解之缘(五)
|
1月前
|
编译器 C++
【C++类和对象(中)】—— 我与C++的不解之缘(四)
【C++类和对象(中)】—— 我与C++的不解之缘(四)
|
1月前
|
C++
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
54 1
|
1月前
|
编译器 C语言 C++
C++入门4——类与对象3-1(构造函数的类型转换和友元详解)
C++入门4——类与对象3-1(构造函数的类型转换和友元详解)
20 1
|
1月前
|
存储 编译器 C语言
【C++打怪之路Lv3】-- 类和对象(上)
【C++打怪之路Lv3】-- 类和对象(上)
17 0