1,前言
在修改基础库中的接口时,发现有很多地方都用到了std::move来转换返回值,比如下面这个代码:
std::string GetOsName() {
std::string ret;
return std::move(ret);
}
可能当初编写者的原意是减少一次std::string的拷贝构造,但是却忽略了编译器自身所带的返回值优化策略,导致画蛇添足。
在 C++11 之前,返回一个本地对象意味着这个对象会被拷贝,除非编译器发现可以做返回值优化(named return value optimization,或 NRVO),能把对象直接构造到调用者的栈上。
从 C++11 开始,返回值优化仍可以发生,但在没有返回值优化的情况下,编译器将试图把本地对象移动出去,而不是拷贝出去。这一行为不需要程序员手工用 std::move 进行干预——使用 std::move 对于移动行为没有帮助,反而会影响返回值优化。
而不同的编译模式(Debug或Release)下,编译器所采用的优化策略也不一样,下面深入介绍一下编译器的RVO (return value optimization) 和NRVO (named return value optimization) 。
2,RVO和NRVO
RVO即Return Value Optimization,是一种编译器优化技术,可以把通过函数返回创建的临时对象给”去掉”,然后可以达到少调用拷贝构造的操作。
NRVO和RVO功能类似,也是用作返回值优化,但是它们也有一些区别,看下面这段代码:
class Obj {
};
Obj test_nrvo()
{
Obj obj;
return obj;
}
Obj test_rvo()
{
return Obj();
}
简单来说,NRVO只是RVO的一种变体,我们使用时并不需要在的具体区分上花太多时间,只需知道它们的作用都是用于返回值优化就可以了。
接下来我们在gcc4.9.4的小端机器上进行演示。
我们先将上面的代码补充完整main.cc:
class Obj {
public:
Obj()
{
cout << "Obj()" << endl;
}
Obj(const Obj&)
{
cout << "Obj(const Obj&)"
<< endl;
}
~Obj()
{
cout << "~Obj()" << endl;
}
};
Obj test_nrvo()
{
Obj obj;
return obj;
}
Obj test_rvo()
{
return Obj();
}
int main()
{
cout << "*** 1 ***" << endl;
auto obj1 = test_nrvo();
cout << "*** 2 ***" << endl;
auto obj2 = test_rvo();
cout << "*** 3 ***" << endl;
return 0;
}
编译命令:g++ main.cc -std=c++11
输出结果为:
*** 1 ***
Obj()
*** 2 ***
Obj()
*** 3 ***
~Obj()
~Obj()
我们主要看《*** 3 ***》之上的输出,RVO和NRVO都只是调用了一次普通的构造函数,并没有想象中会调用拷贝构造函数,这是因此gcc编译器帮我们默认开启了RVO优化。
去掉RVO优化,再次编译:g++ main.cc -std=c++11 -fno-elide-constructors
输出结果为:
*** 1 ***
Obj()
Obj(const Obj&)
~Obj()
Obj(const Obj&)
~Obj()
*** 2 ***
Obj()
Obj(const Obj&)
~Obj()
Obj(const Obj&)
~Obj()
*** 3 ***
~Obj()
~Obj()
可见当类的成员变量较复杂,拷贝开销较大时,编译器的RVO优化可以帮助我们提高程序的运行速度。
但是当我们稍微修改一下代码,结果又会完全不同:
Obj test_case(int n)
{
Obj obj_1, obj_2;
if (n > 0)
return obj_1;
else
return obj_2;
}
int main()
{
cout << "*** 0 ***" << endl;
auto obj0 = test_case(1);
cout << "*** 1 ***" << endl;
auto obj1 = test_nrvo();
cout << "*** 2 ***" << endl;
auto obj2 = test_rvo();
cout << "*** 3 ***" << endl;
return 0;
}
输出结果为:
*** 0 ***
Obj()
Obj()
Obj(Obj &&)
~Obj()
~Obj()
*** 1 ***
Obj()
*** 2 ***
Obj()
*** 3 ***
我们发现竟然调用了拷贝构造函数,这跟上面说的RVO完全不一样,代码差别也仅仅只是多了一个if else。
这就要说一下RVO的实现原理了,在没有RVO优化的情况下,一个函数是会在自己的堆栈帧中为返回值分配空间的;
而在RVO优化开启的时候,函数共用了父堆栈帧来避免复制,当我们添加if else 分支时,编译器不知道要放置哪个返回值。
3,std::move VS RVO
std::move是c11引入的新语法,很多人误解了std::move的用法,导致在错误的情况下使用了它,我们先来看一下std::move的实现(gcc4.9.4):
template<typename _Tp>
constexpr typename std::remove_reference<_Tp>::type&&
move(_Tp&& __t) noexcept
{ return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }
std::move的关键作用是将入参转换为右值,它还指示编译器可以移动对象,而不移动任何东西。
移动的代价远远小于拷贝,但是比RVO高,它主要做两件事:
1,“窃取”所有的数据
2,“欺骗“我们所”窃取“的对象忘掉一切。
如果我们想指示编译器移动,可以定义移动构造函数和移动赋值运算符。比如在上面的代码中加上移动构造函数:
Obj(Obj &&)
{
cout << "Obj(Obj &&)"
<< endl;
}
那么std::move是否是一直有益的呢?
如果编译器可以做 RVO,那么 RVO。否则,编译器调用移动构造函数;
但是理想总是和现实背道而驰,我们看一下下面这个函数:
Obj test_move()
{
Obj obj;
return std::move(obj);
}
大家不妨先推演一下结果,是否和test_nrvo一样,仅仅只是调用了一次默认构造函数?
输出结果为:
Obj()
Obj(Obj &&)
~Obj()
出乎意料或者说意料之中,多调用了一次移动构造和析构,这是因为这个函数违背了RVO标准原则:
in a return statement in a function with a class return type, when the expression is the name of a non-volatile automatic object (other than a function or catch-clause parameter) with the same cv-unqualified type as the function return type, the copy/move operation can be omitted by constructing the automatic object directly into the function’s return value
在具有类返回类型的函数的 return 语句中,当表达式是具有与函数返回类型相同的 cv 非限定类型的非易失性自动对象(函数或 catch 子句参数除外)的名称时,通过将自动对象直接构造到函数的返回值中,可以省略复制/移动操作
也就是说,我们必须保证返回值语句的返回类型必须和函数的返回类型相同,。
通过上面std::move函数的定义,可以发现它的返回值是std::remove_reference<_Tp>::type&&,也就是Obj&&,与test_move函数返回值Obj不同,因此没有触发RVO。
因此我们再将函数test_move的返回值修改为Obj&&,再次执行输出为:
*** 3 ***
Obj()
~Obj()
Obj(Obj &&)
注意这里的输出顺序和上面的第一种方式不一样,很明显编译器执行了RVO优化。
当然,实际开发中不应采用这种方式,这是对本地局部对象的引用,仅仅只是为了演示效果。
4,std::move+RVO+std::string
回到引言中介绍的这个例子,我们以vs2013 release版为例,该环境下,编译器所采用的是NRVO优化策略,这会导致std::string分别被调用一次默认构造函数,以及移动构造函数。
而不使用std::move(ret),仅依靠编译器的优化,则只会调用一次默认构造函数,,错误的用法导致多调用了一次移动构造函数 + 析构函数,这会带来很大的额外开销嘛?
我们看一下vs2013实现的std::string源码:(仅截取部分)
basic_string(_Myt&& _Right) _NOEXCEPT
: _Mybase(_Right._Getal())
{
_Tidy();
_Assign_rv(_STD forward<_Myt>(_Right));
}
void _Assign_rv(_Myt&& _Right)
{
if (_Right._Myres < this->_BUF_SIZE)
_Traits::move(this->_Bx._Buf, _Right._Bx._Buf,
_Right._Mysize + 1);
else
{
this->_Getal().construct(&this->_Bx._Ptr, _Right._Bx._Ptr);
_Right._Bx._Ptr = pointer();
}
this->_Mysize = _Right._Mysize;
this->_Myres = _Right._Myres;
_Right._Tidy();
}
可以很明显看到vs2013中的std::string做了SSO优化,if分支就是简单的栈内存拷贝,不涉及到额外开销;
我们主要看else分支,对于长度大于_BUF_SIZE的字符串,会进行一次指针拷贝(第17行代码):
template<class _Ty,
class... _Types>
void construct(_Ty *_Ptr,
_Types&&... _Args)
{
_Mytraits::construct(*this, _Ptr,
_STD forward<_Types>(_Args)...);
}
template<class _Objty,
class... _Types>
static void construct(_Alloc& _Al, _Objty *_Ptr,
_Types&&... _Args)
{
_Al.construct(_Ptr, _STD forward<_Types>(_Args)...);
}
template<class _Objty,
class... _Types>
void construct(_Objty *_Ptr, _Types&&... _Args)
{
::new ((void *)_Ptr) _Objty(_STD forward<_Types>(_Args)...);
}
注意一下第三步中的new语法,它和我们常见的new操作并不相同,它只是简单的将第二个指针拷贝给第一个参数,并不涉及到内存malloc;
因此最终this→_Bx._Ptr和_Right._Bx._Ptr指向相同的地址,完成了移动构造。
因此,在编译器开启NRVO优化时,多调用一次std::move,对于std::string并不会带来较大的额外开销,但是还是可以避免调用的。
5,总结
1,目前的常用c++编译器都支持NRVO,C++11也已经把“允许编译器进行NRVO”写入了标准。经过测试,gcc编译器在debug和release模式下均支持NRVO,VS在debug模式下不支持NRVO,仅支持RVO,而在release模式下也支持NRVO;
2,std::move只是一个右值转换,它的代价低于拷贝构造,但是高于RVO,如果本地对象有资格获得RVO,就不要使用std::move;
3,对于复杂的函数,比如if分支返回函数值的情况,编译器无法使用RVO优化,此时可以考虑引入std::move。
|