《EffectiveModernC-》第五章-移动语义,完美转发

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

Item 23:理解std::movestd::forward

这里是一个C++11的std::move的示例实现。它并不完全满足标准细则,但是它已经非常接近了。

1
2
3
4
5
6
7
8
9
template<typename T>                            //在std命名空间
typename remove_reference<T>::type&&
move(T&& param)
{
using ReturnType = //别名声明,见条款9
typename remove_reference<T>::type&&;

return static_cast<ReturnType>(param);
}

std::move接受一个对象的引用,准确的说是一个万能引用(universal reference)。去除对象类型的引用后统一加上&&来变成右值类型ReturnType,再把param强转为这个右值类型。

​ 在C++14中可以被更简单地实现。多亏了函数返回值类型推导(见Item3)和标准库的模板别名std::remove_reference_t(见Item9),std::move可以这样写:

1
2
3
4
5
6
template<typename T>
decltype(auto) move(T&& param) //C++14,仍然在std命名空间
{
using ReturnType = remove_referece_t<T>&&;
return static_cast<ReturnType>(param);
}

​ 如你所见,move名字叫做move但是他并不移动任何资源,只是简单的把类型强转为右值,这样看来,也许叫做rvalue_cast更适合一些。所以对一个对象使用std::move就是告诉编译器,这个对象很适合被移动,move的出现(其实就是右值的出现)帮助我们更容易指定可以被移动的对象。如果你看过auto_ptr的实现,知道他为什么不如unqiue_ptr,你就知道右值的出现解决了很多棘手问题。

下面我们看个例子:

1
2
3
4
5
class Annotation {
public:
explicit Annotation(const std::string text);

};

当复制text到一个数据成员的时候,为了避免一次复制操作的代价,你仍然记得来自Item41的建议,把std::move应用到text上,因此产生一个右值:

1
2
3
4
5
6
7
8
9
10
11
class Annotation {
public:
explicit Annotation(const std::string text)
:value(std::move(text)) //“移动”text到value里;这段代码执行起来
{ … } //并不是看起来那样



private:
std::string value;
};

​ 到了这里可能会让你失望了,text并不是被移动到value,而是被拷贝。诚然,text通过std::move被转换到右值,但是text被声明为const std::string,所以在转换之前,text是一个左值的const std::string,而转换的结果是一个右值的const std::string,但是纵观全程,const属性一直保留。

​ 当编译器决定哪一个std::string的构造函数被调用时,考虑它的作用,将会有两种可能性:

1
2
3
4
5
6
7
class string {                  //std::string事实上是
public: //std::basic_string<char>的类型别名

string(const string& rhs); //拷贝构造函数
string(string&& rhs); //移动构造函数

};

​ 在类Annotation的构造函数的成员初始化列表中,std::move(text)的结果是一个const std::string的右值。这个右值不能被传递给std::string的移动构造函数,因为移动构造函数只接受一个指向non-conststd::string的右值引用。这很容易解释,因为non-const的引用类型不会限制修改内容,这最终会导致你修改了const的内容,这显然是不会被编译器允许的。换个更易懂的角度来看,移动操作本质就是对资源的移动,一个const的右值类型怎么做资源移动呢?这显然非常不合理,因此会调用到拷贝构造。另外要提一点,拷贝构造函数的const左值引用对左右值都可以适配。

从这个例子中,可以总结出两点。

  • 第一,不要在你希望能移动对象的时候,声明他们为const。对const对象的移动请求会悄无声息的被转化为拷贝操作。

  • 第二点,std::move不仅不移动任何东西,而且它也不保证它执行转换的对象可以被移动。关于std::move,你能确保的唯一一件事就是将它应用到一个对象上,你能够得到一个右值。

再来讨论下std::forward

​ 关于std::forward的故事与std::move是相似的,但是与std::move总是无条件的将它的实参为右值不同,std::forward只有在满足一定条件的情况下才执行转换。std::forward有条件的转换。

最常见的情景是一个模板函数,接收一个通用引用形参,并将它传递给另外的函数:

1
2
3
4
5
6
7
8
9
10
11
12
void process(const Widget& lvalArg);        //处理左值
void process(Widget&& rvalArg); //处理右值

template<typename T> //用以转发param到process的模板
void logAndProcess(T&& param)
{
auto now = //获取现在时间
std::chrono::system_clock::now();

makeLogEntry("Calling 'process'", now);
process(std::forward<T>(param));
}

