第五章 右值引用、移动语义和完美转发

移动语义使得编译器可以将一些代价高昂的复制操作转移成移动操作。例如STL中的很多复制操作。移动构造函数和移动赋值运算符可以使用移动语义,创建只移对象成为可能,例如shared_ptr、unique_ptr和thread等等。

完美转发使得人们可以撰写接受任何实参的模板函数,并将其转发到其他函数,目标函数会接受到与转发函数所接受的完全相同的实例。

二十三 理解std::move和std::forward

首先最重要的一点就是:从字面意思来说:move是移动操作,forward是转发操作,但是事实上,move不进行任何移动,forward也不进行任何转发。这两者在运行期不做任何操作(调试也是进不去的),不会生成任何可执行代码。

那这两个函数是干什么的呢?

事实上,这两个函数仅仅是进行强制类型转换的函数(其实是函数模板)。move函数无条件将实参转换为右值,forward则在某个特定条件满足时才执行同一个强制转换。

接下来分析这两个函数的源代码:

template<typename _Tp>
typename std::remove_reference<_Tp>::type&& //C++11中的typedef
move(_Tp&& __t)
{ 
    return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); 
}
//C++14风格
template<typename _Tp>
decltype(auto) move(_Tp&& __t)
{
    using ReturnType = remove_reference_t<T>&&;
    return static_cast<ReturnType>(param);
}

这个模板中使用了一个模板——引用移除(remove_reference),定义于头文件 <type_traits> ,此模板出啊如一个类型T,若T为引用类型,则提供成员 typedef type ,其为T所引用的类型(即偏特化版本)。否则 type 为T 。(C++14中则使用using别名声明)

//C++11
template< class T > struct remove_reference      {typedef T type;};
template< class T > struct remove_reference<T&>  {typedef T type;}; //偏特化版本(左值)
template< class T > struct remove_reference<T&&> {typedef T type;}; //偏特化版本(右值)
//C++14
template< class T > using remove_reference_t = typename remove_reference<T>::type;

从上述代码我们可以知道,模板接受的无论是左值还是右值,都只返回T的类型,这是什么原因呢?

事实上,引用在内部的实现是一个常量指针。也就是说,C++编译器在编译过程中使用常量指针作为引用的内部实现,因此引用所占用的空间大小和指针相同。但是从使用的角度来说,引用只是一个别名。故C++是隐藏了引用的存储空间这个实现细节。

    int test = 3;
00362778  mov         dword ptr [test],3  //将3放入test的内存(ptr [test]代表访问test的内存地址)中
    int &tt = test;
0036277F  lea         eax,[test]  //将test的地址放入到寄存器中
00362782  mov         dword ptr [tt],eax  //将寄存器的值存入到tt的内存中

从这段汇编中可以得知:常量引用就是引用的内部实现机制。那对于右值引用呢?

    int &&ttr = 3;
01062785  mov         dword ptr [ebp-30h],3  //将3放入到局部变量中
0106278C  lea         eax,[ebp-30h]  //将局部变量的地址放入到寄存器中
0106278F  mov         dword ptr [ttr],eax  //将寄存器的值放入到ttr的内存中

上述的汇编展示的很明显,右值引用就是用一个局部变量去保存右值的值,然后继续用常量指针去指向它。那这个局部变量是什么呢?我们可以看到是[ebp-30h],中括号的意思不用多说,就是地址,ptr就是指向这个地址,也就是说,现在是把3存入到ebp-30h所在的地址处。ebp是基址寄存器,基址指针寄存器(extended base pointer)内存放一个指针,该指针指向系统栈最上面一个栈帧的底部。30h自然就是指十六进制的30。

到这里我们就非常清楚左值和右值引用的本质到底是什么了,也就对为什么remove_reference能轻松返回左值引用和右值引用所指向的部分感到不奇怪了。

继续分析move,move函数使用了模板参数类型推导,其形参的类型是万能引用。也就是说这个万能引用将把实参的类型完整传入,紧接着函数内部使用static_cast进行强制类型转换,无论是左值还是右值,通通变为右值。

template<typename _Tp>
    inline _Tp&&
    forward(typename std::remove_reference<_Tp>::type& __t) 
    { return static_cast<_Tp&&>(__t); }

template<typename _Tp>
    inline _Tp&&
    forward(typename std::remove_reference<_Tp>::type&& __t) 
    {
      static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument"
            " substituting _Tp is an lvalue reference type");
      return static_cast<_Tp&&>(__t);
    }

而对于forward来说,要做到完美转发。因为一个函数

int f(T&& a);

传入实参的时候,是一个右值,而当传入的值绑定到a的时候,这个值就变成了一个左值。这时候内部的函数就无法分辨传入的值是左值还是右值了。所以需要完美转发左右值到内部函数调用。

当传入的类型是左值引用的时候,返回左值引用,但传入的类型是右值引用的时候,返回右值引用。但是传入的类型如果是普通的T呢,这里也是会强转为右值类型的。也就是说,除了传入左值引用外,传入别的类型(右值引用,普通值类型)都返回右值引用。

