Loading

C++ 11中的右值引用

左值引用

C++中,有一个C语言没有的概念叫做引用,也就是

int i = 10;
int& j = i;

所谓引用,可以理解成指针常量,及它的指向无法更改,在初始化时便被确定下来,但可以修改地址中的内容。指针与引用还是有差别的,但本文不予以说明,具体可以参考百度

const int* p1 = &a;		// 常量指针,p1与p2等价
int const* p2 = &b;		// 常量指针
int *const p3 = &c;		// 指针常量,引用

右值引用

int&& i = 10;

此即为右值引用,绑定了纯右值10,而非为引用的引用。右值引用在C++ 11中被加入,主要是为了解决两个问题

  1. 临时对象的非必要的拷贝操作(C++11后采用移动语义解决)
  2. 模板函数中的非完美转发(C++11后采用完美转发解决)

首先在C++ 11后,值分为左值,将亡值与纯右值三种,它们的关系如下

  • 左值
  • 右值
    • 纯右值:按值返回的函数的返回值,lambda表达式,运算表达式的结果等,纯右值在表达式结束后将会被销毁
    • 将亡值:std::move的返回值,函数返回T&&的值等,即将要被移动的值可以称作是将亡值

临时对象的非必要的拷贝操作

临时对象

临时对象便是上文所说到的纯右值了。不可见的匿名对象的临时对象通常出现在以下两种情况

  1. 传递函数参数时发生的隐式转换
  2. 函数按值返回时

传递函数参数时发生的隐式转换

int GetLength(const std::string& str);
char c[];
// 调用上面的函数
std::cout << GetLength(c) << std::endl;

应该一眼就能看出,在将c传入GetLength函数中时发生了一次隐式转换。具体的来说,编译器会生产出一个std::string的临时变量(暂且称作s1),然后调用s1的构造函数,以c为参数进行构造,然后再将s1作为函数参数传入GetLength中。也就是说GetLength中的str将绑定在这个临时变量s1上,直到函数返回时销毁。这种转换只会出现在函数参数以值传递或以常量引用(reference-to-const)传递上

int GetLength(std::string str);
int GetLength(const std::string& str);

那么为什么必须是reference-to-const而不是reference-to-non-const

int GetLength(std::string& str);
char c[];
// std::cout << GetLength(c) << std::endl;	//编译器报错,非const引用传递参数不能完成隐式转化

回忆一下值传递和引用传递,后者是可以在函数体内更改原对象的值的。对于隐式转换来说,参数str将会绑定在一个临时变量上,而我们在函数中做出的修改是在这个临时变量上的,与原对象没有任何关系,像是一头替罪羊。这就是C++中禁止reference-to-non-const参数产生临时变量的原因

函数返回值对象时

当函数返回一个值对象的时候,会创建一个该对象的拷贝,然后再将该值return回去,这种情况一般是无法避免的,但是现代编译器会采用返回值优化策略(Return Value Optimization)

MyClass GetMyClass() {
    MyClass temp = MyClass();
    return temp;
}
MyClass GetMyClass_Directly() {
    return MyClass();
}
auto a = GetMyClass_Directly();

// 题外话
// MyClass Myc1 = GetMyClass();		//对Myc1拷贝构造
// MyClass Myc2;
// Myc2 = GetMyClass();				//对Myc2拷贝赋值 

第一个函数会创建一个左值来接受一个临时变量,再将该左值返回,第二个函数则直接返回一个临时变量。作为一名合格的程序员,抛开RVO不谈,应该能很直观的看出第二个函数的性是高于第一个的,因为避免了一次拷贝构造函数以及析构函数的调用

以C++的角度来看,GetMyClass_Directly以值传递的方式返回了一个MyClass对象。也就是说,再return这行代码中,先调用了MyClass的构造函数,构建了一个临时对象(c1),然后再把c1拷贝到另一块临时对象c2上,这时函数将保存好t2的地址(存放在eax寄存器中),返回。返回后GetMyClass_Directly的栈区间被撤销(离开了作用域,此时t1的生命周期结束,被析构)。此时a接受到了t2的地址,根据地址找到了t2这个临时对象,接着利用t2进行拷贝构造,最后构造完成,再调用t2的析构函数将其销毁

// 调用构造函数构建c1
// 调用拷贝构造函数构建c2
// 调用析构函数销毁c1