考虑两次对logAndProcess的调用,一次左值为实参,一次右值为实参:

1
2
3
4
Widget w;

logAndProcess(w); //用左值调用
logAndProcess(std::move(w)); //用右值调用

​ 在logAndProcess函数的内部,形参param被传递给函数process。函数process分别对左值和右值做了重载。当我们使用左值来调用logAndProcess时,自然我们期望该左值被当作左值转发给process函数,而当我们使用右值来调用logAndProcess函数时,我们期望process函数的右值重载版本被调用。

当且仅当传递给函数logAndProcess的用以初始化param的实参是一个右值时,param会被转换为一个右值。这就是std::forward做的事情。这就是为什么std::forward是一个有条件的转换:它的实参用右值初始化时,转换为一个右值。

请记住:

  • std::move执行到右值的无条件的转换,但就自身而言,它不移动任何东西。
  • std::forward只有当它的参数被绑定到一个右值时,才将参数转换为右值。
  • std::movestd::forward在运行期什么也不做。

Item 24: 区分通用引用与右值引用

T&&auto&&我们一般认为是万能引用的标准形式,但是有一些corner case你需要注意下,比如:

  • 不要在T和auto前加const,这会导致变成右值引用。即使一个简单的const修饰符的出现,也足以使一个引用失去成为通用引用的资格:

    1
    2
    template <typename T>
    void f(const T&& param); //param是一个右值引用
  • 类型推导的T/auto+&&才是通用引用,当类里看见了一个函数形参类型为“T&&”,他不一定是通用引用,因为T在类定义是可能已经推导完成了:

    来自std::vector

    1
    2
    3
    4
    5
    6
    7
    template<class T, class Allocator = allocator<T>>   //来自C++标准
    class vector
    {
    public:
    void push_back(T&& x);

    }

    push_back函数的形参当然有一个通用引用的正确形式,然而,在这里并没有发生类型推导。因为push_back在有一个特定的vector实例之前不可能存在,而实例化vector时的类型已经决定了push_back的声明。

    作为对比,std::vector内的概念上相似的成员函数emplace_back,却确实包含类型推导: 如你所见Args实实在在的进行了类型推导。

    1
    2
    3
    4
    5
    6
    7
    template<class T, class Allocator = allocator<T>>   //依旧来自C++标准
    class vector {
    public:
    template <class... Args>
    void emplace_back(Args&&... args);

    };

这里提一句,万能引用的底层本质是引用折叠,我们后面iten28会提到这个概念。

item25:对右值引用使用std::move,对通用引用使用std::forward

在右值引用上使用std::forward也是可以的,但是代码长语义不够清晰,一个简单的例子:

1
2
3
4
void fun(classType&& newName) 
{
name = std::move(newName);
}

你要写成:

1
2
3
4
void fun(classType&& newName) 
{
name = std::forward<classType>(newName);
}

这实在有些自找麻烦,因为:

  • forward你得写上classType,多写东西
  • move看起来语义很清晰,你知道std::move后的东西一定是一个右值,对于forward,你可能还需要稍微考虑下。

因此对于右值引用,请直接使用std::move()

对于万能引用来说,使用std::forward则是一个更好的选择

观察如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Widget {
public:
template<typename T>
void setName(T&& newName) //通用引用可以编译,
{ name = std::move(newName); } //但是代码太太太差了!


private:
std::string name;
std::shared_ptr<SomeDataStructure> p;
};

std::string getWidgetName(); //工厂函数

Widget w;

auto n = getWidgetName(); //n是局部变量

w.setName(n); //把n移动进w!

//现在n的值未知

对于右值来说,使用move没什么问题,因为右值代表了你想使用移动语义。而对于一个左值n来说,因为move函数导致了左值n强转为右值,newname因为移动语义把自身的资源转移给了name。这会导致你完全不知道的情况下改变了你的左值。

你可能会指出,如果为const左值和为右值分别重载setName可以避免整个问题,比如这样:

1
2
3
4
5
6
7
8
9
10
class Widget {
public:
void setName(const std::string& newName) //用const左值设置
{ name = newName; }

void setName(std::string&& newName) //用右值设置
{ name = std::move(newName); }


};

