移动语义

什么是移动语义

举个例子。有一个人在放风筝,他手中的线连接着天上的风筝,过了一会他不想玩了,就把风筝送给了旁边的另一个人,现在是另一个人手里拿着线连着同样的风筝。而这里的关键点就在于风筝没变,只是和风筝连接的人变了,也就是资源从一个人移动到了另一个人,这也就是C++的移动语义。

移动构造函数和移动赋值操作符

看下面的代码:

class String
{
public:
    String(const char* p)
    {
        size_t size = std::strlen(p) + 1;
        data = new char[size];
        std::memcpy(data, p, size);
    }
    String(const String& another)
    {
        size_t size = std::strlen(another.data) + 1;
        data = new char[size];
        std::memcpy(data, another.data, size);
    }
    ~String()
    {
        delete[] data;
    }

private:
    char* data;
};

这里我们实现了一个字符串类,里面用一个指针指向存储数据的堆空间。其中我们实现了构造函数、拷贝构造函数以及析构函数,拷贝构造函数通过深拷贝将another的字符串复制给了当前对象。

拷贝构造函数的参数 const String& another 可以绑定到所有种类的字符串对象上,例如:

String a(x);
String b(x + y);
String c(some_func_returning_a_string());

现在我们来讨论移动语义,可以看到上面三种情况,其实只有第一种情况是真正需要深拷贝的,因为这里的 x 可能在这一句之后还会被用到。对于另两种情况,它们的参数都是一个临时的字符串对象,它们的生命周期在这一句执行完之后就会结束,所以你并不可能在这一句之后再访问到这两个参数,所以深拷贝在这里其实是一种无意义的行为。

我们知道 x 是一个左值,而 x + ysome_func_returning_a_string() 都是一个右值(纯右值),所以我们可以通过这个属性来将这两种情况分开,即再定义一个参数为右值引用的构造函数:

String(String&& another)
{
    data = another.data;
    another.data = nullptr;
}

可以看到在这个构造函数中,我们并没有进行深拷贝,而只是进行了简单的指针的复制,并且将原来的指针设置成 nullptr (防止对这个空间多次调用 delete),这就像我们把原来的字符串对象“偷了”过来。而这个构造函数就叫做移动构造函数。注意这里关键点在于被移动的那个对象在此之后并不可能再被访问到,所以这种移动是可以接受的。以左值为参数最终会调用拷贝构造函数,以右值为参数最终会调用移动构造函数,这也是为什么说右值具备可移动的属性。

类似的,我们可以写出两种赋值运算符的重载函数:

String& operator=(String& another)
{
  size_t size = std::strlen(another.data) + 1;
  data = new char[size];
  std::memcpy(data, another.data, size);
}
String& operator=(String&& another)
{
  data = another.data;
  another.data = nullptr;
}

第二种就叫做移动赋值操作符

std::move

template< class T >
typename std::remove_reference<T>::type&& move( T&& t ) noexcept;

std::move 用于标识参数 t 具备可移动的性质,我们知道只有右值才具备可移动的性质,所以其实这个函数会返回参数 t 的右值引用(如果 t 本身就是一个引用类型的变量,则返回 t 去掉引用的原始类型的右值引用)。

注意这里的关键点在于 std::move 并不会对参数 t 本身做任何改动,它只是返回 t 的右值引用,从而可以将返回值传给移动构造函数或者移动赋值操作符或者其他具有移动语义的函数,即最终的目的是移动 t 的资源,只不过 std::move 本身并不关心和实现移动的具体操作。我们知道右值本身就具有可移动的属性,所以通常 std::move 是用于返回一个左值的右值引用,从而最终移动这个左值的资源。

std::vector<int> arr1 = { 1, 2, 3 };
std::vector<int> arr2 = std::move(arr1);
cout << arr1.size() << endl;  // 0

完美转发

引用折叠

首先介绍一下引用折叠(reference collapsing)。C++ 中无法直接定义引用的引用,也就是你不能这么写:int& & re = 1,哪怕只是两个 & 中间有个空格,编译器也不会认为这是右值引用,只会报错:无法声明引用的引用。

但是通过 templatetypedef 可以做出类似效果的声明:

typedef int&  lref;
typedef int&& rref;
int n;
lref&  r1 = n;  // type of r1 is int&
lref&& r2 = n;  // type of r2 is int&
rref&  r3 = n;  // type of r3 is int&
rref&& r4 = 1;  // type of r4 is int&&