int a = 3;
forward<int &&>(a); //返回右值
forward<int &>(a);  //返回左值
forward<int>(a);    //返回右值

示例如下:

#include<iostream>  
using namespace std;

struct X {};
void inner(const X&) { cout << "inner(const X&)" << endl; } //const左值引用
void inner(X&&) { cout << "inner(X&&)" << endl; }   //不涉及型别推导,是右值引用
template<typename T>
void outer(T&& t) { inner(forward<T>(t)); } //涉及型别推导,是万能引用

int main()
{
    int test = 3;
    int &tt = test;
    int &&ttr = 3;

    X a;
    outer(a);   //inner(const X&)
    X &&t = X();    //现在的t可以对其赋值,是一个左值
    outer(t);   //inner(const X&)
    outer(X());
    X t2;
    outer(move(t2));    //inner(X&&)
    inner(forward<X>(X())); //inner(X&&)
    X t3;
    inner(forward<X>(t3));  //inner(X&&)
    X t4;
    inner(forward<X&>(t4)); //inner(const X&)
    return 0;
}

要注意的一点是,在复制构造函数中,我们通常将参数写为const类型,因为对于实参,我们只需要去读取,而不用改写。如果恰好我们在复制构造函数中使用了按值传递,并且在内部对传入的实参进行move强转的话,这里实际上无法触发移动操作,而是继续以复制操作运行

因为实参拥有const属性的话,强转过来的右值引用也具有const属性。

class{
public:
    ecplicit A(string test) : value(move(text)) //仍旧是被复制进value
    {
        ...
    }
    ...
};

具有const属性的右值会去匹配string的构造函数:

class string{
public:
    string(const string& s);
    string(string&& s);
    ...
};

对于const类型的左值引用来说,其可以指向右值:const string str = "hello world";,但是移动构造函数只能接受非常量string型别的右值引用作为形参(将值移动会改变原来的值,而常量不允许被改变)。所以这里最终调用的是复制构造函数而非移动构造函数。也就是说,text的值是被复制进value的。

从上例中,可以得知,如果想取得对某个对象执行移动操作的能力,则不要将其声明为常量。因为针对常量对象执行的已从操作会变为复制操作,其次,move不移动任何东西,也不能保证经过其强制型别转换的对象具备可移动能力。唯一确定的是经过move移动的结果必然是右值(但可能由const属性)。

理论上来说forward包含了move的功能,可以替代move,但是事实上,move的书写更加简洁,目的性也更强:无条件的转换为右值。也就是说move想要传达的意思是无条件的向右值型别的强制型别转换。而forward则想说明仅仅对绑定到右值的引用实施向右值型别的强制型别转换

二十四 区分万能引用和右值引用

一般情况下,涉及到型别推导的T&&类型是万能引用,不涉及到型别推导的就是右值引用。

void f(Widget&& param); //右值引用
template <typename T>
void f(T&& param);  //万能引用

万能引用可以绑定到左值引用,右值引用,const或者非const对象,volatile或者非volatile对象。

万能引用代表的是什么类型,取决于初始化形参的实参类型,当传入左值的时候,万能引用代表左值类型,若传入右值则代表右值类型。

万能引用对格式要求比较严格,必须是T&&类型的型别推导。

template<typename T>
void f(vector<T>&& param);  //右值引用,类型不是T&&
template<typename T>
void f(const T&& param);    //右值引用,存在const饰词

但是如果是存在类中的型别推导成员函数的话,如果该成员函数的类型取决于类类型,那么当类实例化的时候,该成员函数的类型也就固定了,所以也不是万能引用,而是右值引用。

template<class T, class Allocator = allocator<T>>
class vector{
public:
    void push_back(T&& x);  //右值引用
};

但是存在于vector中的emplace_back却是万能引用。

template<class T, class Allocator = allocator<T>>
class vector{
public:
    temlate<class... Args>  //和类对象无关的类型,故要推导
    void emplace_back(Args&&... args);  //万能引用
};

auto变量也可以作为万能引用,准确的说,声明为auto&&型别的变量都是万能引用,因为它们肯定涉及顶别推导并且肯定有正确的形式(T&&)。

C++14中的lambda表达式可以使用auto&&形参,所以经常会用到auto&&形式的万能引用。

auto fun = [](auto&& f, auto&&... params)
{
    forward<decltype(f)>(f)(forward<decltype(params)>(params)...);
};

对于万能引用的实现,主要的原理就是所谓的引用折叠,将在后续介绍。

二十五 针对右值引用实施std::move,针对万能引用实施std::forward

右值引用仅会绑定到可供移动的对象上,也就说,如果形参型别是右值引用,则绑定的对象可供移动。所以在将该对象传递给其他函数的时候,就可以使用该对象的右值属性,将该对象转换成右值,通过move函数