这样的话,当然可以工作,但是有缺点。

  • 首先编写和维护的代码更多(两个函数而不是单个模板);

  • 其次,效率下降。

    考虑这样一段代码

    1
    w.setName("Adela Novak");

    ​ 对于通用引用的版本来说,字面字符串"Adela Novak"传递给setName的参数时,T会被推倒出const char[]类型,然后引用到这个字面字符串,再传递给setName内部的赋值运算,这个过程中没有任何的临时变量被创建。但是对于分别重载左右值的版本,你会发现由于参数写的是string类型,因此你需要隐式构造一个临时对象,然后让string&&右值引用 来引用这个临时对象。可以发现我们需要多创建一次临时对象,这显然影响了性能。

    ​ 当然你可以再添加一对const char[]版本的setName, 然后也分别做左右值引用两个版本就好了。但是可以显而易见,这种代码编写和维护会变得很困难。

  • 如果有n个参数,每个参数都有两种可能,你就要写$2^n$种重载函数, 这也太难维护代码了(比如make_shared这种使用了variadic template,常常参数包里有多个参数)。

因此以上三条殊途同归都在说的一个问题就是:编写和维护起来很困难。

现在我想已经说服了你,你应该,就像标题说的一样:对右值引用使用std::move,对通用引用使用std::forward

下面讨论一些其他的场景,比如在某些情况,你可能需要在一个函数中多次使用绑定到右值引用或者通用引用的对象,并且想确保在完成其他操作前,这个对象不会被移动,也就是内部资源不会被其他操作移动走。

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
void setSignText(T&& text) //text是通用引用
{
sign.setText(text); //使用text但是不改变它

auto now =
std::chrono::system_clock::now(); //获取现在的时间

signHistory.add(now,
std::forward<T>(text)); //有条件的转换为右值
}

我们想要确保text的值不会被sign.setText改变,因为我们想要在signHistory.add中继续使用。因此std::forward只在最后使用,前面sign.setText(text);的text只是一个右值引用变量, 是一个左值,因此不会调用到移动操作。

如果你在按值返回的函数中,返回值绑定到右值引用或者通用引用上,需要对返回的引用使用std::move或者std::forward。要了解原因,考虑两个矩阵相加的operator+函数,左侧的矩阵为右值(可以被用来保存求值之后的和):

1
2
3
4
5
6
Matrix                              //按值返回
operator+(Matrix&& lhs, const Matrix& rhs)
{
lhs += rhs;
return std::move(lhs); //移动lhs到返回值中
}

通过在return语句中将lhs转换为右值(通过std::move),lhs可以移动到返回值的内存位置。如果省略了std::move调用,

1
2
3
4
5
6
Matrix                              //同之前一样
operator+(Matrix&& lhs, const Matrix& rhs)
{
lhs += rhs;
return lhs; //拷贝lhs到返回值中
}

lhs是个左值的事实,会强制编译器拷贝它到返回值的内存空间。假定Matrix支持移动操作,并且比拷贝操作效率更高,在return语句中使用std::move的代码效率更高。

如果Matrix不支持移动操作,将其转换为右值不会变差,因为右值可以直接被Matrix的拷贝构造函数拷贝(见Item23)。如果Matrix随后支持了移动操作,operator+将在下一次编译时受益。就是这种情况,通过将std::move应用到按值返回的函数中要返回的右值引用上,不会损失什么(还可能获得收益)。

你可能听过rvo/nrvo的技术,这种优化警告我们不要在返回值使用move,这是否和上面违背了呢?

如下代码

1
2
3
4
5
6
Widget makeWidget()                 //makeWidget的“拷贝”版本
{
Widget w; //局部对象
//配置w
return w; //“拷贝”w到返回值中
}

可能想要“优化”代码,把“拷贝”变为移动:

1
2
3
4
5
6
Widget makeWidget()                 //makeWidget的移动版本
{
Widget w;

return std::move(w); //移动w到返回值中(不要这样做!)
}

我的注释告诉你这种想法是有问题的,但是问题在哪?

这是错的,因为对于这种优化,标准化委员会远领先于开发者。早就为人认识到的是,makeWidget的“拷贝”版本可以避免复制局部变量w的需要,通过在分配给函数返回值的内存中构造w来实现。这就是所谓的返回值优化return value optimization,RVO),这在C++标准中已经实现了。如果局部对象可以被返回值优化消除,就绝不使用std::move或者std::forward

请记住:

  • 最后一次使用时,在右值引用上使用std::move,在通用引用上使用std::forward
  • 对按值返回的函数要返回的右值引用和通用引用,执行相同的操作。
  • 如果局部对象可以被返回值优化消除,就绝不使用std::move或者std::forward

