浅墨浓香

想要天亮进城,就得天黑赶路。

导航

第15课 完美转发(std::forward)

Posted on 2019-08-09 00:58  浅墨浓香  阅读(8793)  评论(7编辑  收藏  举报

一、理解引用折叠 

(一)引用折叠

  1. 在C++中,“引用的引用”是非法的。像auto& &rx = x;(注意两个&之间有空格)这种直接定义引用的引用是不合法的,但是编译器在通过类型别名或模板参数推导等语境中,会间接定义出“引用的引用”,这时引用会形成“折叠”。

  2. 引用折叠会发生在模板实例化、auto类型推导、创建和运用typedef和别名声明、以及decltype语境中

(二)引用折叠规则

  1. 两条规则

    (1)所有右值引用折叠到右值引用上仍然是一个右值引用。如X&& &&折叠为X&&。

    (2)所有的其他引用类型之间的折叠都将变成左值引用。如X& &, X& &&, X&& &折叠为X&。可见左值引用会传染,沾上一个左值引用就变左值引用了根本原因:在一处声明为左值,就说明该对象为持久对象,编译器就必须保证此对象可靠(左值)

  2. 利用引用折叠进行万能引用初始化类型推导

    (1)当万能引用(T&& param)绑定到左值时,由于万能引用也是一个引用,而左值只能绑定到左值引用。因此,T会被推导为T&类型。从而param的类型为T& &&,引用折叠后的类型为T&。

    (2)当万能引用(T&& param)绑定到右值时,同理,右值只能绑定到右值引用上,故T会被推导为T类型。从而param的类型就是T&&(右值引用)。

【编程实验】引用折叠

#include <iostream>

using namespace std;

class Widget{};

template<typename T>
void func(T&& param){}

//Widget工厂函数
Widget widgetFactory() 
{
    return Widget();
}

//类型别名
template<typename T>
class Foo
{
public:
    typedef T&& RvalueRefToT;
};

int main()
{
    int x = 0;
    int& rx = x;
    //auto& & r = x; //error,声明“引用的引用”是非法的!

    //1. 引用折叠发生的语境1——模板实例化
    Widget w1;
    func(w1); //w1为左值,T被推导为Widget&。代入得void func(Widget& && param);
              //引用折叠后得void func(Widget& param)

    func(widgetFactory()); //传入右值,T被推导为Widget,代入得void func(Widget&& param)
                           //注意这里没有发生引用的折叠。

    //2. 引用折叠发生的语境2——auto类型推导
    auto&& w2 = w1; //w1为左值auto被推导为Widget&,代入得Widget& && w2,折叠后为Widget& w2
    auto&& w3 = widgetFactory(); //函数返回Widget,为右值,auto被推导为Widget,代入得Widget w3

    //3. 引用折叠发生的语境3——tyedef和using
    Foo<int&> f1;  //T被推导为int&,代入得typedef int& && RvalueRefToT;折叠后为typedef int& RvalueRefToT

    //4. 引用折叠发生的语境3——decltype
    decltype(x)&& var1 = 10;  //由于x为int类型,代入得int&& rx。
    decltype(rx) && var2 = x; //由于rx为int&类型,代入得int& && var2,折叠后得int& var2

    return 0;
}
引用折叠示例代码

二、完美转发

(一)std::forward原型

//左值版本
template<typename T>
T&& forward(typename remove_reference<T>::type& param)
{
    return static_cast<T&&>(param); //可能会发生引用折叠!
}

//右值版本
template<typename T>
T&& forward(typename remove_reference<T>::type&& param)
{
    return static_cast<T&&>(param); 
}

(二)分析std::forward<T>实现条件转发的原理(以转发Widget类对象为例

 

  1. 当传递给func函数的实参类型左值Widget时,T被推导为Widget&类别。然后forward会实例化为std::forward<Widget&>,并返回Widget&(左值引用,根据定义是个左值!

  2. 而当传递给func函数的实参类型右值Widget时,T被推导为Widget。然后forward被实例化为std::forward<Widget>,并返回Widget&&(注意,匿名的右值引用是个右值!)

  3. 可见,std::forward会根据传递给func函数实参(注意,不是形参)的左/右值类型进行转发当传给func函数左值实参时,forward返回左值引用,并将该左值转发给process。而当传入func的实参为右值时,forward返回右值引用,并将该右值转发给process函数。

【编程实验】不完美转发和完美转发

#include <iostream>
using namespace std;

void print(const int& t)  //左值版本
{
    cout <<"void print(const int& t)" << endl;
}

void print(int&& t)     //右值版本
{
    cout << "void print(int&& t)" << endl;
}

template<typename T>
void testForward(T&& param)
{
    //不完美转发
    print(param);            //param为形参,是左值。调用void print(const int& t)
    print(std::move(param)); //转为右值。调用void print(int&& t)

    //完美转发
    print(std::forward<T>(param)); //只有这里才会根据传入param的实参类型的左右值进转发
}

int main()
{
    cout <<"-------------testForward(1)-------------" <<endl;
    testForward(1);    //传入右值

    cout <<"-------------testForward(x)-------------" << endl;
    int x = 0;
    testForward(x);    //传入左值

    return 0;
}
/*输出结果
-------------testForward(1)-------------
void print(const int& t)
void print(int&& t)
void print(int&& t)       //完美转发,这里转入的1为右值,调用右值版本的print
-------------testForward(x)-------------
void print(const int& t)
void print(int&& t)
void print(const int& t) //完美转发,这里转入的x为左值,调用左值版本的print
*/
不完美转发和完美转发示例代码

三、std::move和std::forward

(一)两者比较

  1. move和forward都是仅仅执行强制类型转换的函数。std::move无条件地将实参强制转换成右值。而std::forward则仅在某个特定条件满足时(传入func的实参是右值时)才执行强制转换

  2. std::move并不进行任何移动,std::forward也不进行任何转发。这两者在运行期都无所作为。它们不会生成任何可执行代码,连一个字节都不会生成。

(二)使用时机

  1. 针对右值引用的最后一次使用实施std::move,针对万能引用的最后一次使用实施std::forward

  2. 在按值返回的函数中,如果返回的是一个绑定到右值引用或万能引用的对象时,可以实施std::move或std::forward。因为如果原始对象是一个右值,它的值就应当被移动到返回值上,而如果是左值,就必须通过复制构造出副本作为返回值。

(三)返回值优化(RVO)

  1.两个前提条件

    (1)局部对象类型函数返回值类型相同

    (2)返回的就是局部对象本身(含局部对象或作为return 语句中的临时对象等)

  2. 注意事项

    (1)在RVO的前提条件被满足时,要么避免复制要么会自动地用std::move隐式实施于返回值

    (2)按值传递的函数形参,把它们作为函数返回值时,情况与返回值优化类似。编译器这里会选择第2种处理方案,即返回时将形参转为右值处理

    (3)如果局部变量有资格进行RVO优化,就不要把std::move或std::forward用在这些局部变量中。因为这可能会让返回值丧失优化的机会。

【编程实验】RVO优化和std::move、std::forward

#include <iostream>
#include <memory>
using namespace std;

//1. 针对右值引用实施std::move,针对万能引用实施std::forward
class Data{};

class Widget
{
    std::string name;
    std::shared_ptr<Data> ptr;
public:
    Widget() { cout <<"Widget()"<<endl; };

    //复制构造函数
    Widget(const Widget& w):name(w.name), ptr(w.ptr)
    {
        cout <<"Widget(const Widget& w)" << endl;
    }
    //针对右值引用使用std::move
    Widget(Widget&& rhs) noexcept: name(std::move(rhs.name)), ptr(std::move(rhs.ptr))
    {
        cout << "Widget(Widget&& rhs)" << endl;
    }

    //针对万能引用使用std::forward。
    //注意,这里使用万能引用来替代两个重载版本:void setName(const string&)和void setName(string&&)
    //好处就是当使用字符串字面量时,万能引用版本的效率更高。如w.setName("SantaClaus"),此时字符串会被
    //推导为const char(&)[11]类型,然后直接转给setName函数(可以避免先通过字量面构造临时string对象)。
    //并将该类型直接转给name的构造函数,节省了一个构造和释放临时对象的开销,效率更高。
    template<typename T>
    void setName(T&& newName)
    {
        if (newName != name) { //第1次使用newName
            name = std::forward<T>(newName); //针对万能引用的最后一次使用实施forward
        }
    }
};

//2. 按值返回函数
//2.1 按值返回的是一个绑定到右值引用的对象
class Complex 
{
    double x;
    double y;
public:
    Complex(double x =0, double y=0):x(x),y(y){}
    Complex& operator+=(const Complex& rhs) 
    {
        x += rhs.x;
        y += rhs.y;
        return *this;
    }
};

Complex operator+(Complex&& lhs, const Complex& rhs) //重载全局operator+
{
    lhs += rhs;
    return std::move(lhs); //由于lhs绑定到一个右值引用,这里可以移动到返回值上。
}

//2.2 按值返回一个绑定到万能引用的对象
template<typename T>
auto test(T&& t)
{
    return std::forward<T>(t); //由于t是一个万能引用对象。按值返回时实施std::forward
                               //如果原对象一是个右值,则被移动到返回值上。如果原对象
                               //是个左值,则会被拷贝到返回值上。
}

//3. RVO优化
//3.1 返回局部对象
Widget makeWidget()
{
    Widget w;

    return w;  //返回局部对象,满足RVO优化两个条件。为避免复制,会直接在返回值内存上创建w对象。
               //但如果改成return std::move(w)时,由于返回值类型不同(Widget右值引用,另一个是Widget)
               //会剥夺RVO优化的机会,就会先创建w局部对象,再移动给返回值,无形中增加一个移动操作。
               //对于这种满足RVO条件的,当某些情况下无法避免复制的(如多路返回),编译器仍会默认地对
               //将w转为右值,即return std::move(w),而无须用户显式std::move!!!
}

//3.2 按值形参作为返回值
Widget makeWidget(Widget w) //注意,形参w是按值传参的。
{
    //...

    return w; //这里虽然不满足RVO条件(w是形参,不是函数内的局部对象),但仍然会被编译器优化。
              //这里会默认地转换为右值,即return std::move(w)
}

int main()
{
    cout <<"1. 针对右值引用实施std::move,针对万能引用实施std::forward" << endl;
    Widget w;
    w.setName("SantaClaus");

    cout << "2. 按值返回时" << endl;
    auto t1 = test(w); 
    auto t2 = test(std::move(w));

    cout << "3. RVO优化" << endl;
    Widget w1 = makeWidget();   //按值返回局部对象(RVO)
    Widget w2 = makeWidget(w1); //按值返回按值形参对象

    return 0;
}
/*输出结果
1. 针对右值引用实施std::move,针对万能引用实施std::forward
Widget()
2. 按值返回时
Widget(const Widget& w)
Widget(Widget&& rhs)
3. RVO优化
Widget()
Widget(Widget&& rhs)
Widget(const Widget& w)
Widget(Widget&& rhs)
*/

四、完美转发失败的情形

(一)完美转发失败

  1. 完美转发不仅转发对象,还转发其类型、左右值特征以及是否带有const或volation等修饰词。而完美转发的失败,主要源于模板类型推导失败或推导的结果是错误的类型。

  2. 实例说明:假设转发的目标函数f,而转发函数为fwd(天然就应该是泛型)。函数如下:

template<typename… Ts>
void fwd(Ts&&… params)
{
     f(std::forward<Ts>(params)…);
}

f(expression);    //如果本语句执行了某操作
fwd(expression);  //而用同一实参调用fwd则会执行不同操作,则称完美转发失败。

(二)五种完美转发失败的情形

  1. 使用大括号初始化列表时

  (1)失败原因分析:由于转发函数是个模板函数,而在模板类型推导中,大括号初始不能自动被推导为std::initializer_list<T>

  (2)解决方案:先用auto声明一个局部变量,再将该局部变量传递给转发函数。

  2. 0和NULL用作空指针时

  (1)失败原因分析:0或NULL以空指针之名传递给模板时,类型推导的结果是整型而不是所希望的指针类型。

  (2)解决方案:传递nullptr,而非0或NULL。

  3. 仅声明static const 整型成员变量,而无其定义时。

  (1)失败原因分析:C++中常量一般是进入符号表的,只有对其取地址时才会实际分配内存。调用f函数时,其实参是直接从符号表中取值,此时不会发生问题。但当调用fwd时由于其形参是万能引用,而引用本质上是一个可解引用的指针。因此当传入fwd时会要求准备某块内存以供解引用出该变量出来但因其未定义,也就没有实际的内存空间, 编译时可能失败(取决于编译器和链接器的实现)。

  (2)解决方案:在类外定义该成员变量。注意这声变量在声明时一般会先给初始值。因此定义时无需也不能再重复指定初始值

  4. 使用重载函数名或模板函数名

  (1)失败原因分析:由于fwd是个模板函数,其形参没有任何关于类型的信息。当传入重载函数名或模板函数(代表许许多多的函数)时,就会导致fwd的形参不知绑定到哪个函数上。

  (2)解决方案:在调用fwd调用时手动为形参指定类型信息。

  5. 转发位域时

  (1)失败原因分析:位域是由机器字的若干任意部分组成的(如32位int的第3至5个比特),但这样的实体是无法直接取地址的。而fwd的形参是个引用,本质上就是指针,所以也没有办法创建指向任意比特的指针

  (2)解决方案:制作位域值的副本,并以该副本来调用转发函数。

【编程实验】完美转发失败的情形及解决方案

#include <iostream>
#include <vector>

using namespace std;

//1. 大括号初始化列表
void f(const std::vector<int>& v)
{
    cout << "void f(const std::vector<int> & v)" << endl;
}

//2. 0或NULL用作空指针时
void f(int x)
{
    cout << "void f(int x)" << endl;
}


//3. 仅声明static const的整型成员变量而无定义
class Widget
{
public:
    static const  std::size_t MinVals = 28; //仅声明,无定义(因为静态变量需在类外定义!)
};

//const std::size_t Widget::MinVals; //在类外定义,无须也不能重复指定初始值。

//4. 使用重载函数名或模板函数名
int f(int(*pf)(int))
{
    cout <<"int f(int(*pf)(int))" << endl;
    return 0;
}

int processVal(int value) { return 0; }
int processVal(int value, int priority) { return 0; }

//5.位域
struct IPv4Header
{
    std::uint32_t version : 4,
                  IHL : 4,
                  DSCP : 6,
                  ECN : 2,
                  totalLength : 16;
    //...
};

template<typename T>
T workOnVal(T param)  //函数模板,代表许许多多的函数。
{
    return param;
}

//用于测试的转发函数
template<typename ...Ts>
void fwd(Ts&& ... param)  //转发函数
{
    f(std::forward<Ts>(param)...);  //目标函数
}

int main()
{
    cout <<"-------------------1. 大括号初始化列表---------------------" << endl;    
    //1.1 用同一实参分别调用f和fwd函数
    f({ 1, 2, 3 });  //{1, 2, 3}会被隐式转换为std::vector<int>
    //fwd({ 1, 2, 3 }); //编译失败。由于fwd是个函数模板,而模板推导时{}不能自动被推导为std:;initializer_list<T>
    //1.2 解决方案
    auto il = { 1,2,3 };
    fwd(il);

    cout << "-------------------2. 0或NULL用作空指针-------------------" << endl;
    //2.1 用同一实参分别调用f和fwd函数
    f(NULL);   //调用void f(int)函数,
    fwd(NULL); //NULL被推导为int,仍调用void f(int)函数
    //2.2 解决方案:使用nullptr
    f(nullptr);  //匹配int f(int(*pf)(int))
    fwd(nullptr);

    cout << "-------3. 仅声明static const的整型成员变量而无定义--------" << endl;
    //3.1 用同一实参分别调用f和fwd函数
    f(Widget::MinVals);   //调用void f(int)函数。实参从符号表中取得,编译成功!
    fwd(Widget::MinVals); //fwd的形参是引用,而引用的本质是指针,但fwd使用到该实参时需要解引用
                          //这里会因没有为MinVals分配内存而出现编译失败(取决于编译器和链接器)
    //3.2 解决方案:在类外定义该变量

    cout << "-------------4. 使用重载函数名或模板函数名---------------" << endl;
    //4.1 用同一实参分别调用f和fwd函数
    f(processVal);   //ok,由于f形参为int(*pf)(int),带有类型信息,会匹配int processVal(int value)
    //fwd(processVal); //error,fwd的形参不带任何类型信息,不知该匹配哪个processVals重载函数。
    //fwd(workOnVal);  //error,workOnVal是个函数模板,代表许许多多的函数。这里不知绑定到哪个函数
    //4.2 解决方案:手动指定类型信息
    using ProcessFuncType = int(*)(int);
    ProcessFuncType processValPtr = processVal;
    fwd(processValPtr);
    fwd(static_cast<ProcessFuncType>(workOnVal));   //调用int f(int(*pf)(int))

    cout << "----------------------5. 转发位域时---------------------" << endl;
    //5.1 用同一实参分别调用f和fwd函数
    IPv4Header ip = {};
    f(ip.totalLength);  //调用void f(int)
    //fwd(ip.totalLength); //error,fwd形参是引用,由于位域是比特位组成。无法创建比特位的引用!
    //解决方案:创建位域的副本,并传给fwd
    auto length = static_cast<std::uint16_t>(ip.totalLength);
    fwd(length);
    
    return 0;
}
/*输出结果
-------------------1. 大括号初始化列表---------------------
void f(const std::vector<int> & v)
void f(const std::vector<int> & v)
-------------------2. 0或NULL用作空指针-------------------
void f(int x)
void f(int x)
int f(int(*pf)(int))
int f(int(*pf)(int))
-------3. 仅声明static const的整型成员变量而无定义--------
void f(int x)
void f(int x)
-------------4. 使用重载函数名或模板函数名---------------
int f(int(*pf)(int))
int f(int(*pf)(int))
int f(int(*pf)(int))
----------------------5. 转发位域时---------------------
void f(int x)
void f(int x)
*/