C++的std::move与std::forward原理总结

发布时间 2023-10-27 10:45:40作者: 黄河大道东

  阅读大型的C++开源项目代码,基本逃不过std::movestd::forward,例如webRTC
所以搞懂其原理,很有必要。

0、左值与右值的理解

左值和右值的概念

  C++中左值(lvalue)和右值(rvalue)是比较基础的概念,虽然平常几乎用不到,但C++11之后变得十分重要,它是理解 move/forward 等新语义的基础。

  左值与右值这两个概念是从 C 中传承而来的,左值指既能够出现在等号左边,也能出现在等号右边的变量;右值则是只能出现在等号右边的变量。

int a;		// a 为左值
a = 3;		// 3 为右值
  • 左值是可寻址的变量,有持久性;
  • 右值一般是不可寻址的常量,或在表达式求值过程中创建的无名临时对象,短暂性的。

左值和右值主要的区别之一是左值可以被修改,而右值不能。

左值引用和右值引用

  • 左值引用:引用一个对象;
  • 右值引用:就是必须绑定到右值的引用,C++11中右值引用可以实现“移动语义”,通过 && 获得右值引用。
int x = 6;					// x是左值,6是右值
int &y = x;					// 左值引用,y引用x

int &z1 = x * 6;			// 错误,x*6是一个右值
const int &z2 =  x * 6;		// 正确,可以将一个const引用绑定到一个右值

int &&z3 = x * 6;			// 正确,右值引用
int &&z4 = x;				// 错误,x是一个左值

  右值引用和相关的移动语义是C++11标准中引入的最强大的特性之一,通过std::move()可以避免无谓的复制,提高程序性能。

1. std::move

  别看它的名字叫 move,其实std::move并不能移动任何东西,它唯一的功能是将一个左值/右值强制转化为右值引用,继而可以通过右值引用使用该值,所以称为移动语义

  std::move的作用:将对象的状态或者所有权从一个对象转移到另一个对象,只是转移,没有内存的搬迁或者内存拷贝所以可以提高利用效率,改善性能。 它是怎么个转移法,将在文章的最后面解释。

  看到std::move的代码,意味着给std::move的参数,在调用之后,就不再使用了。

1.1 函数原型

/**
 *  @brief  Convert a value to an rvalue.
 *  @param  __t  A thing of arbitrary type.
 *  @return The parameter cast to an rvalue-reference to allow moving it.
 */
template <typename T>
typename remove_reference<T>::type&& move(T&& t) {
	return static_cast<typename remove_reference<T>::type&&>(t);
}

用到的remove_reference定义

/// remove_reference
  template<typename _Tp>
    struct remove_reference
    { typedef _Tp   type; };

  template<typename _Tp>
    struct remove_reference<_Tp&>
    { typedef _Tp   type; };

  template<typename _Tp>
    struct remove_reference<_Tp&&>
    { typedef _Tp   type; };

1.2 参数讨论

  先看参数 T&& t,其参数看起来是个右值引用,其是不然!!!

  因为 T 是个模板,当右值引用和模板结合的时候,就复杂了。T&&并不一定表示右值引用,它可能是个左值引用又可能是个右值引用。

再弄个清爽的代码解释一下:

template<typename T>
void func( T&& param){
    
}
func(5);		// 5是右值,param是右值引用
int a = 10;
func(a);		// x是左值,param是左值引用

  这里的&&是一个未定义的引用类型,称为通用引用 Universal References(https://isocpp.org/blog/2012/11/universal-references-in-c11-scott-meyers)

  它必须被初始化,它是左值引用还是右值引用却决于它的初始化,如果它被一个左值初始化,它就是一个左值引用;如果被一个右值初始化,它就是一个右值引用。

  注意:只有当发生自动类型推断时(如函数模板的类型自动推导,或auto关键字),&&才是一个Universal References。

1.3 通用引用

  这里还可以再深入一下通用引用,解释为什么它一会可以左值引用,一会可以右值引用。

  既然 T 是个模板,那 T 就可以是 string ,也可以是 string& ,或者 string&& 。

  那参数就变成string&& && param了,这么多 & 怎么办?好吓人!!!没事,稳住,C++ 11立了规矩,太多&就要折叠一下(也就是传说中的引用折叠)。具体而言

X& &、X&& &、X& &&都折叠成X&

X&& &&折叠成X&&

  所以,想知道 param 最终是什么引用,就看 T 被推导成什么类型了。
可以用下面的一个测试程序,来验证。

#include <iostream>
#include <type_traits>
#include <string>
using namespace std;
template<typename T>
void func(T&& param) {
    if (std::is_same<string, T>::value)
        std::cout << "string" << std::endl;
    else if (std::is_same<string&, T>::value)
        std::cout << "string&" << std::endl;
    else if (std::is_same<string&&, T>::value)
        std::cout << "string&&" << std::endl;
    else if (std::is_same<int, T>::value)
        std::cout << "int" << std::endl;
    else if (std::is_same<int&, T>::value)
        std::cout << "int&" << std::endl;
    else if (std::is_same<int&&, T>::value)
        std::cout << "int&&" << std::endl;
    else
        std::cout << "unkown" << std::endl;
}

int getInt() { return 10; }

int main() {
    int x = 1;
    // 传递参数是右值 T 推导成了int, 所以是 int&& param, 右值引用
    func(1);
    // 传递参数是左值 T 推导成了int&, 所以是int&&& param, 折叠成 int&,左值引用
    func(x);
    // 参数getInt是右值 T 推导成了int, 所以是int&& param, 右值引用
    func(getInt());
    return 0;
}

1.4 返回值

  以 T 为 string 为例子,简化一下函数定义:

// T 的类型为 string
// remove_reference<T>::type 为 string 
// 整个std::move 被实例如下
string&& move(string&& t) {					// 可以接受右值
    return static_cast<string&&>(t);		// 返回一个右值引用
}

  显而易见,用static_cast,返回的一定是个右值引用。

综上,std::move基本等同于一个类型转换:static_cast<T&&>(lvalue);

即,输入可以是左值,右值,输出,是一个右值引用。

1.5 std::move的常用例子

1.5.1 用于vector添加值

以下是一个经典的用例:

// 摘自https://zh.cppreference.com/w/cpp/utility/move
#include <iostream>
#include <utility>
#include <vector>
#include <string>
int main() {
    std::vector<std::string> v;
    std::string str = "Hello";
    // 调用常规的拷贝构造函数,新建字符数组,拷贝数据
    v.push_back(str);
   	std::cout << "After copy, str is \"" << str << "\"\n";
    //调用移动构造函数,掏空str,掏空后,最好不要使用str
    v.push_back(std::move(str));
    std::cout << "After move, str is \"" << str << "\"\n";
    std::cout << "The contents of the vector are \"" << v[0]
                                         << "\", \"" << v[1] << "\"\n";
}
// 输出
// After copy, str is "Hello"
// After move, str is ""
// The contents of the vector are "Hello", "Hello"

1.5.2 用于unique_ptr传递

#include <stdio.h>
#include <unistd.h>
#include <memory>
#include <iostream>

/***** 类定义开始****/
class TestC {
public:
    TestC(int tmpa, int tmpb):a(tmpa),b(tmpb) {
        std::cout<< "construct TestC " << std::endl;
    }
    ~TestC() {
        std::cout<< "destruct TestC " << std::endl;
    }
    void print() {
        std::cout << "print a " << a << " b " << b << std::endl;
    }
private:
    int a = 10;
    int b = 5;
};
/***** 类定义结束****/
void TestFunc(std::unique_ptr<TestC> ptrC) {
    printf("TestFunc called \n");
    ptrC->print();
}
int main(int argc, char* argv[]) {
    std::unique_ptr<TestC> a(new TestC(2, 3));
    // 初始化也可以写成这样
    // std::unique_ptr<TestC> a = std::make_unique<TestC>(2, 3);
    TestFunc(std::move(a));
    // 执行下面这一句会崩溃,因为a已经没有控制权
    a->print();
    return 0;
}
// 执行后输出
// construct TestC 
// TestFunc called 
// print a 2 b 3
// destruct TestC 

  从日志可见,只有一次构造。

  这种类型的代码,在大型开源项目,如 webRTC ,随处可见。下次看到了不用纠结,不用关心细节了。只要直到最后拿到 unique_ptr 的变量(左值)有控制权就行了。

1.6 再说转移对象控制权

  从上面1.5.2的例子,看到std::move(a)之后,执行a->print();后会崩溃,这是为什么呢?
  其实不全部是std::move的功劳,还需要使用方,即unique_ptr配合才行。

请看这篇文章:https://blog.csdn.net/newchenxf/article/details/116274506

当调用TestFunc(std::move(a));TestFunc的参数 a 要初始化,调用的是operator=,关键代码截取如下:

class unique_ptr {
private:
	T* ptr_resource = nullptr;
	...
	
    // "="号赋值操作,此处的 move 即为 a
    unique_ptr& operator=(unique_ptr&& move) noexcept {
		move.swap(*this);  // 置换后 a 就变成空了,丢失了原来的控制权
		return *this;
	}
    
	// 交换函数
	void swap(unique_ptr<T>& resource_ptr) noexcept {
		std::swap(ptr_resource, resource_ptr.ptr_resource);  // 这里把空换给了 a 
	}

  从operator=函数看,执行完赋值后,智能指针的托管对象即 ptr_resource,交换了本来函数的参数 a ,托管对象 ptr_resource 为空,现在换来了一个有用的,把空的换给了a,于是 a 的资源为空,所以 a 使用资源时就会出现空指针的错误!

2. std::foward

  有了前面的讨论,这个就简单一些了,不铺的很开。先看函数原型:

/**
 *  @brief  Forward an lvalue.
 *  @return The parameter cast to the specified type.
 *  This function is used to implement "perfect forwarding".
 */
template<typename _Tp>
constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type& __t) noexcept { 		return static_cast<_Tp&&>(__t); 
}

  /**
   *  @brief  Forward an rvalue.
   *  @return The parameter cast to the specified type.
   *  This function is used to implement "perfect forwarding".
   */
template<typename _Tp>
constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type&& __t) noexcept {
	static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument substituting _Tp is an lvalue reference type");
	return static_cast<_Tp&&>(__t);
}