class Widget
{
public:
    Widget(Widget &&rhs) : name(move(rhs.move)), p(move(rhs.p)){ ... }
private:
    string name;
    shared_ptr<DataStructure> p;
};

但是对于万能引用,形参不一定会绑定到可供移动的对象上,万能引用只有在以右值进行初始化的时候才会强制转换为右值型别。所以该需求正好适用于完美转发,即forward函数

class Widget
{
public:
    template<typename T>
    void setName(T &&newName){ 
        name = forward<T>(newName);
    }
private:
    string name;
    shared_ptr<DataStructure> p;
};

上述代码中,传入的实参是string&&类型(右值),即T&&为string&&,根据模板推导原则,T是string,即forward\(newName),返回的是static_cast\

//C++11
template<typename T, class... Args>
shared_ptr<T> make_shared(Args&&... args);
//C++14
template<typename T, class... Args>
unique_ptr<T> make_unique(Args&&... args);

对于一个按值返回的函数中,如果返回的值是一个绑定到右值引用或者万能引用的对象,则当返回该对象的时候,可以对该对象实施move函数或者forward函数,以增加运行效率。或者在函数中多次使用该对象的时候,在最后一次使用的时候,实施move或者forward。

A f(A&& a){
    something to a;
    return move(a);//比return a效率更高,即便a不支持移动操作也会隐式的调用复制操作
}
template<typenmae T>
A f(T&& a){
    something to a;
    return forward<T>(a);   //对于右值,使用移动操作,对于左值,使用复制操作
}

如果函数是按值返回局部变量的话,这里最好不要对局部变量的返回使用move函数强转为右值类型,因为C++中存在一种优化,叫做返回值优化(RVO),对于返回值优化我之前做过介绍,需要两个条件:1.局部对象型别和返回值对象型别相同;2.返回的就是局部对象本身。满足这两个条件后,编译器会自动将该局部变量优化为形参中的一个引用类型,这样优化的目的在于减少由于局部变量拷贝造成的隐式构造和隐式析构的次数。而如果无法进行该优化的时候,编译器也会自动执行对返回值的move操作。如果我们手动加上move操作,无疑会破坏编译器进行返回值优化的条件。造成“负优化”的情况。

二十六 避免依万能引用型别进行重载

当把万能引用作为重载函数的候选类型的时候,容易让该重载版本在未被预料到的情况下被调用到。

#include <iostream>
#include <set>
#include <string>
using namespace std;
multiset<string> names;
template<typename T>
void log(T&& name)
{
    names.emplace(forward<T>(name));
    cout << "universal reference" << endl;
}
string db[] = {"a", "b", "c", "d", "e"};
string namefromIdx(int idx)
{
    return db[idx];
}
void log(int idx)
{
    names.emplace(namefromIdx(idx));
    cout << "int" << endl;
}
int main()
{
    string petName = "tony";
    log(petName);
    log(string("hulk"));
    log("steve");
    log(2);
    short x = 3;
    //log(x);   //error C2668: “log”: 对重载函数的调用不明确
    for (auto it : names) cout << it << ' ';
    cout << endl;

    return 0;
}

上述代码的万能引用存在着一个重载版本,其形参为int类型。当传入string类型的左值、右值,或者使隐式转换的char *字符串。都可以正确的调用万能引用版本。传入int类型也能调用int版本,但是传入的是short类型的时候,会被绑定到万能引用的版本上。原因是传入short类型,需要int进行型别提升才能匹配,而形参版本为T&&可以将T推导为short,从而产生精确匹配。而当传入的short类型进行string类型的构造时,会产生构造失败。

上述代码就是万能引用在重载中的缺陷。也就是说,一旦万能引用成为重载候选,它就会吸引走大批的实参型别,造成类型匹配的问题。

另外一个例子是在类中的成员函数,如果类拥有一个带有完美转发的构造函数,则类很有可能会将原本匹配到复制构造函数和移动构造函数的函数调用匹配到完美转发中,这样造成的结果就是,代码无法通过编译。甚至是派生类中对基类的复制和移动构造函数的调用也会因为可能被匹配到完美转发中而失败,这会带来非常多的麻烦。简而言之,对于非常量的左值型别而言,它们一般都会形成相对于复制构造函数的更加匹配,并且它们还是劫持派生类中对基类的复制和移动构造函数的调用。

