我刚听完Scott Meyers关于C++0x的软件工程广播播客采访。大多数新特性对我来说都是有意义的,我现在对C++0x非常兴奋,只有一个例外。我仍然不懂移动语义。。。到底是什么?
当前回答
这就像复制语义,但不必复制所有数据,而是从“移动”的对象中窃取数据。
其他回答
如果你真的对移动语义的一个好的、深入的解释感兴趣,我强烈建议你阅读关于它们的原始论文《向C++语言添加移动语义支持的建议》
这本书非常容易阅读和阅读,为他们提供的好处提供了极好的理由。WG21网站上还有其他关于移动语义的最新论文,但这篇文章可能是最直接的,因为它从顶层的角度处理问题,并没有深入到语言的细节。
你知道复制语义是什么意思吗?这意味着您有可复制的类型,对于用户定义的类型,您可以明确地编写复制构造函数和赋值运算符,或者编译器隐式地生成它们。这将复制一份。
移动语义基本上是一种用户定义的类型,带有构造函数,它接受一个非常量的r值引用(使用&&(是两个&符号)的新类型引用),这称为移动构造函数,赋值运算符也是如此。那么,移动构造函数做什么呢?它不是从源参数复制内存,而是将内存从源“移动”到目标。
你什么时候想这样做?那么std::vector就是一个例子,假设您创建了一个临时std::vector,然后从一个函数返回它,比如:
std::vector<foo> get_foos();
当函数返回时,复制构造函数将产生开销,如果(在C++0x中也是如此)std::vector有一个移动构造函数,而不是复制它。它只需要设置指针并将动态分配的内存“移动”到新实例。这有点像std::auto_ptr的所有权转移语义。
我发现用示例代码来理解移动语义是最容易的。让我们从一个非常简单的字符串类开始,它只包含一个指向堆分配的内存块的指针:
#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&绑定到string类型的所有表达式,允许您在以下示例中创建副本:
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等表达式为“lvalues”。
第2行和第3行中的参数不是lvalues,而是rvalues,因为基础字符串对象没有名称,因此客户端无法在稍后的时间点再次检查它们。右值表示在下一个分号处销毁的临时对象(更准确地说:在词汇上包含右值的完整表达式的末尾)。这一点很重要,因为在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中,编译器根据赋值运算符的参数是左值还是右值,在复制构造函数和移动构造函数之间进行选择。
因此,如果你说a=b,复制构造函数将初始化它(因为表达式b是一个左值),赋值运算符将用新创建的深度副本交换内容。这就是复制和交换习惯用法的定义——制作一个副本,用副本交换内容,然后通过离开范围来删除副本。这里没有什么新鲜事。
但是如果你说a=x+y,move构造函数将初始化它(因为表达式x+y是一个右值),所以不涉及深度复制,只涉及有效的移动。这仍然是一个独立于论点的对象,但它的构造微不足道,因为堆数据不必复制,只需移动即可。不需要复制它,因为x+y是一个右值,同样,可以从右值表示的字符串对象中移动。
总之,复制构造函数进行深度复制,因为源必须保持不变。另一方面,move构造函数可以复制指针,然后将源中的指针设置为null。以这种方式“取消”源对象是可以的,因为客户端无法再次检查对象。
我希望这个例子能传达出要点。为了保持简单,我特意省略了很多关于重新赋值引用和移动语义的内容。如果你想了解更多详情,请参阅我的补充答案。
我写这个是为了确保我能正确理解。
创建移动语义是为了避免不必要地复制大型对象。Bjarne Stroustrup在其著作《C++编程语言》中使用了两个默认情况下发生不必要复制的示例:一个是交换两个大对象,另一个是从方法返回一个大对象。
交换两个大对象通常涉及将第一个对象复制到临时对象,将第二个对象复制给第一个对象,以及将临时对象复制到第二个。对于内置类型,这非常快,但对于大型对象,这三个副本可能需要大量时间。“移动赋值”允许程序员重写默认的复制行为,而是交换对对象的引用,这意味着根本没有复制,而且交换操作更快。可以通过调用std::move()方法来调用移动赋值。
默认情况下,从方法返回对象涉及在调用方可访问的位置复制本地对象及其关联数据(因为调用方无法访问本地对象,并且在方法完成时会消失)。当返回内置类型时,此操作非常快,但如果返回大型对象,则可能需要很长时间。移动构造函数允许程序员重写此默认行为,并通过将返回给调用者的对象指向与本地对象关联的堆数据来“重用”与本地对象相关的堆数据。因此不需要复制。
在不允许创建本地对象(即堆栈上的对象)的语言中,这些类型的问题不会发生,因为所有对象都分配在堆上,并且总是通过引用访问。
我的第一个回答是对移动语义的极其简化的介绍,为了保持简单,特意省略了许多细节。然而,还有很多东西需要移动语义,我认为是时候用第二个答案来填补空白了。第一个答案已经很老了,简单地用完全不同的文本替换它并不合适。我认为它仍然可以作为第一个介绍。但如果你想深入了解,请继续阅读:)
Stephan T.Lavavej花时间提供了宝贵的反馈。非常感谢你,斯蒂芬!
介绍
移动语义允许对象在特定条件下拥有其他对象的外部资源。这在两方面很重要:
把昂贵的复制品变成廉价的移动。请参见我的第一个答案。注意,如果一个对象不管理至少一个外部资源(直接或通过其成员对象间接),那么移动语义将不会比复制语义提供任何优势。在这种情况下,复制对象和移动对象意味着完全相同的事情:类cannot_benefit_from_move_semantics{int a;//移动int意味着复制int浮动b;//移动浮动意味着复制浮动双c;//移动替身意味着复制替身字符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()之间一定有一些深刻的区别,因此每次调用时都会创建一个新的临时auto_ptr对象。a是左值的示例,而maketriangle()是右值的示例。
从左值(如a)移动是危险的,因为我们可以稍后尝试通过调用未定义的行为来调用成员函数。另一方面,从诸如make_triangle()之类的右值移动是完全安全的,因为在复制构造函数完成其任务后,我们不能再次使用临时值。没有表示所述临时的表达式;如果我们再次简单地编写maketriangle(),就会得到一个不同的临时变量。事实上,从临时移动的已在下一行中删除:
auto_ptr<Shape> c(make_triangle());
^ the moved-from temporary dies right here
请注意,字母l和r在赋值的左侧和右侧具有历史渊源。这在C++中不再是正确的,因为有些左值不能出现在赋值的左侧(比如没有赋值运算符的数组或用户定义的类型),而有些右值可以出现(带有赋值运算符的类类型的所有右值)。
类类型的右值是一个表达式,它的求值将创建一个临时对象。在正常情况下,同一范围内没有其他表达式表示同一临时对象。
Rvalue参考
我们现在明白从左值转移是潜在的危险,但从右值转移是无害的。如果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
在实践中,您可以忘记常量X&&。限制从右值读取不是很有用。
右值引用X&&是一种仅绑定到右值的新引用。
隐式转换
Rvalue引用经历了几个版本。从版本2.1开始,如果存在从Y到X的隐式转换,则右值引用X&&也绑定到不同类型Y的所有值类别。在这种情况下,创建了类型X的临时值,并且右值引用绑定到该临时值:
void some_function(std::string&& r);
some_function("hello world");
在上面的示例中,“helloworld”是constchar[12]类型的左值。由于存在从constchar[12]到constchar*到std::string的隐式转换,因此创建了一个std::字符串类型的临时变量,并将r绑定到该临时变量。这是右值(表达式)和临时值(对象)之间的区别有点模糊的情况之一。
移动构造函数
带有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;
}
此移动构造函数与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()是一个右值。move构造函数将所有权从临时转移到c。再次,这正是我们想要的。
move构造函数将托管资源的所有权转移到当前对象中。
移动分配运算符
最后一个缺失的部分是移动赋值运算符。它的任务是释放旧资源并从其论证中获取新资源:
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;
}
};
现在source是unique_ptr类型的变量,它将由move构造函数初始化;也就是说,参数将移动到参数中。参数仍然需要是右值,因为移动构造函数本身有一个右值引用参数。当控制流到达运算符=的右括号时,源超出范围,自动释放旧资源。
移动分配运算符将托管资源的所有权转移到当前对象,释放旧资源。移动和交换习惯用法简化了实现。
从lvalues移动
有时,我们想从左值转移。也就是说,有时我们希望编译器将左值视为右值,这样它就可以调用move构造函数,即使它可能不安全。为此,C++11在header<utility>中提供了一个名为std::move的标准库函数模板。这个名称有点不幸,因为std::move只是将左值转换为右值;它自己不会移动任何东西。它只允许移动。也许它应该被命名为std::cast_to_value或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。”
move(somevalue)将左值转换为右值,从而启用后续移动。
X值
注意,即使std::move(a)是一个右值,它的求值也不会创建一个临时对象。这个难题迫使委员会引入第三种价值类别。可以绑定到右值引用的东西,即使它不是传统意义上的右值,也被称为xvalue(eXpiring值)。传统的右值被重命名为prvalues(纯右值)。
prvalue和xvalue都是右值。Xvalue和lvalues都是glvalue(广义lvalues)。用图表更容易掌握这些关系:
expressions
/ \
/ \
/ \
glvalues rvalues
/ \ / \
/ \ / \
/ \ / \
lvalues xvalues prvalues
注意,只有xvalue是真正新的;剩下的只是由于重命名和分组。
C++98的右值在C++11中称为prvalue。将前面段落中出现的所有“右值”替换为“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());
也许令人惊讶的是,自动对象(未声明为静态的局部变量)也可以隐式移出函数:
unique_ptr<Shape> make_square()
{
unique_ptr<Shape> result(new Square);
return result; // note the missing std::move
}
为什么move构造函数接受左值结果作为参数?结果的范围即将结束,它将在堆栈展开期间被销毁。之后,没有人会抱怨结果发生了某种变化;当控制流返回到调用者时,结果不再存在!因此,C++11有一个特殊的规则,允许从函数返回自动对象,而不必编写std::move。事实上,您不应该使用std::move将自动对象移出函数,因为这会抑制“命名返回值优化”(NRVO)。
切勿使用std::move将自动对象移出函数。
注意,在两个工厂函数中,返回类型都是值,而不是右值引用。Rvalue引用仍然是引用,一如既往,您永远不应该返回对自动对象的引用;如果你欺骗编译器接受你的代码,调用方最终会得到一个悬空引用,如下所示:
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只是一个具有名称的普通变量。您可以在构造函数的主体内任意频繁地使用参数,并且它始终表示相同的对象。含蓄地离开它是危险的,因此语言禁止这样做。
命名的右值引用是左值,就像任何其他变量一样。
解决方案是手动启用移动:
class Foo
{
unique_ptr<Shape> member;
public:
Foo(unique_ptr<Shape>&& parameter)
: member(std::move(parameter)) // note the std::move
{}
};
您可以辩称,在初始化成员后,参数不再使用。为什么没有特殊的规则可以像使用返回值一样默默地插入std::move?可能是因为这会给编译器实现者带来太多负担。例如,如果构造函数主体在另一个翻译单元中呢?相比之下,返回值规则只需检查符号表即可确定return关键字之后的标识符是否表示自动对象。
也可以按值传递参数。对于unique_ptr这样的仅移动类型,似乎还没有确定的习惯用法。就我个人而言,我更喜欢传递值,因为它会减少界面中的混乱。
特殊成员功能
C++98隐式地按需声明三个特殊的成员函数,即在某个地方需要它们时:复制构造函数、复制赋值运算符和析构函数。
X::X(const X&); // copy constructor
X& X::operator=(const X&); // copy assignment operator
X::~X(); // destructor
Rvalue引用经历了几个版本。从版本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&&也与lvalues绑定:
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);
}
如您所见,move接受任何类型的参数,这要归功于转发引用T&&,并返回一个右值引用。std::remove_reference<T>::type元函数调用是必要的,因为否则,对于X类型的lvalues,返回类型将是X&&&,这将折叠为X&。由于t始终是左值(请记住,命名的右值引用是左值),但我们希望将t绑定到右值引用,因此必须将t显式转换为正确的返回类型。返回右值引用的函数的调用本身就是一个xvalue。现在您知道xvalue的来源了;)
返回右值引用(如std::move)的函数的调用是一个xvalue。
注意,在本例中,通过右值引用返回是很好的,因为t不表示自动对象,而是由调用者传入的对象。