本章围绕move
和forward
这两个函数展开,详细介绍了右值引用,普适引用,移动语义,完美转发等概念。
Item 23:Understand std::move and std::forward.
std::move
和std::forward
仅仅是进行转换的函数。std::move
无条件地将它的参数转换为右值,而std::forward
只在某些条件满足时进行这种转换。
(1)move的特性
下面一个接近标准库实现的std::move
实现的例子:
template <typename T> // version C++11
typename remove_reference<T>::type&& move(T&& param) {
using ReturnType = typename remove_reference<T>::type&&;
return static_cast<ReturnType>(param);
}
tmeplate <typename T> // version C++14
decltype(auto) move(T&& param) {
using ReturnType = remove_reference_t<T>&&;
return static_cast<ReturnType>(param);
}
其中remove_reference_t
用于移除类型的引用,返回原始类型。比如std::remove_reference::type
就等同于int
。在上面的代码中,我们先移除了对象的引用,然后加上&&,转化为右值。
所以我们看到,std::move
只做了转换,并没有进行实际的移动,在一个对象上应用std::move
就告知了编译器这个对象可以被移动,这就是它叫这个名字的原因:易于标记出可能被移动的对象。
因此,这就出现了一个问题:我们只转化了引用,对const并没有进行任何操作。下面看一个例子:
class Annotation {
public:
explicit Annotation(const std::string text)
: value(std::move(text))
{...}
...
private:
std::string value;
};
会收到IDE送来的警告,告诉我们最好不要对const类型进行移动构造。
我们来看看std::string
的复制构造函数和移动构造函数:
class string {
public:
...
string(const string& rhs);
string(string&& rhs);
...
};
假设输入const string&&
,显然没办法传给string(string&& rhs)
,但能传给string(const string& rhs)
。因此value
的构造使用了复制构造函数,即使它的参数是右值引用。
因此,我们可以总结:
- 不要把希望移动的变量声明为
const
。 std::move
不意味着移动任何东西,甚至不保证它转换的对象可移动。它只保证它的转换结果一定是右值。
(2)forward的特性
std::forward
与std::move
很类似,只是std::move
是无条件的转换,而std::forward
是有条件的转换。下面举个例子:
void process(const Widget& lval);
void process(Widget&& rval);
template <typename T>
void logAndProcess(T&& param) {
auto now = std::chrono::system_clock::now();
makeLogEntry("Calling 'process'", now);
process(std::forward<T>(param));
}
我们希望在param
类型为左值引用时调用process(const Widget& lval)
,在param
为右值引用时调用process(Widget&& rval)
。但param
是函数参数,它本身永远是左值。因此我们需要一种方法在条件满足时将其转换为右值——logAndProcess
的实参为右值。这就是std::forward
要做的,有条件的转换,即当且仅当它的参数是通过右值初始化时进行转换。
两者最大的区别是std::move
是无条件的转换,而std::forward
只在参数为右值引用时将其转换为右值。
Item 24:Distinguish universal references from rvalue references.
在Item1中我们就介绍了普适引用(universal reference)这个东西,那时候我们基本上把它和右值引用是画等号,在这一节中,我们对他们两进行详细的区别。
T&&
有两个含义,第一个就是右值引用,它的主要作用是标记一个可以移动的对象;第二个含义则既可能是右值引用也可能是左值引用,我们可以将其绑定在任何对象上,称其为“普适引用”。
普适引用有两个场景:
函数模板
template <typename T>
void f(T&& param);
auto声明
auto&& var2 = var1;
它们的共同点就是需要类型推断。如果不需要类型推断,例如Widget&& var1 = Widget();
,这就不是普适引用,就只是一个右值引用。
普适引用的初始化式决定了它是右值引用还是左值引用:如果初始化式是右值,普适引用就是右值引用;如果初始化式是左值,普适引用就是左值引用:
template <typename T>
void f(T&& param); // universal reference
Widget w;
f(w); // lvalue passed to f: Widget&
f(std::move(w)); // rvalue passed to f: Widget&&
光有类型推断还不足够,普适引用要求引用的声明格式必须是T&&
,而不是std::vector&&
或const T&&
这样的声明。
当然你在模板中看到了一个函数参数为T&&
,也不代表它是普适引用,因为这里可能根本不需要类型推断。下面这个例子中,push_back
的参数x
不是普适引用,因为编译器会先实例化vector
,之后你就发现push_back
根本没有涉及到类型推断。
template <class T, class Allocator = allocator<T>>
class vector {
public:
void push_back(T&& x);
...
};
与之相反,emplace_back
应用了类型推断:
template <class T, class Allocator = allocator<T>>
class vector {
public:
template <class... Args>
void emplace_back(Args&&... args);
...
};
Item 25:Use std::move on rvalue references, std::forward on universal references.
前面我们已经讨论过,右值引用总是可以无条件转换为右值因此用std::move
,但普适引用不一定是右值,因此要用std::forward
做有条件的右值转换。
由于move
没有办法应对const
,所以有的人会说,我们重载两个函数来灵活应对,当传入const
时,我们使用复制,否则就使用移动构造。
class Widget {
public:
void setName(const std::string& newName) {
name = newName;
}
void setName(std::string&& newName) {
name = std::move(newName);
}
};
想得很美好,代码也能运行,但实际情况是如果我有$n$个参数,则需要重载$2^n$个版本。
所以我们可以用std::forward
(对于普适引用)去移动它们。
template <typename T>
void setSignText(T&& text) {
sign.setText(text); // use text, but don't modify it
auto now = std::chrono::system_clock::now();
signHistory.add(now, std::forward<T>(text));
// conditionally cast text to rvalue
}
如果有一个按值返回的函数,其返回的对象是右值引用或普适引用,那么也可以用std::move
或std::forward
来获得更好的性能:
Matrix operator+(Matrix&& lhs, const Matrix& rhs) {
lhs += rhs;
return std::move(lhs);
}
//或者是
template <typename T>
Fraction reduceAndCopy(T&& frac) {
frac.reduce();
return std::forward<T>(frac);
}
如果Matrix
和frac
不支持移动,用std::move
和std::forward
也不会有副作用。等到他们支持移动了,上面的代码马上就能享受到性能的提升。
如果返回的对象是个local对象,有些人可能会想到用std::move
来避免复制(注意和上面返回的区别):
Widget makeWidget() {
Widget w;
...
return std::move(w);
}
但这样并不对,有个概念叫RVO,即“返回值优化”,即编译器会在返回一个local对象时,如果函数的返回类型就是值类型,那么编译器可以直接将这个local对象构造在接收函数返回值的对象上,省掉中间的复制过程。换句话说,在RVO的帮助下,直接返回这个local对象要比返回它的右值还要节省。
Item 26:Avoid overloading on universal references.
注意:根据C++的重载决议规则,普适引用版本总会被优先匹配。
假设我对普适引用进行重载:
vector<string> names;
/*part1*/
template <typename T>
void logAndAdd(T&& name) {
names.emplace(std::forward<T>(name));
}
/*part2*/
//获取ID后,利用函数nameFromIdx得到string再构造
std::string nameFromIdx(int idx)
{
return to_string(idx) + "_myname";
};
void logAndAdd(int idx) {
names.emplace(nameFromIdx(idx));
}
//调用
short nameId=10086;
logAndAdd(nameId); //error
对上面的代码我们可以分为以下几种情况:
- part1和part2存在,输入int。换完美运行重载函数,
- part1和part2存在。输入short,优先运行普适引用版本,报错。
- part1删除,保留part2,输入short。编译器隐式转换,扩大范围,转换为int,完美运行。
因此,我们知道普适引用版本在重载决议中的顺序都非常靠前,它们几乎能完美匹配所有类型,重载版本很可能无法顺利工作(除非类型完全匹配,不包含任何隐式转换)
在类的构造函数这里,情况变得更糟了:
class Person {
public:
template <typename T>
explicit Person(T&& n) : name(std::forward<T>(n)) {}
explicit Person(int idx) : name(nameFromIdx(idx)) {}
...
private:
std::string name;
};
根据item17,某个类有模板构造函数不会阻止编译器为它生成复制和移动构造函数,因此Person
中的构造函数实际上有4个:
template <typename T>
explicit Person(T&& n)
: name(std::forward<T>(n)) {}
explicit Person(int idx)
: name(nameFromIdx(idx)) {}
Person(const Person& rhs);
Person(Person&& rhs);
当我们调用如下代码时,我们以为调用了复制构造函数,实际上却是匹配到了普适引用的版本,这相当违反直觉!
Person p1("Baolan");
Person p2(p1);//error
const Person p3("Clearlove");
Person p4(p3);//work
因为生成的默认拷贝构造函数需要const
,此时编译器判断输入的Baolan不是完美匹配,所以他选择了普适引用的版本,除非我们像Clearlove那样构造cosnt Person
这样才能顺利通过编译。
Item 27:Familiarize yourself with alternatives to overloading on universal references.
如何解决上一章的问题呢?一种思路是放弃重载普适引用,原书中介绍了三种方式:完全放弃重载构建两个不同名函数,通过值传递,通过C98的老办法const T&构造。这里先不做介绍,接下来介绍两种能够重载普适引用的办法,这两种办法非常trick,运用了很多现代CPP编程的特性,非常值得学习。
(1)使用标签分发(Tag dispatch)
重载决议是在所有参数上发生的,那么如果我们人为的增加一个Tag参数,用Tag参数来匹配,就能避免普适引用带来的问题。
首先是原始版本:
std::multiset<std::string> names;
template <typename T>
void logAndAdd(T&& name) {
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
template <typename T>
void logAndAdd(T&& name) {
logAndAddImpl(std::forward<T>(name), std::is_integral<T>());
}
这里的问题在于,当实参是左值时,T
会被推导为左值引用,即如果实参类型是int
,那么T
就是int&
,std::is_integral()
就会返回false。这里我们需要把T
可能的引用性去掉:
template <typename T>
void logAndAdd(T&& name) {
logAndAddImpl(
std::forward<T>(name),
std::is_integral<typename std::remove_reference<T>::type>()
);
}
然后logAndAddImpl
提供两个特化版本。为什么用std::true_type
/std::false_type
而不用true/false
?前者是编译期值,后者是运行时值。
template <typename T>
void logAndAddImpl(T&& name, std::false_type) {
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
std::string nameFromIdx(int idx);
template <typename T>
void logAndAddImpl(T&& name, std::true_type) {
logAndAdd(nameFromIdx(idx));
}
我们并没有给logAndAddImpl
的第二个参数起名字,说明它就是一个Tag。这种方法常用于模板元编程。
(2)限制模板使用普适引用
Tag dispatch的思路是利用一个不重载的函数作为入口,他会安排一个tag参数,再分发给重载函数。但这种办法并不能解决类的构造函数问题,编译器依然会自己生成复制和移动构造函数。
我们可以利用C++的SFINAE特性(Substitution Failure Is Not An Error),使用std::enable_if
,只有里面的条件为真是,这个参数才有效,否则我们就忽略它。
class Person {
public:
template <typename T,
typename = typename std::enable_if<condition>::type>
explicit Person(T&& n);
};
我们引入了一个强大的工具,现在需要考虑怎么运用它。我们之前的问题是:我重载了普适引用,但由于const
,引用符等问题导致我的重载函数和传入的参数不能完美匹配,从而让编译器选择了普适引用,现在我想让编译器明白:有没有CV符号不重要,只要长得差不多,你就赶紧给我调用重载版本!!!
因此,我们可以把condition设为:忽略CV符号和引用,T
和person
是不是同一种类型。标准库中对应的工具是std::decay
,它会把对象身上的引用和cv特性都去掉。它在处理数组和函数类型时会把它们转为指针类型。
引用和cv特性都去掉。它在处理数组和函数类型时会把它们转为指针类型。
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
的构造函数,上面的版本已经能解决了:在传入的参数类型为Person
时调用我们希望的复制和移动构造函数,而在其它时候调用完美转发函数。
最后一个问题是派生类,
class SpecialPerson: public Persion {
public:
SpecialPerson(const SpecialPerson& rhs) // copy ctor: calls Person forwarding ctor!
: Person(rhs)
{...}
SpecialPerson(SpecialPerson&& rhs) // move ctor: calls Person forwarding ctor!
: Person(std::move(rhs))
{...}
};
看起来这并没有解决, 因为std::is_same::value
是false
,我们需要的是std::is_base_of
,其作用是判断是否为基类,我们就可以改为:
class Person {
public:
template <
typename T,
typename = typename std::enable_if<
!std::is_base_of<
Person,
typename std::decay<T>::type
>::value>
>::type
>
explicit Person(T&& n);
...
};
//C++14中代码可以省一点:
class Person {
public:
template <
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person, std::decay_t<T>>::value>
>
>
explicit Person(T&& n);
...
};
还没有结束,最后一个问题:如何区分整数类型和非整数类型。直接看最终版本,这个版本就代表了:如果参数T不是派生出来的也不是整数,那么就采用普适引用版本。
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)
: name(std::forward<T>(n))
{...}
explicit Person(int idx)
: name(nameFromIdx(idx))
{...}
...
private:
std::string name;
};
Item 28:Understand reference collapsing.
一般来说,引用的引用在C++中是非法的,你不能:
int& &m;
但在类型推断中,有一套单独的规则:引用折叠。可以看出这套规则中只要不是4个&,统统折叠为左值。
T& & => T&
T& && => T&
T&& & => T&
T&& && => T&&
引用折叠就是std::forward
依赖的关键特性。一个简化的std::forward
实现:
template <typename T>
T&& forward(typename remove_reference<T>::type& param) {
return static_cast<T&&>(param);
}
假设我传入了一个widget&
,则
remove_reference::type
去除引用,将T变为widget&
,此时Widget&& forward(Widget& param) { return static_cast<Widget& &&>(param); }
利用引用折叠
widget& && = widget&
,返回。
若传入的是右值,则在折叠阶段,返回widget&&
Widget&& forward(Widget& param)
{
return static_cast<Widget&&>(param);
}
Item 30:Familiarize yourself with perfect forwarding failure cases.
假设有一个非完美转发的函数f
,和它对应的完美转发版本fwd
,我们会遇到几个问题:
template <typename T>
void fwd(T&& param) {
f(std::forward<T>(param));
}
//我们希望以下两个函数有相同行为
f(expression);
fwd(expression);
(1)花括号初始化
void f(const std::vector<int>& v);
f({1, 2, 3}); // fine, "{1, 2, 3}" implicitly converted to std::vector<int>
fwd({1, 2, 3}); // error! doesn't compile
原因在于,编译器知道f
的形参类型,所以它知道可以把实参类型隐式转换为形参类型。但编译器不知道fwd
的形参类型,因此需要通过实参进行类型推断。这里完美转发会在发生以下情况时失败:
- 无法推断出
fwd
的某个参数类型。 - 推断出错误类型。这里的“错误”可以是推断出的类型无法实例化
fwd
,也可以是fwd
的行为与f
不同。后者的一个可能原因是f
是重载函数的名字,推断的类型不对会导致调用错误的重载版本。
在fwd({1, 2, 3})
这个例子中,问题在于它是一个“未推断上下文”,标准规定禁止推断作为函数参数的花括号初始化式,除非形参类型是std::initializer_list
。
解决方案很简单,这里我们应用了Item2中提到的一个auto
特性:会优先推断接收的表达式为std::initializer_list
。
auto il = {1, 2, 3};
fwd(il);
(2)使用0或NULL
例子见Item8,结论就是不要用0
或NULL
作为空指针,用nullptr
。
(3)只有声明的static const
或constexpr
的整数成员
通常来说我们不需要给类的声明为static const
或constexpr
的整数成员一个定义,因为编译器会把这些成员直接替换为对应的整数值:
class Widget {
public:
static constexpr std::size_t MinVals = 28;
...
}; // no def for MinVals
...
std::vector<int> widgetData;
widgetData.reserve(Widget::MinVals);
如果没有任何地方取MinVals
的地址,编译器就没有必要给它安排一块内存,可以直接替换为整数字面值。否则我们就要给MinVals
一个定义,不然程序会在链接阶段出错。
void f(std::size_t val);
f(Widget::MinVals); // fine, treated as 28
fwd(Widget::MinVals); // error! shouldn't link
问题在于fwd
的参数类型是非const引用,这相当于取了MinVals
的地址,因此我们需要给它一个定义,注意这里就不用给初始值了,否则编译器会报错的。
constexpr std::size_t Widget::MinVals; // in Widget's .cpp file
(4)重载的函数名字和模板名字
假设f
的参数是一个函数:
void f(int (*pf)(int));
void f(int pf(int)); //这样也可以
以及我们有两个重载函数:
int processVal(int value);
int processVal(int value, int priority);
当我们把processVal
传入时:
f(processVal); // fine
fwd(processVal); // error! which processVal?
因为fwd
的参数没有类型,processVal
这个名字本身也没能给出一个确定的类型。
模板函数也有这样的问题:
template <typename T>
T workOnVal(T param) {...}
fwd(workOnVal); // error! which workOnVal instantiation?
解决方案就是确定下来重载函数名字或模板函数名字对应的函数类型:
using ProcessFuncType = int (*)(int);
ProcessFuncType processValPtr = processVal;
fwd(processValPtr);
fwd(static_cast<ProcessFuncType>(workOnVal));
(5)位域
struct IPv4Header {
std::uint32_t version:4,
IHL:4,
DSCP:6,
ECN:2,
totalLength:16;
...
};
void f(std::size_t sz);
IPv4Header h;
...
f(h.totalLength); // fine
fwd(h.totalLength); // error!
问题在于fwd
的参数是非const引用,而C++标准禁止创建位域的非const引用。实际上,位域的const引用就是引用一个临时的复制整数。解决方案很简单:把位域的值复制出来,再传入fwd
:
auto length = static_cast<std::uint16_t>(h.totalLength);
fwd(length);