我发现通过示例代码理解移动语义最容易。让我们从一个非常简单的字符串类开始,它仅保存指向堆分配的内存块的指针:
#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 的表达式,这使你可以在以下示例中进行复制:
string a(x); // Line 1
string b(x + y); // Line 2
string c(some_function_returning_a_string()); // Line 3
string(string&& that) // string&& is an rvalue reference to a string
{
data = that.data;
that.data = nullptr;
}
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
是一个rvalue),所以没有涉及深拷贝,只有高效的移动。 that
仍然是独立于参数的对象,但它的构造是微不足道的,因为堆数据不必被复制,只需移动即可。不需要复制它,因为x + y
是一个rvalue,而且再次从由rvalues表示的字符串对象中移动也可以。delete[]
会被定义为无操作。 - fredoverflowTurning expensive copies into cheap moves. See my first answer for an example. Note that if an object does not manage at least one external resource (either directly, or indirectly through its member objects), move semantics will not offer any advantages over copy semantics. In that case, copying an object and moving an object means the exact same thing:
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
// ...
};
Implementing safe "move-only" types; that is, types for which copying does not make sense, but moving does. Examples include locks, file handles, and smart pointers with unique ownership semantics. Note: This answer discusses std::auto_ptr
, a deprecated C++98 standard library template, which was replaced by std::unique_ptr
in C++11. Intermediate C++ programmers are probably at least somewhat familiar with std::auto_ptr
, and because of the "move semantics" it displays, it seems like a good starting point for discussing move semantics in 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
这样的左值移动是危险的,因为我们稍后可能会尝试通过 a
调用成员函数,从而引发未定义行为。另一方面,从像 make_triangle()
这样的右值移动是完全安全的,因为在复制构造函数完成其工作后,我们无法再次使用临时对象。没有表达式表示该临时对象;如果我们简单地再次编写 make_triangle()
,我们将获得一个 不同的 临时对象。实际上,在下一行中,已经移动了原始的临时对象:auto_ptr<Shape> c(make_triangle());
^ the moved-from temporary dies right here
l
和r
在赋值的左侧和右侧具有历史渊源。但在C++中这已不再成立,因为存在不能出现在赋值左侧的lvalue(例如数组或没有赋值运算符的用户定义类型),也存在可以出现在赋值左侧的rvalue(所有具有赋值运算符的类类型的rvalue)。
类类型的rvalue是一个表达式,其评估创建临时对象。 在正常情况下,同一作用域内没有其他表达式指代相同的临时对象。
X&&
。传统的引用X&
现在称为lvalue引用。(请注意,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&&
。只能从rvalue读取的限制并不是很有用。X&&
是一种新的引用类型,只绑定到rvalue。X&&
还绑定到不同类型Y
的所有值类别,前提是存在从Y
到X
的隐式转换。在这种情况下,创建一个临时的X
类型,并将rvalue引用绑定到该临时对象。void some_function(std::string&& r);
some_function("hello world");
"hello world"
是类型为 const char[12]
的 lvalue。由于存在从 const char[12]
通过 const char*
到 std::string
的隐式转换,因此将创建一个类型为 std::string
的临时对象,并将 r
绑定到该临时对象。这是其中一种 rvalue(表达式)和 temporary(对象)之间区别有点模糊的情况之一。
具有 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
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
超出范围,自动释放旧资源。std::move
的标准库函数模板,位于头文件<utility>
中。
这个名称有点不幸,因为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)
将左值强制转换为右值,从而启用后续的移动。
std::move(a)
是一个右值,它的评估不会创建临时对象。这个难题迫使委员会引入了第三个值类别。一些可以绑定到右值引用的东西,即使在传统意义上不是右值,被称为xvalue (eXpiring value)。传统的右值被重新命名为prvalues (Pure rvalues)。
prvalues和xvalues都是rvalues。xvalues和lvalues都是glvalues(广义左值)。通过图表更容易理解它们之间的关系:
expressions
/ \
/ \
/ \
glvalues rvalues
/ \ / \
/ \ / \
/ \ / \
lvalues xvalues prvalues
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
}
请注意,在两个工厂函数中,返回类型都是值,而不是右值引用。右值引用仍然是引用,像往常一样,永远不要返回对自动对象的引用;如果你欺骗编译器接受你的代码,调用者最终将得到一个悬空引用。永远不要使用"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
{}
};
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
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
转换为正确的返回类型。t
不表示自动对象,而是由调用者传入的对象。
返回右值引用的函数调用,例如
std::move
,是一个 xvalue。
假设你有一个返回大型对象的函数:
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这样只重新排列项的算法、vector中的reallocation以及其capacity()被超过的情况等。
当这样的复制/销毁对是昂贵的时候,通常是因为对象拥有一些重型资源。例如,vector<string>可能拥有一个包含一系列具有自己动态内存的string对象的动态分配内存块。复制这样的对象是昂贵的:必须为源中每个动态分配块分配新的内存,并将所有值复制到其中。然后您需要释放刚刚复制的所有内存。然而,移动大型的vector<string>仅意味着将一些指针(引用动态内存块)复制到目标并在源中将它们清零。
用通俗易懂的语言来说:
复制一个对象意味着复制它的“静态”成员,并为其动态对象调用 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());
=
操作符的左侧),因此您需要一个命名引用来作为lvalue的对象。rvalue恰恰相反,是没有命名引用的对象。因此,匿名对象和rvalue是同义词。因此: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
转换为 rvalue,并且 other_function
将会接收到该对象作为一个未命名的对象。当然,如果 other_function
没有特定的重载来处理未命名对象,那么这种区别就不重要了。template<typename T>
void some_function(T&& a)
{
other_function(std::forward<T>(a));
}
这是一个使用完美转发的原型函数签名,通过std::forward
在C++11中实现。该函数利用了一些模板实例化规则:
`A& && == A&`
`A&& && == A&&`
T
是指向A
的左值引用(T = A&),那么a
也是(A& && => A&)。如果T
是指向A
的右值引用,则a
也是(A&& && => A&&)。在这两种情况下,a
是实际作用域中的命名对象,但T
包含了从调用者作用域角度看其“引用类型”的信息。这些信息(T
)作为模板参数传递给forward
,并根据T
的类型移动或不移动'a'。它类似于复制语义,但不必复制所有数据,而是可以从“移动”对象中窃取数据。
std::vector<foo> get_foos();
如果(在C++0x中)std::vector有一个移动构造函数而不是复制,那么当函数返回时,您将从复制构造函数中获得开销,它可以只设置指针并将动态分配的内存“移动”到新实例。这有点像使用std::auto_ptr的所有权转移语义。
我写这篇文章是为了确保我正确理解它。
移动语义是为了避免不必要的大对象复制而创建的。Bjarne Stroustrup在他的书《C++程序设计语言》中使用了两个例子,其中默认情况下会出现不必要的复制:一是交换两个大对象,二是从方法返回一个大对象。
交换两个大对象通常涉及将第一个对象复制到临时对象中,将第二个对象复制到第一个对象中,然后将临时对象复制到第二个对象中。对于内置类型,这非常快,但对于大对象,这三个复制可能需要很长时间。"移动赋值"允许程序员覆盖默认的复制行为,而是交换对象的引用,这意味着根本没有复制,交换操作更快。可以通过调用std::move()方法来调用移动赋值。
默认情况下,从方法返回对象涉及将局部对象及其关联数据复制到可由调用者访问的位置(因为局部对象不可由调用者访问,并且当方法完成时消失)。当返回内置类型时,此操作非常快,但如果返回大对象,则可能需要很长时间。移动构造函数允许程序员覆盖此默认行为,而是通过将要返回的对象指向与局部对象相关联的堆数据来"重用"与局部对象相关联的堆数据。因此不需要复制。
在不允许创建本地对象(即堆栈上的对象)的语言中,不会出现这些类型的问题,因为所有对象都分配在堆上,并且始终通过引用访问。
swap()
函数。有时需要使用 std::move()
方法来调用移动赋值操作符,但这并不会实际移动任何东西,只是让编译器知道该参数是可移动的。有时需要使用带转发引用的 std::forward<>()
,而其他时候编译器知道一个值可以被移动。 - Tony DelroyT
的对象,并返回相同类型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);
}