//派生类构造函数的错误匹配
#include <iostream>
using namespace std;
string db[] = { "natasha romanoff", "bruce banner", "thor odinson", "Wanda", "vision" };
string namefromIdx(int idx)
{
    return db[idx];
}
class A
{
public:
    template<typename T>
    explicit A(T&& p) : name(forward<T>(p)) { cout << "A universal reference ctor" << endl; }   //完美转发构造函数
    A() {}
    explicit A(int i) : name(namefromIdx(i)) { cout << "Aint ctor" << endl; }   //普通构造函数
    A(const A& a) : name(a.name) { cout << "A lvalue ctor" << endl; }
    A(A&& a) : name(move(a.name)) { cout << "A rvalue ctor" << endl; }
private:
    string name;
};
class B : public A
{
public:
    B(){}
    B(const B& rhs) : A(rhs) { cout << "B lvalue ctor" << endl; }
    B(B&& rhs) : A(move(rhs)) { cout << "B rvalue ctor" << endl; }
};
int main()
{
    B b1;   //编译无法通过
    B b2 = b1;//编译无法通过
    /*原因:error C2664: “std::basic_string<char,std::char_traits<char>,
    std::allocator<char>>::basic_string(const std::basic_string<char,std::char_traits<char>,
    std::allocator<char>> &)”: 无法将参数 1 从“B”转换为“std::initializer_list<_Elem>”
    */
    B b3(move(b1));
    return 0;
}

二十七 熟悉依万能引用型别进行重载的替代方案

由二十六节我们可以知道,最好不要把带有万能引用的函数作为重载函数,因为由万能引用的匹配规则会导致很多错误的匹配和绑定。那在有重载需求的时候,如何去处理呢?这一节就会介绍万能引用重载的替代方案。

舍弃重载

当出现重载需求的时候,使用不同的函数名,这样的解决方法最简单,但是如果对于类的构造函数来说,是不可以的。

传递const T&型别的形参

简单来说就是把万能引用分开为const左值引用和右值引用,但是这样做效率不高,代码冗余也较多。

传值

对于万能引用来说,本质上的目的是为了达到对左值和右值的智能处理,也就是说传入左值的时候使用拷贝,而传入右值的时候使用移动,那当我们使用传值的时候,就不需要再考虑左值右值的问题了,因为从形参到实参必须会产生一次构造,诞生一个临时对象,而我们直接对临时对象使用move函数即可。这样不会产生万能引用,也就不会导致错误的匹配和绑定了。

class A
{
public:
    explicit A(string p) : name(move(p)){ } //完美转发构造函数
    explicit A(int i) : name(namefromIdx(i)){ } //普通构造函数
private:
    string name;
};

标签分派

无论是传递左值常量还是传值,都不支持完美转发。如果既不想放弃重载又不想放弃万能转发,有什么折中的方法呢?

重载函数在调用时的决议,会考察所有重载版本的形参,以及调用端传入的实参,然后选择全局最佳匹配的函数。这需要将所有的形参/实参组合都考虑在内。一个万能引用形参通常导致的结果是无论传入了什么都给出一个精确匹配结果,不过若可以增加非万能引用的形参,并且通过该形参来区分不同的重载版本,就可以杜绝万能引用的匹配。这个想法就是标签分派

具体方法就是在刚才的万能引用函数的内部调用一个内部实现函数,用这个内部实现的函数进行int和其他类型的区分。(STL中有很多这样的实现)具体例子如下:

template<typename T>
void log(T&& name)
{
    //C++11
    logImpl(forward<T>(name), is_integral<typename remove_reference<T>::type>());
    //C++14
    logImpl(forward<T>(name), is_integral<remove_reference_t<T>>()); 
}

当传入的类型是右值的时候,T会被推导为正确的类型,而当传入的类型是左值的时候(如int&),T就会被推导为左值类型(int&),这样在判断is_integral的时候,就会判断为非int。所以需要在进行判断的时候去除引用性质。通过remove_reference(C++14中为remove_reference_t)来进行(二十三节有介绍)。

这样就能智能分派int类型和非int类型的参数了。is_integral在运行期会返回true和false,也就是说内部函数需要实现两个版本。

#include <iostream>
#include <set>
#include <string>
#include <type_traits>
using namespace std;
multiset<string> names;
string db[] = { "natasha romanoff", "bruce banner", "thor odinson", "Wanda", "vision" };
//auto namefromIdx = [](int idx) { return db[idx]; };   //不带拖尾(表示返回类型)
auto namefromIdx = [](int idx)->string { return db[idx]; };
template<typename T>
void logImpl(T&& name, false_type)  //标签为false_type
{
    names.emplace(forward<T>(name));
}
void logImpl(int idx, true_type)    //标签为true_type
{
    names.emplace(namefromIdx(idx));
}
template<typename T>
void mylog(T&& name)    //通过标签分派(is_integral<typename remove_reference<T>::type>())
{
    //C++11(任选其一)
    logImpl(forward<T>(name), is_integral<typename remove_reference<T>::type>());
    //C++14(任选其一)
    //logImpl(forward<T>(name), is_integral<remove_reference_t<T>>());
}
int main()
{
    string petName = "tony stark";
    mylog(petName);
    mylog(string("tchalla"));
    mylog("steve rogers");
    mylog(2);
    long x = 3;
    mylog(x);   //可以正确匹配

    return 0;
}

在上述设计中,true_type和false_type就是所谓的标签。这种标签分派的设计是模板元编程的标准构件。

类似于is_integral的is_XXX函数位于类type_traits中。都是通过标签true_type和false_type来进行区分的。

