对象的移动语义需要移动构造函数和移动赋值运算符。当源对象是将在操作完成后销毁的临时对象时,编译器可以使用它们,或者正如你将看到的,在使用 std::move() 时显式使用。移动将内存和其他资源的所有权从一个对象移动到另一个对象。它基本上对数据成员进行浅复制,并切换已分配内存和其他资源的所有权,以防止悬空指针或资源并防止内存泄漏。
移动构造函数和移动赋值运算符都将数据成员从源对象移动到新对象,使源对象处于某种有效但不确定的状态。通常,源对象的数据成员被重置为空值,但这不是必需的。为安全起见,请勿使用任何已被移出的对象,因为这会触发未定义的行为。 std::unique_ptr 和 shared_ptr 除外。标准库明确指出,智能指针在移动时必须将其内部指针重置为 nullptr,这使得在从它们移动后重用这些智能指针是安全的。
在 C++ 中,左值是可以取地址的东西,例如命名变量。这个名字来源于左值可以出现在赋值的左侧这一事实。另一方面,右值是任何不是左值的东西,例如字面量、临时对象或值。通常,右值位于赋值运算符的右侧。例如以下语句:
int a { 4 * 2 };
在这个语句中,a 是一个左值,它有一个名字,你可以用 &a 获取它的地址。另一方面,表达式 4 * 2 的结果是一个右值。它是一个临时值,当语句完成执行时会被销毁。在此示例中,此临时值的副本存储在名称为 a 的变量中。
右值引用是对右值的引用。特别是,当右值是临时对象或使用 std::move() 显式移动的对象时,它是一个应用的概念,将在本节后面解释。右值引用的目的是使得在涉及右值时可以选择特定的函数重载。这允许某些通常涉及复制大值的操作改为复制指向这些值的指针。
函数可以通过使用 && 作为参数规范的一部分来指定右值引用参数,例如,type&& name。通常,临时对象将被视为 const type&,但是当存在使用右值引用的函数重载时,可以将临时对象解析为该重载。以下示例演示了这一点。代码首先定义了两个 handleMessage() 函数,一个接受左值引用,一个接受右值引用:
void handleMessage(string& message) // lvalue reference parameter {
cout << format("handleMessage with lvalue reference: {}", message) << endl;
}
void handleMessage(string&& message) // rvalue reference parameter {
cout << format("handleMessage with rvalue reference: {}", message) << endl;
}
可以使用命名变量作为参数调用 handleMessage():
string a { "Hello " };
handleMessage(a);
因为 a 是命名变量,所以调用接受左值引用的 handleMessage() 函数。handleMessage() 通过其引用参数所做的任何更改都将更改 a 的值。
你还可以使用表达式作为参数调用 handleMessage():
string b { "World" };
handleMessage(a + b);
不能使用接受左值引用的 handleMessage() 函数,因为表达式 a + b 产生一个临时值,它不是左值。在这种情况下,将调用右值引用重载。因为参数是临时的,所以 handleMessage() 通过其引用参数所做的任何更改都将在调用返回后丢失。
字面量也可以用作 handleMessage() 的参数。这也会触发对右值引用重载的调用,因为字面量不能是左值(尽管字面量可以作为参数传递给常量引用参数):
handleMessage("Hello World"); // Calls handleMessage(string&& value)
如果删除接受左值引用的 handleMessage() 函数,则使用 handleMessage(b) 等命名变量调用 handleMessage() 将导致编译错误,因为右值引用参数(字符串&&)永远不会绑定到左值(b )。可以使用 std::move() 强制编译器调用 handleMessage() 的右值引用重载。 move() 唯一做的就是将左值转换为右值引用;也就是说,它不做任何实际移动。但是,通过返回右值引用,它允许编译器找到接受右值引用的句柄 Message() 的重载,然后可以执行移动。下面是一个使用 move() 的例子:
handleMessage(std::move(b)); // Calls handleMessage(string&& value)
正如我之前所说,但值得重复的是,命名变量是左值。所以,在 handleMessage() 函数内部,message 右值引用参数本身是一个左值,因为它有一个名字!如果你想将这个右值引用参数作为右值转发给另一个函数,那么需要使用 std::move() 将左值转换为右值引用。例如,假设你使用右值引用参数添加以下函数:
void helper(std::string&& message) { }
如下调用它不会编译:
void handleMessage(std::string&& message) { helper(message); }
正确的方法是使用 std::move():
void handleMessage(std::string&& message) { helper(std::move(message)); }
右值引用不限于函数的参数。你可以声明一个右值引用类型的变量并为其赋值,尽管这种用法并不常见。考虑以下代码,这在 C++ 中是非法的:
int& i { 2 }; // Invalid: reference to a constant
int a { 2 }, b { 3 };
int& j { a + b }; // Invalid: reference to a temporary
使用右值引用,以下是完全合法的:
int&& i { 2 };
int a { 2 }, b { 3 };
int&& j { a + b };
如果将临时对象分配给右值引用,则只要右值引用在范围内,临时对象的生命周期就会延长。
移动语义是通过使用右值引用来实现的。要向类添加移动语义,你需要实现一个移动构造函数和一个移动赋值运算符。移动构造函数和移动赋值运算符应该用 noexcept
限定符标记,以告诉编译器它们不会抛出任何异常。这对于与标准库的兼容性特别重要,因为完全兼容的实现,例如,标准库容器只会移动存储的对象,如果实现了移动语义,它们也保证不会抛出。以下是带有移动构造函数和移动赋值运算符的 Spreadsheet 类定义。还引入了两个辅助方法:cleanup(),用于析构函数和移动赋值运算符,以及 moveFrom(),用于将数据成员从源移动到目标,然后重置源对象。
class Spreadsheet {
public:
Spreadsheet(Spreadsheet&& src) noexcept; // Move constructor
Spreadsheet& operator=(Spreadsheet&& rhs) noexcept; // Move assign
// Remaining code omitted for brevity
private: void cleanup() noexcept;
void moveFrom(Spreadsheet& src) noexcept;
// Remaining code omitted for brevity
};
具体实现如下:
void Spreadsheet::cleanup() noexcept {
for (size_t i { 0 }; i < m_width; i++) {
delete[] m_cells[i];
} delete[] m_cells;
m_cells = nullptr; m_width = m_height = 0;
}
void Spreadsheet::moveFrom(Spreadsheet& src) noexcept {
// Shallow copy of data
m_width = src.m_width;
m_height = src.m_height;
m_cells = src.m_cells;
// Reset the source object, because ownership has been moved!
src.m_width = 0;
src.m_height = 0;
src.m_cells = nullptr;
}
// Move constructor
Spreadsheet::Spreadsheet(Spreadsheet&& src) noexcept {
moveFrom(src);
}
// Move assignment operator
Spreadsheet& Spreadsheet::operator=(Spreadsheet&& rhs) noexcept {
// Check for selfassignment
if (this == &rhs) {
return *this;
}
// Free the old memory and move ownership
cleanup();
moveFrom(rhs);
return *this;
}
移动构造函数和移动赋值运算符都将 m_cells 的内存所有权从源对象移动到新对象。他们将源对象的 m_cells 指针重置为空指针,并将源对象的 m_width 和 m_height 设置为零,以防止源对象的析构函数释放任何内存,因为新对象现在是它的所有者。
当声明一个或多个特殊成员函数(析构函数、复制构造函数、移动构造函数、复制赋值运算符和移动赋值运算符)时,通常需要声明所有这些函数。这被称为五规则。你要么为它们提供显式实现,要么显式默认(=默认)或删除(=删除)它们。
std::exchange()
,在
void Spreadsheet::moveFrom(Spreadsheet& src) noexcept {
m_width = exchange(src.m_width, 0);
m_height = exchange(src.m_height, 0);
m_cells = exchange(src.m_cells, nullptr);
}
moveFrom() 方法使用三个数据成员的直接赋值,因为它们是基本类型。如果你的对象有其他对象作为数据成员,那么你应该使用 std::move() 移动这些对象。假设 Spreadsheet 类有一个名为 m_name 的 std::string 数据成员。 moveFrom() 方法应按如下方式实现:
void Spreadsheet::moveFrom(Spreadsheet& src) noexcept {
// Move object data members
m_name = std::move(src.m_name);
// Move primitives:
m_width = exchange(src.m_width, 0);
m_height = exchange(src.m_height, 0);
m_cells = exchange(src.m_cells, nullptr);
}
移动构造函数和移动赋值运算符的先前实现都使用 moveFrom() 辅助方法,该方法通过执行浅拷贝移动所有数据成员。使用此实现,如果向 Spreadsheet 类添加新数据成员,则必须同时修改 swap() 函数和 moveFrom() 方法。如果您忘记更新其中一个,就会引入错误。为避免此类错误,您可以根据 swap()
函数编写移动构造函数和移动赋值运算符。
首先,可以删除 cleanup() 和 moveFrom() 辅助方法。来自 cleanup() 方法的代码被移至析构函数。移动构造函数和移动赋值运算符可以按如下方式实现:
Spreadsheet::Spreadsheet(Spreadsheet&& src) noexcept {
swap(*this, src);
}
Spreadsheet& Spreadsheet::operator=(Spreadsheet&& rhs) noexcept {
swap(*this, rhs);
return *this;
}
移动构造函数只是将默认构造的 *this 与给定的源对象进行交换。类似地,移动赋值运算符将 *this 与给定的 rhs 对象交换。
根据 swap() 实现移动构造函数和移动赋值运算符需要更少的代码。添加数据成员时引入错误的可能性也较小,因为你只需更新 swap() 实现以包含这些新数据成员。
如果对象是局部变量、函数参数或临时值,则语句 return object;
被视为右值表达式,并且它们会触发返回值优化 (RVO)。此外,如果对象是局部变量,则命名返回值优化
(NRVO) 可以启动。RVO 和 NRVO 都是复制省略的形式,并且使从函数返回对象非常高效。使用复制省略,编译器可以避免对从函数返回的对象进行任何复制和移动。这导致所谓的零拷贝传值语义。
现在,当使用 std::move() 返回一个对象时会发生什么?无论写成 return object;
还是 return std::move(object);
,编译器都将其视为右值表达式。
但是,通过使用 std::move(),编译器无法再应用 RVO 或 NRVO,因为它仅适用于形式为 return object; 的语句。由于 RVO 和 NRVO 不再适用,如果对象支持,编译器的下一个选项是使用移动语义,如果不支持,则使用复制语义,这会对性能产生很大影响!因此,请牢记以下规则:
从函数返回局部变量或参数时,只需写 return object;并且不要使用 std::move()。
请记住,(N)RVO 仅适用于局部变量或函数参数。因此,返回对象的数据成员永远不会触发 (N)RVO。此外,请注意以下表达式:
return condition ? object1 : object2;
这不是 return object; 的形式,因此编译器无法应用 (N)RVO,而是使用复制构造函数来返回 object1 或 object2。您可以重写 return 语句,如下所示,编译器可以使用 (N)RVO:
if (condition) {
return object1;
} else {
return object2;
}
如果你真的想使用条件运算符,你可以编写以下内容,但请记住这不会触发 (N)RVO 并强制使用移动或复制语义:
return condition ? std::move(object1) : std::move(object2);
到目前为止,建议对非基本类型的函数参数使用常量引用参数,以避免不必要的复制传递给函数的参数。然而,随着右值的引入,事情发生了轻微的变化。想象一下,一个函数无论如何都会复制作为其参数之一传递的参数。这种情况经常会出现在类方法中。这是一个简单的例子:
class DataHolder {
public:
void setData(const std::vector<int>& data) { m_data = data; }
private:
std::vector<int> m_data;
};
setData() 方法复制传入的数据。既然你已经熟悉右值和右值引用,你可能想要添加一个重载来优化 setData() 方法,以避免在右值的情况下进行任何复制。这是一个例子:
class DataHolder {
public:
void setData(const std::vector<int>& data) { m_data = data; }
void setData(std::vector<int>&& data) { m_data = std::move(data); }
private:
std::vector<int> m_data;
};
当使用临时对象调用 setData() 时,不会生成任何副本,而是移动数据。
不幸的是,这种为左值和右值优化 setData() 的方法需要实现两个重载。幸运的是,有一种更好的方法涉及使用按值传递的单一方法。是的,按值传递!到目前为止,建议始终使用常量引用参数传递对象以避免任何不必要的复制,但现在我们建议使用按值传递。接下来说明原因。对于不涉及复制的参数,通过引用传递给常量仍然是可行的方法。按值传递建议仅适用于函数无论如何都会复制的参数。在这种情况下,通过使用按值传递语义,代码对于左值和右值都是最佳的。如果传入一个左值,它会被精确复制一次,就像引用常量参数一样。而且,如果传入右值,则不会进行复制,就像右值引用参数一样。
class DataHolder {
public:
void setData(std::vector<int> data) { m_data = std::move(data); }
private:
std::vector<int> m_data;
};
如果将左值传递给 setData(),它会被复制到参数 data 中,然后移动到 m_data。如果一个右值被传递给 setData(),它被移动到 data 参数中,然后再次移动到 m_data。
对于函数本身会去复制的参数,请使用按值传递,但前提是参数是支持移动语义的类型。否则,使用常量引用参数。
在本章的前面,介绍了五规则。到目前为止的所有讨论都是为了解释你应该如何编写这五个特殊成员函数:析构函数、复制和移动构造函数,以及复制和移动赋值运算。但是,在现代 C++ 中,你应该采用所谓的零规则。
零规则指出你应该以不需要这五个特殊成员函数中的任何一个的方式设计您的类。那要怎么做?基本来说,你应该避免使用任何旧式的动态分配内存。相反,使用现代结构,例如标准库容器。例如,使用 vector
五规则应限于自定义资源获取即初始化 (RAII) 类。 RAII 类获取资源的所有权并在正确的时间处理其释放。它是一种设计技术,例如被 vector 和 unique_ptr 使用。