Item26:避免在通用引用上重载

上一个item讲到对通用引用使用std::forward的原因,我们可以写出如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T>
void logAndAdd(T&& name)
{
auto now = std::chrono::system_lock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}

std::string petName("Darla"); //跟之前一样
logAndAdd(petName); //跟之前一样,拷贝左值到multiset
logAndAdd(std::string("Persephone")); //移动右值而不是拷贝它
logAndAdd("Patty Dog"); //在multiset直接创建std::string
//而不是拷贝一个临时std::string

比如我们现在想重载一下logAndAdd

1
2
3
4
5
6
7
8
std::string nameFromIdx(int idx);   //返回idx对应的名字

void logAndAdd(int idx) //新的重载
{
auto now = std::chrono::system_lock::now();
log(now, "logAndAdd");
names.emplace(nameFromIdx(idx));
}

之后的两个调用按照预期工作:

1
2
3
4
5
6
7
std::string petName("Darla");           //跟之前一样

logAndAdd(petName); //跟之前一样,
logAndAdd(std::string("Persephone")); //这些调用都去调用
logAndAdd("Patty Dog"); //T&&重载版本

logAndAdd(22); //调用int重载版本

但是当这样的代码出现时:

1
2
3
short nameIdx;
//给nameIdx一个值
logAndAdd(nameIdx); //错误!

有两个重载的logAndAdd。使用通用引用的那个推导出T的类型是short,因此可以精确匹配。对于int类型参数的重载也可以在short类型提升后匹配成功。根据正常的重载解决规则,精确匹配优先于类型提升的匹配,所以被调用的是通用引用的重载。所有这一切的原因就是对于short类型通用引用重载优先于int类型的重载。

​ 通用引用的函数在C++中是最贪婪的函数。它们几乎可以精确匹配任何类型的实参(极少不适用的实参在Item30中介绍)。这也是把重载和通用引用组合在一块是糟糕主意的原因:通用引用的实现会匹配比开发者预期要多得多的实参类型。

​ 一个更容易掉入这种陷阱的例子是写一个完美转发构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
class Person {
public:
template<typename T>
explicit Person(T&& n) //完美转发的构造函数,初始化数据成员
: name(std::forward<T>(n)) {}

explicit Person(int idx) //int的构造函数
: name(nameFromIdx(idx)) {}


private:
std::string name;
};

就像在logAndAdd的例子中,传递一个不是int的整型变量(比如std::size_tshortlong等)会调用通用引用的构造函数而不是int的构造函数,这会导致编译错误。这里这个问题甚至更糟糕,因为Person中存在的重载比肉眼看到的更多。在Item17中说明,在适当的条件下,C++会生成拷贝和移动构造函数,即使类包含了模板化的构造函数,模板函数能实例化产生与拷贝和移动构造函数一样的签名,也在合适的条件范围内。如果拷贝和移动构造被生成,Person类看起来就像这样:

1
2
3
4
5
6
7
8
9
10
11
12
class Person {
public:
template<typename T> //完美转发的构造函数
explicit Person(T&& n)
: name(std::forward<T>(n)) {}

explicit Person(int idx); //int的构造函数

Person(const Person& rhs); //拷贝构造函数(编译器生成)
Person(Person&& rhs); //移动构造函数(编译器生成)

};

这种实现会导致不符合人类直觉的结果,如下:

1
2
Person p("Nancy"); 
auto cloneOfP(p); //从p创建新Person;这通不过编译!

这里我们试图通过一个Person实例创建另一个Person,显然应该调用拷贝构造即可。但是这份代码不是调用拷贝构造函数,而是调用完美转发构造函数。然后,完美转发的函数将尝试使用Person对象p初始化Personstd::string数据成员,编译器就会报错。

编译器的理由如下:实例化之后,Person类看起来是这样的

1
2
3
4
5
6
7
8
9
10
class Person {
public:
explicit Person(Person& n) //由完美转发模板初始化
: name(std::forward<Person&>(n)) {}

explicit Person(int idx); //同之前一样

Person(const Person& rhs); //拷贝构造函数(编译器生成的)

};

显然non-const的p会匹配到完美转发的构造函数,因为这种匹配优先级更高, 如果是一个const的就会匹配到拷贝构造函数了。

1
2
const Person cp("Nancy");   //现在对象是const的
auto cloneOfP(cp); //调用拷贝构造函数!