从这里可以看出引用折叠的规则,只有右值引用的右值引用会被折叠成右值引用,而其他三种组合情况最终都会折叠成左值引用。

类似的 template 也可以实现引用折叠:

template<typename T>
void func1(T& arg);

template<typename T>
void func2(T&& arg);

func1(1);  // compile failure
func2(1);

func1(1) 会产生编译错误,因为不管 T 是左值引用还是右值引用,最终 T& 都会被折叠成左值引用,所以不能接收右值为参数。而 func2(1)T 被推导成了 int&& 这样右值引用的右值引用被推导成了右值引用,所以可以接收右值为参数。

而引用折叠有什么用呢,它使得 std::forward 和完美转发成为可能。

std::forward

还是先来一个例子,假设现在有一个函数调用 func1(a, b, c),现在目标是实现一个 func2,使得 func2(a, b, c) 的效果和 func1(a, b, c) 的效果一模一样。

最简单的方法就是在 func2 里面直接调用 func1

template<typename A, typename B, typename C>
void func2(A& a, B& b, C& c)
{
    func1(a, b, c);
}

但是这种方法的问题是 func2 无法接收右值为参数,因为根据引用折叠的规则,T& 只能被推导成左值引用,所以没法接收右值。那么用 const T& 同时接收左值和右值行不行呢?

template<typename A, typename B, typename C>
void func2(const A& a, const B& b, const C& c)
{
    func1(a, b, c);
}

这确实解决了之前的问题,但是又引入了新的问题,func1 这时候就不能接收非 const 的参数了。

这时候我们又想到,为什么模板参数后面一定要加引用呢,不加行不行?

template<typename A, typename B, typename C>
void func2(A a, B b, C c)
{
    func1(a, b, c);
}

这样不管参数是左值还是右值都可以传进来,并且 const 也能推导出来并传给 func1。但这里其实有一个比较有意思的问题,假如传给 func2 的是一个右值引用,那么根据C++的模板类型推导规则,模板类型 A 会被推导为这个右值引用所引用的那个类型,这样虽然可以成功调用,但是此时 a 变为了一个左值,这样再用 a 调用 func1,就会使用拷贝构造函数而不是移动构造函数来初始化第一个参数,这样就和直接拿一个右值调用 func1 的行为不一致。总结来说就是参数的右值属性被丢弃了。

而在 C++11中这个问题才能得到解决:

template<typename A, typename B, typename C>
void func2(A&& a, B&& b, C&& c)
{
    func1(a, b, c);
}

在这种情况下,如果参数传进来一个左值,则根据引用折叠规则 A 会被推导成左值引用以接收左值参数。假如传进来一个右值,则 A 会被推导成非引用类型,因为这样 A&& 就可以接收右值参数了(注意这里 A 不会被推导成右值引用)。

这时候又来了个问题,假如传进来的是右值,则现在 a 是一个右值引用,但是C++中右值引用是一个左值,所以此时 func1得到的是一个左值参数,而和我们传进来的右值参数不一样。所以我们还需要再做一件事,转发参数的值类别。

template<typename A, typename B, typename C>
void func2(A&& a, B&& b, C&& c)
{
    func1(static_cast<A&&>(a), static_cast<B&&>(b), static_cast<C&&>(c));
}

假如传给 func2 的参数是一个左值,则 A 被推导成了左值引用,然后在调用 func1 的时候 static_cast<A&&>(a) 又把 a 转成了一个左值引用(本来就是~)。假如传给 func2 的是一个右值,则 A 被推导成了右值引用,然后 static_cast<A&&>(a)a 从左值转成了右值传给了 func1 。这样,我们完美的将传给 func1 的参数转发给了 func2,Good!

最后,这一大串的 static_cast<A&&>(a) 比较丑陋,所以可以用库函数里的 std::forward 实现相同的功能。

std::forward<A>(a);
static_cast<A&&>(a);  // same

Perfect! 这就是完美转发(perfect forwarding)。而这里的 a 被称为转发引用(forwarding reference)

比较一下 std::movestd::forward

std::move 的含义是在你不再需要一个对象的资源时,可以通过 std::move 得到这个对象的右值引用从而调用具有移动语义的函数从而将这个对象的资源转移给其他对象。

std::forward 的目的是将传给当前模板函数的参数的值类别转发给之后的函数,如果调用这个函数时这个参数是左值则 std::forward 会得到这个对象的左值引用,如果调用这个函数时这个参数是右值则 std::forward 会得到这个对象的右值引用。

Last modification:December 2nd, 2021 at 09:33 pm