05 右值引用、移动语义和完美转发
优质
小牛编辑
131浏览
2023-12-01
- 移动语义使编译器可以用开销较低的移动操作替换昂贵的拷贝操作(但不是所有情况下移动都会比拷贝快),是 move-only 类型对象的支持基础
- 完美转发可以将某个函数模板的实参转发给其他函数,转发后的实参保持完全相同的值类型(左值、右值)
- 右值引用是移动语义和完美转发的实现基础,它引入了一种新的引用符号(&&)来区别于左值引用
- 这些名词很直观,但概念上容易与名称类似的函数混淆
- 移动操作的函数要求传入的实参是右值,无法传入左值,因此需要一个能把左值转换为右值的办法,这就是 std::move 做的事。std::move 本身不进行移动,只是将实参强制转换为右值,以允许把转换的结果传给移动函数
- 完美转发指的是,将函数模板的实参转发给另一个函数,同时保持实参传入给模板时的值类型(传入的实参是左值则转发后仍是左值,是右值则转发后仍是右值)。如果不做任何处理的话,不论是传入的是左值还是右值,在传入之后都会变为左值,因此需要一个转换到右值的操作。std::move 可以做到这点,但它对任何类型都会一视同仁地转为右值。这就需要一个折衷的办法,对左值实参不处理,对右值实参(传入后会变为左值)转换为右值,这就是 std::foward 所做的事
- 如果要表示参数是右值,则需要引入一种区别于左值的符号,这就是右值引用符号(&&)。右值引用即只能绑定到右值的引用,但其本身是左值(引用都是左值)。它只是为了区别于左值引用符号(&)而引入的一种符号标记
- 在模板中,带右值引用符号(T&&)并不表示一定是右值引用(这种不确定类型的引用称为转发引用),因为模板参数本身可以带引用符号(int&),此时为了使结果合法(int& && 是不合法的),就引入了引用折叠机制(int& && 折叠为 int&)
23 std::move 和 std::forward 只是一种强制类型转换
- std::move 不完全符合标准的实现如下
template<typename T>
decltype(auto) move(T&& x)
{
using ReturnType = remove_reference_t<T>&&;
return static_cast<ReturnType>(x);
}
- std::move 会保留 cv 限定符
void f(int&&)
{
std::cout << 1;
}
void f(const int&)
{
std::cout << 2;
}
const int i = 1;
f(std::move(i)); // 2
- 这可能导致的一个问题是,传入右值却执行拷贝操作
class A {
public:
explicit A(const std::string x)
: s(std::move(x)) {} // 转为 const std::string&&,调用 std::string(const std::string&)
private:
std::string s;
};
class A {
public:
explicit A(std::string x)
: s(std::move(x)) {} // 转为 std::string&&,调用 std::string(std::string&&)
private:
std::string s;
};
- C++11 之前的转发很简单
void f(int&) { std::cout << 1; }
void f(const int&) { std::cout << 2; }
// 用多个重载转发给对应版本比较繁琐
void g(int& x)
{
f(x);
}
void g(const int& x)
{
f(x);
}
// 同样的功能可以用一个模板替代
template<typename T>
void h(T& x)
{
f(x);
}
int main()
{
int a = 1;
const int b = 1;
g(a); h(a); // 11
g(b); h(b); // 22
g(1); // 2
h(1); // 错误
}
- C++11 引入了右值引用,但原有的模板无法转发右值。如果使用 std::move 则无法转发左值,因此为了方便引入了 std::forward
void f(int&) { std::cout << 1; }
void f(const int&) { std::cout << 2; }
void f(int&&) { std::cout << 3; }
// 用多个重载转发给对应版本比较繁琐
void g(int& x)
{
f(x);
}
void g(const int& x)
{
f(x);
}
void g(int&& x)
{
f(std::move(x));
}
// 同样可以用一个模板来替代上述功能
template<typename T>
void h(T&& x)
{
f(std::forward<T>(x)); // 注意 std::forward 的模板参数是 T
}
int main()
{
int a = 1;
const int b = 1;
g(a); h(a); // 11
g(b); h(b); // 22
g(std::move(a)); h(std::move(a)); // 33
g(1); h(1); // 33
}
- 看起来完全可以用 std::forward 取代 std::move,但 std::move 的优势在于清晰简单
h(std::forward<int>(a)); // 3
h(std::move(a)); // 3
- 结合可变参数模板,完美转发可以转发任意数量的实参
template<typename... Ts>
void f(Ts&&... args)
{
g(std::forward<Ts>(args)...); // 把任意数量的实参转发给 g
}
24 转发引用与右值引用的区别
- 带右值引用符号不一定就是右值引用,这种不确定类型的引用称为转发引用
template<typename T>
void f(T&&) {} // T&&不一定是右值引用
int a = 1;
f(a); // T 推断为 int&,T&& 是 int& &&,折叠为 int&,是左值引用
f(1); // T 推断为 int,T&& 是 int&&,右值引用
auto&& b = a; // int& b = a,左值引用
auto&& c = 1; // int&& c = 1,右值引用
- 转发引用必须严格按 T&& 的形式涉及类型推断
template<typename T>
void f(std::vector<T>&&) {} // 右值引用而非转发引用
std::vector<int> v;
f(v); // 错误
template<typename T>
void g(const T&&) {} // 右值引用而非转发引用
int i = 1;
g(i); // 错误
- T&& 在模板中也可能不涉及类型推断
template<class T, class Allocator = allocator<T>>
class vector {
public:
void push_back(T&& x); // 右值引用
template <class... Args>
void emplace_back(Args&&... args); // 转发引用
...
};
std::vector<A> v; // 实例化指定了 T
// 对应的实例化为
class vector<A, allocator<A>> {
public:
void push_back(A&& x); // 不涉及类型推断,右值引用
template <class... Args>
void emplace_back(Args&&... args); // 转发引用
...
};
- auto&& 都是转发引用,因为一定涉及类型推断。完美转发中,如果想在转发前修改要转发的值,可以用 auto&& 存储结果,修改后再转发
template<typename T>
void f(T x)
{
auto&& res = doSomething(x);
doSomethingElse(res);
set(std::forward<decltype(res)>(res));
}
- lambda 中也可以使用完美转发
auto f = [](auto&& x) { return g(std::forward<decltype(x)>(x)); };
// 转发任意数量实参
auto f = [](auto&&... args) {
return g(std::forward<decltype(args)>(args)...);
};
25 对右值引用使用 std::move,对转发引用使用 std::forward
- 右值引用只会绑定到可移动对象上,因此应该使用 std::move。转发引用用右值初始化时才是右值引用,因此应当使用 std::forward
class A {
public:
A(A&& rhs) : s(std::move(rhs.s)), p(std::move(rhs.p)) {}
template<typename T>
void f(T&& x)
{
s = std::forward<T>(x);
}
private:
std::string s;
std::shared_ptr<int> p;
};
- 如果希望只有在移动构造函数保证不抛异常时才能转为右值,则可以用 std::move_if_noexcept 替代 std::move
class A {
public:
A() = default;
A(const A&) { std::cout << 1; }
A(A&&) { std::cout << 2; }
};
class B {
public:
B() {}
B(const B&) noexcept { std::cout << 3; }
B(B&&) noexcept { std::cout << 4; }
};
int main()
{
A a;
A a2 = std::move_if_noexcept(a); // 1
B b;
B b2 = std::move_if_noexcept(b); // 4
}
- 如果返回对象传入时是右值引用或转发引用,在返回时要用 std::move 或 std::forward 转换。返回类型不需要声明为引用,按值传递即可
A f(A&& a)
{
doSomething(a);
return std::move(a);
}
template<typename T>
A g(T&& x)
{
doSomething(x);
return std::forward<T>(x);
}
- 返回局部变量时,不需要使用 std::move 来优化
A makeA()
{
A a;
return std::move(a); // 画蛇添足
}
- 局部变量会直接创建在为返回值分配的内存上,从而避免拷贝,这是 C++ 标准诞生时就有的 RVO(return value optimization)。RVO 的要求十分严谨,它要求局部对象类型与返回值类型相同,且返回的就是局部对象本身,而使用了 std::move 反而不满足 RVO 的要求。此外 RVO 只是种优化,编译器可以选择不采用,但标准规定,即使编译器不省略拷贝,返回对象也会被作为右值处理,所以 std::move 是多余的
A makeA()
{
return A{};
}
auto x = makeA(); // 只需要调用一次 A 的默认构造函数
26 避免重载使用转发引用的函数
- 如果函数参数接受左值引用,则传入右值时执行的仍是拷贝
std::vector<std::string> v;
void f(const std::string& s)
{
v.emplace_back(s);
}
// 传入右值,执行的依然是拷贝
f(std::string("hi"));
f("hi");
- 让函数接受转发引用即可解决此问题
std::vector<std::string> v;
template<typename T>
void f(T&& s)
{
v.emplace_back(std::forward<T>(s));
}
// 现在传入右值时变为移动操作
f(std::string("hi"));
f("hi");
- 但如果重载这个转发引用版本的函数,就会导致新的问题
std::vector<std::string> v;
template<typename T>
void f(T&& s)
{
v.emplace_back(std::forward<T>(s));
}
std::string makeString(int n)
{
return std::string("hi");
}
void f(int n) // 新的重载函数
{
v.emplace_back(makeString(n));
}
// 之前的调用仍然正常
f(std::string("hi"));
f("hi");
// 对于重载版本的调用也没问题
f(1); // 调用重载版本
// 但对于非int(即使能转换到 int)参数就会出现问题
unsigned i = 1;
f(i); // 转发引用是比 int 更精确的匹配
// 为std::vector<std::string> 传入 short,用 short 构造 std::string 导致错误
- 转发引用几乎可以匹配任何类型,因此应该避免对其重载。此外,如果在构造函数中使用转发引用,会导致拷贝构造函数不能被正确匹配
std::string makeString(int n)
{
return std::string("hi");
}
class A {
public:
A() = default;
template<typename T>
explicit A(T&& n) : s(std::forward<T>(n)) {}
explicit A(int n) : s(makeString(n)) {}
private:
std::string s;
};
unsigned i = 1;
A a(i); // 依然调用模板而出错,但还有一个更大的问题
A b("hi"); // OK
A c(b); // 错误:调用的仍是模板,用 A 初始化 std::string 出错
- 模板构造函数不会阻止合成拷贝和移动构造函数(会阻止合成默认构造函数),上述问题的实际情况如下
class A {
public:
template<typename T>
explicit A(T&& n) : s(std::forward<T>(n)) {}
A(const A& rhs) = default;
A(A&& rhs) = default;
private:
std::string s;
};
A a("hi"); // OK
A b(a); // 错误:调用的仍是模板,用 A 初始化 std::string 出错
// 传入的是 A&,T&& 比 const A& 更匹配
const A c("hi");
A d(c); // OK
- 上述问题在继承中会变得更为复杂,如果派生类的拷贝和移动操作调用基类的构造函数,同样会匹配到使用了转发引用的模板,从而导致编译错误
class A {
public:
template<typename T>
explicit A(T&& n) : s(std::forward<T>(n)) {}
private:
std::string s;
};
class B : public A {
public:
B(const B& rhs) : A(rhs) {} // 错误:调用基类模板而非拷贝构造函数,const B 不能转为 std::string
B(B&& rhs) : A(std::move(rhs)) noexcept {} // 错误:调用基类模板而非移动构造函数,B 不能转为 std::string
};
27 重载转发引用的替代方案
- 上述问题的最直接解决方案是,不使用重载。其次是使用 C++98 的做法,不使用转发引用
class A {
public:
template<typename T>
explicit A(const T& n) : s(n) {}
private:
std::string s;
};
A a("hi");
A b(a); // OK
- 直接按值传递也是一种简单的方式,而且解决了之前的问题
std::string makeString(int n)
{
return std::string("hi");
}
class A {
public:
explicit A(std::string n) : s(std::move(n)) {}
explicit A(int n) : s(makeString(n)) {}
private:
std::string s;
};
unsigned i = 1;
A a(i); // OK,调用 int 版本的构造函数
- 不过上述方法实际上是规避了使用转发引用,下面是几种允许转发引用的重载方法
标签分派(tag dispatching)
- 标签分派的思路是,额外引入一个参数来打破转发引用的万能匹配
std::vector<std::string> v;
template<typename T>
void g(T&& s, std::false_type)
{
v.emplace_back(std::forward<T>(s));
}
std::string makeString(int n)
{
return std::string("hi");
}
void g(int n, std::true_type)
{
v.emplace_back(makeString(n));
}
template<typename T>
void f(T&& s)
{
g(std::forward<T>(s), std::is_integral<std::remove_reference_t<T>>());
}
unsigned i = 1;
f(i); // OK:调用 int 版本
使用std::enable_if在特定条件下禁用模板
- 标签分派用在构造函数上不太方便,这时可以使用 std::enable_if,它可以强制编译器在满足特定条件时禁用模板
class A {
public:
template<typename T, typename =
std::enable_if_t<!std::is_same_v<A, std::decay_t<T>>>>
explicit A(T&& n) {}
private:
std::string s;
};
- 但这只是在参数具有和类相同的类型时禁用模板,派生类调用基类的构造函数时,派生类和基类也是不同类型,不会禁用模板,因此还需要使用 std::is_base_of
class A {
public:
template<typename T, typename =
std::enable_if_t<!std::is_base_of_v<A, std::decay_t<T>>>>
explicit A(T&& n) {}
private:
std::string s;
};
class B : public A {
public:
B(const B& rhs) : A(rhs) {} // OK:不再调用模板
B(B&& rhs) : A(std::move(rhs)) noexcept {} // OK:不再调用模板
};
- 接着在参数为整型时禁用模板,即可解决之前的所有问题
std::string makeString(int n)
{
return std::string("hi");
}
class A {
public:
template<typename T, typename =
std::enable_if_t<!std::is_base_of_v<A, std::decay_t<T>>
&& !std::is_integral_v<std::remove_reference_t<T>>>>
explicit A(T&& n) : s(std::forward<T>(n)) {}
explicit A(int n) : s(makeString(n)) {}
private:
std::string s;
};
unsigned i = 1;
A a(1); // OK:调用 int 版本的构造函数
A b("hi"); // OK
A c(b); // OK
- 为了更方便调试,可以用 static_assert 预设错误信息,这个错误信息将在不满足预设条件时出现在诊断信息中
std::string makeString(int n)
{
return std::string("hi");
}
class A {
public:
template<typename T, typename =
std::enable_if_t<!std::is_base_of_v<A, std::decay_t<T>>
&& !std::is_integral_v<std::remove_reference_t<T>>>>
explicit A(T&& n) : s(std::forward<T>(n))
{
static_assert(std::is_constructible_v<std::string, T>,
"Parameter n can't be used to construct a std::string");
}
explicit A(int n) : s(makeString(n)) {}
private:
std::string s;
};
28 引用折叠
- 引用折叠会出现在四种语境中:模板实例化、auto 类型推断、decltype 类型推断、typedef 或 using 别名声明
- 引用的引用是非法的
int a = 1;
int& & b = a; // 错误
- 当左值传给接受转发引用的模板时,模板参数就会推断为引用的引用
template<typename T>
void f(T&&);
int i = 1;
f(i); // T为int&,T& &&变成了引用的引用,于是需要引用折叠的机制
- 为了使实例化成功,编译器生成引用的引用时,将使用引用折叠的机制,规则如下
& + & → &
& + && → &
&& + & → &
&& + && → &&
- 引用折叠是 std::forward 的支持基础
// 不完整的实现
template<typename T>
T&& forward(remove_reference_t<T>& x)
{
return static_cast<T&&>(x); // 如果传递左值 A,T 推断为 A&,此时需要引用折叠
}
// 传递左值 A 时相当于
A& && forward(remove_reference_t<A&>& x)
{
return static_cast<A& &&>(x);
}
// 简化后
A& forward(A& x)
{
return static_cast<A&>(x);
}
// 传递右值 A 相当于
A&& forward(remove_reference_t<A>& x)
{
return static_cast<A&&>(x);
}
// 简化后
A&& forward(A& x)
{
return static_cast<A&&>(x);
}
- auto&& 与使用转发引用的模板原理一样
int a = 1;
auto&& b = a; // a 是左值,auto 被推断为 int&,int& && 折叠为 int&
- decltype 同理,如果推断中出现了引用的引用,就会发生引用折叠
- 如果在 typedef 的创建或求值中出现了引用的引用,就会发生引用折叠
template<typename T>
struct A {
using RvalueRef = T&&; // typedef T&& RvalueRef;
};
int a = 1;
A<int&>::RvalueRef b = a; // int& && 折叠为 int&,int& b = a
- 并且 top-level cv 限定符会被丢弃
using A = const int&; // low-level
using B = int&&; // low-level
static_assert(std::is_same_v<volatile A&&, const int&>);
static_assert(std::is_same_v<const B&&, int&&>);
29 移动不比拷贝快的情况
- 在如下场景中,C++11 的移动语义没有优势
- 无移动操作:待移动对象不提供移动操作,移动请求将变为拷贝请求
- 移动不比拷贝快:待移动对象虽然有移动操作,但不比拷贝操作快
- 移动不可用:本可以移动时,要求移动操作不能抛异常,但未加上 noexcept 声明
- 除了上述情况,还有一些特殊场景无需使用移动语义,比如之前提到的 RVO
- 移动不一定比拷贝代价小得多。比如 std::array 实际是带 STL 接口的内置数组。不同于其他容器的是,其他容器把元素存放于堆上,自身只持有一个指向堆内存的指针,移动容器时只需要移动指针,在常数时间内即可完成移动
- 而 std::array自身存储了内容,没有这样的指针,移动或拷贝对元素逐个执行,需要线性时间复杂度,所以移动并不比拷贝快多少
- 另一个移动不一定比拷贝快的例子是 std::string,一种实现是使用small string optimization(SSO),在字符串很小时(一般是15字节)存储在自身内部,而不使用堆上分配的内存,因此对小型字符串的移动并不比拷贝快
30 无法完美转发的类型
- 用相同实参调用原函数和转发函数,如果两者执行不同的操作,则称完美转发失败。完美转发失败源于模板类型推断不符合预期,会导致这个问题的类型包括:大括号初始化值、作为空指针的 0 和 NULL、只声明但未定义的整型 static const 数据成员、重载函数的名称和函数模板名称、位域
大括号初始化
void f(const std::vector<int>& v) {}
template<typename T>
void fwd(T&& x)
{
f(std::forward<T>(x));
}
f({ 1, 2, 3 }); // OK,{1, 2, 3} 隐式转换为 std::vector<int>
fwd({ 1, 2, 3 }); // 无法推断 T,导致编译错误
// 解决方法是借用 auto 推断出 std::initializer_list 类型再转发
auto x = { 1, 2, 3 };
fwd(x); // OK
作为空指针的 0 或 NULL
- 0 和 NULL 作为空指针传递给模板时,会推断为 int 而非指针类型
void f(int*) {}
template<typename T>
void fwd(T&& x)
{
f(std::forward<T>(x));
}
fwd(NULL); // T推断为int,转发失败
只声明但未定义的 static const 整型数据成员
- 类内的 static 成员的声明不是定义,如果 static 成员声明为 const,则编译器会为这些成员值执行 const propagation,从而不需要为它们保留内存。对整型 static const 成员取址可以通过编译,但会导致链接期的错误。转发引用也是引用,在编译器生成的机器代码中,引用一般会被当成指针处理。程序的二进制代码中,从硬件角度看,指针和引用的本质相同
class A {
public:
static const int n = 1; // 仅声明
};
void f(int) {}
template<typename T>
void fwd(T&& x)
{
f(std::forward<T>(x));
}
f(A::n); // OK:等价于 f(1)
fwd(A::n); // 错误:fwd 形参是转发引用,需要取址,无法链接
- 但并非所有编译器的实现都有此要求,上述代码可能可以链接。考虑到移植性,最好还是提供定义
// A.h
class A {
public:
static const int n = 1;
};
// A.cpp
const int A::n;
重载函数的名称和函数模板名称
- 如果转发的是函数指针,可以直接将函数名作为参数,函数名会转换为函数指针
void g(int) {}
void f(void(*pf)(int)) {}
template<typename T>
void fwd(T&& x)
{
f(std::forward<T>(x));
}
f(g); // OK
fwd(g); // OK
- 但如果要转发的函数名对应多个重载函数,则无法转发,因为模板无法从单独的函数名推断出函数类型
void g(int) {}
void g(int, int) {}
void f(void(*)(int)) {}
template<typename T>
void fwd(T&& x)
{
f(std::forward<T>(x));
}
f(g); // OK
fwd(g); // 错误:不知道转发的是哪一个函数指针
- 转发函数模板名称也会出现同样的问题,因为函数模板可以看成一批重载函数
template<typename T>
void g(T x)
{
std::cout << x;
}
void f(void(*pf)(int)) { pf(1); }
template<typename T>
void fwd(T&& x)
{
f(std::forward<T>(x));
}
f(g); // OK
fwd(g<int>); // 错误
- 要让转发函数接受重载函数名称或模板名称,只能手动指定需要转发的重载版本或模板实例。不过完美转发本来就是为了接受任何实参类型,而要传入的函数指针类型一般是未知的
template<typename T>
void g(T x)
{
std::cout << x;
}
void f(void(*pf)(int)) { pf(1); }
template<typename T>
void fwd(T&& x)
{
f(std::forward<T>(x));
}
using PF = void(*)(int);
PF p = g;
fwd(p); // OK
fwd(static_cast<PF>(g)); // OK
位域
- 转发引用也是引用,实际上需要取址,但位域不允许直接取址
struct A {
int a : 1;
int b : 1;
};
void f(int) {}
template<typename T>
void fwd(T&& x)
{
f(std::forward<T>(x));
}
A x{};
f(x.a); // OK
fwd(x.a); // 错误
- 实际上接受位域实参的函数也只能收到位域值的拷贝,因此不需要使用完美转发,换用传值或传 const 引用即可。完美转发中也可以通过强制转换解决此问题,虽然转换的结果是一个临时对象的拷贝而非原有对象,但位域本来就无法做到真正的完美转发
fwd(static_cast<int>(x.a)); // OK