​ 当继承纳入考虑范围时,完美转发的构造函数与编译器生成的拷贝、移动操作之间的交互会更加复杂。尤其是,派生类的拷贝和移动操作的传统实现会表现得非常奇怪。来看一下:

1
2
3
4
5
6
7
8
9
10
class SpecialPerson: public Person {
public:
SpecialPerson(const SpecialPerson& rhs) //拷贝构造函数,调用基类的
: Person(rhs) //完美转发构造函数!
{ … }

SpecialPerson(SpecialPerson&& rhs) //移动构造函数,调用基类的
: Person(std::move(rhs)) //完美转发构造函数!
{ … }
};

如同注释表示的,派生类的拷贝和移动构造函数没有调用基类的拷贝和移动构造函数,而是调用了基类的完美转发构造函数!为了理解原因,要知道派生类将SpecialPerson类型的实参传递给其基类,然后通过模板实例化和重载解析规则作用于基类Person。最终,代码无法编译,因为std::string没有接受一个SpecialPerson的构造函数。

我希望到目前为止,已经说服了你,如果可能的话,避免对通用引用形参的函数进行重载。

请记住:

  • 对通用引用形参的函数进行重载,通用引用函数的调用机会几乎总会比你期望的多得多。
  • 完美转发构造函数是糟糕的实现,因为对于non-const左值,它们比拷贝构造函数而更匹配,而且会劫持派生类对于基类的拷贝和移动构造函数的调用。

item27:熟悉通用引用重载的替代方法

item26讨论了大量你不应该对万能引用作为形参的函数重载 的原因。

这个条款探讨了几种,通过避免在万能引用上重载的设计,或者通过限制万能引用可以匹配的参数类型,来实现所期望行为的方法。

1.放弃重载

第一种就是放弃重载,logAndAdd是许多函数的代表,这些函数可以使用不同的名字来避免在通用引用上的重载的弊端。例如两个重载的logAndAdd函数,可以分别改名为logAndAddNamelogAndAddNameIdx

可惜的是,这种方式不能用在第二个例子,Person构造函数中,因为构造函数的名字被语言固定了。

2.传递const T&

一种替代方案是退回到C++98,然后把传递万能引用替换为传递lvalue-refrence-to-const。我们知道常左值引用比较特殊,它不仅可以对左值,还可以对右值进行引用。事实上,这是Item26中首先考虑的方法。缺点是效率不高。现在我们知道了通用引用和重载的相互关系,所以放弃一些效率来确保行为正确简单可能也是一种不错的折中。

3.传值

通常在不增加复杂性的情况下提高性能的一种方法是,将按传引用形参替换为按值传递,这是违反直觉的。该设计遵循Item41中给出的建议,即在你知道要拷贝时就按值传递,因此会参考那个条款来详细讨论如何设计与工作,效率如何。这里,在Person的例子中展示:

1
2
3
4
5
6
7
8
9
10
11
12
class Person {
public:
explicit Person(std::string n) //代替T&&构造函数,
: name(std::move(n)) {} //std::move的使用见条款41

explicit Person(int idx) //同之前一样
: name(nameFromIdx(idx)) {}


private:
std::string name;
};

​ 因为没有std::string构造函数可以接受整型参数,所有int或者其他整型变量(比如std::size_tshortlong等)都会使用int类型重载的构造函数。相似的,所有std::string类似的实参(还有可以用来创建std::string的东西,比如字面量“Ruth”等)都会使用std::string类型的重载构造函数。

​ 没有意外情况。我想你可能会说有些人使用0或者NULL指代空指针会调用int重载的构造函数让他们很吃惊,但是这些人应该参考Item8反复阅读直到使用0或者NULL作为空指针让他们恶心。

4.标签分发tag dispatch

下面是原来的代码,以免你再分心回去查看:

1
2
3
4
5
6
7
8
9
std::multiset<std::string> names;       //全局数据结构

template<typename T> //志记信息,将name添加到数据结构
void logAndAdd(T&& name)
{
auto now = std::chrono::system_clokc::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}

我们在logAndAdd中对不同的情况做标签分发:

比如对于接受idx的版本,我们判断一下T的类型是不是整数即可,如果是整数,就进入到整数的那个实现版本里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template<typename T>
void logAndAdd(T&& name)
{
logAndAddImpl(std::forward<T>(name),
std::is_integral<T>()); //不那么正确
}

