c++11完美转发
一、c++11的右值引用和移动语义
高级语言的初学者,一般都会遇到左值这个概念,左值可以简单理解成可以直接操作(比如赋值之类的),但是在c++11里提供了右值这个概念,右值是什么意思呢?在旧的c++版本中,经常会有这种现象,经常需要生成中间对象来进行对象的操作(比如压入向量或者链表等),然后再把这个中间操作销毁。这既浪费了时间,又浪费了精力。这时候儿右值这个概念就来了,不过右值这个概念经常要和移动语义std::move这个概念成对出现,因为要想使用右值,需要通过移动语义来操作它。比如std::vector::emplace_back这个函数,就提供了这种方式,所以一般在c++11中推荐使用这个函数来对向量进行操作。
移动语义其实从字面上就理解了,就是把浪费的中间对象重新移动到可以使用的地方,这样,就没有中间对象这一说了,只要把这个中间对象的句柄转向另外实际需要的地方就可以了(但是,一定要注意std::move并没有真正移动任何东西)。深究到编译器的内部,无论是左值还是右值,其实就是一个对象的地址罢了。使用移动语义的好处是说的学术一些,其实就是大大减少了“深拷贝”的次数。
右值引用使用两个&符号(&&)来表示,这个在前面提到过。这里举一个操作的简单例子:
说明:以下代码均在最新的vs2019上测试通过。
void Stand(int t)
{
std::cout << "call stand function!" << std::endl;
}
void LeftRef(int& t)
{
std::cout << "call non const LeftRef function!" << std::endl;
}
void LeftRef(const int& t)
{
std::cout << "call const LeftRef function!" << std::endl;
}
void RightRef(int&& t)
{
std::cout << "call non const RightRef function!" << std::endl;
}
void RightRef(const int&& t)
{
std::cout << "call const RightRef function" << std::endl;
}
void main()
{
int a = 0;
Stand(a);
RightRef(std::move(a));
}
同时,会发现上面的例程中,使用的std::move这个函数,可以说c++11推出了这个函数后,右值引用才算了真正被应用了起来:
template< class T >
typename std::remove_reference<T>::type&& move( T&& t ) noexcept;(C++11 起) (C++14 前)
template< class T > (C++14 起)
constexpr typename std::remove_reference<T>::type&& move( T&& t ) noexcept;
在c++11里还有一个讨巧的:
template< class T >
typename std::conditional<
!std::is_nothrow_move_constructible<T>::value && std::is_copy_constructible<T>::value,
const T&,
T&&
>::type move_if_noexcept(T& x) noexcept;(C++11 起) (C++14 前)
template< class T > (C++14 起)
constexpr typename std::conditional<
!std::is_nothrow_move_constructible<T>::value && std::is_copy_constructible<T>::value,
const T&,
T&&
>::type move_if_noexcept(T& x) noexcept;
这个函数是如果移动构造函数抛出异常就拿到左值引用,如果不抛出异常呢就拿到右值引用,这个可以可以用来做强异常的保证。
右值引用还有一个小的应用场景,大家一般都会忽略,但是有时候儿可能用得着,比如在内存操作中,可以在直接在内存中进行处理,不用专门再开辟新的缓冲区进行管理。这也是一个小的应用,因为右值引用也可以进行实际操作数据区的内容。
二、完美转发
在上面提到了向量中的 std::vector::emplace_back ,它的定义如下:
template< class... Args > (since C++11)
void emplace_back( Args&&... args ); (until C++17)
template< class... Args > (until C++17)
reference emplace_back( Args&&... args );
在实际的应用场景中,会经常有这种情况,看下面的例子:
//可以把注释去除,看看有没有惊喜
//void Test(int t)
//{
// std::cout << "call Test" << std::endl;
//}
void Test( int& t)
{
std::cout << "call Test ref" << std::endl;
}
void Test(const int& t)
{
std::cout << "call Test const ref" << std::endl;
}
void Test(int&& t)
{
std::cout << "call right ref" << std::endl;
}
void Test(const int&& t)
{
std::cout << "call const right ref" << std::endl;;
}
//C++标准里的转发引用
template<class T>
void std_forward(T&& t)
{
std::cout << "test dispatch!" << std::endl;
//Test(std::forward<T>(t));
Test(t);
}
int Forward()
{
//注意:如果非类型精确匹配,则注释的重载可以生效
//但是要注意右值的函数定义注释,否则仍然会有问题
//double a = 1.0;
//Test(a);
int a = 0;
std_forward(a);
const int b = 20;
std_forward(b);
std_forward(std::move(a));
system("pause");
return 0;
}
什么是完美转发,其实就是一个参数对象的准确传递的过程。它有一些规则,先看一下引用折叠的方式:
X& + R => X& (R代表右值)
X& + & => X&
X&& +R => X&&
X&& + & => X&
X& + && => X&
X&& + && => X&&
这个引用折叠其实就是当两种不同的类型遇到后,合并的规则,可以仔细考虑一下,为什么右值引用非常有用?
在c++中,存在这么一个问题,即如果参数数量和类型无法固定,那么怎么办?解决的方法有以下几种形式:
最初的方法是使用重载函数,把所有相关的参数数量和类型都搞一遍,这是最简单最暴力的解决方法,但是这样的代码一个是不优雅,另外一个是大量的逻辑重复的代码。
然后就是可以进一步,使用模板函数,这样可以大大降低函数数量,但是这里有一个问题,就是如何保证参数类型的完全一致。这在c++11以前是一个痛点。
最后就是c++11引入了完美转发(std::forward),完美的解决了这个问题。
看一下c++中如何定义的这个函数:
template< class T >
T&& forward( typename std::remove_reference<T>::type& t ) noexcept;(1) (C++11 起) (C++14 前)
template< class T >
constexpr T&& forward( typename std::remove_reference<T>::type& t ) noexcept;(1) (C++14 起)
template< class T >
T&& forward( typename std::remove_reference<T>::type&& t ) noexcept;(2) (C++11 起) (C++14 前)
template< class T >
constexpr T&& forward( typename std::remove_reference<T>::type&& t ) noexcept;(2) (C++14 起)
转发后的规则,就是上面讲得折叠引用的规则。要注意例程里面的const的关键字,可以试着处理一下。你会得到不同惊喜。c++的标准里,特别提到了“转发引用”,这里就不展开了,以后在分析模板相关的内容时再说,如果有兴趣,可以去cppreference上去看看。
三、例程
说完了枯燥的规则,老样子,先看一个例程:
#include <string>
struct Data
{
int d = 0;
};
class ForwardSample
{
public:
//普通构造函数
ForwardSample(Data dt,std::string name):dt_(dt),name_(name)
{
};
//移动构造函数
ForwardSample(Data&&dt, std::string &&name) :dt_(std::move(dt)), name_(std::move(name))
{
};
~ForwardSample() {};
private:
Data dt_ ;
std::string name_ = "";
};
上面的简单的例子其实是一个小总结,看到是有两个参数,可实际可能会有更多,四五个总是有的。这时候儿怎么办呢?前面说过了,只能祭出模板大法,而不会傻乎乎的去写N个重载,当然,有的时候儿写N个重载犯一回傻也是好的。省得模板挖得坑不好填。
好,修改后的例程如下:
#include <string>
struct Data
{
int d = 0;
};
class ForwardSampleT
{
public:
//普通构造函数---注意注释的默认值
template <typename T,typename N = std::string>
ForwardSampleT(T && dt, N && name/*="w"*/)
:dt_(std::forward<T>(dt)), name_(std::forward<N>(name))
{
};
private:
Data dt_;
std::string name_ = "";
};
void Test()
{
Data da;
ForwardSampleT fs = { 0,"test" };
ForwardSampleT fs1 = { fs };
}
int main()
{
Test();
return 0;
}
这个程序跑起来是OK的。但是你不要被误导,把上面的“=“w””的注释打开,你就会收获一个惊喜“无法将参数 1 从“_Ty”转换为“const Data &” TestCopyElision …\ForwardSample.h 30 ”和“无法将参数 1 从“ForwardSampleT”转换为“const Data &” …\ForwardSample.h 30 ”。为什么呢?原因很简单,这是c++的函数匹配原则在捣鬼,如果没有默认值精确匹配是默认的拷贝构造函数,如果有了默认值,就直接找写好的模板构造函数了。
那么这个问题怎么解决呢?SFINAE(这个在c++0X时可非常有名气,当初自己还写了不少类似的代码,结果在c++11正式出来后,基本被覆盖,好伤心)和c++提供的萃取技术能解决这个问题。
class ForwardSampleT
{
public:
//普通构造函数
template <typename T, typename N = std::string,
typename = std::enable_if_t<std::is_convertible_v<T,Data>>>
ForwardSampleT(T && dt, N && name = "w")
:dt_(std::forward<T>(dt)), name_(std::forward<N>(name))
{
};
private:
Data dt_;
std::string name_ = "";
};
说明:这段代码在Ubuntu18.04中是编译不过去的,应该是版本不支持c++17
不过这里需要提到的是,这个需要c++17以上的版本编译器支持才行。std::enable_if_t其实就是使用了SFINAE,这里用来推导类型的情况。typename = std::enable_if_t这段代码其实是对参数的默认类型进行定义,模板的强大之处就在于此。这个可以和std::is_same_v类比的看。有兴趣的可以查查二者的不同。
通过改造后,最终的程序可以运行了,而且即使在继承等复杂的情况下,仍然是OK的。
四、总结
通过上述的分析可以清楚的看到,新的c++标准强大到了令人惊讶的地步,当真正的c++20推出时,会是什么样子呢?不过完美转发,确实是解决了c++一直存在的一些难题,精确的类型类型匹配是程序的安全的一个重要前提。写得代码多了,你自然就清楚它的重要性。