名词解释文章预先发布于:pokpok.ink
-
移动语义:用不那么昂贵的操作代替昂贵的复制操作,也使得只支持移动变得可能,比如 unique_ptr,将数据的所有权移交给别人而不是多者同时引用。
-
完美转发:目标函数会收到转发函数完全相同类似的实参。
-
右值引用:是这两个机制的底层语言机制,形式是 Type&&,能够引用到“不再使用”的数据,直接用于对象的构造
要注意的是,形参一定是左值,即使类型是右值引用:
void f(Widget&& w) {
/* w 在作用域内就是一个左值。 */
}
实现移动语意和完美转发的重要工具就是std::move
和 std::forward
,std::move
和 std::forward
其实都是强制类型转换函数,std::move
无条件将实参转换为右值,而 std::forward
根据实参的类型将参数类型转化为左值或者右值到目标函数。
std::move(v)
相当于 static_cast<T&&>(v)
,强制将类型转化为需要类型的右值,move 的具体实现为:
template<typename T>
typename remove_reference<T>::type&&
move(T&& param) {
using ReturnType = typename remove_reference<T>::type&&;
return static_cast<ReturnType>(param);
}
-
其中
typename remove_reference<T>::type&&
作用就是为了解决是当入参数是reference to lvalue
的时候,返回类型ReturnType
会因为引用折叠
被推导为T&
,remove_reference<T>::type
就是为了去除推导出的模版参数 T 的引用,从到强制得到T&&
。 -
如果没有
remove_reference<T>
,而是用T&&
来代替函数返回值以及static_cast<>
,就会有下面的推导规则:- 如果入参是
lvalue
,那么T
就会被推导成为T&
,参数param
的类型就变成了T& &&
,再通过引用折叠
的规则,推导最终结果为T&
,而根据表达式的 value category 规则,如果一个函数的返回值类型是左值引用,那么返回值的类型为左值,那么std::move(v)
就不能实现需要的功能,做到强制右值转换。 - 如果入参是
rvalue
,那么T
会被直接推导成T
,参数param
的类型也就变成了T&&
,函数返回的类型(type)也是T&&
,返回的值类别也是右值。
- 如果入参是
-
此外,在 c++14 能直接将
typename remove_reference<T>::type&&
替换为remove_reference_t<T>&&
。
std::forward<T>(v)
的使用场景用于函数需要转发不同左值或者右值的场景,假设有两个重载函数:
void process(const Widget& lvalArg);
void process(Widget&& rvalArg);
有一个函数 LogAndProcess
会根据 param
左值或者右值的不同来区分调用不同函数签名的 process
函数:
template<typename T>
void LogAndProcess(T&& param) {
// DoSomething
logging(param);
process(std::forward<T>(param));
}
这样使用 LogAndProcess
的时候就能区分:
Widget w;
LogAndProcess(w); // call process(const Widget&);
LogAndProcess(std::move(w)); // call process(Widget&&);
这里就有 emplace_back 一种常见的用错的情况,在代码中也经常看见,如果要将某个不用的对象a
放到vector
中:
class A {
A(A&& a) {}
};
A a;
std::vector<A> vec;
vec.push_back(a);
如果使用 push_back
就会造成一次拷贝,常见的错误做法是将其替换为 vector::emplace_back()
:
vec.emplace_back(a);
但是 emplace_back
的实现有 std::forward
根据实参数做转发,实参 a
实际上是个 lvaue
,转发到构造函数时得到的也是左值的 a,就相当于调用赋值拷贝构造:
vec[back()] = a;
解决方法其实只需要调用 std::move
做一次右值转换即可,就能完成数据的移动。
vec.emplace_back(std::move(a));
万能引用和右值引用
万能引用和右值引用最大的区别在于万能引用会涉及模板的推导。但并不是说函数参数中有模板参数就是万能引用,例如 std::vector::push_back()
的函数签名是 push_back(T&& x)
, 但是 T
的类型在 std::vector<T>
声明的时候就已经确定了,在调用push_back
的时候不会涉及类型推导,而 std::vector
的 emplace_back
是确实存在推导的。另外即使类型是 T&&
,但是如果有 const
修饰得到 const T&&
,那么也不是一个合格的万能引用。
对于万能引用,如果是入参是右值引用,模版参数 T
的推导结果还是 T
,而不是 T&&
,这不是右值引用的特殊性,而是左值引用的特殊性,
模板函数的函数参数列表中包含 forwarding reference
且相应的实参是一个 lvalue
的情况时,模版类型会被推导为左值引用。 forwarding reference
是 C++ 标准中的词,翻译叫转发引用;《modern effective c++》的作者 Scott Meyers 将这种引用称之为万能引用(universal reference)。
有了右值引用后,就能通过 std::move
将左值转换为右值,完成目标对象的移动构造,省去大对象的拷贝,但是如果传递的参数是个左值,调用者不希望入参被移动,数据被移走,那就需要提供一个左值引用的版本,因为右值引用无法绑定左值。假设大对象是一个string
,就会写出下面这种函数签名:
void f(const std::string& s);
void f(string&& s);
一个参数没问题,我们学会了左值和右值的区别并给出了不同的函数重载,但是如果参数是两个 string,情况就变得复杂的,针对不同的情况,就需要提供四种函数签名和实现:
void f(const std::string& s1, const std::string& s2);
void f(const std::string& s1, string&& s s2);
void f(string&& s s1, const std::string& s2);
void f(string&& s s1, string&& s s2);
如果参数进一步增加,编码就会越来越复杂,遇到这种情况就可以使用万能引用处理,在函数体内对string做std::forward()
即可:
template<typename T1, typename T2>
void f(T1&& s1, T2&& s2);
万能引用的重载
万能引用存在一个问题,过于贪婪而导致调用的函数不一定是想要的那个,假设 f()
除了要处理左值和右值的 string 外,还有可能需要处理一个整形,例如int
,就会有下面这种方式的重载。
// 万能引用版本的 f(),处理左值右值
template<typename T>
void f(T&& s) {
std::cout << "f(T&&)" << std::endl;
}
// 整数类型版本的 f(),处理整形
void f(int i) {
std::cout << "f(int)" << std::endl;
}
如果用不同的整型去调用f()
,就会发生问题:
int i1;
f(i1); // output: f(int)
size_t i2;
f(i2); // output: f(T&&)
- 如果参数是一个
int
,那么一切正常,调用f(int)
的版本,因为c++规定,如果一个常规函数和一个模板函数具备相同的匹配性,优先使用常规函数。 - 但是如果入参是个
size_t
,那么就出现问题了,size_t
的类型和int
并不相等,需要做一些转换才能变成int
,那么就会调用到万能引用的版本。
如何限制万能引用呢?
1、标签分派:根据万能引用推导的类型,f(T&&)
新增一个形参变成f(T&&, std::true_type)
和f(T&&, std::false_type)
,调用f(args, std::is_integral<T>())
就能正确分发到不同的 f()
上。
2、模板禁用:std::enable_if
能强制让编译器使得某种模板不存在一样,称之为禁用,底层机制是 SFINAE
std::_enable_if
的正确使用方法为:
template<typename T,
typename = typename std::enable_if<condition>::type>
void f(T param) {
}
应用到前面的例子上,希望整型只调用f(int)
而 string 会调用 f(T&&)
,就会有:
void f(int i) {
std::cout << "f(int)" << std::endl;
}
template<typename T,
typename = typename std::enable_if<
std::is_same<
typename std::remove_reference<T>::type,
std::string>::value
>::type
>
void f(T&& s) {
std::cout << "f(T&&)" << std::endl;
}
模板的内容看上去比较长,其实只是在std::enable_if
的condition
内希望入参的类型为string
,无论左值和右值,这样就完成了一个万能引用的正确重载。
在c++中,引用的引用是非法的,但是编译器可以推导出引用的引用的引用再进行折叠,通过这种机制实现移动语义和完美转发。
模板参数T
的推导规则有一点就是,如果传参是个左值,T
的推导类型就是T&
,如果传参是个右值,那么T
推导结果就是T
(不变)。引用的折叠规则也很简单,当编译器出现引用的引用后,结果会变成单个引用,在两个引用中,任意一个的推导结果为左值引用,结果就是左值引用,否则就是右值引用。
编译器如果要在一个按值返回的函数省略局部对象的复制和移动,需要满足两个条件:
- 局部对象的类型和返回值类型相同
- 返回的就是局部对象本身
如果在return
的时候对局部变量做std::move()
,那么就会使得局部变量的类型和返回值类型不匹配,原本可以只构造一次的操作,变成了需要构造一次加移动一次,限制了编译器的发挥。
另外,如果不满足上面的条件二,按值返回的局部对象是不确定的,编译器也会将返回值当作右值处理,所以对于按值返回局部变量这种情况,并不需要实施std::move()
。
【文章原创作者:香港显卡服务器 http://www.558idc.com/hkgpu.html 网络转载请说明出处】