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

文章预先发布于:pokpok.ink

名词解释

  • 移动语义:用不那么昂贵的操作代替昂贵的复制操作,也使得只支持移动变得可能,比如 unique_ptr,将数据的所有权移交给别人而不是多者同时引用。

  • 完美转发:目标函数会收到转发函数完全相同类似的实参。

  • 右值引用:是这两个机制的底层语言机制,形式是 Type&&,能够引用到“不再使用”的数据,直接用于对象的构造

要注意的是,形参一定是左值,即使类型是右值引用:

void f(Widget&& w) {
    /* w 在作用域内就是一个左值。 */
}

实现移动语意和完美转发的重要工具就是std::movestd::forwardstd::movestd::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);
}
  1. 其中 typename remove_reference<T>::type&& 作用就是为了解决是当入参数是 reference to lvalue 的时候,返回类型ReturnType会因为引用折叠被推导为 T&remove_reference<T>::type 就是为了去除推导出的模版参数 T 的引用,从到强制得到 T&&

  2. 如果没有remove_reference<T>,而是用 T&& 来代替函数返回值以及 static_cast<>,就会有下面的推导规则:

    • 如果入参是 lvalue,那么 T 就会被推导成为 T&,参数 param 的类型就变成了 T& &&,再通过引用折叠的规则,推导最终结果为 T&,而根据表达式的 value category 规则,如果一个函数的返回值类型是左值引用,那么返回值的类型为左值,那么 std::move(v) 就不能实现需要的功能,做到强制右值转换。
    • 如果入参是 rvalue,那么 T 会被直接推导成 T,参数 param 的类型也就变成了 T&&,函数返回的类型(type)也是 T&&,返回的值类别也是右值。
  3. 此外,在 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::vectoremplace_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_ifcondition内希望入参的类型为string,无论左值和右值,这样就完成了一个万能引用的正确重载。

引用折叠

在c++中,引用的引用是非法的,但是编译器可以推导出引用的引用的引用再进行折叠,通过这种机制实现移动语义和完美转发。

模板参数T的推导规则有一点就是,如果传参是个左值,T的推导类型就是T&,如果传参是个右值,那么T推导结果就是T(不变)。引用的折叠规则也很简单,当编译器出现引用的引用后,结果会变成单个引用,在两个引用中,任意一个的推导结果为左值引用,结果就是左值引用,否则就是右值引用。

返回值优化(RVO)

编译器如果要在一个按值返回的函数省略局部对象的复制和移动,需要满足两个条件:

  1. 局部对象的类型和返回值类型相同
  2. 返回的就是局部对象本身

如果在return的时候对局部变量做std::move(),那么就会使得局部变量的类型和返回值类型不匹配,原本可以只构造一次的操作,变成了需要构造一次加移动一次,限制了编译器的发挥。

另外,如果不满足上面的条件二,按值返回的局部对象是不确定的,编译器也会将返回值当作右值处理,所以对于按值返回局部变量这种情况,并不需要实施std::move()

posted @ 2022-04-19 09:54  pokpok  阅读(248)  评论(2编辑  收藏  举报