// 调用拷贝构造函数构建a
// 调用析构函数销毁c2

// 调用析构函数销毁a

可以看得出来,c1c2这两个临时变量的存在是十分不必要的,他们为了构建a而被生成,在构建结束之后又被销毁。为什么不能直接把MyClass构建出来的对象直接交给a呢?这其实就是RVO正在做的事情,编译器在设法提高C++的性能

MyClass GetMyClass_Directly() {
    return MyClass();
}
auto a = GetMyClass_Directly();

首先编译器会在GetMyClass_Directly中 “偷偷” 添加一个引用传值,然后把a传进去(此时a的内存空间已经存在,但还没被开始构造,利用断点可以很清楚的看到这点),然后再用a去替换函数体中的临时对象,在函数体内部就完成了a的构造。

也就是说在我们现在的编译器中运行上面的代码,我们只会观察到一次构造函数的调用(函数内部的构建)和一次析构函数的调用(程序结束时的析构a

以上为RVO优化,还有一种叫做NRVO(Named Return Value Optimization,具名返回值优化)

MyClass GetMyClass() {
    MyClass temp = MyClass();
    return temp;
}
auto a = GetMyClass();

GetMyClass,编译器将使用NRVO策略。虽然t1已经无法避免(被我们使用左值temp接住了),但编译器会把自动把t2优化掉,优化的方法有两种,下面来看看具体的优化流程

  1. 第一种做法与RVO类似,也就是将即将被构造的a当作一个引用参数传入函数中去构造,以下是优化后的调用情况

    // 调用构造函数构建一个临时对象
    // 调用拷贝构造函数构造temp
    // 调用析构函数销毁临时对象
    // 调用析构函数销毁a
    
  2. 第二种涉及到汇编的层面,即就是在函数返回后,不把temp析构掉,而是直接把temp的地址存放到eax寄存器中,返回到GetMyClass的调用点上,a再用eax寄存器中的地址进行构造,构造结束之后再将temp析构。这里发现到temp已经超出了他的作用域,虽然GetMyClass这块栈已经失效,但是还没有其他内容去擦写这篇内存,所以说temp值实际上还有有效的。

    各位应该都听说过 “绝对不要返回局部变量的引用” 这一条款

    int& RF_test()
    {
        int a = 10;
        return a;
    }
    

    按照我们以往的了解,a在函数返回后将被销毁,引用返回后就得不到a本身了,将会得到不确定的结果

    int& a = RF_test();
    std::cout << a << std::endl;	// 10
    

    但是很奇怪的是,我们仍然能正确的在控制台得到输出。这是因为局部变量在栈空间中分配内存,函数返回时栈指针回退,此时尚可调用改内存上的值(对象虽然被销毁了,但是内存还在),而当主调函数继续调用其它被调函数时,栈指针上移,上一次函数调用所分配的空间会被本次调用覆盖,此时再引用原来的局部变量就会出现不可预见的结果。

    void NT_test() { int b = 100; }
    
    int& a = RF_test();
    NT_test();
    // 调用函数,使栈指针上移
    std::cout << a << std::endl;
    // 出现不确定结果
    

    总结:不要返回局部变量的引用(不管是左值引用还是右值引用)。编译器不会报错,但由于各种因素,会出现不确定的结果

在实际编程的时候,我们会发现编译器并不能保证所有的返回值都能够优化,比如

  1. 不同的返回路径上返回不同名的对象(比如if XXX 的时候返回x,else的时候返回y)
  2. 引入 EH 状态的多个返回路径(就算所有的路径上返回的都是同一个具名对象)
  3. 在内联asm语句中引用了返回的对象名
  4. ...

也就是说RVO,NRVO等方法也不能完全解决因为函数返回对象时导致的效率问题,直到C++11中出现了右值引用,令其特殊情况能用std::movestd::forward解决,具体后文中会讲到。在一般情况下,若局部对象可能适用于返回值优化,那么绝对不使用std::movestd::forward(两者的成本虽然低于拷贝,但仍高于RVO,且可能会 “帮倒忙” )

MyClass GetMyClass_Directly()  {  
    return MyClass();
}
auto&& a = GetMyClass_Directly();

如果我们这次采用右值引用来接受这个返回值,同时忽略RVO,有以下结果

// 调用构造函数构建临时对象c1
// 调用拷贝构造函数构建另一个临时对象c2
// 调用析构函数销毁临时对象c1
// a绑定到了c2

// 调用析构函数销毁c2

右值引用会延长右值(c2)的生命周期,直到程序结束时再销毁

临时对象到这里应该算是讲完了,右值引用除了绑定右值外,还可以用来实现移动语义

移动语义

移动语义可以理解为转换所有权。

在此之前需要明确一个定义

void f(Widget&& w);

很明显的,这是一个右值引用,但是需要注意的是,形参(w)永远是左值,即使它的类型是右值引用。

// 深拷贝构造函数
MyClass(const MyClass& Myc) : m_ptr(new int(*Myc.m_ptr)), m_str(Myc.m_str) {}

// 移动构造函数 实现了移动语义 它接受一个右值,将它的指针资源所有权转到自己类中的成员上,同时还调用std::string的移动构造函数
MyClass(MyClass&& Myc) : m_ptr(Myc.m_ptr), m_str(std::move(Myc.m_str)) { Myc.m_ptr=nullptr; }

如下代码,由于函数的返回值为右值,故会调用到移动构造函数。但若不实现移动语义,将会匹配到拷贝构造函数上(常量左值引用是个 “万能”的引用类型,可以接受左值、右值、常量左值和常量右值),导致不必要的拷贝操作

auto a = GetMyClass_Directly();

std::move

std::move本质上与移动语义(move语义)并没有什么联系,即使它叫做std::move,但是它并不会做出所谓的移动操作,它只做一件事:把实参强制转换成右值,然后返回一个右值引用

MyClass Myc;
auto a1 = Myc;		// 调用到拷贝构造函数
auto a2 = std::move(Myc);		// 调用到移动构造函数

这里可以看出,通过std::move,对Myc做出了强制型别转换,然后交给移动构造函数去移动。应该注意的是,Myc的所有权已经被转移,若此时再访问则将会出现不确定结果。所以我们应当确保该变量不再使用了,才能将它转移

还有一点是,我们应该避免std::move一个常量左值,因为在经历强制转换后,const属性并不会被去掉,而是会被转换成为一个常量右值引用,导致类成员(例如m_str)移动构造函数无法调用,使得最后仍然执行了拷贝构造函数

MyClass(const std::string text) : m_str(std::move(text)) {}		// 在构建m_str时还是调用到拷贝构造

小测试

struct ListNode
{
    ListNode() = default;
    ListNode(const ListNode& node) = default;
    explicit ListNode(string _data, ListNode* _next = nullptr) 
        : data(std::move(_data)), nextNode(_next) {}

    string data;
    ListNode* nextNode = nullptr;
};
void TestIteratorOperator()
{
    string str = "Jelly";
    ListNodeIterator i(new ListNode(str));
    cout << str << endl;
}

这里的测试函数会输出什么?会输出Jelly,原因在于

explicit ListNode(string _data, ListNode* _next = nullptr) : data(std::move(_data)), nextNode(_next) {}

这里在调用移动构造时会产生一份拷贝,然后移动语义移动的是这份拷贝

以上代码只用于测试,并无实际开发用途

模板函数中的非完美转发

通用引用(Universal Reference)

首先应明确两点

  1. 右值引用一定为T&&
  2. T&&不一定是右值引用,还可能是通用引用
template<typename T>
void func1(T&& param);		//通用引用

void func2(MyClass&& Myc);	// 右值引用
struct MoveStruct
{
    MoveStruct() { std::cout << "default struct" << std::endl; }
    MoveStruct(const MoveStruct& _ref) { std::cout << "copy struct" << std::endl; }
    MoveStruct(MoveStruct&& _move) noexcept { std::cout << "move struct" << std::endl; }
};

template<typename T>
struct TestForward
{
    T data;
    // 因为不存在类型推到 所以这其实是一个右值引用 而对右值引用使用std::forward是应该避免的行为
    TestForward(T&& _data) : data(std::forward<T>(_data)) {}
};

int main()
{
    MoveStruct m;
    // 编译出错 无法将左值绑定到右值引用
    // TestForward<MoveStruct> tf(m);
    
    TestForward<MoveStruct> tf(std::move(m));
}

正确做法是

template<typename T>
struct TestForward
{
    T data;

    // 通用引用
    template<typename U>
    TestForward(U&& _data) : data(std::forward<U>(_data)) {}
};

int main()
{
    MoveStruct m;
    TestForward<MoveStruct> tf1(m);
    TestForward<MoveStruct> tf2(MoveStruct());
}

不知道读者有没有注意到,以上的代码编译器只会输出一次default和一次ref。那我构建的匿名MoveStruct对象哪里去了?它不应该没移动进TestForward中吗?难道它被编译器优化掉了?

其实答案很简单,tf2是一个函数而不是一个对象,它的函数签名为TestForward<MoveStruct> tf2(MoveStruct(*)()),它接受一个以MoveStruct为返回值,参数为空的函数指针,同时返回一个TestForward<MoveStruct>

正确的做法应该是

MoveStruct m;
TestForward<MoveStruct> tf2(std::move(m));

话题回到通用引用上,关键在于区分T&&何时为通用引用何时为右值引用。当参数的类型为T&&格式时,且需要对T的类型进行推导,此即为通用引用,否则为右值引用。除了以上使用模板的例子,还有以下使用auto的情况

  MyClass Myc;
  MyClass&& Myc1 = MyClass();		//右值引用
//MyClass&& Myc2 = Myc;				//错误,无法绑定左值
auto&& Myc3 = Myc;				    //通用引用,被左值初始化,相当于MyClass& Myc3 = Myc;
auto&& Myc4 = MyClass();			//通用引用,被右值初始化,相当于MyClass&& Myc4 = MyClass();

总结一下

  1. 如果一个变量或参数的声明类型是T&&,并且需要推导出类型T, 为通用引用(且不能被const修饰)(不一定是templateauto也可以,因为auto也存在推导)

  2. 通用引用是需要初始化的,如果是左值来初始化,那就是左值引用,如果是右值来初始化,那就是右值引用

  3. 经过推导的T&&类型,所发生的相较于右值引用(&&)的变化,叫做引用折叠,或叫做引用坍缩(传统C++类型并不支持对一个引用类型继续引用)

    引用折叠的规则如下

    1. 所有右值引用折叠到右值引用上仍然是一个右值引用。(T&& && 变成 T&&

    2. 所有的其他引用类型之间的折叠都将变成左值引用。 (T& & 变成 T&T& && 变成 T&T&& & 变成 T&

std::forward

正如我们知道的,std::move并未进行任何移动,同样的std::forward也不进行任何转发,两者的区别为,std::move会无条件的将实参强制转换成为右值,而std::forward仅在特定情况下才实施这样的转换

首先我们应该了解到std::forward除了取用一个函数实参外,还需要一个模板类型实参,以用来得到其需要转换的类型

void processVal(const MyClass& Myc);
void processVal(MyClass&& Myc);

template<typename T>
void forwardVal(T&& param)
{
    //Some codes..
    processVal(std::forward<T>(param));
}

前文中提到的,形参param是一个左值,而若直接当作参数传递给processVal时,必然会调用到常量引用的重载版本,这就是所谓的非完美转发。而若调用到std::forward,通过模板实参Tstd::forward获取到param是通过何种类型完成初始化的。若调用到forwardVal时传入的为左值,则在调用processVal时仍为左值;若调用forwardVal时传入的为右值,则调用processVal时,std::forward会将param强制转换为右值,以调用到正确的重载版本。前文中提到的 “特定情况才实施转换” 便是这样的效果

以上即为通过std::forward实现的完美转发,同std::move与移动语义一样,应牢牢记住std::forward只是用于类型转换,它自身与转发并无联系

结合移动语义与完美转发,再加上一点通用引用和变参模板,我们可以实现这样的一个工厂函数

template<typename... Args>
MyClass* CreateIns(Args&&... arg) {
    return new MyClass(std::forward<Args>(arg)...);
}

完美转发使用场景

通过上面的例子,我们发现到std::move常出现在右值引用中,而std::forward则常出现在通用引用中,事实上,这正是Effective Modern C++中的一个条款

条款25:针对右值引用实时std::move,针对通用引用实施std::forward

如果我们偏偏不按照条款说的去做,在右值引用中使用std::forward

MyClass(MyClass&& Myc) : m_str(std::forward<std::string>(Myc.m_str)) {}
MyClass(MyClass&& Myc) : m_str(std::move(Myc.m_str)) {}
string name = "Mike";
// true
cout << is_same<decltype(std::forward<std::string>(name)), std::string&&>::value;

对比一下可以发现,虽然实现的效果相同,但是调用到std::forward的明显会更加麻烦,而且若程序员错误的使用了std::string&作为模板实参,会导致构建m_str调用到的是拷贝构造。总的来说,在右值引用中使用std::forward,不仅打得字变多了,还更容易出错

同样的来看看在通用引用中使用std::move

template<typename T>
void setName(T&& _name) {
    // 无论通用引用传进来什么,一律转换成右值
    name = std::move(_name);	// 假设该函数为类内成员函数,name是该类中的成员
}
string name = "Jelly";
// 函数模板支持自动推导 不需要显示指明类型
XX.setName(name);
// 此时name被置为 ""
std::cout << name << std::endl;

这是一份很糟糕的代码,若传入函数的是一个左值,虽然该函数成功调用,但是事后该左值将变成一个不确定的值,将不能够再被调用。所以正确的方法应该是这样

template<typename T>
void setName(T&& _name) {
    name = std::forward<T>(_name);
}

如果我们尝试着使用重载的方法实现以上代码

void setName(const std::string& _name) {
    name = _name;
}
void setName(std::string&& _name) {
    name = std::move(_name);
}

下方的代码虽然可行,但是首先我们需要编写并维护更多的代码,若有n个形参,我们将必须实现2^n个重载函数,而这个问题在通用引用方法中只需要书写一个变参模板即可解决(参考std::make_XXX的实现)。其次是当发生隐式转换的时候,效率会打折扣

const char* c = "Chen";
// 调用到右值引用的版本
w.setName(c);

因为这是const char*类型,而此类型将会隐式转换为一个std::string的临时变量,再将该变量进行移动赋值,最后再析构这个临时变量

手撕标准库

有了上文中的基础知识,下面我们再来手撕一遍。相信学习完这篇文章之后大家将对移动语义和完美转发有更深刻的认识

my_move

首先再次明确std::move的工作,它无视参数是左值或是右值,都会强制返回一个右值引用。因此我们需要一种方法,能看清参数的本质,提取出它的值类型

C++标准库中使用的是std::remove_reference,下面来手撕一遍

template<typename T>
struct remove_ref {
    using type = T;
};

template<typename T>
struct remove_ref<T&> {
    using type = T;
};

template<typename T>
struct remove_ref<T&&> {
    using type = T;
};

template<typename T>
using remove_ref_type = typename remove_ref<T>::type;

经过简单的类型萃取,就能从值类型,右值引用或者是左值引用中得出“值类型”。完成了这一步之后我们只需要对类型进行一次强转即可(static_cast

// 通用引用
template<typename T>
constexpr decltype(auto) my_move(T&& param) {
    return static_cast<remove_ref_type<T>&&>(param);
}

int main()
{
    int data = 10;
    // 测试代码
    std::cout << std::boolalpha << std::is_same<decltype(my_move(data)), int&&>::value << std::endl;
}

由此可见,std::move是根据通用引用实现的,然后无条件强转为右值

my_forward

首先再次明确std::forward的工作,std::forward<T>会根据<T>的类型,将参数强转为左值或右值。时常搭配通用引用,减少代码量

先来看看这个不是很正规的实现

class MyClass {};

template<typename T>
constexpr T&& my_forward(T&& param) {
    return static_cast<T&&>(param);
}

int main()
{
    MyClass m;
    decltype(auto) test1 = my_forward<MyClass&&>(std::move(m));
    decltype(auto) test2 = my_forward<MyClass&>(m);
    // true true
    std::cout << boolalpha << is_same<decltype(test1), decltype(std::forward<MyClass&&>(std::move(m)))>::value << std::endl;
    std::cout << boolalpha << is_same<decltype(test2), decltype(std::forward<MyClass&>(m))>::value << std::endl;
}

太棒了,成功的完成了显式左右值类型的转换,那么再看看在通用引用中的表现

class MyClass {};

template<typename T>
constexpr T&& my_forward(T&& param) {
    return static_cast<T&&>(param);
}

void test_left_right(const MyClass& _left) { std::cout << "left" << std::endl; }
void test_left_right(MyClass&& _right) { std::cout << "right" << std::endl; }

template<typename T>
void universal_ref(T&& param) {
    // 这里对my_forward的调用其实并不是通用引用 因为显式指明了T的类型
    test_left_right(my_forward<T>(param));
}

int main() 
{
    MyClass m;
    // universal_ref(std::move(m));
    // 输出 left
    universal_ref(m);
}

universal_ref<T>中,传入右值的版本似乎不能通过编译。原因是经过std::move之后调用的universal_ref<T>T被推导为光秃秃的MyClass,也就是说调用到了my_forward<MyClass>,而又因为函数参数一定是个左值,也就是说发生了以下这种调用

// 而此时我们用一个左值来调用这个函数 显然是错误的
constexpr MyClass&& my_forward(MyClass&& param);

这是一个矛盾的问题,我们尝试在通用引用中依靠非通用引用的“转发”来解决左右值传递的问题,而我们“转发”实现中识别左值右值却又依赖于通用引用

那么此时有两种解决方法,一种是针对右值版本来一次std::move,例如

void not_universal_ref(MyClass&& param) {
    test_left_right(my_forward<MyClass>(std::move(param)));
}
void not_universal_ref(MyClass& param) {
    test_left_right(my_forward<MyClass&>(param));
}
int main()
{
    MyClass m;
    not_universal_ref(std::move(m));
    not_universal_ref(m);
}

这种方式并没有使用到通用引用,而是直接进行了显式转换,因此根本没有解决问题。正确的做法是对my_forward下手,实现两个版本的重载

template<typename T>
constexpr T&& my_forward(T&& rifht_param) {
    return static_cast<T&&>(rifht_param);
}

template<typename T>
constexpr T&& my_forward(T& left_param) {
    return static_cast<T&&>(left_param);
}

void test_left_right(const MyClass& _left) { std::cout << "left" << std::endl; }
void test_left_right(MyClass&& _right) { std::cout << "right" << std::endl; }

template<typename T>
void universal_ref(T&& param) {
    test_left_right(my_forward<T>(param));
}

下面分析几种调用情况

  • universal_ref(std::move(m));
    

    调用到了右值版本的通用引用,universal_ref<T>中的T推导为MyClass,而又因为param作为形参是个左值,因此调用到左值版本的my_forward<MyClass>,然后进行static_cast<MyClass&&>强转,结果符合要求

  • universal_ref(m);
    

    调用到了左值版本的通用引用,universal_ref<T>中的T推导为MyClass&,而又因为形参左值,所以调用到左值版本的my_forward<MyClass&>,经过引用折叠MyClass& &&折叠为MyClass&static_cast<MyClass&>强转后结果不变,仍未左值,符合要求

  • decltype(auto) test1 = my_forward<MyClass&&>(std::move(m));	// 强转时运用到引用折叠 && T&& = T&&
    decltype(auto) test2 = my_forward<MyClass>(std::move(m));	// 强转时不需要引用折叠 && T = T&&
    

    显式指定类型来调用my_forward<T>,因为参数被std::move,因此调用到右值版本的my_forward<MyClass&&>,经过引用折叠MyClass&& &&变为MyClass&&,然后进行static_cast<MyClass&&>强转,符合要求

  • decltype(auto) test3 = my_forward<MyClass&>(m);
    

    显式指定类型来调用my_forward<T>,因为m是左值类型,因此调用到左值版本的my_forward<MyClass&>,经过引用折叠MyClass& &&变为MyClass&,然后static_cast<MyClass&>强转,结果不变仍为左值,符合要求

  • // 以下代码都会调用到my_forward(T& left_param)
    int data = 10;
    test_left_right(my_forward(data));	// right
    int& ref_data = data;
    test_left_right(my_forward(ref_data));	// right
    test_left_right((my_forward(std::ref(data))));	// left
    
    int&& right_data = 5;
    test_left_right(my_forward(x));	// right
    

    利用模板函数的自动推导来调用my_forward,结果出错,因此我们需要禁用模板函数的自动推导

最后的实现版本如下。额外的,在C++中,右值转换为左值是不允许的行为,因此可以加入静态断言预防错误

template<typename T>
inline constexpr bool is_lvalue_ref_value = false;

template<typename T>
inline constexpr bool is_lvalue_ref_value<T&> = true;

template<typename T>
constexpr T&& my_forward(std::remove_reference_t<T>&& rifht_param) {	
    // forward an lvalue as either an lvalue or an rvalue
    static_assert(!is_lvalue_ref_value<T>, "cant forward an rvalue as an lvalue");
    return static_cast<T&&>(rifht_param);
}

template<typename T>
constexpr T&& my_forward(std::remove_reference_t<T>& left_param) {	
    // forward an rvalue as an rvalue
    return static_cast<T&&>(left_param); 
}

在函数参数中使用std::remove_reference_t代替了引用折叠,两者都能实现一样的功能。但有了std::remove_reference_t后,模板函数便不再具有自动推导的功能,需要我们显式指定(自动推导的目的是为了推导出T,而此情境下发生了从std::remove_reference_t<T>推导出T的情况,即从结果推原因的逆向推导,这是不可能推导出来的)

补充

常见的不可拷贝只能移动的类型

std::threadstd::futurestd::unique_ptr

移动操作与指针复制

归根结底,移动操作为什么能比复制操作高效,本质上是因为在最底层的数据结构中,对指向堆上的指针进行了复制,然后将旧指针置空

尽管STL中的容器都实现了移动语义,但不见得所有容器的移动操作的消耗都如同指针复制那般低

// 对于std::array而言 它的数组并不是分配在堆上的
std::array<int, 1000> arr;
// 4000
std::cout << sizeof(arr) << std::endl;

尽管std::array的数据是直接存储在对象内的,但如果它存储的数据本身支持移动操作,那么移动此std::array的代价还是要比复制低很多的(假设存储了1000个可移动的元素,那么移动std::array的代价可能移动1000次该元素)

引用折叠与通用引用

template<typename T>
void universal_ref(T&& param);

在通用引用中,实参在传递给函数模板时,推导出来的模板形参会将实参是左值还是右值的信息编码到结果型别中,具体体现为(以int类型为例)

如果传递进的是左值,那么模板形参会将会是int&

void universal_ref(int& && param);
// 经过引用折叠后
void universal_ref(int& param);

如果传递进的是右值,那么模板形参会将会是int

void universal_ref(int&& param);
// 不需要引用折叠

auto类型的推导本质上和模板类型推导是同一套工作流程(除了大括号初始物)

int data = 20;
auto&& leftRefData = data;
// 推导被展开成
int& && leftRefData = data;
// 引用折叠后的结果为
int& leftRefData = data;
auto&& rightRefData = std::move(data);
// 推导被展开成
int && leftRefData = data;
// 不需要经过引用折叠
int&& leftRefData = data;

通用引用坑点及解决方案

通用引用与函数重载

正常情况下,应该避免在函数重载中使用通用引用

考虑以下场景,有一个全局数据结构nameSet用来记录用户的名字,为了避免函数调用过程中产生额外的拷贝,这个logAndAdd采用了完美转发的设计

std::multiset<std::string> nameSet;

template<typename T>
void logAndAdd(T&& name) {
    // Codes...
    nameSet.emplace(std::forward<T>(name));
}

但当需求发生变化,需要提供一个int类型的index作为函数重载时

void logAndAdd(int index) {
    // Codes...
    nameSet.emplace(nameFromIndex(index));
}

但是当遇到隐式转化的调用场景时,实际上会匹配到通用引用的版本

short index = 10;
// 不存在short到std::string的类型转化内 编译失败
logAndAdd(index);

在函数重载中使用通用引用,会让通用引用吸引走大部分原本能接受隐式转化的参数,从而令编译出错

通用引用与构造函数

当通用引用与构造函数结合在一起时,问题可能会更大。与上述例子类似,这里也采用了通用引用和完美转发来正确的转发参数

class Person {
    std::string name;
public:
    template<typename T>
    explicit Person(T&& n) : name(std::forward<T>(n)) {}

    explicit Person(int index) : name(fromIndex(index)) {}

    Person() = default;
    Person(const Person&) = default;
    Person(Person&&) = default;
};

但这份代码同样具有上文中提到的隐式转化匹配导致的编译出错的问题,而且还会额外导致拷贝构造函数失效(甚至会导致派生类调用基类移动或拷贝构造函数失效)

Person p;
// 编译出错
Person p1(p);
// 正常运行 调用到移动构造
Person p2(std::move(p));

对于Person p1(p)而言,形参p的类型是Person

  • 通用引用实例化出的函数签名是Person (Person& n)
  • 拷贝构造函数的函数签名是Person (const Person&)

因此对于形参而言,无const类型显然更合适,因此会匹配到通用引用的版本,进而导致name无法被构造,编译出错

对于Person p2(std::move(p))而言,形参p的类型是Person&&

  • 通用引用实例化出的函数签名是Person (Person&& n)
  • 拷贝构造函数的函数签名是Person (Person&&)

两个完全一致的函数签名,因此会优先匹配非模板的函数,调用正常

C++重载决议:若在函数调用时,一个模板实例化函数和一个非模板函数具备相等的匹配程度,则优先选用常规函数

解决方案:标签分发或SFINAE

使用标签分发

对于通用引用和函数重载所造成的难题,可以采用标签分发来解决,这本质上也是利用了函数重载的特性,不同的标签类型对应调用不同版本的重载函数,这是发生在编译期间的操作。这个实现由几个细节点

  • std::is_integral的基类是std::bool_constantstd::true_typestd::false_type的基类也是std::bool_constant,因此构建出来的对象可以视作标签,用于分发匹配给不同的特化
  • logAnddAdd传递进左值是,类型是T&,若要判断是否为整形首先需要去除引用符号。int&并不是整形类型
std::multiset<std::string> nameSet;

template<typename T>
void logAndAdd(T&& param) {
    logAndAddImpl(std::forward<T>(param), std::is_integral<std::remove_reference_t<T>>{});
}

template<typename T>
void logAndAddImpl(T&& name, std::true_type) {
    // Codes...
    nameSet.emplace(std::forward<T>(name));
}

template<typename T>
void logAndAddImpl(T&& index, std::false_type) {
    // Codes...
    nameSet.emplace(nameFromIndex(index));
}

使用SFINAE

对于通用引用与构造函数的例子,我们应明确要解决两个问题

  • 通用引用导致构造函数失效问题
  • 通用引用抢占隐式转化形参导致匹配错误问题
class Person {
    std::string name;
public:
    template<typename T, typename U = std::enable_if_t<
            !std::is_base_of_v<Person, std::decay_t<T>> && !std::is_integral_v<std::remove_reference_t<T>>>>
    explicit Person(T&& n) : name(std::forward<T>(n)) {}

    explicit Person(int index) : name(fromIndex(index)) {}

    Person() = default;
    Person(const Person&) = default;
    Person(Person&&) = default;
};

使用std::decay_t的目的是:对于传递进函数的Person,不管他是PersonPerson&Person&&还是携带constvolatile标识符,都应该当作Person类型处理——即应该调用到拷贝或移动函数

显式指定std::forward的类型

struct TestForwardStruct{};

void test_forward(TestForwardStruct& param) { std::cout << "left" << std::endl; }
void test_forward(TestForwardStruct&& param) { std::cout << "right" << std::endl; }

int main()
{
    TestForwardStruct m;
    // 底层是static_cast<TestForwardStruct&&> 输出right
    test_forward(std::forward<TestForwardStruct>(std::move(m)));
    // 底层是static_cast<TestForwardStruct&&> 输出right
    test_forward(std::forward<TestForwardStruct>(m));

    // 正确的写法
    // 底层是static_cast<TestForwardStruct&&> 输出right
    test_forward(std::forward<TestForwardStruct>(std::move(m)));
    // 底层是static_cast<TestForwardStruct& &&> 输出left
    test_forward(std::forward<TestForwardStruct&>(m));
}

所以说在显式指定类型时,若要返回右值,那么由于引用折叠的特性,std::forward<T>std::forward<T&&>都能完成工作;若要返回左值,那么应该使用std::forward<T&>

函数返回左值引用还是右值引用

对于局部变量,返回哪种都是违反了条款。正确的做法是按值返回,以利用编译器的RVO。函数返回值是纯右值,结果可直接用于移动语义

class MyClass{};
// 违反了 "不要返回临时变量的引用" 这一条款
MyClass&& create_instance() { return MyClass(); }
int main() {
    MyClass&& m = create_instance();
}

对于类成员,通常返回左值引用,以代表对函数返回值的修改将应用到类本身

std::forward转发失败的情况

std::forward转发失败,无非就是因为模板类型推导失败,或者推导结果错误,一般来说可以分为以下几种

  • 大括号初始物:对于{1, 2, 3}来说,它本身不具备类型,自然也不是std::initializer_list,因此推导会失败
  • NULLNULL其实是0,它会被推导成int,而不是我们想要的指针类型,即推导结果错误
  • static const:略
  • 位域:C++中一项规定:non-const-reference不得绑定到位域
posted @ 2020-10-26 00:39  _FeiFei  阅读(395)  评论(0编辑  收藏  举报