什么是移动语义?
我刚刚听完了关于C ++ 0x的关于Scott Meyers的软件工程无线电播客采访。 大部分新功能对我来说都很有意义,而且我现在对C ++ 0x感到非常兴奋,除了一个。 我仍然没有得到移动语义...他们究竟是什么?
我发现使用示例代码理解移动语义是最容易的。 让我们从一个非常简单的字符串类开始,它只保存一个指向堆分配内存块的指针:
#include <cstring>
#include <algorithm>
class string
{
char* data;
public:
string(const char* p)
{
size_t size = strlen(p) + 1;
data = new char[size];
memcpy(data, p, size);
}
既然我们选择自己管理记忆,我们需要遵循三条规则。 我将推迟编写赋值运算符,并且现在只实现析构函数和复制构造函数:
~string()
{
delete[] data;
}
string(const string& that)
{
size_t size = strlen(that.data) + 1;
data = new char[size];
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,而不是深度复制堆数据。 实际上,我们已经“窃取”了原始属于源字符串的数据。 再一次,关键的洞察是,在任何情况下,客户都不能检测到源已被修改。 由于我们在这里没有真正做一个副本,我们称这个构造函数为“移动构造函数”。 它的工作是将资源从一个对象移到另一个对象而不是复制它们。
恭喜,您现在了解移动语义的基础知识! 让我们继续执行赋值操作符。 如果你不熟悉复制和交换习惯用法,学习它并回来,因为这是一个非常棒的与异常安全相关的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
,移动构造函数会初始化that
(因为表达式x + y
是一个右值),所以不涉及深度拷贝,只有有效的移动。 that
仍然是一个独立的论点,但它的构建是微不足道的,因为堆数据不需要复制,只需移动即可。 没有必要复制它,因为x + y
是一个右值,同样,从rval表示的字符串对象移动也是可以的。
总而言之,复制构造函数会生成深层副本,因为源必须保持不变。 另一方面,移动构造函数只能复制指针,然后将源中的指针设置为null。 以这种方式“消除”源对象是可以的,因为客户端无法再次检查对象。
我希望这个例子得到了主要观点。 右值引用和移动语义有很多,我故意省略它来简化它。 如果你想了解更多细节,请参阅我的补充答案。
我的第一个回答是移动语义的一个非常简单的介绍,并且许多细节被保留下来以保持简单。 然而,移动语义还有很多,我认为是第二次填补空白的时候了。 第一个答案已经很老了,而且把它换成完全不同的文本并不合适。 我认为它作为第一次介绍仍然很好。 但如果你想深入挖掘,请阅读:)
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
// ...
};
实施安全的“仅移动”类型; 也就是说,复制没有意义的类型,但移动确实如此。 示例包括锁,文件句柄和具有唯一所有权语义的智能指针。 注意:这个答案讨论了std::auto_ptr
,这是一个过时的C ++ 98标准库模板,它在C ++ 11中被替换为std::unique_ptr
。 中级C ++程序员可能至少对std::auto_ptr
有些熟悉,并且由于显示了“移动语义”,它似乎是讨论C ++ 11中移动语义的一个很好的起点。 因人而异。
什么是举动?
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()
之间有什么区别? 他们不是同一类型吗? 事实上他们是,但他们有不同的价值类别。
价值类别
显然,表达式a
中的auto_ptr
变量和表达式make_triangle()
之间必定存在某种深刻的区别,它表示调用一个函数的调用,该函数按值返回一个auto_ptr
,因此每次调用它时都会创建一个新的临时auto_ptr
对象。 a
是一个左值的例子,而make_triangle()
是一个右值的例子。
从左值如移动a
是危险的,因为我们以后可以尝试通过调用一个成员函数a
,调用未定义的行为。 另一方面,从诸如make_triangle()
类的make_triangle()
移动是完全安全的,因为在拷贝构造函数完成其工作之后,我们不能再次使用临时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&&
。 限制读取rvalues并不是很有用。
右值引用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
绑定到该暂时的。 这是rvalues(表达式)和临时对象(对象)之间的区别有点模糊的情况之一。
移动构造函数
具有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
拷贝构造函数所做的,但它只能用rvalues提供:
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;
}
};
请注意移动赋值运算符的这种实现如何复制析构函数和移动构造函数的逻辑。 你是否熟悉复制交换习惯用法? 它也可以用于移动语义作为移动和交换的习惯用法:
unique_ptr& operator=(unique_ptr source) // note the missing reference
{
std::swap(ptr, source.ptr);
return *this;
}
};
现在,该source
是unique_ptr
类型的变量,它将由移动构造函数初始化; 也就是说,参数将被移入参数中。 该参数仍然需要是右值,因为移动构造函数本身具有右值引用参数。 当控制流达到operator=
的右大括号时, source
超出范围,自动释放旧的资源。
移动赋值操作符将托管资源的所有权转移到当前对象中,释放旧资源。 移动和交换习惯用法简化了实现。
从左值移动
有时候,我们想从左值移动。 也就是说,有时我们希望编译器将左值视为右值,因此它可以调用移动构造函数,即使它可能不安全。 为此,C ++ 11在头文件<utility>
提供了一个名为std::move
的标准库函数模板。 这个名字有点不幸,因为std::move
只是将左值转换为右值; 它本身不会移动任何东西。 它只是使移动。 也许它应该被命名为std::cast_to_rvalue
或者std::enable_move
,但是现在我们被std::cast_to_rvalue
了这个名字。
以下是你如何从一个左值显式移动:
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)
将左值转换为右值,从而启用后续移动。
Xvalues
请注意,即使std::move(a)
是一个右值,它的评估也不会创建一个临时对象。 这个难题迫使委员会引入第三个价值类别。 可以绑定到右值引用的东西,即使它不是传统意义上的右值,也称为xvalue(eXpiring值)。 传统的rvalues被重新命名为prvalues(纯rvalues)。
prvalues和xvalues都是rvalues。 Xvalues和Lvalues都是glvalues(广义左值)。 用关系图更容易理解关系:
expressions
/
/
/
glvalues rvalues
/ /
/ /
/ /
lvalues xvalues prvalues
请注意,只有xvalues是新的; 剩下的只是由于重命名和分组。
C ++ 98 rvalues在C ++ 11中被称为prvalues。 在前面的段落中将所有出现的“右值”用“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
是一个左值。 如果你看看它的类型,你会看到一个右值引用,但右值引用仅仅意味着“一个绑定到右值的引用”。 这并不意味着参考本身是一个右值! 事实上, 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
关键字之后的标识符是否表示自动对象。
您也可以按值传递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);
}
正如你所看到的, move
通过转发引用T&&
接受任何类型的参数,并且它返回一个右值引用。 std::remove_reference<T>::type
元函数调用是必须的,因为否则,对于X
类型的左值,返回类型将是X& &&
,它会折叠为X&
。 由于t
总是一个左值(记住一个名为右值的引用是一个左值),但我们希望将t
绑定到右值引用,所以我们必须明确地将t
赋给正确的返回类型。 一个返回右值引用的函数本身就是一个xvalue。 现在你知道xvalues来自哪里;)
调用返回右值引用的函数(如std::move
)是一个xvalue。
请注意,在此示例中,通过右值引用返回很好,因为t
不表示自动对象,而是由调用者传入的对象。
移动语义基于右值引用 。
右值是一个临时对象,它将在表达式的末尾被销毁。 在当前的C ++中,右值只能绑定到const
引用。 C ++ 1X将允许非const
rvalue引用,斯佩尔特T&&
,这对于一个右值对象的引用。
由于右值将在表达式的末尾死亡,因此可以窃取其数据。 不是将其复制到另一个对象中,而是将其数据移入其中。
class X {
public:
X(X&& rhs) // ctor taking an rvalue reference, so-called move-ctor
: data_()
{
// since 'x' is an rvalue object, we can steal its data
this->swap(std::move(rhs));
// this will leave rhs with the empty data
}
void swap(X&& rhs);
// ...
};
// ...
X f();
X x = f(); // f() returns result as rvalue, so this calls move-ctor
在上面的代码中,对于旧编译器,使用X
的拷贝构造函数将f()
的结果复制到x
。 如果你的编译器支持移动语义,并且X
有一个移动构造函数,那就调用它。 由于它的rhs
论证是一个右值,我们知道它不再需要,我们可以窃取它的价值。
所以值从无名临时从返回移动 f()
到x
(而数据x
,初始化为空X
,移动到暂时的,这将在转让之后被摧毁)。