0%

[Effective C++ 笔记]条款 21. 必须返回对象时,不要试图返回其引用

简介

  • 绝不在函数中返回一个指向局部变量的引用或指针
  • 不要在函数中返回一个动态分配的对象
  • 不要在有可能多次调用的函数中返回一个局部静态变量

引言

在了解引用传递比值传递更高效之后,我们有可能会尽量在各种地方使用引用传递,甚至在一些不应该使用引用传递的地方也如此。例如考虑以下例子:(代码见 ex1

1
2
3
4
5
6
7
8
9
10
11
class Rational {
public:
Rational(int nummerator = 0, int denominator = 1)
: n(nummerator), d(denominator) {}

private:
int n, d;
friend Rational operator*(const Rational& lhs, const Rational& rhs) {
return Rational(lhs.n * rhs.n, lhs.d * rhs.d);
}
};

这是一个比较简单的用来表示有理数的类,他的 operator* 返回的是一个值而不是引用。由于有人可能会考虑值返回需要额外的构造和析构成本因此想通过引用传递来避免。这种思路是好的,但我们必须时刻记住,引用只是一个别称,一个既有对象的别名,因此我们在使用引用的时候,一定要时刻清楚它所代表的对象(该对象的另一个名称)是什么,在这个例子中,如果我们返回一个引用,我们必须清楚他原先代表的 Rational 对象是什么。下面列出几种错误/不好的引用传递做法。

错误使用引用传递例子

传递栈空间构造的局部变量的引用

首先可能大部分人会犯的错误是直接返回局部变量的引用,例如:(代码见 ex2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Rational {
public:
Rational(int nummerator = 0, int denominator = 1)
: n(nummerator), d(denominator) {}

void print() { std::cout << n << "/" << d << std::endl; }

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

在这种情况下,相对于我们上面正确的代码其实不会起节省开销的作用,因为 result 的构造同样需要成本。并且 result 是一个局部变量,当这个函数返回时这个变量就已经被销毁了,因此我们返回的是一个无意义值(甚至更糟糕,因为连该局部变量的空间都已经被收回了)。事实上当我们尝试编译的时候,编译器会发出以下警告:

1
2
3
4
5
6
7
g++ main.cpp -o main.out -std=c++98
main.cpp: In function ‘const Rational& operator*(const Rational&, const Rational&)’:
main.cpp:18:16: warning: reference to local variable ‘result’ returned [-Wreturn-local-addr]
18 | return result;
| ^~~~~~
main.cpp:17:18: note: declared here
17 | Rational result(lhs.n * rhs.n, lhs.d * rhs.d);

而假如我们想对返回值进操作,哪怕仅仅就是将其赋值如下:

1
Rational c = a * b;

都会导致未定义行为,我的运行结果是程序直接崩溃退出,如下(因为访问了未经分配的空间):

1
2
$ ./main.out
[1] 14359 segmentation fault (core dumped) ./main.out

传递堆中生成变量的引用

有人可能考虑以下的情况,因此觉得既然局部变量会被销毁,那么就在堆空间里动态分配一个对象好了,如下:(代码见 ex3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Rational {
public:
Rational(int nummerator = 0, int denominator = 1)
: n(nummerator), d(denominator) {
std::cout << "Rational constructed.\n";
}

~Rational() { std::cout << "Rational destroyed.\n"; }

void print() { std::cout << n << "/" << d << std::endl; }

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

这里我们在构造和析构函数中分别加了输出语句方便我们判断每个变量的情况,显然这种方法很明显的问题是在堆中构造的变量的删除的归属权问题。假设用户在使用时没有看到内部实现的实现基本上是不会考虑到对一个返回变量进行删除的,而这会导致资源泄漏,如下所示:

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

运行结果为:

1
2
3
4
5
6
7
Rational constructed.
Rational constructed.
Rational constructed.
Rational constructed.
Rational destroyed.
Rational destroyed.
Rational destroyed.

构造函数被调用了四次(分别是 a, b, c 以及返回的 *result),但析构函数却只被调用了三次。因此这种方法同样是不可取的。(即便使用者看到了内部实现想从外部进行删除也是很麻烦的)

传递静态变量的引用

还有一部分人可能会想到通过静态变量节省多次构造函数的开销,如下:(代码见 ex4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Rational {
public:
Rational(int nummerator = 0, int denominator = 1)
: n(nummerator), d(denominator) {}

void print() { std::cout << n << "/" << d << std::endl; }

private:
int n, d;
friend const Rational& operator*(const Rational& lhs, const Rational& rhs) {
static Rational result;
result.n = lhs.n * rhs.n;
result.d = lhs.d * rhs.d;
return result;
}
};

这样看似确实避免了额外的开销(静态变量只会被构造一次),但同样会引起诸多问题,首先是多线程问题,这么保证不会线程之间的对这个函数的调用对其影响。此外还有一个很致命的问题,考虑以下例子:

1
2
3
4
5
6
7
8
9
10
Rational a(2, 1);
Rational b(3, 1);
Rational c(1, 1);
Rational d(2, 1);

if ((a * b) == (c * d)) {
std::cout << "2 * 3 = 1 * 2\n";
} else {
std::cout << "2 * 3 != 1 * 2\n";
}

这段代码运行结果为:

1
2
$ ./main.out
2 * 3 = 1 * 2

显然这种实现也是有问题的,其根本问题在于,在这段代码里,我们实际上是把 operator*() 中的 resultoperator*() 中的 result 比较,因此永远会判断相等!这就是我们一直强调的我们在使用引用的时候一定要时刻清楚该引用的另一个名字是什么。而在这里调用了两次 operator* 只是对 result 进行了两次更新,但最后值是只有一个的(在这个例子中是 2)。

结论

  • 绝不在函数中返回一个指向局部变量的引用或指针
  • 不要在函数中返回一个动态分配的对象
  • 不要在有可能多次调用的函数中返回一个局部静态变量

其他