C++:关于函数返回的一件小事——是返回值还是返回引用?

Tue 07 August 2018 / In categories programming

C++, C++11

C语言中的指针的错误使用,是很多内存错误的根源。C++中引入了_引用(reference)_,来化解指针的毒。但是引用并不是一剂完全的解毒药,还有好多毒还不能解。

下面这个毒就是个例子:

int& f() {
  int a = 1;
  return a;
}

上面的代码,函数f()中返回了自身局部变量a的引用。这是相当危险的行为,当f()返回时,相应的栈上的内存消解了,局部变量也随之而亡。所以,使用f()的返回值是未定义的行为,轻则出现计算错误(获取到的值非预期值),重则出现程序错误(segmental fault)。

什么是为定义的行为?简单地说,C++把自己处理不了的情况都叫做为定义行为。未定义的行为越多,也就是在抽象完备性上存在漏洞,就会转化为编程时候的坑。

一个靠谱的编译器,会为上述行为提出一个告警:

main.cc:4:10: warning: reference to stack memory associated with local variable 'a' returned [-Wreturn-stack-address]
  return a;
         ^
1 warning generated.

我们或许可以得出一个简单的结论,返回函数局部变量的引用,是不对的。

即使是C++11新增的右值引用(rvalue reference)也是不行的。

所以,对于局部变量,我们要勇敢地返回它的值,即便它是一个很臃肿的局部变量……

返回值优化

如果我们返回的局部变量很臃肿,是一个很大的结构体,该怎么办?总的来说,有两个办法

  • 编译器帮你优化
  • 自己手动优化

先假设我们有一个很大的类,叫做Foo:

struct Foo {
  int i;
  // 假设这里有许许多多你看不见的属性
}

接着我们修改我们的f(),使他返回Foo

Foo f() {
  Foo a;
  a.i = 101;
  return a;
}

由于Foo很大,当我们使用f()的返回值,比如Foo foo = f();的时候,按照常理,f()的返回值会被赋予foo,这个过程可能会发生拷贝,导致性能下降。这时候靠谱的编译器会自动进行返回值优化,避免这个拷贝。这个聪明的动作叫做Return Value Optimization。这就是所谓的编译器自动优化。

那总有些情况,编译器是无法优化的,只好靠手动优化了。

使用const引用

我们可以把foo变成函数返回值的引用,比如:const Foo& foo = f();。由于是引用,所以就避免了拷贝。这里有几点需要注意。

第一,引用必须是静态的,也就是必须用const来修饰这个引用。这是因为函数的返回值属于右值,也就是(rvalue)。普通的引用是左值引用,也就是lvalue reference。左值引用不能指向右值引用,只有const的左值引用才能用来指向右值。

所谓右值,简单的说就是不能放在等号左边的值。比如这个表达式是非法的:f().i = 110;

本来f()的返回值是一个临时的变量,在它调用结束后,就应该销毁了。可是通过像这样const Foo& foo = f();,把临时的返回值赋值给一个const左值引用,f()返回值并不会立即销毁。这等于是在const引用的作用域内,延长了f()返回值的存活时间。

使用右值引用

C++11新增了一个引用类型,那就是右值引用(rvalue reference)。那什么是右值引用?这个似乎解释起来有点困难,顾名思义,右值引用是专门指向右值的引用(有点废话)。那什么是右值?前面说了,等号左边的是左值,那等号右边的是右值吧?好像也不对,因为一个变量也可以出现在等号右边,赋值给另外一个变量。好吧,到底什么是右值?更准确的说,不能放在等号左边的,就是右值。就像1234这种字面值,或者前面提到的函数f()的返回值,这些都是不能放到等号左边的。

C++11引入的右值引用,是为了作为补充,和既有的左值引用有所区别。另外右值引用是C++11的特性,所以编译的时候要加上-std=c++11呢。

右值引用的写法是&&,所以可以把const Foo& foo = f();改写成右值引用形式:const Foo&& foo = f();。这样做看起来好像没有多大差别!那再改一下,把const去掉:Foo&& foo = f();。这就是右值的好处,不加const就可以之间指向右值,而且可以对右值进行更改,比如:foo.i = 122;

再谈一下右值引用和左值引用的关系和差别:右值引用本身是一个左值,所以左值引用可以指向一个右值引用(晕菜了吧!),这意味着下面的写法是可行的:

int k = 10;
int&& i = 1;
i++; // 右值引用是左值,可以加加
i = k; // 右值引用是左值,可以重新复制

同时也因为着下面的写法是不可行的:

int&& i = 1;
int&& j = i;

原因是右值引用是左值,所以一个右值引用不能被另外一个右值引用所指向。

还是默念前面说过的那句话,能出现在等号左边的是左值,不能出现在等号右边的是右值。

偷天换日

上面说了这么多,你应该能看出来右值引用不是让你平时随便把玩的,它肩负着一个特殊的使命,那就是偷天换日。上面写到对于这个语句Foo foo = f();,编译器会自动优化,直接用f()的返回值当作foo。但是,在foo太复杂的情况,或者是编译器不够聪明的情况下,优化有可能不会进行。这时候默认的行是是调用Foo的拷贝构造函数,用f()返回的临时变量来拷贝构造 foo。我们的假设是Foo很复杂,所以这是个很大的工程,非常消耗性能。但这里有一点可以优化,也就是f()返回的是临时变量,不会有其他人使用了,何不将其的内容直接搬移过来使用,说不定可以节省许多在拷贝构造中所需要的内存分配和释放的操作。这个偷天换日的任务就交给右值引用了。

首先,偷天换日之前有一个准备工作要做,那就是定义一个搬移构造函数(move constructor,也叫移动构造函数)。和拷贝构造函数Foo(Foo&)不一样的是,搬移构造函数接受的是一个右值引用:Foo(Foo&&)。如果是从一个临时变量来构造新的Foo的话,编译器会优先调用搬移构造函数,来把临时对象开膛破肚,取出自己需要的东西。拷贝构造函数就不敢这么做,因为它不确定对象在其他地方有没有被使用,如果误操作了,恐怕会被人打。

std::move

C++11提供了std::move来废弃一个对象,也就是把它标识为临时变量,这样可以丢给搬移构造函数处理,比如下面的例子:

std::string x1 = "hello, world!";
std::string x2 = std::move(x1);
std::cout << "x1 = " << x1 << std::endl;
std::cout << "x2 = " << x2 << std::endl;

由于x1通过std::move被标识为临时对象,于是就被x2给开膛破肚,存储的内容被抢走了。上面的例子输出结果是:

x1 = 
x2 = hello, world!

C++11之后标准库中大部分容器都支持搬移构造,都可以被开膛破肚。std::arrary是少数的例外。

所以,C++11给了我们一个选择,对于函数的返回值,如果编译器没有办法自动优化,我们提供搬移构造函数给编译器,从而降低拷贝构造的成本。

小结

作为小结的话,我决定从How to use move semantics with std::string during function return? duplicate摘抄CodeAngry的Comments:

1. Return by const reference& if the A object will outlive the reference's scope and you need it readonly. 2. If the A object gets out of scope and/or you need to copy/modify it, return a value. 3. If you need to modify the original value return, while it stays in scope, reference&. --- Don't return reference then make a copy to modify it, always return value for write access. – CodeAngry Jul 14 '15 at 12:35 

其他参考链

Load Disqus Comments