对接受万能引用的模板施加限制

但是在类中,编译器会自动生成一些构造函数(如之前的复制和移动构造函数),这些自动生成的构造函数绕过了自动分派设计。也就是说,这个时候编译器不保证所有传入的参数都能绕过分派。还是会被万能引用构造函数所匹配。并且基类中的万能引用也会导致派生类在以传统方式实现其复制和构造移动函数的时候调用到万能引用构造函数。

//具体类的设计如下
class A
{
public:
    template<typename T>
    explicit A(T&& p){  //完美转发构造函数
        cout << "A universal reference ctor" << endl; 
        ctorImpl(forward<T>(name), is_integral<remove_reference_t<T>>());
    }
    template<typename T>
    void ctorImpl(T&& p, false_type) {
        name = forward<T>(p);
        cout << "universal reference impletation" << endl;
    }
    void ctorImpl(int i, true_type) {
        name = namefromIdx(i);
        cout << "int impletation" << endl;
    }
    A() {}
    //explicit A(int i) : name(namefromIdx(i)) { cout << "Aint ctor" << endl; } //普通构造函数
    A(const A& a) : name(a.name) { cout << "A lvalue ctor" << endl; }
    A(A&& a) : name(move(a.name)) { cout << "A rvalue ctor" << endl; }
private:
    string name;
};

这里存在的问题就是本来会复制和移动构造函数的调用仍然会匹配到万能引用上去。

这里需要介绍一种新方法:enable_if

enable_if可以强制编译器表现出来的行为如同特定的模板不存在一般,这样的模板称为禁用的。默认情况下所有的模板都是启动的,但是实施了enable_if的模板只会在满足enable_if指定的条件的前提下才会启用。在之前讨论的情况下,可以把这个条件定义为:只有传入给完美转发构造函数的类型不是A才会启用。这样,当传入的类型是A的时候,就会正确调用类的复制/移动构造函数了。

enable_if原型如下:

template<bool B, class T = void> struct enable_if;

enable_if使得函数在判断条件B仅仅为true时才有效,基本用法如下:

#include <iostream>
#include <type_traits>
using namespace std;
/*is_arithmetic:若T为算术类型(即整数类型或浮点类型)或其cv限定版本,
则提供等于true的成员常量value。对于任何其他类型,value为false*/
template<typename T>
typename enable_if<is_arithmetic<T>::value, T>::type
func(T t)
{
    return t;
}
int main()
{
    auto a = func(1);
    auto b = func(1.1);
    //auto c = func("string");  //error C2672: “func”: 未找到匹配的重载函数

    return 0;
}

所以我们可以在万能引用的模板参数中添加一个enable_if确保输入的类不是A类,但是同时也需要清楚,在审查T的类型的时候应该忽略以下几点:

  • T是否是一个引用:无论是A、A&、A&&都应该和A做同样的处理
  • T是否带由const和volatile饰词:带有cv饰词和不带有的A,应该做同样的处理

所以模板参数应该这么写:

class A
{
public:
    template<typename T, typename enable_if<
                        !is_same<A, 
                                typename decay<T>::type //去除cv饰词的类型
                                  >::value  //判相等的结果
                                            >::type //enable_if的结果
            >
explcit A(T&& n);
};

但是对于派生类来说,传入的可能不是A类,而是A类的派生类。也就是说,复制或者移动一个类B(A的派生类)型别的对象的时候,我们会期望通过基类的复制或者移动构造函数来完成该对象基类部分的复制或者移动。is_same显然无法完成这种比较,所以可以使用is_base_of来完成,这个函数对于类A和类A的派生类,与基类A进行比较的结果都为真。

std::is_base_of<T, TDerive>::value; //真(TDeriveT的派生类)
std::is_base_of<T, T>::value;   //

但是这里仅仅有is_base_of还不够,也就是说,还要为类添加一个处理整形实参的构造函数的重载版本,并且进一步限制模板构造函数,禁止其匹配整形实参的构造函数重载版本。

#include <iostream>
#include <type_traits>
using namespace std;
string db[] = { "natasha romanoff", "bruce banner", "thor odinson", "Wanda", "vision" };
auto namefromIdx = [](int idx) { return db[idx]; };
class A
{
public:
    template<typename T, enable_if_t<
        !is_base_of<A,  decay_t<T>>::value  //不是派生类或者基类
     && !is_integral<remove_reference_t<T>>::value  //不是int
        >
    >
    explicit A(T&& p) : name(forward<T>(p)) { cout << "A universal reference ctor" << endl; }   //完美转发构造函数
    A() {}
    explicit A(int i) : name(namefromIdx(i)) { cout << "Aint ctor" << endl; }   //普通构造函数
    A(const A& a) : name(a.name) { cout << "A lvalue ctor" << endl; }
    A(A&& a) : name(move(a.name)) { cout << "A rvalue ctor" << endl; }
private:
    string name;
};
class B : public A
{
public:
    B() {}
    B(const B& rhs) : A(rhs) { cout << "B lvalue ctor" << endl; }
    B(B&& rhs) : A(move(rhs)) { cout << "B rvalue ctor" << endl; }
};
int main()
{
    B b1;
    B b2 = b1;
    B b3(move(b1));
    A a1(3);
    A a2(a1);
    A a3(move(a1));
    return 0;
}