template<typename T> //非整型实参:添加到全局数据结构中
void logAndAddImpl(T&& name, std::false_type) //译者注:高亮std::false_type
{
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}

void logAndAddImpl(int idx, std::true_type) //译者注:高亮std::true_type
{
logAndAdd(nameFromIdx(idx)); //std::string nameFromIdx(int idx);
}

但这里是有瑕疵的,比如对于一个右值整数作为logAndAdd的参数,T会推导出int,这没什么问题,但是对于一个左值整数,T会推导出int&(推导规则请看第一章),而int&并不会被std::is_integral<T>()的结果认为是true,因此我们需要去掉一下引用符,利用std::remove_reference即可

1
2
3
4
5
6
7
8
template<typename T>
void logAndAdd(T&& name)
{
logAndAddImpl(
std::forward<T>(name),
std::is_integral<typename std::remove_reference<T>::type>()//(在C++14中,你可以通过`std::remove_reference_t<T>`来简化写法)
);
}

在这个设计中,类型std::true_typestd::false_type是“标签”(tag),其唯一目的就是强制重载解析按照我们的想法来执行。

5.约束使用万能引用的模板

可以先了解下SFINAE , 博客链接: https://chillstepp.github.io/2022/07/24/C-SFINAE/#%E7%BC%96%E8%AF%91%E6%9C%9F%E6%88%90%E5%91%98%E6%A3%80%E6%B5%8B-%E6%9C%89%E6%95%88-%E6%97%A0%E6%95%88

在iterm26还讨论了一种情形是:万能引用作为形参的构造函数 和 编译器自动生成的构造函数 的问题,他们的重载也导致了一些不符合直觉的后果,本着苦了谁都不能苦调用者的精神,我们不希望那种情况出现。

我们可以写出一个最基本的版本:

其中decay会移除T的引用和cv限定符

1
2
3
4
5
6
7
8
9
10
11
12
13
class Person {
public:
template<
typename T,
typename = typename std::enable_if<
!std::is_same<Person,
typename std::decay<T>::type
>::value
>::type
>
explicit Person(T&& n);

};

假定从Person派生的类以常规方式实现拷贝和移动操作:

1
2
3
4
5
6
7
8
9
10
11
12
class SpecialPerson: public Person {
public:
SpecialPerson(const SpecialPerson& rhs) //拷贝构造函数,调用基类的
: Person(rhs) //完美转发构造函数!
{ … }

SpecialPerson(SpecialPerson&& rhs) //移动构造函数,调用基类的
: Person(std::move(rhs)) //完美转发构造函数!
{ … }


};

此时Person(rhs)这一句rhs是SpecialPerson类型的,因此会导致我们的EnableIf并不会通过,因此要做点修改要认为是子类即可,同时可以简化成_t版本的,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Person  {                                         //C++14
public:
template<
typename T,
typename = std::enable_if_t< //这儿更少的代码
!std::is_base_of<Person,
std::decay_t<T> //还有这儿
>::value
> //还有这儿
>
explicit Person(T&& n);

};

加入此时有重载函数的出现,比如通过id来构造一个person:

此时我们就在enable的condition里加上新限制即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Person {
public:
template<
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person, std::decay_t<T>>::value
&&
!std::is_integral<std::remove_reference_t<T>>::value
>
>
explicit Person(T&& n) //对于std::strings和可转化为
: name(std::forward<T>(n)) //std::strings的实参的构造函数
{ … }

explicit Person(int idx) //对于整型实参的构造函数
: name(nameFromIdx(idx))
{ … }

//拷贝、移动构造函数等

private:
std::string name;
};

请记住:

  • 通用引用和重载的组合替代方案包括使用不同的函数名,通过lvalue-reference-to-const传递形参,按值传递形参,使用tag dispatch
  • 通过std::enable_if约束模板,允许组合通用引用和重载使用,但它也控制了编译器在哪种条件下才使用通用引用重载。
  • 通用引用参数通常具有高效率的优势,但是可用性就值得斟酌。

Item28:理解引用折叠

1
2
3
4
5
6
7
template<typename T>
void func(T&& param);

Widget widgetFactory(); //返回右值的函数
Widget w; //一个变量(左值)
func(w); //用左值调用func;T被推导为Widget&
func(widgetFactory()); //用右值调用func;T被推导为Widget

我们首先要明确再c++中引用的引用是非法的,编译器却可以这样做。

1
2
3
4
5
6
7
8
9
func(w);  //T推导为Widget&
//把Widget&带入T
template<typename Widget&>
void func(Widget& && param);