有两个函数:

  第一个,参数是左值引用,可以接受左值。

  第二个,参数是右值引用,可以接受右值。

  根据引用折叠的原理,如果传递的是左值,Tp 推断为 string& ,则返回变成static_cast<string& &&>,也就是 static_cast<string&>,所以返回的是左值引用。

  如果传递的是右值,Tp 推断为 stringstring&& ,则返回变成
static_cast<string&&>,所以返回的是右值引用。

  反正不管怎么着,都是一个引用,那就都是别名,也就是谁读取std::forward,都直接可以得到std::foward所赋值的参数。这就是完美转发的基本原理!

/**
 * 编译:g++ test_forward.cpp -lpthread -o out
 * 执行:./out
 * 这是测试代码,不够严谨,仅为了说明std::forward的用途
 * 例子的意思是,希望执行一个函数,函数放在子线程执行,函数由业务方随时定义
 * */
#include <stdio.h>
#include <unistd.h>
#include <memory>
#include <iostream>
#include <thread>

template <typename Closure>
class ClosureTask {
public:
    explicit ClosureTask(std::string &&name, Closure &&closure):
        name_(std::forward<std::string>(name)),
        closure_(std::forward<Closure>(closure)) {
        }
    
    bool DoTask() {
        // 执行Lambda函数
        closure_();
        return true;
    }
private:
    typename std::decay<Closure>::type closure_;
    std::string name_;
};

// 异步调用,非阻塞
template <typename Closure>
void PostTask(std::string &&name, Closure &&closure) {
    std::unique_ptr<ClosureTask<Closure>> queueTask(
        // 用 forward 透传 name
        new ClosureTask<Closure>(std::forward<std::string>(name),
        // 用 forward 透传 closure
        std::forward<Closure>(closure)));
    printf("PostTask\n");
    // 启动一个线程执行任务,taskThread的第二个参数,也是一个Lambda表达式
    // =号表示外部的变量都可以在表达式内使用, &queueTask表示表达式内部要使用该变量
    std::thread taskThread([=, &queueTask]() { 
        printf("start thread\n");
        queueTask->DoTask();
        printf("thread done\n");
    });
    taskThread.detach();
}

int main(int argc, char* argv[]) {
    printf("start\n");
    // 参数2,传递的是Lambda表达式
    // Lambda 是最新的 C++11 标准的典型特性之一。Lambda 表达式把函数看作对象
    PostTask("TestForward", []() mutable {
        // 执行一个任务,任务的内容就在这里写
        printf("I want to do something here\n");
    });
    return 0;
}

参考

本文的原文地址

c++ 之 std::move 原理实现与用法总结

[c++11]我理解的右值引用、移动语义和完美转发