这里确实可以正确的调用了,最后的输出为:

A lvalue ctor
B lvalue ctor
A rvalue ctor
B rvalue ctor
Aint ctor
A lvalue ctor
A rvalue ctor

权衡

对于完美转发来说,如果发生了错误,错误信息将会非常难以理解,对于一般的函数,如果发生类型不匹配的问题,编译器会直接给出类型不匹配的错误,而对于完转发来说,万能引用是可以匹配一切类型的,这样只有在被转发后才会有类型不匹配的错误信息。这里如何使错误信息容易理解呢?

这里可以使用C++11的一个新特性:static_assert。使用方法如下:

static_assert ( bool_constexpr , message )

如果第一个参数常量表达式的值为false,会产生一条编译错误,错误位置就是该static_assert语句所在行,第二个参数就是错误提示字符串(C++17中可省略此参数)。

使用static_assert,我们可以在编译期间发现更多的错误,用编译器来强制保证一些契约,并帮助我们改善编译信息的可读性,尤其是用于模板的时候。

static_assert可以用在全局作用域中,命名空间中,类作用域中,函数作用域中,几乎可以不受限制的使用。

编译器在遇到一个static_assert语句时,通常立刻将其第一个参数作为常量表达式进行演算,但如果该常量表达式依赖于某些模板参数,则延迟到模板实例化时再进行演算,这就让检查模板参数成为了可能。

性能方面,由于是static_assert编译期间断言,不生成目标代码,因此static_assert不会造成任何运行期性能损失。

由上述可知,刚才的万能引用构造函数中可以添加以下这句:

P.s. is_constructible这个型别特征能够在编译期间判定具备某个型别的对象是否从另一型别(或另一组型别)的对象(或另一组对象)出发构造出。

static_assert(is_constructible<string, T>::value, "parameter cannot be constructed a string");

二十八 理解引用折叠

C++使禁止引用的引用的,也就是说

void func(T & & t); //错误,不可以有引用的引用

具体的原因就是因为,引用是别名,而不是一个数据。

但是在万能引用中,我们发现,当传入左值引用的时候:

template<typename T>
void func(T&& param);
func(w);

模板实例化之后的代码为:

void func(Widget& && param);

这不就是引用的引用吗?但是这是合乎规则的,原因就在于——引用折叠

也就是说,编译器可以在特殊的语境中产生引用的引用,例如模板实例化。

一共有两种引用——左值引用和右值引用。所以一共有四种组合:左值—左值、左值—右值、右值—左值、右值—右值。如果引用的引用出现在允许的语境,例如模板实例化的过程中。该双重引用会折叠成单个引用。规则如下:

如果任一引用为左值引用,则结果是左值引用。否则(即两个皆为右值引用),结果为右值引用。

引用折叠是使forward函数正确运行的关键。

如下是forward的实现过程

template<typename T>
T&& forward(typename remove_reference<T>::type& __t) 
{ 
    return static_cast<T&&>(__t); 
}

如果传递给forward的实参型别是一个左值Widget,则T会被推导为Widget&型别,此时模板会实例化为以下情况:

Widget& && forward(typename remove_reference<Widget&>::type& __t)
{ 
    return static_cast<Widget& &&>(__t); 
}

型别特征typename remove_reference<Widget&>::type产生的结果是Widget型别,加上左值引用之后就是左值引用的型别,并且返回值和传入static_cast的参数通过引用折叠,转化为左值型别。最终结果为:

Widget& forward(Widget& __t)
{ 
    return static_cast<Widget&>(__t); 
}

当传入右值型别的时候,推导的过程是一致的。不过引用折叠最终得到的结果是右值引用型别。

引用折叠出现的语境一共有四种:

  • 模板实例化
  • auto类型推导
  • 生成和使用typedef和别名声明
  • 使用decltype时

模板实例化已经在上面介绍过了。在auto进行类型推导的时候:

Widget w;
auto&& w1 = w;  //1
auto&& w2 = Widget();   //2

1式中,初始化w1的是一个左值,因此auto型别推导的结果是Widget&,实际上auto被推导为Widget&,这样Widget& &&得出的结果就是Widget&。2式中,以右值来初始化w2,这个时候,auto推导的结果就是Widget,也就是说未产生引用的引用,w2的类型是Widget&&

所以,对于万能引用来说,其实就是满足以下两个条件的语境中的右值引用。

  • 型别推导的过程会区别左值和右值。T型别的左值推导是T&,而右值推导是T。
  • 会发生引用折叠

当使用typedef和别名声明的时候,如下例子:

template<typename T>
class A
{
public:
    typedef T&& Value;
};
A<int &>::Value a = 3;  //error C2440: “初始化”: 无法从“int”转换为“int &”

说明a现在的类型是int&。这里发生了引用折叠:typedef int& && Value得到typedef int& Value

最后一种decltype是返回名字或者表达式的型别。decltype中可能发生引用折叠:decltype(x)会先取出x的类型,再通过引用折叠规则来定义变量。

int t = 3;
int w2 = 5;
int w3 = 5;
int &v1 = t;
decltype(v1)&& v3 = w2;            //v1的类型为int&,所以v3为int& &&,折叠后为int&
decltype(v2)&& v4 = std::move(w2); //v2的类型为int&&,所以v4为int&& &&,折叠后为int&&
decltype(w1)&& v5 = std::move(w3); //w1的类型为int,所以v5为int&&

二十九 假定移动操作不存在、成本高、未使用

C++11中有一个新的容器:std::array,这个容器实际上是带有STL接口的内建数组。其他容器都是将内容存放在堆上的。并在类中指涉到该内存。正是由于该指针的存在,把整个容器的内容在常熟时间内加以移动才成为了可能。而array型别的对象则没有这个指针,因为其数据内容直接存在在对象内。

这也就是说,当对array进行移动操作的时候,最终会对容器中的每一个元素移动或复制。

类似的情况还出现在string中,由于短字符串的优化,多个相同内容的短字符串会指向同一块内存(小型字符串优化),这样进行移动的时候,产生的其实是复制操作。

在部分C++98代码升级到C++11后,底层的复制操作只有在已知移动不会抛出异常的前提下才会应用移动将其替换。

当传入对象是左值型别的时候,通常也不会使用移动操作,而是以复制操作代替。

三十 熟悉完美转发的失败情形

完美转发的含义是我们不仅要转发对象,还要转发其显著特征:型别,是左值还是右值,以及对应的const、volatile饰词。

转发函数,天然就应该是泛型的。对于这样的泛型,一种拓展就是使得转发函数不只是模板,而且是可变长参数模板。形式如下:

#include <iostream>
using namespace std;
class A     //测试类
{
public:
    A(int _x, int _y) : x(_x), y(_y){ } //构造函数,用于完美转发的目标函数
    friend ostream& operator<<(ostream &out, const A& a);
private:
    int x;
    int y;
};
ostream& operator<<(ostream &out, const A&a)
{
    out << a.x << ' ' << a.y;
    return out;
}
template<typename... Ts>    //完美转发的转发函数
A f(Ts&&... params)
{
    A a(forward<Ts>(params)...);    //转发可变参数
    return a;
}
int main()
{
    A a = f(2, 3);
    //A b = f<int>(2, 3, 5);    //error C2661: “A::A”: 没有重载函数接受 3 个参数
    cout << a << endl;  //2 3
    return 0;
}

标准库中这样的函数很多,如emplce系列,make_shared,make_unique等。

但是完美转发也是有失败的情况存在的。给定目标函数和转发函数,以特定实参调用转发函数和目标函数,如果执行不同的操作,则说明完美转发失败。

大括号初始化物

如下代码:

void f(const vector<int>& v)    //目标函数
{
    for (auto it : v) cout << it << ' ';
    cout << endl;
}

template<typename T>    //转发函数
void tf(T&& params)
{
    f(forward<T>(params));
}

int main()
{
    f({1,2,3});
    //tf({1,2,3}); //error C2672: “tf”: 未找到匹配的重载函数;error C2783: “void tf(T &&)”: 未能为“T”推导模板参数

    return 0;
}

上述代码就是一个完美转发失败的例子,对于同样的参数,目标函数和转发函数表现出的结果是不一样的。原因是什么呢?

对f的直接调用中,编译器接受了调用端的实参型别,又接收了f所声明的形参型别,编译器会比较这两个型别,来确定它们是否兼容。如果有必要会执行隐式类型转换。上述代码中就执行了{1,2,3}到vector\的构造。构造了一个临时对象,从而f就可以绑定这个临时对象。

而在实行tf的调用(内部间接调用f)时,编译器就不会比较tf的调用处传入的实参和f的形参了,取而代之的是模板类型推导。也就是说,编译器比较的是f的形参型别和tf进行类型推导后的参数型别。

完美转发会在以下两个条件成立时失败:

  • 编译器无法未一个或多个tf的形参推导出型别结果。
  • 编译器为一个或多个tf的形参推导出了“错误的”型别结果,这里的”错误的“意思是只tf推导的实例化无法通过编译或者tf推导出来的型别和直接传递给f的型别不一致。

上述代码中,问题在于向未声明initializer_list型别的函数模板形参传递了大括号初始化物。这个问题叫做非推导语境,也就是说由于tf的形参未声明为initializer_list,编译器就会禁止在tf的调用过程中从表达式{1,2,3}出发来推导型别。所以根据这个规则,编译器会拒绝这个调用。

