22-4 std::move

一旦你开始更频繁地使用移动语义,就会遇到这样的情况:你希望调用移动语义,但需要操作的对象是左值而非右值。以下面的交换函数为例:

#include <iostream>
#include <string>

template <typename 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)的对象后,该函数通过制作三份副本来交换它们的值。因此,该程序输出:

image

正如上一课所示,复制操作可能效率低下。而这个版本的交换操作会生成3个副本,导致大量冗余的字符串创建与销毁,从而降低运行速度。

然而在此处进行复制实属多余。我们真正需要实现的只是交换a和b的值,而通过3次移动操作同样能完美达成目标!因此若将复制语义转换为移动语义,就能显著提升代码性能。

但如何实现?问题在于参数a和b是左值引用而非右值引用,导致我们无法调用移动构造函数和移动赋值运算符替代复制构造函数与复制赋值。默认情况下,系统会执行复制行为。我们该如何解决?


std::move

在C++11中,std::move是标准库函数,它通过静态转换(static_cast)将参数转换为右值引用,从而触发移动语义。因此,我们可以使用std::move将左值转换为优先移动而非复制的类型。该函数定义在实用程序头文件中。

以下是与上述相同的程序,但新增了mySwapMove()函数,该函数使用std::move将左值转换为右值,从而调用移动语义:

#include <iostream>
#include <string>
#include <utility> // for std::move

template <typename 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;
}

这段代码的输出结果与上述相同:

image

但这种方式效率更高。初始化 tmp 时,我们不复制 x,而是使用 std::move 将左值变量 x 转换为右值。由于参数是右值,移动语义被调用,x 被移动到 tmp 中。

经过几次交换操作后,变量 x 的值被移动到 y,而 y 的值又被移动到 x。


另一个示例

在向容器(如std::vector)填充左值元素时,我们同样可以使用std::move。

在下面的程序中,我们首先使用复制语义向向量添加元素,随后使用移动语义向向量添加元素。

#include <iostream>
#include <string>
#include <utility> // for std::move
#include <vector>

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

	// We use std::string because it is movable (std::string_view is not)
	std::string str { "Knock" };

	std::cout << "Copying str\n";
	v.push_back(str); // calls l-value version of push_back, which copies str into the array element

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

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

	v.push_back(std::move(str)); // calls r-value version of push_back, which moves str into the array element

	std::cout << "str: " << str << '\n'; // The result of this is indeterminate
	std::cout << "vector:" << v[0] << ' ' << v[1] << '\n';

	return 0;
}

在作者的机器上,该程序输出:

image

在第一种情况下,我们向 push_back() 传递了一个左值,因此它使用复制语义向向量添加元素。因此,str 中的值保持不变。

在第二种情况下,我们向push_back()传递了一个右值(实际上是通过std::move转换的左值),因此它使用移动语义向向量添加元素。这种方式更高效,因为向量元素可以直接获取字符串的值,而无需进行复制操作。


被移动的对象将处于有效但可能未确定的状态

当我们从临时对象移动值时,被移动对象剩余的值并不重要,因为临时对象无论如何都会立即被销毁。但若对左值对象使用了std::move()呢?由于移值后仍可访问这些对象(如上例中str移值后仍被打印),了解其残留值便具有实用价值。

对此存在两种观点。一种主张被移动的对象应重置为默认/初始状态,即不再拥有资源。上文示例中 str 被清空为空字符串即属此类。

另一种观点主张应采取最便捷的方式,若清除被移动对象不方便,则不必强制执行。

那么标准库在此场景下如何处理?C++标准对此规定:“除非另有说明,被移动对象[指C++标准库定义的类型]应置于有效但未指定的状态。”

在上例中,作者在调用 std::move 后打印 str 的值时,显示的是空字符串。但这并非强制要求,它本可能打印任何有效字符串——包括空字符串、原始字符串或其他有效字符串。因此我们应避免使用被移动对象的值,因为结果取决于具体实现。

某些情况下,我们希望复用已被移动的对象(而非分配新对象)。例如上文 mySwapMove() 的实现中,我们先将资源从 a 中移出,再将另一资源移入 a。这种做法是安全的,因为在移出资源与赋予 a 新确定值之间的时间段内,我们从未使用过 a 的值。

对于被移出的对象,调用任何不依赖其当前值的函数都是安全的。这意味着我们可以设置或重置被移出对象的值(使用operator=,或任何clear()或reset()成员函数)。我们也可检测被移出对象的状态(例如使用empty()判断对象是否存有值)。但应避免使用operator[]或front()(返回容器首个元素)等函数,因这些函数依赖容器存有元素,而被移出的容器可能存有元素也可能不存。

关键要点
std::move() 向编译器传递了程序员不再需要该对象值的提示。仅对需要移动其值的持久对象使用 std::move(),且在此之后切勿对该对象的值做出任何假设。当前值被移动后,可为被移动对象赋予新值(例如使用 operator=)。


std::move 在哪些场景还有用处?

在对元素数组进行排序时,std::move 同样能发挥作用。许多排序算法(如选择排序和冒泡排序)通过交换元素对来实现排序。在之前的课程中,我们不得不借助复制语义来完成交换操作。现在我们可以使用移动语义,这将更高效。

当需要将智能指针管理的对象内容移动到另一个智能指针时,它同样能发挥作用。

相关内容:
std::move() 有个实用变体 std::move_if_noexcept():若对象具有无异常移动构造函数,则返回可移动右值;否则返回可复制左值。本主题详见第27.10节——std::move_if_noexcept。


总结

当需要将左值视为右值以调用移动语义而非复制语义时,即可使用std::move。

posted @ 2026-01-28 00:08  游翔  阅读(5)  评论(0)    收藏  举报