《深入理解C++11》笔记–右值引用:移动语义和完美转发
上一篇:《深入理解C++11》笔记–构造函数
这篇文章介绍的了第三章中右值引用相关的内容。在介绍该内容之前,会对一些相关问题进行解释,便于理解后面的内容。 并且,提前说明,许多编译器会多拷贝构造和移动构造进行优化省略,这样就看不到拷贝构造和移动构造的过程,需要在编译器选项中设置-fno-elide-constructors来关闭优化。
指针成员和拷贝构造
当一个类中含有指针成员时,由于默认的拷贝构造函数只会进行浅拷贝,所以当我们写出一下代码时:
class Base{
public:
Base():data(new int(0)){}
//Base(const Base& base): data(base.data){} 默认的拷贝构造
~Base()
{
std::cout << "~Base()" << std::endl;
delete data;
}
void out()
{
std::cout << data << ":" << *data << std::endl;
}
private:
int* data;
};
int main()
{
Base base1;
{
Base base2(base1);
base1.out(); // address1:0
base2.out(); // address2:0
} // ~Base(),base2析构
base1.out(); // address1:未知
return 0;
}
由于base2只是拷贝了base1的指针,所以它们的指针地址是相同的,当base2析构之后,所指向的内存地址已经被释放,当base1再去调用的时候已经是野指针了,会造成未知后果。出现这种情况,我们只需要自己重新实现拷贝构造函数重新申请堆空间即可。
Base(const Base& base): data(new int(*base.data)){}
移动语义
上一部分说到重新实现拷贝构造,但是这样还会一些问题,比如下面的例子:
Base getBase()
{
return Base(); // 无参构造一次,临时变量拷贝构造一次
}
int main()
{
Base a = getBase(); // 拷贝构造第二次
return 0;
}
这样拷贝构造函数一共被调用了两次,申请空间又释放内存,效率比较低。我们可以通过在getBase传入引用来减少对内存的操作,但是有时候又希望能从返回值直接返回对象。C++11中新增了移动构造函数,可以避免这个问题:
Base(Base&& base): data(base.data){ base.data = nullptr; }
移动语义,即移动构造函数接受一个”右值引用”参数,可以暂时理解成临时变量的引用,在后面会进行说明。在移动构造函数中,将原来的指针成员指向nullptr,这样能够避免多次的申请释放。大家可能会有疑问:原来的指针指向nullptr之后,原来的类对象不就不能正常使用了么?这里就涉及到移动构造函数被触发的条件:用临时变量进行拷贝构造,才能触发移动构造。怎么养才会产生临时变量,我们需要先了解C++中的左值、右值和右值引用。
需要注意的是,编译器默认会生成移动构造和拷贝构造函数,但是如果我们自己实现了移动构造、拷贝构造函数、赋值构造函数、析构函数中的任意一个,那么编译器就不会生成默认的移动构造和拷贝构造函数。如果只实现拷贝构造函数,那么这个类只有拷贝语义;如果只实现了移动构造函数,那么这个类只有移动语义。当我们不知道一个类是否可移动时,可以借助模板类来判断:std::is_move_constructible<T>::value;
左值、右值和右值引用
典型情况下左值和右值可以通过赋值表达式中的位置进行判断,在等号左边的为左值、等号右边的为右值。下面的例子中,a就是左值,b + c就是右值。
a = b + c;
另外一个判别方法是:可以取地址、有名字的就是左值,否则就是右值。
&a就符合左值的条件,而&(b + c)是不行的。再细分,右值又分为将亡值(expiring value)和纯右值(pure value)。
纯右值主要有以下几种:
- 函数返回的非引用临时变量
- 运算表达式产生的临时变量值,比如上面的b + c
- 不和对象关联的字面量值,比如:1,‘a’,true
- 类型转换的返回值、lambda表达式
将亡值是C++11中新增的和右值引用有关的概念:将要被移动的对象,比如:函数返回值为T&&的返回对象、std::move的返回值(下面会说明)、类型转换为T&&的函数返回值(下面会说明)。
由于右值通常不具备名字,所以只能通过右值引用来找到它。比如:
T&& a = returnValue();
原来我们一般都是通过左值对象接收返回:
T b = returnValue();
这两种方式的区别在于,左值的方式会增加一次对象的构造和析构;而右值会延长returnValue返回的临时变量的生命周期,进而减少对象的构造和析构。但是需要注意,右值引用无法绑定任何左值。比如:
int a = 0;
int&& b = a; // 编译失败
右值引用可以绑定右值,普通的引用是否能够绑定呢?答案是可以,不过需要const进行修饰:
T& a = returnValue(); // 编译失败
const T& a = returnValue();
和右值引用一样,普通引用绑定了右值以后会延长生命周期,不过区别在于无法对引用内容进行修改。另外,也可以通过以下的几个模板类判断引用的类型:
std::is_lvalue_reference<std::string&&>::value; // 是否为左值引用
std::is_rvalue_reference<std::string&&>::value; // 是否为右值引用
std::is_reference<std::string&&>::value; // 是否为引用
std::move:强制转化成右值
std::move函数的作用是用来把左值强制转换成右值,和类型转换static_cast<T&&>(lvalue);
的作用类似。另外需要注意,通过这两种方式把左值转化成右值,左值不会被析构。std::move常常被用来在移动构造函数中,把成员转化成右值,以便于调用该成员的移动构造函数(该成员含有堆内存或者文件句柄等资源)。例如:
class Inherit{
public:
Inherit(Inherit&& in):base(std::move(in.base)){}
private:
Base base; // 该成员类中含所有堆内存
};
因为如果不使用std::move转化成右值,那么会调用成员Base的拷贝构造函数,导致文章开头时说的野指针问题。
还有一个用法是来实现高效的swap函数:
template<typename T>
void swap(T& a, T& b)
{
T temp(std::move(a));
a = std::move(b);
b = std::move(temp);
}
如果T支持移动语义,并且T中含有堆内存资源,这样全程都没有对资源进行多余的申请和释放。
完美转发
最后,终于要介绍完美转发了。完美转发是指:在函数模板中,完全按照模板参数的类型将参数传递给模板函数中调用的另一个函数,并且不产生额外开销。例如:
template<typename T>
void func(T t)
{
other_func(t);
}
该模板函数把t”转发”给了另一个函数,但是这种方法会在t传入的时候生成一个临时变量,会产生额外开销。再来看看另一种实现:
void other_func(int t){}
template<typename T>
void func(const T& t)
{
other_func(t);
}
我们用了常量左值,保证不会产生额外开销,但是又有一个问题,要是目标函数(other_func)不接受常量左值类型,那就会产生问题;或者使用常量左值,那将导致费非常量左值参数无法传入。那要如何解决呢?一种方法是重载多个接口,这样就会造成大量的代码冗余。
在C++11中,新增了引用折叠(reference collapsing)的规则,并结合新的模板推导规则来完成完美转发。比如:
typedef const int T;
typedef T& TR;
TR& tr = 1; // 在C++98中编译失败,C++11中编译通过
这是因为C++11会对引用类型进行折叠,TR&其实最终被认为是int&。具体规则如下,简单总结就是:一旦出现了左值引用定义,就认为是左值引用。
而模板的推导规则是:实参X是左值引用就被推导为X&,实参X是右值引用则被推导成X&&。了解了以上内容之后,我们可以这样实现模板函数:
template<typename T>
void func(T&& t)
{
other_func(static_cast<T&&>(t));
}
普通参数直接使用了移动语义,而如果入参是左值引用X&,那么推导出的结果就是:
void func(X& && t) // 等于X&
{
other_func(static_cast<X& &&>(t));
}
如果是右值引用,推导出的结果是:
void func(X&& && t) // 等于X&&
{
other_func(static_cast<X&& &&>(t));
}
这样,无论入参是什么类型模板都能接收,而且都会被正确的转发到对应类型的转发函数。而前面说过,static_cast<T&&>
就相当于std::move,那上面的static_cast<T&&>
都能替换成std::move,但C++11中还有一个std::forward专门用于转发(需要对forward制定基本类型),其实效果都是一样的,不过更便于区分使用场景。下面我们举一个完整的例子:
void other_func(int& a){std::cout << "int&" << std::endl;}
void other_func(int&& a){std::cout << "int&&" << std::endl;}
void other_func(const int& a){std::cout << "const int&" << std::endl;}
void other_func(const int&& a){std::cout << "const int&&" << std::endl;}
template<typename T>
void func(T&& t)
{
other_func(std::forward<T>(t));
}
int main()
{
int a = 0;
int b = 0;
const int c = 0;
const int d = 0;
func(a); // int&,非常量左值
func(std::move(b)); // int&&,非常量右值
func(c); // const int&,常量左值
func(std::move(d)); // const int&&,常量右值
return 0;
}
可以看到只定义了一个模板函数,所有的类型都被正确的转发了。除了书中讲到的内容,另外还发现了有意思的情况。当不实现某些转发函数时,会有意外的结果。例如不实现非常量左值的转发函数,那入参为非常量左值时会转发到常量左值对应的转发函数,其他的组合大家可以自己试试。我总结出来的规律是:
- 没有对应的非常量函数,就会调用常量函数
- 没有对应的右值引用,就会调用对应的左值引用