但是我们曾经说过,auto变量在以大括号初始化无完成初始化时,型别推导可以成功,推导出来的型别为initializer_list。这样,在调用tf的时候,就能执行型别推导了。

auto l = {1,2,3};   
cout << typeid(l).name() << endl;   //class std::initializer_list<int>
tf(l);  //1 2 3

0和NULL用作空指针

之前说过,若是把0和NULL以空指针之名传递给模板,型别推导最终的结果将会是整形(一般是int)而非所传递实参的指针型别。所以:0和NULL都不能用作空指针以进行完美转发,使用完美转发的时候,要使用nullptr

仅有声明的整形static const成员变量

在类中如果有static类型的成员变量,一般是在类内声明,在类外定义;如果有const类型的成员变量,一般是在初始化列表中进行一次初始化;而如果是有static const类型的成员变量,则不需要定义,直接在类内声明并初始化。

class A
{
public:
    static const size_t a = 10;
};

但是对于这样的变量,如果传入到目标函数和转发函数中,结果可能是不一样的,也就是说,由于没有成员变量的定义,从而无法完成链接。但是有的编译器可以正确调用(VS2017可以正确调用)。

void f2(const int t)
{
    cout << t << endl;
}
template<typename T>
void tf(T&& params)
{
    f2(forward<T>(params));
}
f2(A::a);
tf(A::a);   //可能无法链接

解决方法就是在类外进行一次定义,不过不需要再指定其值。

const size_t A::a;

重载的函数名字和模板名字

如下代码:

int pf(int x)   //重载函数
{
    cout << x << endl;
    return x;
}
int pf(int x, int y)    //重载函数
{
    cout << x << ' ' << y << endl;
    return x;
}
void f(int(*pfp)(int))  //目标函数,调用重载函数的第一种类型
{
    int x = 10;
    pfp(x);
}
template<typename T>    //转发函数
void tf(T&& t)
{
    f(forward<T&&>(t));
}
int main()
{
    f(pf);
    //tf(pf);//error C2672: “tf”: 未找到匹配的重载函数;error C2783: “void tf(T &&)”: 未能为“T”推导模板参数

    return 0;
}

上述代码的问题存在于,当用f调用pf的时候,f知道需要调用什么版本的重载函数,但是tf不知道,因为作为一个函数模板,他没有任何关于型别需求的信息,这也使得编译器不可能决议应该传递哪个函数重载版本。

同样的问题会出在使用函数模板来代替(或附加于)重载函数名字的场合。函数模板不仅仅是一个函数,而是许许多多个函数。

template<typename T>    //函数模板
T pf(T x)
{
    cout << x << endl;
    return x;
}
void f(int(*pfp)(int))  //目标函数
{
    int x = 10;
    pfp(x);
}
template<typename T>    //转发函数
void tf(T&& t)
{
    f(forward<T&&>(t));
}
int main()
{
    f(pf);
    //tf(pf);   //同之前代码一样的错误

    return 0;
}

如果要解决这种问题就可以采用类似之前“auto”的方式。先指定一个类型,或者直接使用强制类型转换。

int pf(int x)
{
    cout << x << endl;
    return x;
}
int pf(int x, int y)
{
    cout << x << ' ' << y << endl;
    return x;
}void f(int(*pfp)(int))
{
    int x = 10;
    pfp(x);
}
template<typename T>
void tf(T&& t)
{
    f(forward<T&&>(t));
}
int main()
{
    using pftype = int(*)(int); //定义函数指针类型的别名声明
    pftype pfp = pf;
    f(pfp);
    tf(pfp);
    tf(static_cast<pftype>(pf));    //都可以正确调用

    return 0;
}

位域

将位域(结构体的某一个成员,占用一个类型(如int)的一部分)作为函数实参的时候也会导致完美转发的失败。如下代码:

struct A    //包含着许多位域的结构体
{
    uint32_t version : 4,
        IHL : 4,
        DSCP : 6,
        ECN : 2,
        totlength : 16;
};
void f(const int t) //目标函数
{
    cout << t << endl;
}
template<typename T>    //转发函数
void tf(T&& params)
{
    f(forward<T>(params));
}

int main()
{
    A a = {1,2,3,4,5};
    f(a.totlength);
    //tf(a.totlength);  //error C2664: “void tf<uint32_t&>(T)”: 无法将参数 1 从“uint32_t”转换为“unsigned int &”

    return 0;
}

C++中有一个禁止的规则:非const引用不得绑定到位域,原因是因为位域是由机器字的若干任一部分组成的,这样的实体无法对其直接取址。C++中可以取址的最小单位是char。

要使用位域作为参数还是先进行一次赋值,使用一个临时变量先保存位域的值。也就是所谓的按值传递。或者使用常量引用。常量引用事实上不能绑定到位域,它们绑定的是“常规对象”,其中复制了位域的值。

auto length = static_cast<uint16_t>(h.totlength);
tf(length);

猜你喜欢

转载自blog.csdn.net/u012630961/article/details/81016126