//引用折叠导致
void func(Widget& && param);
//变为
void func(Widget& param);

引用折叠的一个best practice是std::forward

std::forward可以简单实现为:

1
2
3
4
5
template<typename T>                                //在std命名空间
T&& forward(typename remove_reference<T>::type& param)
{
return static_cast<T&&>(param);
}

考虑当传入的实参是个左值,那么推导如下,左值被T推导为Widget&

1
2
Widget& && forward(Widget& param)
{ return static_cast<Widget& &&>(param); }

根据引用折叠规则,返回值和强制转换可以化简,最终版本的std::forward调用就是:

1
2
Widget& forward(Widget& param)
{ return static_cast<Widget&>(param); }

也就是当实参是左值,最后会推导出左值引用,左值引用也是一个左值,左值返回左值,这很好。

当实参是右值时:

1
2
Widget&& forward(Widget& param)
{ return static_cast<Widget&&>(param); }

此时就会返回右值,右值返回右值,这也很好。

到此我们可以发现通过forward利用引用折叠完美转发了实参的类型。

Item29:移动操作的缺点

存在几种情况,移动语义并无优势:

  • 没有移动操作:要移动的对象没有提供移动操作,所以移动的写法也会变成复制操作。
  • 移动不会更快:要移动的对象提供的移动操作并不比复制速度更快。
  • 移动不可用:进行移动的上下文要求移动操作不会抛出异常,但是该操作没有被声明为noexcept

Item30:熟悉完美转发失败的情况

1.花括号初始化器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void f(const std::vector<int>& v)
{
cout<<1<<endl;
}

template<typename... Ts>
void fwd(Ts&&... params) //接受任何实参
{
f(std::forward<Ts>(params)...); //转发给f
}

int main()
{

f({1,2,3});//ok
fwd({1,2,3});//error

auto x = {1,2,3};
fwd(x);//ok
return 0;
}

​ 在对f的直接调用(例如f({ 1, 2, 3 })),编译器看看调用地传入的实参(即{1,2,3}),看看f声明的形参类型。它们把调用地的实参和声明的实参进行比较,看看是否匹配,并且必要时执行隐式转换操作使得调用成功。在上面的例子中,从{ 1, 2, 3 }生成了临时std::vector<int>对象,因此f的形参v会绑定到std::vector<int>对象上。

​ 当通过调用函数模板fwd间接调用f时,编译器不再把传入给fwd的实参和f的声明中形参类型进行比较。而是推导传入给fwd的实参类型,然后比较推导后的实参类型和f的形参声明类型。当下面情况任何一个发生时,完美转发就会失败:

  • 编译器不能推导出fwd的一个或者多个形参类型。这种情况下代码无法编译。
  • 编译器推导“错”了fwd的一个或者多个形参类型。在这里,“错误”可能意味着fwd的实例将无法使用推导出的类型进行编译,但是也可能意味着使用fwd的推导类型调用f,与用传给fwd的实参直接调用f表现出不一致的行为。这种不同行为的原因可能是因为f是个重载函数的名字,并且由于是“不正确的”类型推导,在fwd内部调用的f重载和直接调用的f重载不一样。

将花括号初始化传递给未声明为std::initializer_list的函数模板形参,被判定为——就像标准说的——“非推导上下文”。简单来讲,这意味着编译器不准在对fwd的调用中推导表达式{ 1, 2, 3 }的类型(注意花括号和std::initializer_list不是一个东西),因为fwd的形参没有声明为std::initializer_list。对于fwd形参的推导类型被阻止,编译器只能拒绝该调用。

使用花括号初始化的auto的变量的类型推导是成功的。这种变量被视为std::initializer_list对象,在转发函数应推导出类型为std::initializer_list的情况,这提供了一种简单的解决方法——使用auto声明一个局部变量,然后将局部变量传进转发函数,这就是上面第三次可以调用ok的原因。

2.0或者NULL作为空指针

会推导成int,请使用nullptr

3.仅有声明的整型static const数据成员

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>
#include <vector>
using namespace std;

class Widget {
public:
static const std::size_t MinVals = 28; //MinVal的声明

};
//没有MinVals定义
template<typename... T>
void f(T... params)
{
cout<<"1"<<endl;
}

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

