Skip to content

M.4 - std::move

Key Takeaway
  • 只有右值才能触发移动语义,因此必须是右值(字面量、匿名对象)或者被std::move()作用的左值才能够触发
  • std::move() 会提示编译器,程序员已经不再需要该值了。所以,std::move() 应该被应用于那些希望资源被移出的对象,且此后不应当假定该对象还持有该资源。给资源被移出的对象赋新值是可以的。

随着你开始频繁地使用移动语义,你会发现有时候在你需要使用它的时候,对象是左值而不是右值导致你无法使用。考虑下面这个swap函数的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
#include <string>

template<class T>
void myswapCopy(T& a, T& b)
{
    T tmp { a }; // invokes copy constructor
    a = b; // invokes copy assignment
    b = tmp; // invokes copy assignment
}

int main()
{
    std::string x{ "abc" };
    std::string y{ "de" };

    std::cout << "x: " << x << '\n';
    std::cout << "y: " << y << '\n';

    myswapCopy(x, y);

    std::cout << "x: " << x << '\n';
    std::cout << "y: " << y << '\n';

    return 0;
}

传入两个T类型的对象(在这个例子中是std::string)给函数,然后将它们的值交换(在这个过程中创建了三个副本)。程序输出结果为:

1
2
3
4
x: abc
y: de
x: de
y: abc

这个版本的swap函数创建了三个副本, 而创建副本是低效的操作,这其中涉及到了多次字符串的创建和销毁,显然效率是很低的。

实际上,这里并不需要创建拷贝,因为我们执行希望将a和b的值交换,这个操作使用三次移动也可完成啊!所以如果能将拷贝语义替换为移动语义,代码无疑会更高效。

但是应该怎么做呢?这里的问题在于,a和b都是左值引用,而不是右值引用,所以没有办法调用移动构造函数移动赋值运算符。默认情况下,基于左值引用执行的是拷贝构造函数和拷贝赋值操作符。那究竟应该如何实现呢?

std::move

在 C++11 中 标准库提供了std::move函数用于将实参转换(使用静态类型转换)为右值引用,以便激活移动操作。因此,我们可以使用 std::move 对一个左值类型进行转换,使其能够被移动。std::move 定义在utility头文件中。

下面的程序和之前的类似,但是 myswapMove() 函数使用std::move函数将左值转换成了右值,所以调用了移动语义相关操作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
#include <string>
#include <utility> // for std::move

template<class T>
void myswapMove(T& a, T& b)
{
    T tmp { std::move(a) }; // invokes move constructor
    a = std::move(b); // invokes move assignment
    b = std::move(tmp); // invokes move assignment
}

int main()
{
    std::string x{ "abc" };
    std::string y{ "de" };

    std::cout << "x: " << x << '\n';
    std::cout << "y: " << y << '\n';

    myswapMove(x, y);

    std::cout << "x: " << x << '\n';
    std::cout << "y: " << y << '\n';

    return 0;
}

程序输出:

1
2
3
4
x: abc
y: de
x: de
y: abc

程序的输出结果一样,但是效率要高得多。当tmp初始化时,它没有创建x副本,这里我们使用 std::move将左值x转换成了一个右值。因为参数是右值,所以调用了移动构造函数,x被移动到了tmp

经过几次转换,变量x的值被移动到了y,而y的值也被移动到了x。

另外一个例子

我们还可以使用std::move来填充容器的元素,例如使用左值填充 std::vector

在下面的例子中,我们首先使用拷贝语义向vector添加元素。然后使用移动语义做同样的操作。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
#include <string>
#include <utility> // for std::move
#include <vector>

int main()
{
    std::vector<std::string> v;

    // 这里使用 std::string 是因为它是可移动的 (std::string_view 不可移动)
    std::string str { "Knock" };

    std::cout << "Copying str\n";
    v.push_back(str); // 调用左值版本的 push_back,它会将str拷贝到数组元素

    std::cout << "str: " << str << '\n';
    std::cout << "vector: " << v[0] << '\n';

    std::cout << "\nMoving str\n";

    v.push_back(std::move(str)); // 调用右值版本的 push_back,它会将str移动到数组元素

    std::cout << "str: " << str << '\n'; // 打印中奖结果
    std::cout << "vector:" << v[0] << ' ' << v[1] << '\n';

    return 0;
}

在笔者的机器上会输出如下信息:

1
2
3
4
5
6
7
Copying str
str: Knock
vector: Knock

Moving str
str:
vector: Knock Knock

在第一种情况下,传入 push_back() 的是一个左值,所以它会使用拷贝语义向vector添加元素。因此,原字符串还被保存在str中。

对于第二种情况,传入 push_back() 的是一个右值(准确来讲是通过std::move将左值转换成的右值),所以它会使用移动语义向vector添加元素。这个操作无疑是更加高效的,因为vector的元素“窃取了”字符串的值,而没有创建它的拷贝。

资源被移动的对象处于一种有效,但可能不确定的状态

当我们从一个临时对象中把资源移动出来时,这个临时对象中还剩下什么其实并不重要,因为它马上就要被销毁了。但是对于一个左值对象来说,当我们对它使用std::move()后会怎么样呢?因为该左值在其资源被移动后,仍然可能会被访问(在这个例子中,当我们打印str的值时,内容已经没有了),所以有必要知道该对象资源被移动后,还有什么留在原对象中。

关于这个问题的思考有两个派系。一派人任务,资源已经被移动走的对象,应该被重置为某种默认状态或0幢,因为它不再拥有资源。上面的例子很好地展示了这种情况。

另外一派人则认为应该“怎么方便怎么来”,如果清理该对象很麻烦,那就不必清理。

那么,标准库是如何做的呢?关于这个问题,C++标准认为:“除非有明确的规定,否则C++标准库中定义的类型,在资源被移动后应该被置于任意一种有效的状态”。

在上面的例子中,当左值打印 str 值时,程序输出的空字符串。但是,这并不是一定的,打印任何其他合法的字符串也可以,包括空字符串、原本的字符串或任何合法的字符串。因此,我们应该避免继续使用已经被移动的左值对象,因为其结果取决于编译器的具体实现。

在有些场景下,我们需要继续使用已经被移动的对象(而不希望重新分配一个新对象)。例如,在上面 myswapMove() 的实现中,我们首先将a的资源移出,然后将其他资源移动到a。这么做可行是因为,a的值被移除到a再次获得一个确定的值的这段时间内,我们不会使用a的值。

对于一个被移动了资源的对象来说,调用任何不依赖其值的函数是安全的。也就是说,我们可以设置、重置该对象的值(使用=clear()reset()成员函数)。我们可以检查它的状态(例如使用 empty() 判断该对象是否有值)。但是,我们必须避免operator[] 或 front() 这样的函数,因为这些函数依赖于对象中存放的值,而被移动后的对象可能可以,也可能不能够提供这些值。

关键信息

std::move() 会提示编译器,程序员已经不再需要该值了。所以,std::move() 应该被应用于那些希望资源被移出的对象,且此后不应当假定该对象还持有该资源。给资源被移出的对象赋新值是可以的。

std::move 还有什么用途?

std::move 在进行数组排序时也很有用。很多排序算法 (例如选择排序和冒泡排序)是基于交换两个元素而实现的。在之前的课程中,我们使用了基于拷贝语义的交换,现在可以将它替换成基于移动语义的交换了,这么做效率会高很多。

此外,当我们需要将某个智能指针管理的内容转移到另一个智能指针时,该函数也很有用。

小结

当一个左值需要被当做右值使用,以便触发移动语义时,可以使用std::move