原blog:《Effective Modern C++》学习记录(四):右值与移动语义 | RecursiveThoughts
1 左值 lvalue 与右值 rvalue
1.1 区分
-
右值对应着从函数返回的临时对象,左值对应着可以被引用reference的(通过名字、指针pointer或左值引用)。
-
能取到地址的,是左值;不能取到地址的,是右值。
-
拷贝右值的构造通常是移动构造,拷贝左值的构造通常是拷贝构造。
1.2 函数传参时
-
一旦参数进入函数内部,它们就获得了具体的名称和持久的存储位置,因此所有的参数都是左值。
-
即使传的参数是右值引用类型rvalue reference types,它本身在函数中也是一个左值。
1.3 实参arguments和形参parameters
int sum(int a, int b)
{
return a + b;
}
int main()
{
int x = 10;
int y = 20;
sum(x, y);
sum(10, 20);
}
其中,a
和b
是形参,x、y、10、20
是实参。形参是左值,而实参可能是左值,也可能是右值。
2 通用引用和右值引用
2.1 概念
右值引用:主要存在原因是为了识别可移动操作的对象。
通用引用:保留绑定对象的左值/右值和const性质。
因为通用引用是引用,所以它们必须被初始化。一个通用引用的初始值决定了它是代表了右值引用还是左值引用。如果初始值是一个右值,那么通用引用就会是对应的右值引用,如果初始值是一个左值,那么通用引用就会是一个左值引用。对那些是函数形参的通用引用来说,初始值在调用函数的时候被提供:
template<typename T>
void f(T&& param); //param是一个通用引用
Widget w;
f(w); //传递给函数f一个左值;param的类型
//将会是Widget&,即左值引用
f(std::move(w)); //传递给f一个右值;param的类型会是
//Widget&&,即右值引用
2.2 区别
对于通用引用,类型推导是必要的。
2.2.1 两种情况会出现通用引用
//函数模板形参
template<typename T>
void f(T&& param);
//auto声明符
auto&& var2 = var1;
2.2.2 标准的右值引用
void f(Widget&& param); //没有类型推导,
//param是一个右值引用
Widget&& var1 = Widget(); //没有类型推导,
//var1是一个右值引用
2.2.3 易被理解成通用引用的右值引用
- 虽然类型T会被推导,但param的类型声明不是
T&&
,而是std::vector<T>&&
,因此param是一个右值引用。
template <typename T>
void f(std::vector<T>&& param); //param是一个右值引用
//此时如果传递左值实参会报错
std::vector<int> v;
f(v);
push_back
函数在有一个特定的vector
实例之前不可能存在,而实例化vector
时,类型已经确定了,进而也决定了push_back
的声明。
template<class T, class Allocator = allocator<T>>
class vector
{
public:
void push_back(T&& x);
…
}
例如:实例化以下对象时,
std::vector<Widget> v;
std::vector
模板会被实例化为以下代码,因此push_back
这里的形参总会是右值引用而不是通用引用。
class vector<Widget, allocator<Widget>> {
public:
void push_back(Widget&& x); //右值引用
…
};
const T&&
和const auto&&
都是右值引用,因为通用引用必须能同时绑定左值和右值,而const
破坏了这一特性。
3 std::move
3.1 作用
仅仅是执行转换(cast)的函数模板,无条件的将它的实参转换为右值。
3.2 内部实现示例
并不满足标准细则,但接近
- C++11:
//在std命名空间
template<typename T>
typename remove_reference<T>::type&& move(T&& param)
{
using ReturnType = typename remove_reference<T>::type&&;
return static_cast<ReturnType>(param);
}
解析:
1、返回类型是typename remove_reference<T>::type&&
,代表一个去除引用修饰(因为引用折叠,所以要去除引用修饰),得到原始类型,再添加&&,使其成为右值引用的类型。
2、参数T&& param
是通用引用,可以接受任何类型的参数(左值或右值)。
3、用using
将typename remove_reference<T>::type&&
声明为ReturnType
4、最后用static_cast
将参数强制转换为右值引用。
- C++14:
//在std命名空间
template<typename T>
decltype(auto) move(T&& param)
{
using ReturnType = remove_referece_t<T>&&;
return static_cast<ReturnType>(param);
}
3.3 使用示例
用std::move
实现以下移动构造函数
class Widget {
public:
Widget(Widget&& rhs)
: s(std::move(rhs.s))
{
}
…
private:
std::string s;
};
3.4 注意
const右值作为构造函数的实参,会调用拷贝构造函数而不是移动构造函数。因为不允许const对象被传递给可以修改他们的函数(例如移动构造函数)
- 示例:
class Annotation {
public:
explicit Annotation(const std::string text)
:value(std::move(text)) //“移动”text到value里;这段代码执行起来
{ … } //并不是看起来那样
…
private:
std::string value;
};
上面的std::string value
构造时调用的是下面的拷贝构造函数而不是移动构造函数。
class string { //std::string事实上是
public: //std::basic_string<char>的类型别名
…
string(const string& rhs); //拷贝构造函数
string(string&& rhs); //移动构造函数
…
};
4 std::forward
4.1 作用
只对绑定了右值的引用进行右值转换。
4.2 使用
假设函数模板logAndProcess
用来将通用引用形参传递给函数process
处理。这里重载了两个process
函数版本分别处理左值和右值。
void process(const Widget& lvalArg); //处理左值
void process(Widget&& rvalArg); //处理右值
template<typename T>
void logAndProcess(T&& param)
{
process(std::forward<T>(param));
}
传入左值w
作为实参时,形参param会被类型推导为Widget&
(左值引用),std::forward
也会返回左值引用,所以会调用process(const Widget& lvalArg);
。
传入std::move(w)
作为实参时,传入的实参类型是Widget&&
,T
会被推导为Widget
,param
会被推导为Widget&&
类型。此时,param
本身是左值,但它的类型是Widget&&
(右值引用)类型。std::forward
也会返回右值引用类型,所以会调用void process(Widget&& rvalArg);
。
Widget w;
logAndProcess(w); //用左值调用
logAndProcess(std::move(w)); //用右值调用
5 std::move和std::forward的使用场景
5.1 右值引用-std::move,通用引用-std::forward
- 当把右值引用转发给其他函数时,右值引用应该无条件转换为右值(通过
std::move
)
class Widget {
public:
Widget(Widget&& rhs) //rhs是右值引用
: name(std::move(rhs.name)),
p(std::move(rhs.p))
{ … }
…
private:
std::string name;
std::shared_ptr<SomeDataStructure> p;
};
- 当转发通用引用时,通用应用应该有条件的转换为右值(通过
std::forward
)
class Widget {
public:
template<typename T>
void setName(T&& newName) //newName是通用引用
{ name = std::forward<T>(newName); }
…
};
-
当转发右值引用时使用
std::forward
可能会导致的问题:由于需要传递一个模板实参,就会有更多的犯错的可能,如果模板实参传递
std::string&
会导致rhs.s
转发为左值,进而导致成员变量std::string s
被复制而不是被移动构造。
class Widget{
public:
Widget(Widget&& rhs) //不自然,不合理的实现
: s(std::forward<std::string>(rhs.s))
{
}
…
}
-
当转发通用引用时使用
std::move
可能会导致的问题——意外改变左值:一个局部变量左值
n
作为参数传入setName
后,被转发为一个右值,移动给了成员变量name
,此时n
变成了未定义的值。
class Widget {
public:
template<typename T>
void setName(T&& newName)
{ name = std::move(newName); }
…
private:
std::string name;
std::shared_ptr<SomeDataStructure> p;
};
std::string getWidgetName(); //工厂函数
Widget w;
auto n = getWidgetName(); //n是局部变量,一个左值
w.setName(n); //把左值n转为右值移动进w
… //现在n的值未知
5.2 在最后一次时使用
函数中多次使用绑定到右值引用或通用引用的对象,要确保在完成其他操作前,这个对象不会被移动。于是,在最后一次使用时使用std::move
和std::forward
。
template<typename T>
void setSignText(T&& text) //text是通用引用
{
get1(text); //使用text但是不改变它
get2(std::forward<T>(text)); //有条件的转换为右值
}
5.3 按值返回右值引用或通用引用形参时
这里返回一个右值引用的形参,优先通过std::move
将其转换为右值。
Matrix operator+(Matrix&& lhs, const Matrix& rhs) //按值返回
{
lhs += rhs;
return std::move(lhs); //移动lhs到返回值中
}
如果直接返回左值,会强制编译器拷贝它到返回值的内存空间。而将其转换为右值,如果Matrix
支持移动操作,就会提高代码效率;如果不支持,右值也可以被Matrix
的拷贝构造函数拷贝,不会出现问题。
Matrix operator+(Matrix&& lhs, const Matrix& rhs)
{
lhs += rhs;
return lhs; //拷贝lhs到返回值中
}
按值返回通用引用形参时,也是同样优先使用
std::forwad
返回。
5.4 避免使用的情况
以下可能会抑制编译器优化,导致性能下降
Widget makeWidget() //makeWidget的移动版本
{
Widget w;
…
return std::move(w);
}
5.4.1 RVO
返回值优化(return value optimization,RVO)
作用:C++标准中已经实现了,只要满足RVO的条件,针对以下例子,编译器会避免拷贝局部变量w
,而直接在分配给函数返回值的内存中构造w
来实现。
RVO满足条件:返回一个局部对象,局部对象与函数返回值的类型相同
Widget makeWidget()
{
Widget w;
…
return w;
}
5.4.2 隐式移动
对于有些很难让编译器实现RVO的情况,比如控制路径返回不同局部变量时:
Widget makeWidget(bool flag) {
Widget w1, w2;
if (flag) return w1; // 可能无法优化
else return w2; // 因为编译器需在运行时决定构造哪个对象
}
或不满足RVO条件,按值返回函数形参:
Widget makeWidget(Widget w) //传值形参,与函数返回的类型相同
{
…
return w;
}
C++标准规定,如果一个函数返回一个局部变量或传值形参,若未RVO优化,则隐式将返回对象当做右值处理,也就是以上两段代码会被编译器看作:
Widget makeWidget(bool flag) {
Widget w1, w2;
if (flag) return std::move(w1); // 可能无法优化
else return std::move(w2); // 因为编译器需在运行时决定构造哪个对象
}
Widget makeWidget(Widget w)
{
…
return std::move(w);
}
6 通用引用与重载
6.1 避免在通用引用上重载
原因:通用引用几乎可以精准匹配任何类型的实参。
例1:(这里重载是为了传字符串走完美转发重载版本,传整型走int重载版本)传入short
,通用引用推导的类型是short
,因此匹配优先级高于int
,所以调用通用引用重载版本,进而出错。
//通用引用版本
template<typename T>
void logAndAdd(T&& name)
{
names.emplace(std::forward<T>(name));
}
//传入int的重载版本
std::string nameFromIdx(int idx); //返回idx对应的名字
void logAndAdd(int idx)
{
names.emplace(nameFromIdx(idx));
}
std::string petName("Darla");
logAndAdd(petName); //调用T&&重载版本
logAndAdd(std::string("Persephone")); //调用T&&重载版本
logAndAdd("Patty Dog"); //调用T&&重载版本
logAndAdd(22); //调用int重载版本
short nameIdx;
logAndAdd(nameIdx); //错误!
例2:(这里第二个构造函数希望去调用拷贝构造函数)手动添加构造函数重载或编译器生成也会出现一样问题,这里传p
不会调用拷贝构造,而会调用完美转发的构造函数,而完美转发构造函数内部需要的类型是string
而不是Person
,编译出错。
class Person {
public:
template<typename T> //完美转发的构造函数
explicit Person(T&& n)
: name(std::forward<T>(n)) {}
explicit Person(int idx); //int的构造函数
Person(const Person& rhs); //拷贝构造函数(编译器生成)
Person(Person&& rhs); //移动构造函数(编译器生成)
};
Person p("Nancy");
Person copyP(p); //从p创建新Person;这通不过编译!
原因:Person p
匹配实例化后的完美转发构造函数更精准,所以会调用完美转发构造函数。如果是const Person p("Nancy")
,则两者一样会优先调用拷贝构造函数。
class Person {
public:
explicit Person(Person& n) //传入p实例化的完美转发构造函数
: name(std::forward<Person&>(n)) {}
Person(const Person& rhs); //拷贝构造函数(编译器生成的)
…
};
例3:甚至基类声明了完美转发构造函数,派生类实现自己的拷贝和移动构造函数时会调用基类的完美转发构造函数而不是基类的拷贝或者移动构造。因为实际派生类传递给基类构造函数的参数类型是SpecialPerson。
class SpecialPerson: public Person {
public:
SpecialPerson(const SpecialPerson& rhs) //拷贝构造函数,调用基类的
: Person(rhs) //完美转发构造函数!
{ … }
SpecialPerson(SpecialPerson&& rhs) //移动构造函数,调用基类的
: Person(std::move(rhs)) //完美转发构造函数!
{ … }
…
};
6.2 同时使用通用引用和重载的办法
6.2.1 标签分派tag dispatch
继续以前面的例子为例:封装一个函数logAndAddImpl
实现具体功能以及重载,如果std::is_integral<typename std::remove_reference<T>::type>()
接收到的参数是int
,则会调用下面第二个形参为std::true_type
的重载版本;反之则调用第二个形参为std::false_type
的重载版本。
template<typename T>
void logAndAdd(T&& name)
{
logAndAddImpl(
std::forward<T>(name),
//因为左值会被推导为T&,而int&不是整型类型(引用不是整型类型)
//因此需要去引用性
std::is_integral<typename std::remove_reference<T>::type>()
);
}
template<typename T>
void logAndAddImpl(T&& name, std::false_type)
{
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
std::string nameFromIdx(int idx);
void logAndAddImpl(int idx, std::true_type)
{
logAndAdd(nameFromIdx(idx));
}
6.2.2 std::enable_if
版本1:应对构造函数使用通用引用的问题
typename std::decay<T>::type
表示移除引用和const
或volatile
标识符的修饰。std::is_same
用来比较两种类型,此时配合std::decay
即Person
、Person&
、Person&&
、const Person
、volatile Person
、const volatile Person
等都和Person
一样。std::is_same<T1, T2>::value
获取bool值作为条件。typename = typename std::enabe_if<conditon>::type
指condition
为true
时才会启用这个模板。
class Person {
public:
template<
typename T,
typename = typename std::enable_if<
!std::is_same<Person, typename std::decay<T>::type>
::value>
::type>
explicit Person(T&& n);
…
};
版本2:用std::is_base_of
代替std::is_same
可以额外应对派生类拷贝和移动构造函数错误调用基类完美转发构造函数的问题。
class Person {
public:
template<
typename T,
typename = typename std::enable_if<
!std::is_base_of<Person, typename std::decay<T>::type>
::value>
::type>
explicit Person(T&& n);
…
};
版本3:除了区分拷贝和移动构造函数,还可以额外区分整型有参构造函数和完美转发构造函数
//C++14风格
class Person {
public:
template<
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person, std::decay_t<T>>::value
&&
!std::is_integral<std::remove_reference_t<T>>::value
>
>
explicit Person(T&& n) //对于std::strings和可转化为
: name(std::forward<T>(n)) //std::strings的实参的构造函数
{ … }
explicit Person(int idx) //对于整型实参的构造函数
: name(nameFromIdx(idx))
{ … }
… //拷贝、移动构造函数等
private:
std::string name;
};
7 可能失败的完美转发
完美转发标准模板:
template<typename... Ts>
void fwd(Ts&&... params) //接受任何实参
{
f(std::forward<Ts>(params)...); //转发给f
}
完美转发失败原因:编译器不能推导出fwd的一个或多个形参类型;编译器推导“错”了fwd的一个或者多个形参类型(将推导出的类型传入f和直接将原实参传入f表现出不一致的行为)。
7.1 花括号初始化
设定f声明为:
void f(const std::vector<int>& v) ;
直接用花括号初始化调用f会将std::initializer_list
与f形参关联,然后隐式转换为std::vector<int>
,因此可以。
用花括号初始化调用fwd,则不会形参关联,而是首先会执行类型推导。这时因为函数模板无法将花括号初始化推导为std::initializer_list
,因此错误。
f({1, 2, 3}); //可以
fwd({1, 2, 3}); //错误
解决办法:先使用auto推导类型(auto可以推导出花括号初始化的类型为std::initializer_list
),声明一个局部变量,再将局部变量传进转发函数。
auto il = {1, 2, 3};
fwd(il);
7.2 0或者NULL作为空指针
如果f声明的形参是一个指针,这时候fwd传入0
或NULL
则会推导为整型,与f声明的形参类型不同,因此错误。
解决办法:传入nullptr
而不是0
或NULL
。
7.3 没在类外定义的static const整型成员变量
7.3.1 常量传播
只有整型static const成员变量可以不在类外定义,只需在类内声明,此时编译器不会为其留存空间,直接将声明的值放入需要这个变量的地方。
class Widget {
public:
static const std::size_t MinVals = 28; //MinVal的声明
…
};
… //没有MinVals定义
std::vector<int> widgetData;
widgetData.reserve(Widget::MinVals); //使用MinVals
此时如果有地方有使用Widget::MinVals
的地址,就会出现链接错误。在类外提供它的定义则可解决这个问题。
7.3.2 失败原因
尽管代码中没有使用MinVals
的地址,但fwd的形参是通用引用就会被视为有取地址的操作,引发链接错误。同样可以在类外提供MinVals
的定义解决。
f(Widget::MinVals); //可以,视为“f(28)”
fwd(Widget::MinVals); //错误!不应该链接
7.4 将重载的函数作为参数传入转发函数中
设f被声明为:
void f(int pf(int));
设有重载函数,processVal
int processVal(int value);
int processVal(int value, int priority);
一般情况下将重载函数作为参数传入函数时,可以根据f声明的形参去匹配重载函数,但fwd没有声明具体的形参类型,所以会失败
f(processVal); //可以
解决办法:定义一个显示函数指针指向重载函数,将此显示函数指针传入fwd
int (*processValPtr)(int) = processVal;
fwd(processValPtr);
将函数模板作为参数传入转发函数中也有同样问题,解决办法同样是显示指定传入的函数模板的指针类型。
template<typename T>
T workOnVal(T param) //处理值的模板
{ … }
fwd(workOnVal); //错误!
fwd(static_cast<int (*)(int)>(workOnVal)); //正确