int main()
{
std::vector<int> widgetData;
widgetData.reserve(Widget::MinVals); //使用MinVals
f(Widget::MinVals);//ok ,视为“f(28)”
fwd(Widget::MinVals); //error,undefined reference to `Widget::MinVals'

return 0;
}

​ 因为编译器会对此类成员实行常量传播const propagation),因此消除了保留内存的需要,编译器通过常量传播将值28放入所有提到MinVals的位置来补充缺少的定义(就像它们被要求的那样)。没有为MinVals的值留存储空间是没有问题的。如果要使用MinVals的地址(例如,有人创建了指向MinVals的指针),则MinVals需要存储(这样指针才有可指向的东西),尽管上面的代码仍然可以编译,但是链接时就会报错,直到为MinVals提供定义。

​ 尽管代码中没有使用MinVals的地址,但是fwd的形参是通用引用,而引用,在编译器生成的代码中,通常被视作指针。在程序的二进制底层代码中(以及硬件中)指针和引用是一样的。通过引用传递MinVals实际上与通过指针传递MinVals是一样的,因此,必须有内存使得指针可以指向。通过引用传递的整型static const数据成员,通常需要定义它们,这个要求可能会造成在不使用完美转发的代码成功的地方,使用等效的完美转发失败。

因此我们需要定义MinVals,只要给整型static const提供一个定义,比如这样:

1
const std::size_t Widget::MinVals;  //在Widget的.cpp文件

不过都到了现代cpp时代吗,不如直接用C++17的内敛变量,这样就不用在cpp定义了:

1
2
3
4
5
class Widget {
public:
inline static const std::size_t MinVals = 28; //MinVal的声明
//static constexpr std::size_t MinVals = 28; // 和上面同理,constexpr在这里自带inline特性
};

4.重载函数的名称和模板名称

假定我们的函数f(我们想通过fwd完美转发实参给的那个函数)可以通过向其传递执行某些功能的函数来自定义其行为。假设这个函数接受和返回值都是intf声明就像这样:

1
void f(int (*pf)(int));             //pf = “process function”

值得注意的是,也可以使用更简单的非指针语法声明。这种声明就像这样,含义与上面是一样的:

1
void f(int pf(int));                //与上面定义相同的f

无论哪种写法都可,现在假设我们有了一个重载函数,processVal

1
2
int processVal(int value);
int processVal(int value, int priority);

我们可以传递processValf

1
f(processVal);                      //可以

但是我们会发现一些吃惊的事情。f要求一个函数指针作为实参,但是processVal不是一个函数指针或者一个函数,它是同名的两个不同函数。但是,编译器可以知道它需要哪个:匹配上f的形参类型的那个。因此选择了仅带有一个intprocessVal地址传递给f

工作的基本机制是f的声明让编译器识别出哪个是需要的processVal。但是,fwd是一个函数模板,没有它可接受的类型的信息,使得编译器不可能决定出哪个函数应被传递:

1
fwd(processVal);                    //错误!那个processVal?

单用processVal是没有类型信息的,所以就不能类型推导,完美转发失败。

解决方法很简单,指定一下你到底说的是哪儿个processVal即可:

1
2
3
4
5
6
7
using ProcessFuncType =                         //写个类型定义;见条款9
int (*)(int);

ProcessFuncType processValPtr = processVal; //指定所需的processVal签名
fwd(processValPtr); //可以

fwd(static_cast<ProcessFuncType>(workOnVal)); //也可以

5.位域

IPv4的头部有如下模型:

1
2
3
4
5
6
7
8
struct IPv4Header {
std::uint32_t version:4,
IHL:4,
DSCP:6,
ECN:2,
totalLength:16;

};
1
2
3
4
5
6
void f(std::size_t sz);         //要调用的函数

IPv4Header h;

f(h.totalLength); //可以
fwd(h.totalLength); //错误!

为什么呢?问题在于fwd的形参是引用,而h.totalLength是non-const位域。这看起来也没什么,但是C++标准非常清楚地谴责了这种组合:non-const引用不应该绑定到位域。禁止的理由很充分。位域可能包含了机器字的任意部分(比如32位int的3-5位),但是这些东西无法直接寻址。也就是说没有办法绑定引用到任意bit上,这种修改就不可能完成,因此non-const引用不应该绑定到位域。

解决方法很简单,把位域的数据拷贝出来成副本,用副本做后面的操作就好了。

1
2
3
4
//拷贝位域值
auto length = static_cast<std::uint16_t>(h.totalLength);

fwd(length); //转发这个副本