提问人:dicroce 提问时间:6/24/2010 最后编辑:Jan Schultkedicroce 更新时间:10/13/2023 访问量:590145
什么是移动语义?
What is move semantics?
问:
我刚刚听完了软件工程电台播客对Scott Meyers关于C++11的采访。大多数新功能对我来说都很有意义,除了一个。我仍然没有得到移动语义......它到底是什么?
答:
假设你有一个返回实质性对象的函数:
Matrix multiply(const Matrix &a, const Matrix &b);
当您编写如下代码时:
Matrix r = multiply(a, b);
那么一个普通的C++编译器会为结果创建一个临时对象,调用复制构造函数进行初始化,然后对临时返回值进行解构。C++0x 中的移动语义允许调用“移动构造函数”通过复制其内容进行初始化,然后丢弃临时值而不必销毁它。multiply()
r
r
如果(可能像上面的例子一样)被复制的对象在堆上分配额外的内存来存储其内部表示形式,这一点尤其重要。复制构造函数必须创建内部表示的完整副本,或者在内部使用引用计数和写入时复制语义。移动构造函数将保留堆内存,而只是将指针复制到对象内部。Matrix
Matrix
评论
这就像复制语义,但不必复制所有数据,而是从被“移动”的对象中窃取数据。
你知道复制语义是什么意思吗?这意味着你有可复制的类型,对于用户定义的类型,你要么显式地编写复制构造函数和赋值运算符,要么编译器隐式生成它们。这将进行复制。
移动语义基本上是一个用户定义的类型,带有构造函数,它接受一个 r 值引用(使用 && (是的,两个 & 符号)的新型引用),它是非常量的,这称为移动构造函数,赋值运算符也是如此。那么移动构造函数是做什么的,它不是从它的源参数复制内存,而是将内存从源“移动”到目标。
你什么时候想这样做?那么 std::vector 就是一个例子,假设你创建了一个临时的 std::vector,然后你从一个函数返回它,比如:
std::vector<foo> get_foos();
当函数返回时,您将从复制构造函数中获得开销,如果(并且在 C++0x 中会)std::vector 有一个移动构造函数而不是复制它,只需设置它的指针并“移动”动态分配的内存到新实例。这有点像 std::auto_ptr 的所有权转让语义。
评论
如果你真的对移动语义的深入解释感兴趣,我强烈建议你阅读关于它们的原始论文,“向C++语言添加移动语义支持的建议”。
它非常易于访问且易于阅读,并且为它们提供的好处提供了一个很好的案例。WG21 网站上还有其他关于移动语义的最新论文,但这篇可能是最直接的,因为它从顶层视角处理事情,并没有深入到粗犷的语言细节。
我发现使用示例代码最容易理解移动语义。让我们从一个非常简单的字符串类开始,它只包含指向堆分配的内存块的指针:
#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);
}
既然我们选择自己管理记忆,就需要遵循三法则。我将推迟编写赋值运算符,现在只实现析构函数和复制构造函数:
~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
现在是对移动语义的关键见解。请注意,只有在我们复制的第一行中,这个深层复制才是真正必要的,因为我们可能想稍后检查,如果以某种方式更改,我们会感到非常惊讶。你有没有注意到我刚才说了三遍(如果你包括这句话的话是四遍),而且每次都意味着完全相同的对象?我们称表达式为“lvalues”。x
x
x
x
x
第 2 行和第 3 行中的参数不是左值,而是右值,因为基础字符串对象没有名称,因此客户端无法在以后的时间点再次检查它们。
rvalue 表示在下一个分号处被销毁的临时对象(更准确地说:在词法上包含右值的完整表达式的末尾)。这很重要,因为在初始化 and 期间,我们可以对源字符串做任何我们想做的事情,而客户端无法分辨出区别!b
c
C++0x 引入了一种称为“右值引用”的新机制,其中包括: 允许我们通过函数重载来检测右值参数。我们所要做的就是编写一个带有右值引用参数的构造函数。在该构造函数中,我们可以对源代码执行任何我们想要的事情,只要我们将其保持在某个有效状态:
string(string&& that) // string&& is an rvalue reference to a string
{
data = that.data;
that.data = nullptr;
}
我们在这里做了什么?我们没有深度复制堆数据,而是复制了指针,然后将原始指针设置为 null(以防止源对象析构函数中的“delete[]”释放我们的“刚刚被盗的数据”)。实际上,我们已经“窃取”了最初属于源字符串的数据。同样,关键的见解是,在任何情况下,客户端都无法检测到源已被修改。由于我们在这里没有真正进行复制,因此我们将此构造函数称为“移动构造函数”。它的工作是将资源从一个对象移动到另一个对象,而不是复制它们。
恭喜你,你现在了解了移动语义的基础知识!让我们继续实现赋值运算符。如果您不熟悉复制和交换习语,请学习它并回来,因为它是与异常安全相关的很棒的 C++ 习语。
string& operator=(string that)
{
std::swap(data, that.data);
return *this;
}
};
咦,就这样?你可能会问:“右值参考在哪里?“我们在这里不需要它!”这是我的回答:)
请注意,我们按值传递参数,因此必须像任何其他字符串对象一样进行初始化。究竟是如何初始化的?在C++98的旧时代,答案是“通过复制构造函数”。在 C++0x 中,编译器根据赋值运算符的参数是左值还是右值在复制构造函数和移动构造函数之间进行选择。that
that
that
因此,如果您说 ,复制构造函数将初始化(因为表达式是左值),赋值运算符将内容与新创建的深层副本交换。这就是复制和交换成语的定义——制作一个副本,用副本交换内容,然后通过离开范围来摆脱副本。这里没什么新鲜事。a = b
that
b
但是如果你说,move 构造函数将初始化(因为表达式是右值),所以不涉及深层拷贝,只有高效的移动。 仍然是一个独立于论证的对象,但它的构造是微不足道的,
由于堆数据不必复制,只需移动即可。没有必要复制它,因为它是一个右值,同样,可以从右值表示的字符串对象中移动。a = x + y
that
x + y
that
x + y
总而言之,复制构造函数会进行深度复制,因为源必须保持不变。 另一方面,move 构造函数可以只复制指针,然后将源中的指针设置为 null。以这种方式“取消”源对象是可以的,因为客户端无法再次检查该对象。
我希望这个例子能传达出要点。重值引用和移动语义还有很多,为了保持简单,我故意省略了这些内容。如果您想了解更多详细信息,请参阅我的补充答案。
评论
that.data = 0
delete[]
移动语义是关于转移资源,而不是在没有人再需要源值时复制它们。
在 C++03 中,对象经常被复制,只有在任何代码再次使用该值之前才会被销毁或分配。例如,当您按值从函数返回时(除非 RVO 启动),您返回的值将复制到调用方的堆栈帧中,然后超出范围并被销毁。这只是众多示例之一:当源对象是临时对象时,请参阅按值传递,此类算法只是重新排列项目,当超过其时重新分配,等等。sort
vector
capacity()
当这种复制/销毁对的代价高昂时,通常是因为对象拥有一些重量级资源。例如,可以拥有一个动态分配的内存块,其中包含一个对象数组,每个对象都有自己的动态内存。复制这样的对象是昂贵的:你必须为源中每个动态分配的块分配新的内存,并复制所有值。然后,您需要解除分配刚刚复制的所有内存。但是,移动大意味着只需将几个指针(引用动态内存块)复制到目标,并在源中将它们清零。vector<string>
string
vector<string>
我的第一个答案是对移动语义的极其简化的介绍,为了简单起见,故意省略了许多细节。 然而,还有很多东西需要移动语义,我认为是时候用第二个答案来填补空白了。 第一个答案已经很老了,简单地用完全不同的文本替换它是不对的。我认为它仍然可以很好地作为第一个介绍。但是,如果您想更深入地挖掘,请继续阅读:)
Stephan T. Lavavej 花时间提供了宝贵的反馈。非常感谢你,斯蒂芬!
介绍
移动语义允许对象在特定条件下获得其他对象的外部资源的所有权。这在两个方面很重要:
将昂贵的副本变成廉价的举动。有关示例,请参阅我的第一个答案。请注意,如果对象不管理至少一个外部资源(直接或间接通过其成员对象),则移动语义不会比复制语义提供任何优势。在这种情况下,复制对象和移动对象意味着完全相同的事情:
class cannot_benefit_from_move_semantics { int a; // moving an int means copying an int float b; // moving a float means copying a float double c; // moving a double means copying a double char d[64]; // moving a char array means copying a char array // ... };
实现安全的“仅移动”类型;也就是说,复制没有意义,但移动有意义的类型。示例包括锁、文件句柄和具有唯一所有权语义的智能指针。注意:此答案讨论的是,已弃用的 C++98 标准库模板,该模板已替换为 C++11。中级 C++ 程序员可能至少在某种程度上熟悉 ,并且由于它显示的“移动语义”,它似乎是讨论 C++11 中移动语义的一个很好的起点。YMMV。
std::auto_ptr
std::unique_ptr
std::auto_ptr
什么是移动?
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 | | | |
| +---+ | | +---+ |
+---------+ +---------+
请注意,with 的初始化不会复制三角形,而是将三角形的所有权从 转移到 。我们还说“移入”或“三角形移至”。这听起来可能令人困惑,因为三角形本身总是停留在内存中的同一位置。b
a
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
}
危险无害的举动
危险的是,在句法上看起来像是复制品的东西实际上是一种移动。尝试在 moved-from 上调用成员函数将调用未定义的行为,因此您必须非常小心,不要在 moved from 后使用它: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()
值类别
显然,表示变量的表达式与表示调用返回 by 值的函数的表达式之间一定存在一些深刻的差异,因此每次调用时都会创建一个新的临时对象。 是左值的示例,而是右值的示例。a
auto_ptr
make_triangle()
auto_ptr
auto_ptr
a
make_triangle()
从 lvalue (例如)移动是危险的,因为我们稍后可能会尝试通过 调用未定义的行为来调用成员函数。另一方面,从 rvalues (例如 )移动是完全安全的,因为在复制构造函数完成其工作后,我们不能再使用 temporary 了。没有表示所述临时的表达;如果我们简单地再写一遍,我们会得到一个不同的临时。事实上,moved-from 临时的已经在下一行消失了:a
a
make_triangle()
make_triangle()
auto_ptr<Shape> c(make_triangle());
^ the moved-from temporary dies right here
请注意,字母 和 在作业的左侧和右侧都有历史渊源。这在 C++ 中不再适用,因为有些左值不能出现在赋值的左侧(如数组或没有赋值运算符的用户定义类型),并且有可以的右值(所有带有赋值运算符的类类型的右值)。l
r
类类型的右值是一个表达式,其计算结果会创建一个临时对象。 在正常情况下,同一作用域内没有其他表达式表示相同的临时对象。
右值引用
我们现在明白,从左值移动是潜在的危险,但从左值移动是无害的。如果 C++ 有语言支持来区分左值参数和右值参数,我们可以完全禁止从左值移动,或者至少在调用站点明确从左值移动,这样我们就不会再意外移动。
C++11 对这个问题的答案是右值引用。右值引用是一种仅绑定到右值的新型引用,语法为 。旧的引用现在称为左值引用。(请注意,这不是对引用的引用;C++中没有这样的东西。X&&
X&
X&&
如果我们把这些都放在一起,我们已经有四种不同类型的参考了。它们可以绑定到哪些类型的表达式?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");
在上面的例子中,是 类型的左值。由于存在从 through 到 的隐式转换,因此创建了一个 temporary of 类型,并绑定到该 temporary。这是右值(表达式)和临时(对象)之间的区别有点模糊的情况之一。"hello world"
const char[12]
const char[12]
const char*
std::string
std::string
r
移动构造函数
带有参数的函数的一个有用示例是 move 构造函数 。其目的是将托管资源的所有权从源转移到当前对象。X&&
X::X(X&& source)
在 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;
}
此移动构造函数执行的操作与复制构造函数完全相同,但它只能提供 rvalues:auto_ptr
unique_ptr<Shape> a(new Triangle);
unique_ptr<Shape> b(a); // error
unique_ptr<Shape> c(make_triangle()); // okay
第二行编译失败,因为是左值,但参数只能绑定到右值。这正是我们想要的;危险的举动永远不应该是隐含的。第三行编译得很好,因为是一个右值。move 构造函数会将所有权从 temporary 转移到 .同样,这正是我们想要的。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;
}
};
请注意,移动赋值运算符的这种实现如何复制析构函数和移动构造函数的逻辑。你熟悉复制和交换的成语吗?它也可以应用于移动语义作为移动和交换习语:
unique_ptr& operator=(unique_ptr source) // note the missing reference
{
std::swap(ptr, source.ptr);
return *this;
}
};
现在这是一个类型的变量,它将由 move 构造函数初始化;也就是说,参数将被移动到参数中。该参数仍然需要是右值,因为移动构造函数本身具有右值引用参数。当控制流到达 的右大括号时,将超出范围,自动释放旧资源。source
unique_ptr
operator=
source
移动分配运算符将托管资源的所有权转移到当前对象中,从而释放旧资源。 移动和交换习惯用语简化了实现。
从左值移动
有时,我们想从左值开始。也就是说,有时我们希望编译器将左值视为右值,以便它可以调用移动构造函数,即使它可能不安全。
为此,C++11 提供了一个标准库函数模板,称为 header 。
这个名字有点不幸,因为只是简单地将左值转换为右值;它本身不会移动任何东西。它只是使移动成为可能。也许它应该被命名为 或 ,但我们现在被这个名字困住了。std::move
<utility>
std::move
std::cast_to_rvalue
std::enable_move
以下是如何显式地从 lvalue 移动:
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)
a
c
a
a
std::move(some_lvalue)
将左值转换为右值,从而启用后续移动。
X值
请注意,即使 r 值,它的计算也不会创建临时对象。这个难题迫使委员会引入了第三个价值类别。可以绑定到右值引用的东西,即使它不是传统意义上的右值,也称为 xvalue(eXpiring 值)。传统的右值被重命名为 prvalues(纯右值)。std::move(a)
prvalues 和 xvalues 都是 rvalues。Xvalues 和 lvalues 都是 glvalues(广义左值)。使用图表更容易掌握这些关系:
expressions
/ \
/ \
/ \
glvalues rvalues
/ \ / \
/ \ / \
/ \ / \
lvalues xvalues prvalues
请注意,只有 xvalue 是真正新的;其余的只是由于重命名和分组。
C++98 右值在 C++11 中称为 prvalues。在脑海中将前几段中出现的所有“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
}
为什么移动构造函数接受左值作为参数?范围即将结束,在堆垛放卷时将被销毁。事后,没有人会抱怨不知何故发生了变化;当控制流返回调用方时,不再存在!出于这个原因,C++ 有一个特殊的规则,允许从函数返回自动对象,而无需编写 .事实上,永远不应该使用自动对象将自动对象移出函数,因为这会抑制“命名返回值优化”(NRVO)。result
result
result
result
std::move
std::move
切勿用于将自动对象移出函数。
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!
}
从不通过右值引用返回自动对象。移动仅由 move 构造函数执行,而不是由 执行,也不仅仅是通过将右值绑定到右值引用来执行的。
std::move
加入成员
迟早,你会写出这样的代码:
class Foo
{
unique_ptr<Shape> member;
public:
Foo(unique_ptr<Shape>&& parameter)
: member(parameter) // error
{}
};
基本上,编译器会抱怨这是一个左值。如果你看一下它的类型,你会看到一个右值引用,但右值引用只是意味着“绑定到右值的引用”;这并不意味着引用本身就是右值!事实上,只是一个有名字的普通变量。您可以在构造函数的主体中随意使用,并且它始终表示相同的对象。隐含地离开它会很危险,因此语言禁止它。parameter
parameter
parameter
命名右值引用是一个左值,就像任何其他变量一样。
解决方案是手动启用移动:
class Foo
{
unique_ptr<Shape> member;
public:
Foo(unique_ptr<Shape>&& parameter)
: member(std::move(parameter)) // note the std::move
{}
};
您可能会争辩说,在初始化 之后不再使用它。为什么没有像返回值一样静默插入的特殊规则?可能是因为这对编译器实现者来说负担太大了。例如,如果构造函数正文位于另一个翻译单元中,该怎么办?相比之下,返回值规则只需检查符号表,以确定关键字后面的标识符是否表示自动对象。parameter
member
std::move
return
还可以传递 by 值。对于像这样的仅限移动的类型,似乎还没有既定的成语。就我个人而言,我更喜欢按值传递,因为它会导致界面中的混乱较少。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;
}
这样,要实现的特殊成员函数的数量从 5 个减少到 4 个。这里有异常安全和效率之间的权衡,但我不是这个问题的专家。
转发引用(以前称为通用引用)
请考虑以下函数模板:
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>&
如果参数是 类型的右值,则推导为 ,因此均为 。这是任何人都会期望的。
但是,如果参数是类型的左值,由于一个特殊的规则,则推导为 ,因此其含义类似于 。但是由于 C++ 仍然没有引用引用的概念,因此该类型折叠为 .乍一听可能令人困惑且无用,但引用折叠对于完美转发至关重要(此处不讨论)。X
T
X
T&&
X&&
X
T
X&
T&&
X& &&
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);
}
如您所见,由于转发引用,它接受任何类型的参数,并且它返回一个右值引用。元函数调用是必要的,否则,对于类型的左值,返回类型将是 ,它将折叠成 。由于始终是左值(请记住,命名右值引用是左值),但是我们想要绑定到右值引用,因此我们必须显式转换为正确的返回类型。
返回右值引用的函数的调用本身就是一个 xvalue。现在您知道 xvalues 从何而来;)move
T&&
std::remove_reference<T>::type
X
X& &&
X&
t
t
t
返回右值引用(如 )的函数的调用是 xvalue。
std::move
请注意,在此示例中,通过右值引用返回是可以的,因为它不表示自动对象,而是由调用方传入的对象。t
评论
A string literal is an lvalue; all other literals are prvalues.
用简单(实用)的术语来说:
复制对象意味着复制其“静态”成员并为其动态对象调用运算符。右?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.
};
好的,但是如果我移动一个对象,源对象就会变得无用,不是吗?当然,但在某些情况下,这非常有用。最明显的一个是当我使用匿名对象(temporal、rvalue 对象等,你可以用不同的名称调用它)调用一个函数时:
void heavyFunction(HeavyType());
在这种情况下,将创建一个匿名对象,然后将其复制到函数参数,然后删除。所以,这里最好移动对象,因为你不需要匿名对象,你可以节省时间和内存。
这导致了“rvalue”引用的概念。它们存在于 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; }
};
在这种情况下,当应“复制”类型的对象时,编译器会根据传递的对象是否命名创建左值引用或右值引用。如果没有,则调用 move-constructor,并且您知道该对象是临时的,您可以移动其动态对象而不是复制它们,从而节省空间和内存。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&&`
因此,if 是 (T = A&) 的左值引用,也是 (A& && => A&)。如果是对 的右值引用,也是 (A&& && => A&&)。在这两种情况下,都是实际作用域中的命名对象,但从调用方作用域的角度来看,它包含其“引用类型”的信息。此信息 () 作为模板参数传递给 ,并且 'a' 根据 的类型移动或不移动。T
A
a
T
A
a
a
T
T
forward
T
为了说明对移动语义的需求,让我们考虑这个没有移动语义的例子:
下面是一个函数,它接受一个类型的对象并返回一个相同类型的对象: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 的引用,因为 rvalues 可以绑定到 const 引用),这使得在移动不可用时复制移动的回退。 在许多情况下,移动构造函数会被优化,即使它们会产生可观察到的副作用,请参阅复制省略。 当构造函数将右值引用作为参数时,它被称为“移动构造函数”。它没有义务移动任何东西,类不需要有要移动的资源,并且“移动构造函数”可能无法移动资源,因为在允许(但可能不明智)的情况下,参数是常量右值引用(const T&&)。
我写这篇文章是为了确保我正确理解它。
创建移动语义是为了避免不必要的大型对象复制。Bjarne Stroustrup在他的《C++编程语言》一书中使用了两个示例,其中默认会发生不必要的复制:一个是交换两个大对象,二是从方法返回一个大对象。
交换两个大对象通常涉及将第一个对象复制到临时对象,将第二个对象复制到第一个对象,以及将临时对象复制到第二个对象。对于内置类型,这非常快,但对于大型对象,这三个副本可能需要大量时间。“移动赋值”允许程序员覆盖默认的复制行为,而是交换对对象的引用,这意味着根本没有复制,交换操作要快得多。可以通过调用 std::move() 方法来调用移动赋值。
默认情况下,从方法返回对象涉及在调用方可访问的位置创建本地对象及其关联数据的副本(因为调用方无法访问本地对象,并且在方法完成时消失)。返回内置类型时,此操作非常快,但如果返回大型对象,则可能需要很长时间。移动构造函数允许程序员重写此默认行为,而是通过将返回给调用方的对象指向与本地对象关联的堆数据来“重用”与本地对象关联的堆数据。因此,不需要复制。
在不允许创建本地对象(即堆栈上的对象)的语言中,不会发生这些类型的问题,因为所有对象都分配在堆上,并且始终通过引用访问。
评论
x
y
swap()
std::move()
std::forward<>()
这是Bjarne Stroustrup所著的《The C++ Programming Language》一书中的答案。如果您不想观看视频,可以查看以下文字:
请考虑以下代码片段。从 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;
}
&& 表示“右值引用”,是我们可以绑定右值的引用。“rvalue”旨在补充“lvalue”,大致意思是“可以出现在赋值左侧的东西”。因此,右值大致表示“无法赋值的值”,例如函数调用返回的整数,以及 Vectors 的 operator+() 中的局部变量。res
现在,该声明不会复制!return res;
下一个:什么是移动语义?
评论