我刚刚听完关于 C++0x 的软件工程广播 podcast interview with Scott Meyers。大多数新特性对我来说都是有意义的,我现在对 C++0x 感到非常兴奋,除了一个。我仍然没有得到移动语义...它到底是什么?
我发现使用示例代码最容易理解移动语义。让我们从一个非常简单的字符串类开始,它只包含一个指向堆分配内存块的指针:
#include <cstring>
#include <algorithm>
class string
{
char* data;
public:
string(const char* p)
{
size_t size = std::strlen(p) + 1;
data = new char[size];
std::memcpy(data, p, size);
}
由于我们选择自己管理内存,所以我们需要遵循 rule of three。我将推迟编写赋值运算符,现在只实现析构函数和复制构造函数:
~string()
{
delete[] data;
}
string(const string& that)
{
size_t size = std::strlen(that.data) + 1;
data = new char[size];
std::memcpy(data, that.data, size);
}
复制构造函数定义了复制字符串对象的含义。参数 const string& that
绑定到所有字符串类型的表达式,允许您在以下示例中进行复制:
string a(x); // Line 1
string b(x + y); // Line 2
string c(some_function_returning_a_string()); // Line 3
现在是对移动语义的关键洞察。请注意,只有在我们复制 x
的第一行才真正需要这个深拷贝,因为我们可能想稍后检查 x
,如果 x
发生了某种变化,我们会感到非常惊讶。你有没有注意到我刚才说了三遍 x
(如果包括这句话,四遍)并且每次都表示完全相同的对象?我们称 x
等表达式为“左值”。
第 2 行和第 3 行中的参数不是左值,而是右值,因为底层字符串对象没有名称,因此客户端无法在稍后的时间点再次检查它们。右值表示在下一个分号处销毁的临时对象(更准确地说:在词法上包含右值的完整表达式的末尾)。这很重要,因为在 b
和 c
的初始化过程中,我们可以对源字符串做任何我们想做的事情,而客户端无法区分!
C++0x 引入了一种称为“右值引用”的新机制,除其他外,它允许我们通过函数重载检测右值参数。我们所要做的就是编写一个带有右值引用参数的构造函数。在该构造函数中,我们可以对源代码做任何我们想做的事情,只要我们让它处于某种有效状态:
string(string&& that) // string&& is an rvalue reference to a string
{
data = that.data;
that.data = nullptr;
}
我们在这里做了什么?我们没有深度复制堆数据,而是复制了指针,然后将原始指针设置为 null(以防止源对象的析构函数中的 'delete[]' 释放我们的'刚刚窃取的数据')。实际上,我们已经“窃取”了最初属于源字符串的数据。同样,关键的见解是,在任何情况下客户都无法检测到源已被修改。由于我们在这里并没有真正进行复制,因此我们将此构造函数称为“移动构造函数”。它的工作是将资源从一个对象移动到另一个对象,而不是复制它们。
恭喜,您现在了解了移动语义的基础知识!让我们继续实现赋值运算符。如果您不熟悉 copy and swap idiom,请学习它并回来,因为它是一个与异常安全相关的很棒的 C++ 习惯用法。
string& operator=(string that)
{
std::swap(data, that.data);
return *this;
}
};
咦,就这样? “右值引用在哪里?”你可能会问。 “我们这里不需要!”是我的答案:)
请注意,我们传递参数 that
按值,因此必须像任何其他字符串对象一样初始化 that
。 that
究竟是如何初始化的?在过去的 C++98 中,答案是“由复制构造函数”。在 C++0x 中,编译器根据赋值运算符的参数是左值还是右值,在复制构造函数和移动构造函数之间进行选择。
因此,如果您说 a = b
,复制构造函数 将初始化 that
(因为表达式 b
是左值),并且赋值运算符将内容与新创建的深层副本交换。这就是复制和交换习语的定义——制作一个副本,将内容与副本交换,然后通过离开范围来摆脱副本。这里没有什么新鲜事。
但是如果你说 a = x + y
,move 构造函数 将初始化 that
(因为表达式 x + y
是一个右值),所以不涉及深拷贝,只有一个有效的移动。 that
仍然是一个独立于参数的对象,但它的构造很简单,因为不必复制堆数据,只需移动即可。没有必要复制它,因为 x + y
是一个右值,同样,可以从右值表示的字符串对象中移动。
总而言之,复制构造函数进行深度复制,因为源必须保持不变。另一方面,移动构造函数可以只复制指针,然后将源中的指针设置为空。以这种方式“取消”源对象是可以的,因为客户端无法再次检查该对象。
我希望这个例子能理解重点。右值引用和移动语义还有很多,我故意省略了这些以保持简单。如果您想了解更多详细信息,请参阅 my supplementary answer。
我的第一个答案是对移动语义进行了极其简化的介绍,并且为了保持简单而故意省略了许多细节。然而,还有很多东西需要移动语义,我认为是时候提供第二个答案来填补空白了。第一个答案已经很老了,简单地用完全不同的文本替换它感觉不对。我认为它仍然可以作为第一次介绍。但是,如果您想更深入地挖掘,请继续阅读:)
Stephan T. Lavavej 花时间提供了宝贵的反馈。非常感谢你,斯蒂芬!
介绍
移动语义允许一个对象在某些条件下取得其他一些对象的外部资源的所有权。这在两个方面很重要:
将昂贵的副本变成廉价的举动。例如,请参阅我的第一个答案。请注意,如果一个对象不管理至少一个外部资源(直接或间接通过其成员对象),则移动语义不会比复制语义提供任何优势。在这种情况下,复制对象和移动对象的含义完全相同:class cannot_benefit_from_move_semantics { int a; // 移动一个 int 意味着复制一个 int float b; // 移动一个浮点数意味着复制一个浮点数 double c; // 移动一个 double 意味着复制一个 double char d[64]; // 移动一个 char 数组意味着复制一个 char 数组 // ... };实现安全的“只移动”类型;也就是说,复制没有意义但移动有意义的类型。示例包括具有唯一所有权语义的锁、文件句柄和智能指针。注意:此答案讨论了 std::auto_ptr,这是一个已弃用的 C++98 标准库模板,在 C++11 中已被 std::unique_ptr 取代。中级 C++ 程序员可能至少对 std::auto_ptr 有点熟悉,并且由于它显示的“移动语义”,它似乎是在 C++11 中讨论移动语义的一个很好的起点。 YMMV。
什么是动?
C++98 标准库提供了一个具有唯一所有权语义的智能指针,称为 std::auto_ptr<T>
。如果您不熟悉 auto_ptr
,它的目的是保证动态分配的对象总是被释放,即使在遇到异常时:
{
std::auto_ptr<Shape> a(new Triangle);
// ...
// arbitrary code, could throw exceptions
// ...
} // <--- when a goes out of scope, the triangle is deleted automatically
auto_ptr
的不同寻常之处在于它的“复制”行为:
auto_ptr<Shape> a(new Triangle);
+---------------+
| triangle data |
+---------------+
^
|
|
|
+-----|---+
| +-|-+ |
a | p | | | |
| +---+ |
+---------+
auto_ptr<Shape> b(a);
+---------------+
| triangle data |
+---------------+
^
|
+----------------------+
|
+---------+ +-----|---+
| +---+ | | +-|-+ |
a | p | | | b | p | | | |
| +---+ | | +---+ |
+---------+ +---------+
请注意,使用 a
初始化 b
如何不复制三角形,而是将三角形的所有权从 a
转移到 b
。我们也说“a
移动到 b
”或“三角形从 a
移动到 b
” .这可能听起来令人困惑,因为三角形本身总是停留在内存中的同一位置。
移动对象意味着将其管理的某些资源的所有权转移给另一个对象。
auto_ptr
的复制构造函数可能看起来像这样(有些简化):
auto_ptr(auto_ptr& source) // note the missing const
{
p = source.p;
source.p = 0; // now the source no longer owns the object
}
危险和无害的动作
auto_ptr
的危险之处在于,语法上看起来像副本的东西实际上是移动。尝试在移出的 auto_ptr
上调用成员函数将调用未定义的行为,因此您必须非常小心,不要在移出 auto_ptr
后使用它:
auto_ptr<Shape> a(new Triangle); // create triangle
auto_ptr<Shape> b(a); // move a into b
double area = a->area(); // undefined behavior
但 auto_ptr
并不总是危险。工厂函数是 auto_ptr
的完美用例:
auto_ptr<Shape> make_triangle()
{
return auto_ptr<Shape>(new Triangle);
}
auto_ptr<Shape> c(make_triangle()); // move temporary into c
double area = make_triangle()->area(); // perfectly safe
请注意两个示例如何遵循相同的句法模式:
auto_ptr<Shape> variable(expression);
double area = expression->area();
然而,其中一个会调用未定义的行为,而另一个则不会。那么表达式 a
和 make_triangle()
之间有什么区别?他们不是同一类型的吗?确实如此,但它们有不同的价值类别。
价值类别
显然,表示 auto_ptr
变量的表达式 a
和表示按值返回 auto_ptr
的函数调用的表达式 make_triangle()
之间肯定存在一些深刻的区别,从而创建了一个新的临时 { 2} 对象每次被调用。 a
是 lvalue 的示例,而 make_triangle()
是 rvalue 的示例。
从像 a
这样的左值移动是危险的,因为我们以后可能会尝试通过 a
调用成员函数,从而调用未定义的行为。另一方面,从 make_triangle()
这样的右值移动是完全安全的,因为在复制构造函数完成其工作后,我们不能再次使用临时值。没有表示所述临时的表达式;如果我们再次简单地写 make_triangle()
,我们会得到一个 不同的 临时的。事实上,从临时移出的已经在下一行中消失了:
auto_ptr<Shape> c(make_triangle());
^ the moved-from temporary dies right here
请注意,字母 l
和 r
在分配的左侧和右侧具有历史渊源。这在 C++ 中不再适用,因为有些左值不能出现在赋值的左侧(如数组或没有赋值运算符的用户定义类型),而有些右值可以(类类型的所有右值)带有赋值运算符)。
类类型的右值是一个表达式,其求值会创建一个临时对象。在正常情况下,同一作用域内没有其他表达式表示同一个临时对象。
右值引用
我们现在明白,从左值移动是有潜在危险的,但从右值移动是无害的。如果 C++ 有语言支持来区分左值参数和右值参数,我们可以完全禁止从左值移动,或者至少在调用站点明确地从左值移动,这样我们就不会再意外移动了。
C++11 对这个问题的回答是右值引用。右值引用是一种只绑定到右值的新引用,语法为 X&&
。良好的旧引用 X&
现在称为 左值引用。 (请注意,X&&
不是对引用的引用;在 C++ 中没有这样的东西。)
如果我们将 const
加入其中,我们已经有四种不同类型的引用。它们可以绑定到哪些类型的 X
表达式?
lvalue const lvalue rvalue const rvalue
---------------------------------------------------------
X& yes
const X& yes yes yes yes
X&& yes
const X&& yes yes
实际上,您可以忘记 const X&&
。被限制为从右值读取并不是很有用。
右值引用 X&& 是一种仅绑定到右值的新型引用。
隐式转换
右值引用经历了几个版本。从 2.1 版开始,右值引用 X&&
还绑定到不同类型 Y
的所有值类别,前提是存在从 Y
到 X
的隐式转换。在这种情况下,会创建一个 X
类型的临时对象,并且右值引用绑定到该临时对象:
void some_function(std::string&& r);
some_function("hello world");
在上面的示例中,"hello world"
是 const char[12]
类型的左值。由于存在从 const char[12]
到 const char*
到 std::string
的隐式转换,因此会创建一个 std::string
类型的临时文件,并将 r
绑定到该临时文件。这是右值(表达式)和临时值(对象)之间的区别有点模糊的情况之一。
移动构造函数
move 构造函数 X::X(X&& source)
是具有 X&&
参数的函数的一个有用示例。其目的是将托管资源的所有权从源转移到当前对象。
在 C++11 中,std::auto_ptr<T>
已被 std::unique_ptr<T>
取代,它利用了右值引用。我将开发和讨论 unique_ptr
的简化版本。首先,我们封装一个原始指针并重载运算符 ->
和 *
,所以我们的类感觉就像一个指针:
template<typename T>
class unique_ptr
{
T* ptr;
public:
T* operator->() const
{
return ptr;
}
T& operator*() const
{
return *ptr;
}
构造函数获取对象的所有权,而析构函数将其删除:
explicit unique_ptr(T* p = nullptr)
{
ptr = p;
}
~unique_ptr()
{
delete ptr;
}
现在是有趣的部分,移动构造函数:
unique_ptr(unique_ptr&& source) // note the rvalue reference
{
ptr = source.ptr;
source.ptr = nullptr;
}
此移动构造函数与 auto_ptr
复制构造函数完全一样,但它只能提供右值:
unique_ptr<Shape> a(new Triangle);
unique_ptr<Shape> b(a); // error
unique_ptr<Shape> c(make_triangle()); // okay
第二行编译失败,因为 a
是一个左值,但参数 unique_ptr&& source
只能绑定到右值。这正是我们想要的;危险的举动不应该是含蓄的。第三行编译得很好,因为 make_triangle()
是一个右值。移动构造函数将所有权从临时转移到 c
。同样,这正是我们想要的。
移动构造函数将托管资源的所有权转移到当前对象中。
移动赋值运算符
最后缺少的部分是移动赋值运算符。它的工作是释放旧资源并从其参数中获取新资源:
unique_ptr& operator=(unique_ptr&& source) // note the rvalue reference
{
if (this != &source) // beware of self-assignment
{
delete ptr; // release the old resource
ptr = source.ptr; // acquire the new resource
source.ptr = nullptr;
}
return *this;
}
};
请注意移动赋值运算符的这种实现如何复制析构函数和移动构造函数的逻辑。你熟悉复制和交换的习语吗?它也可以作为 move-and-swap 习语应用于移动语义:
unique_ptr& operator=(unique_ptr source) // note the missing reference
{
std::swap(ptr, source.ptr);
return *this;
}
};
现在 source
是 unique_ptr
类型的变量,它将由移动构造函数初始化;也就是说,参数将被移动到参数中。该参数仍然需要是一个右值,因为移动构造函数本身有一个右值引用参数。当控制流到达 operator=
的右大括号时,source
超出范围,自动释放旧资源。
移动赋值运算符将托管资源的所有权转移到当前对象,释放旧资源。 move-and-swap 习惯用法简化了实现。
从左值移动
有时,我们想从左值转移。也就是说,有时我们希望编译器将左值视为右值,因此它可以调用移动构造函数,即使它可能是不安全的。为此,C++11 在标头 <utility>
中提供了一个名为 std::move
的标准库函数模板。这个名字有点不幸,因为 std::move
只是将左值转换为右值;它自己不移动任何东西。它只是启用移动。也许它应该被命名为 std::cast_to_rvalue
或 std::enable_move
,但我们现在被这个名字所困扰。
以下是从左值显式移动的方法:
unique_ptr<Shape> a(new Triangle);
unique_ptr<Shape> b(a); // still an error
unique_ptr<Shape> c(std::move(a)); // okay
请注意,在第三行之后,a
不再拥有三角形。没关系,因为通过 明确 编写 std::move(a)
,我们明确了我们的意图:“亲爱的构造函数,为了初始化 c
,对 a
做任何你想做的事;我不在乎不再是 a
。请随意使用 a
。”
std::move(some_lvalue) 将左值转换为右值,从而启用后续移动。
X值
请注意,即使 std::move(a)
是一个右值,它的求值也不会创建一个临时对象。这个难题迫使委员会引入第三个价值类别。可以绑定到右值引用的东西,即使它不是传统意义上的右值,也称为 xvalue(eXpiring 值)。传统的右值被重命名为 prvalues(纯右值)。
prvalues 和 xvalues 都是右值。 Xvalues 和 lvalues 都是 glvalues(广义左值)。使用图表更容易掌握这些关系:
expressions
/ \
/ \
/ \
glvalues rvalues
/ \ / \
/ \ / \
/ \ / \
lvalues xvalues prvalues
请注意,只有 xvalues 是真正新的;其余的只是由于重命名和分组。
C++98 右值在 C++11 中称为右值。将前面段落中所有出现的“rvalue”替换为“prvalue”。
移出功能
到目前为止,我们已经看到了局部变量和函数参数的变化。但也可以朝相反的方向移动。如果函数按值返回,则调用站点的某个对象(可能是局部变量或临时变量,但可以是任何类型的对象)将使用 return
语句之后的表达式作为移动构造函数的参数进行初始化:
unique_ptr<Shape> make_triangle()
{
return unique_ptr<Shape>(new Triangle);
} \-----------------------------/
|
| temporary is moved into c
|
v
unique_ptr<Shape> c(make_triangle());
也许令人惊讶的是,自动对象(未声明为 static
的局部变量)也可以隐式移出函数:
unique_ptr<Shape> make_square()
{
unique_ptr<Shape> result(new Square);
return result; // note the missing std::move
}
为什么移动构造函数接受左值 result
作为参数? result
的作用域即将结束,将在堆栈展开过程中被销毁。事后没有人会抱怨 result
发生了某种变化。当控制流返回调用者时,result
不再存在!出于这个原因,C++11 有一个特殊的规则,允许从函数返回自动对象,而无需编写 std::move
。事实上,您应该永远使用 std::move
将自动对象移出函数,因为这会抑制“命名返回值优化”(NRVO)。
切勿使用 std::move 将自动对象移出函数。
请注意,在这两个工厂函数中,返回类型都是一个值,而不是一个右值引用。右值引用仍然是引用,并且与往常一样,您永远不应该返回对自动对象的引用;如果你欺骗编译器接受你的代码,调用者最终会得到一个悬空引用,如下所示:
unique_ptr<Shape>&& flawed_attempt() // DO NOT DO THIS!
{
unique_ptr<Shape> very_bad_idea(new Square);
return std::move(very_bad_idea); // WRONG!
}
永远不要通过右值引用返回自动对象。移动仅由移动构造函数执行,而不是由 std::move 执行,也不仅仅是将右值绑定到右值引用。
搬进会员
迟早,您将编写如下代码:
class Foo
{
unique_ptr<Shape> member;
public:
Foo(unique_ptr<Shape>&& parameter)
: member(parameter) // error
{}
};
基本上,编译器会抱怨 parameter
是一个左值。如果您查看它的类型,您会看到一个右值引用,但右值引用仅仅意味着“绑定到右值的引用”;它确实 not 意味着引用本身是一个右值!实际上,parameter
只是一个带有名称的普通变量。您可以在构造函数的主体中随意使用 parameter
,它始终表示同一个对象。隐含地离开它会很危险,因此语言禁止它。
命名的右值引用是一个左值,就像任何其他变量一样。
解决方案是手动启用移动:
class Foo
{
unique_ptr<Shape> member;
public:
Foo(unique_ptr<Shape>&& parameter)
: member(std::move(parameter)) // note the std::move
{}
};
您可能会争辩说在 member
初始化之后不再使用 parameter
。为什么没有像返回值一样静默插入 std::move
的特殊规则?可能是因为它会给编译器实现者带来太多负担。例如,如果构造函数主体在另一个翻译单元中怎么办?相比之下,返回值规则只需检查符号表以确定 return
关键字之后的标识符是否表示自动对象。
您还可以按值传递 parameter
。对于像 unique_ptr
这样的只移动类型,似乎还没有成熟的习语。就个人而言,我更喜欢按值传递,因为它可以减少界面中的混乱。
特殊成员函数
C++98 隐式地按需声明了三个特殊的成员函数,也就是说,当某处需要它们时:拷贝构造函数、拷贝赋值运算符和析构函数。
X::X(const X&); // copy constructor
X& X::operator=(const X&); // copy assignment operator
X::~X(); // destructor
右值引用经历了几个版本。从 3.0 版开始,C++11 按需声明了两个额外的特殊成员函数:移动构造函数和移动赋值运算符。请注意,VC10 和 VC11 都不符合 3.0 版,因此您必须自己实现它们。
X::X(X&&); // move constructor
X& X::operator=(X&&); // move assignment operator
这两个新的特殊成员函数只有在没有手动声明特殊成员函数时才会隐式声明。此外,如果您声明自己的移动构造函数或移动赋值运算符,则复制构造函数和复制赋值运算符都不会被隐式声明。
这些规则在实践中意味着什么?
如果你编写一个没有非托管资源的类,则不需要自己声明五个特殊成员函数中的任何一个,你将免费获得正确的复制语义和移动语义。否则,您将不得不自己实现特殊的成员函数。当然,如果您的类没有受益于移动语义,则无需实现特殊的移动操作。
请注意,复制赋值运算符和移动赋值运算符可以融合为一个统一的赋值运算符,按值获取其参数:
X& X::operator=(X source) // unified assignment operator
{
swap(source); // see my first answer for an explanation
return *this;
}
这样,要实现的特殊成员函数的数量从五个下降到四个。这里需要在异常安全和效率之间进行权衡,但我不是这个问题的专家。
转发引用(以前称为通用引用)
考虑以下函数模板:
template<typename T>
void foo(T&&);
您可能希望 T&&
只绑定到右值,因为乍一看,它看起来像一个右值引用。但事实证明,T&&
也绑定到左值:
foo(make_triangle()); // T is unique_ptr<Shape>, T&& is unique_ptr<Shape>&&
unique_ptr<Shape> a(new Triangle);
foo(a); // T is unique_ptr<Shape>&, T&& is unique_ptr<Shape>&
如果参数是 X
类型的右值,则 T
被推断为 X
,因此 T&&
表示 X&&
。这是任何人所期望的。但是如果参数是 X
类型的左值,由于特殊规则,T
被推导出为 X&
,因此 T&&
将表示类似于 X& &&
的意思。但由于 C++ 仍然没有对引用的引用的概念,类型 X& &&
被折叠 到 X&
中。起初这可能听起来令人困惑和无用,但引用折叠对于完美转发是必不可少的(这里将不讨论)。
T&& 不是右值引用,而是转发引用。它还绑定到左值,在这种情况下 T 和 T&& 都是左值引用。
如果要将函数模板约束为右值,可以将 SFINAE 与类型特征结合起来:
#include <type_traits>
template<typename T>
typename std::enable_if<std::is_rvalue_reference<T&&>::value, void>::type
foo(T&&);
搬家的实施
现在您已经了解了引用折叠,下面是 std::move
的实现方式:
template<typename T>
typename std::remove_reference<T>::type&&
move(T&& t)
{
return static_cast<typename std::remove_reference<T>::type&&>(t);
}
如您所见,由于转发引用 T&&
,move
接受任何类型的参数,并且它返回一个右值引用。 std::remove_reference<T>::type
元函数调用是必要的,因为否则,对于 X
类型的左值,返回类型将是 X& &&
,它会折叠成 X&
。由于 t
始终是左值(请记住,命名的右值引用是左值),但我们想将 t
绑定到右值引用,我们必须显式地将 t
强制转换为正确的返回类型。返回右值引用的函数调用本身就是一个 xvalue。现在您知道 xvalues 的来源;)
返回右值引用的函数的调用,例如 std::move,是一个 xvalue。
请注意,在此示例中通过右值引用返回很好,因为 t
不表示自动对象,而是表示调用者传入的对象。
假设您有一个返回实体对象的函数:
Matrix multiply(const Matrix &a, const Matrix &b);
当你写这样的代码时:
Matrix r = multiply(a, b);
然后一个普通的C++编译器会为multiply()
的结果创建一个临时对象,调用复制构造函数初始化r
,然后析构这个临时返回值。 C++0x 中的移动语义允许调用“移动构造函数”通过复制其内容来初始化 r
,然后丢弃临时值而不必破坏它。
如果(可能像上面的 Matrix
示例),被复制的对象在堆上分配额外的内存来存储其内部表示,这一点尤其重要。复制构造函数必须要么制作内部表示的完整副本,要么使用引用计数和写时复制语义。移动构造函数将不理会堆内存,只复制 Matrix
对象内的指针。
移动语义是关于转移资源而不是在不再需要源值时复制它们。
在 C++03 中,对象经常被复制,只是在任何代码再次使用该值之前被销毁或分配。例如,当你从一个函数中按值返回时——除非 RVO 启动——你返回的值被复制到调用者的堆栈帧,然后它超出范围并被销毁。这只是众多示例之一:当源对象是临时对象时,请参见按值传递、仅重新排列项目的 sort
等算法、超过 capacity()
时在 vector
中重新分配等。
当这样的复制/销毁对很昂贵时,通常是因为对象拥有一些重量级资源。例如,vector<string>
可能拥有一个动态分配的内存块,其中包含一组 string
对象,每个对象都有自己的动态内存。复制这样一个对象的成本很高:您必须为源中的每个动态分配的块分配新内存,并复制所有值。 然后你需要释放你刚刚复制的所有内存。但是,移动一个大的 vector<string>
意味着只需将几个指针(指向动态内存块)复制到目标并将它们在源中归零。
如果您真的对移动语义的良好、深入的解释感兴趣,我强烈建议您阅读关于它们的原始论文,"A Proposal to Add Move Semantics Support to the C++ Language."
它非常易于阅读且易于阅读,并且很好地说明了它们提供的好处。 the WG21 website 上还有其他关于移动语义的最新论文,但这篇可能是最直接的,因为它从顶层视图处理问题,并没有深入了解坚韧不拔的语言细节。
用简单(实用)的术语:
复制对象意味着复制其“静态”成员并为其动态对象调用 new
运算符。正确的?
class A
{
int i, *p;
public:
A(const A& a) : i(a.i), p(new int(*a.p)) {}
~A() { delete p; }
};
然而,移动一个对象(我重复一遍,从实际的角度来看)只意味着复制动态对象的指针,而不是创建新的指针。
但是,这不危险吗?当然,您可以两次破坏动态对象(分段错误)。因此,为避免这种情况,您应该“使”源指针“无效”以避免两次破坏它们:
class A
{
int i, *p;
public:
// Movement of an object inside a copy constructor.
A(const A& a) : i(a.i), p(a.p)
{
a.p = nullptr; // pointer invalidated.
}
~A() { delete p; }
// Deleting NULL, 0 or nullptr (address 0x0) is safe.
};
好的,但是如果我移动一个对象,源对象就会变得无用,不是吗?当然,但在某些情况下这非常有用。最明显的一个是当我使用匿名对象(时间,右值对象,......,你可以用不同的名字来调用它)调用一个函数时:
void heavyFunction(HeavyType());
在这种情况下,会创建一个匿名对象,然后将其复制到函数参数,然后将其删除。所以,这里最好移动对象,因为您不需要匿名对象,并且可以节省时间和内存。
这导致了“右值”引用的概念。它们存在于 C++11 中只是为了检测接收到的对象是否是匿名的。我想您已经知道“左值”是一个可分配的实体(=
运算符的左侧部分),因此您需要对对象的命名引用才能充当左值。右值正好相反,一个没有命名引用的对象。因此,匿名对象和右值是同义词。所以:
class A
{
int i, *p;
public:
// Copy
A(const A& a) : i(a.i), p(new int(*a.p)) {}
// Movement (&& means "rvalue reference to")
A(A&& a) : i(a.i), p(a.p)
{
a.p = nullptr;
}
~A() { delete p; }
};
在这种情况下,当应该“复制”类型为 A
的对象时,编译器会根据传递的对象是否命名来创建左值引用或右值引用。如果没有,您的移动构造函数被调用并且您知道对象是临时的,您可以移动其动态对象而不是复制它们,从而节省空间和内存。
重要的是要记住“静态”对象总是被复制的。没有办法“移动”静态对象(堆栈中的对象而不是堆中的对象)。因此,当对象没有动态成员(直接或间接)时,“移动”/“复制”的区别是无关紧要的。
如果您的对象很复杂并且析构函数具有其他次要效果,例如调用库的函数、调用其他全局函数或其他任何函数,则最好用标志表示移动:
class Heavy
{
bool b_moved;
// staff
public:
A(const A& a) { /* definition */ }
A(A&& a) : // initialization list
{
a.b_moved = true;
}
~A() { if (!b_moved) /* destruct object */ }
};
因此,您的代码更短(您不需要为每个动态成员执行 nullptr
分配)并且更通用。
其他典型问题:A&&
和 const A&&
有什么区别?当然,在第一种情况下,你可以修改对象,而在第二种情况下,不是,但是,有实际意义吗?在第二种情况下,您无法修改它,因此您无法使对象无效(除非使用可变标志或类似的东西),并且复制构造函数没有实际区别。
什么是完美转发?重要的是要知道“右值引用”是对“调用者范围”中命名对象的引用。但在实际范围内,右值引用是对象的名称,因此,它充当命名对象。如果将右值引用传递给另一个函数,则传递的是一个命名对象,因此该对象不会像临时对象那样被接收。
void some_function(A&& a)
{
other_function(a);
}
对象 a
将被复制到 other_function
的实际参数。如果您希望对象 a
继续被视为临时对象,则应使用 std::move
函数:
other_function(std::move(a));
通过这一行,std::move
会将 a
强制转换为右值,而 other_function
会将对象作为未命名对象接收。当然,如果 other_function
没有特定的重载来处理未命名的对象,那么这种区别并不重要。
那是完美的转发吗?不是,但我们非常接近。完美转发只对使用模板有用,目的是说:如果我需要将一个对象传递给另一个函数,我需要如果我收到一个命名对象,则该对象作为命名对象传递,如果不是,我想像一个未命名的对象一样传递它:
template<typename T>
void some_function(T&& a)
{
other_function(std::forward<T>(a));
}
这是使用完美转发的原型函数的签名,在 C++11 中通过 std::forward
实现。这个函数利用了模板实例化的一些规则:
`A& && == A&`
`A&& && == A&&`
因此,如果 T
是对 A
的左值引用 (T = A&),则 a
也是 (A& && => A& ;)。如果 T
是对 A
的右值引用,则 a
也是 (A&& && => A&&)。在这两种情况下,a
都是实际作用域中的命名对象,但从调用者作用域的角度来看,T
包含其“引用类型”的信息。此信息 (T
) 作为模板参数传递给 forward
,并且根据 T
的类型移动或不移动“a”。
这就像复制语义,但不必复制所有数据,您可以从被“移动”的对象中窃取数据。
你知道复制语义是什么意思吗?这意味着您具有可复制的类型,对于用户定义的类型,您可以明确地定义它,或者明确地编写复制构造函数和赋值运算符,或者编译器隐式生成它们。这将做一个副本。
移动语义基本上是一种用户定义的类型,其构造函数采用非常量的 r 值引用(使用 && (是两个与号)的新引用类型),这称为移动构造函数,赋值运算符也是如此。那么移动构造函数做了什么,而不是从源参数复制内存,而是将内存从源“移动”到目标。
你什么时候想这样做?好吧 std::vector 就是一个例子,假设你创建了一个临时的 std::vector 并且你从一个函数返回它说:
std::vector<foo> get_foos();
当函数返回时,如果(并且在 C++0x 中)std::vector 有一个移动构造函数而不是复制它,你将有来自复制构造函数的开销,它可以设置它的指针并动态分配“移动”内存到新实例。这有点像 std::auto_ptr 的所有权转移语义。
我写这篇文章是为了确保我能正确理解它。
创建移动语义是为了避免不必要的大对象复制。 Bjarne Stroustrup 在他的“C++ 编程语言”一书中使用了两个默认情况下发生不必要复制的示例:一个是交换两个大对象,二是从一个方法返回一个大对象。
交换两个大对象通常包括将第一个对象复制到临时对象,将第二个对象复制到第一个对象,以及将临时对象复制到第二个对象。对于内置类型,这非常快,但对于大型对象,这三个副本可能需要大量时间。 “移动赋值”允许程序员覆盖默认的复制行为,而是交换对对象的引用,这意味着根本没有复制并且交换操作要快得多。可以通过调用 std::move() 方法来调用移动赋值。
默认情况下,从方法中返回对象涉及在调用者可访问的位置复制本地对象及其关联数据(因为调用者无法访问本地对象,并且在方法完成时会消失)。返回内置类型时,此操作非常快,但如果返回大对象,则可能需要很长时间。移动构造函数允许程序员覆盖这个默认行为,而是通过将返回给调用者的对象指向与本地对象关联的堆数据来“重用”与本地对象关联的堆数据。因此不需要复制。
在不允许创建本地对象(即堆栈上的对象)的语言中,这些类型的问题不会发生,因为所有对象都在堆上分配并且总是通过引用访问。
x
和 y
,您不能只“交换对对象的引用”;可能是对象包含引用其他数据的指针,并且这些指针可以交换,但移动运算符不是需要交换任何东西。他们可能会清除移动对象中的数据,而不是保留其中的目标数据。
swap()
。 “可以通过调用 std::move() 方法来调用移动赋值。” - 有时 需要使用 std::move()
- 虽然这实际上并不移动任何东西 - 只是让编译器知道参数是可移动的,有时是 std::forward<>()
(带有转发引用),而其他时候编译器知道可以移动一个值。
为了说明移动语义的必要性,让我们考虑这个没有移动语义的例子:
下面是一个接受 T
类型的对象并返回相同类型 T
的对象的函数:
T f(T o) { return o; }
//^^^ new object constructed
上面的函数使用按值调用,这意味着当调用这个函数时,必须构造一个对象才能被函数使用。因为函数也是按值返回的,所以为返回值构造了另一个新对象:
T b = f(a);
//^ new object constructed
已经构建了两个新对象,其中一个是仅在函数执行期间使用的临时对象。
当从返回值创建新对象时,调用复制构造函数将临时对象的内容复制到新对象b中。函数完成后,函数中使用的临时对象超出范围并被销毁。
现在,让我们考虑一下复制构造函数的作用。
它必须首先初始化对象,然后将所有相关数据从旧对象复制到新对象。根据类的不同,它可能是一个包含大量数据的容器,那么这可能代表大量时间和内存使用情况
// Copy constructor
T::T(T &old) {
copy_data(m_a, old.m_a);
copy_data(m_b, old.m_b);
copy_data(m_c, old.m_c);
}
使用移动语义,现在可以通过简单地移动数据而不是复制来减少大部分工作的不愉快。
// Move constructor
T::T(T &&old) noexcept {
m_a = std::move(old.m_a);
m_b = std::move(old.m_b);
m_c = std::move(old.m_c);
}
移动数据涉及将数据与新对象重新关联。而且根本没有复制。
这是通过 rvalue
引用完成的。
rvalue
引用的工作方式与 lvalue
引用非常相似,但有一个重要区别:
右值引用可以移动,而左值 不能。
为了使强异常保证成为可能,用户定义的移动构造函数不应抛出异常。事实上,当容器元素需要重新定位时,标准容器通常依靠 std::move_if_noexcept 在移动和复制之间进行选择。如果同时提供了复制和移动构造函数,如果参数是右值(纯右值,例如无名临时值或 xvalue,例如 std::move 的结果),则重载决策选择复制构造函数,并且在以下情况下选择复制构造函数参数是一个左值(命名对象或返回左值引用的函数/运算符)。如果仅提供了复制构造函数,则所有参数类别都会选择它(只要它需要对 const 的引用,因为右值可以绑定到 const 引用),这使得在移动不可用时复制移动的后备。在许多情况下,移动构造函数会被优化,即使它们会产生可观察到的副作用,请参阅复制省略。当构造函数将右值引用作为参数时,它被称为“移动构造函数”。没有义务移动任何东西,类不需要移动资源,并且“移动构造函数”可能无法移动资源,因为在参数是允许的(但可能不明智)的情况下是const 右值引用 (const T&&)。
这是 Bjarne Stroustrup 所著的《C++ 编程语言》一书中的an answer。如果不想看视频,可以看下面的文字:
考虑这个片段。从 operator+ 返回涉及将结果从局部变量 res
复制到调用者可以访问它的某个地方。
Vector operator+(const Vector& a, const Vector& b)
{
if (a.size()!=b.size())
throw Vector_siz e_mismatch{};
Vector res(a.size());
for (int i=0; i!=a.size(); ++i)
res[i]=a[i]+b[i];
return res;
}
我们真的不想要一份副本。我们只是想从一个函数中得到结果。所以我们需要移动一个 Vector 而不是复制它。我们可以如下定义移动构造函数:
class Vector {
// ...
Vector(const Vector& a); // copy constructor
Vector& operator=(const Vector& a); // copy assignment
Vector(Vector&& a); // move constructor
Vector& operator=(Vector&& a); // move assignment
};
Vector::Vector(Vector&& a)
:elem{a.elem}, // "grab the elements" from a
sz{a.sz}
{
a.elem = nullptr; // now a has no elements
a.sz = 0;
}
&&表示“右值引用”,是我们可以绑定右值的引用。 “右值”旨在补充“左值”,“左值”大致意思是“可以出现在赋值左侧的东西”。所以右值大致意思是“一个你不能赋值的值”,比如函数调用返回的整数,以及向量的 operator+() 中的 res
局部变量。
现在,语句 return res;
将不会复制!
that.data = 0
,角色会过早地被破坏(当临时死亡时),而且还会两次。你想窃取数据,而不是分享它!delete[]
被 C++ 标准定义为无操作。