在C++面向对象编程中,当我们定义一个类时,需要明确指定当该类的对象被拷贝、移动、赋值或销毁时应该发生什么操作。 这些行为由五个特殊的成员函数来控制:拷贝构造函数、拷贝赋值运算符、析构函数、移动构造函数和移动赋值运算符。我们将这些操作统称为拷贝控制。
拷贝控制是C++类设计中的核心概念。对于刚接触C++的程序员来说,理解这些操作的必要性往往具有挑战性。 这种困惑的根源在于:如果我们没有显式定义这些操作,编译器会自动为我们生成默认版本,但这些默认版本的行为可能并不符合我们的预期。

拷贝构造函数是一种特殊的构造函数,它的第一个参数必须是对同类型对象的引用,并且任何额外的参数都必须有默认值。这个引用参数几乎总是const引用,尽管技术上我们也可以定义接受非const引用的拷贝构造函数。
|class Student { public: Student(); // 默认构造函数 Student(const Student& other); // 拷贝构造函数 // 其他成员... };
拷贝构造函数的参数必须是引用类型,这一点至关重要。如果参数不是引用,那么在调用拷贝构造函数时,就需要拷贝实参来初始化形参,而拷贝实参又需要调用拷贝构造函数,从而导致无限递归。 当我们没有为类定义拷贝构造函数时,编译器会自动合成一个。与默认构造函数不同,即使我们定义了其他构造函数,编译器仍然会合成拷贝构造函数(除非我们明确阻止)。 合成的拷贝构造函数会逐个拷贝对象的非静态成员。对于类类型的成员,使用其拷贝构造函数进行拷贝;对于内置类型的成员,直接拷贝其值;对于数组成员,会逐个拷贝数组的每个元素。
理解拷贝构造函数的工作机制,需要我们首先明确直接初始化和拷贝初始化的区别:
|string book_title(20, '*'); // 直接初始化 string author_name(book_title); // 直接初始化 string isbn = book_title; // 拷贝初始化 string publisher = "清华大学出版社"; // 拷贝初始化 string category = string(10, '#'); // 拷贝初始化
当使用直接初始化时,编译器会使用函数匹配机制来选择最匹配的构造函数。而使用拷贝初始化时,编译器先用右侧运算对象创建一个临时对象,然后用拷贝构造函数将这个临时对象拷贝到正在创建的对象中。
拷贝初始化不仅在使用等号定义变量时发生,还会在以下情况下发生:将对象作为实参传递给非引用类型的形参、从函数返回非引用类型的对象、用花括号列表初始化数组元素或聚合类的成员。
拷贝赋值运算符控制着同类型对象间的赋值操作。它是一个名为operator=的重载运算符函数,必须定义为成员函数,通常返回指向其左侧运算对象的引用。
|class Student { public: Student& operator=(const Student& rhs); // 其他成员... };
为了与内置类型的赋值操作保持一致,赋值运算符通常应该返回指向其左侧运算对象的引用。标准库也普遍要求容器中存储的类型必须具有返回左侧运算对象引用的赋值运算符。 当我们没有定义自己的拷贝赋值运算符时,编译器会为我们合成一个。合成的拷贝赋值运算符会将右侧对象的每个非静态成员赋值给左侧对象的对应成员,使用每个成员类型的拷贝赋值运算符来完成赋值。对于数组成员,会逐个赋值数组的每个元素。
以我们的Student类为例,合成的拷贝赋值运算符等价于:
|Student& Student::operator=(const Student& rhs) { student_id = rhs.student_id; // 使用string的operator= age = rhs.age; // 使用内置类型的赋值 gpa = rhs.gpa; // 使用内置类型的赋值 return *this; // 返回此对象的引用 }
我们在前面类的概念中提到了析构函数,析构函数与构造函数的作用相反:构造函数负责初始化对象的非静态数据成员,析构函数则负责释放对象使用的资源并销毁非静态数据成员。
析构函数是类名前加波浪号(~)的成员函数,它没有返回值,也不接受任何参数。由于不接受参数,它不能被重载,每个类只能有一个析构函数。
|class Student { public: ~Student(); // 析构函数 // 其他成员... };
析构函数的工作过程与构造函数相反。在构造函数中,成员的初始化在函数体执行之前完成,且初始化顺序与成员在类中的声明顺序一致。在析构函数中,先执行函数体,然后按与初始化顺序相反的顺序销毁成员。
析构函数体负责执行类设计者希望在对象最后一次使用之后执行的任何操作。通常,析构函数用于释放对象在生存期内分配的资源。 在销毁阶段,成员按照初始化的逆序进行销毁。类类型的成员通过运行其自身的析构函数来销毁,而内置类型没有析构函数,因此销毁内置类型成员时什么也不做。
需要特别注意的是,析构函数体本身并不直接销毁成员。成员的销毁发生在析构函数体执行完毕之后的隐式销毁阶段。
析构函数会在对象的生存期结束时自动调用:局部变量在离开其作用域时被销毁;当对象被销毁时,其成员也被销毁;容器被销毁时,其元素也被销毁;动态分配的对象在对delete运算符时被销毁;临时对象在创建它的完整表达式结束时被销毁。
在实际的类设计中,这些拷贝控制操作通常不是独立存在的,而应该被视为一个整体。一般来说,如果一个类需要定义其中一个操作,那么它很可能需要定义所有这些操作。
判断一个类是否需要定义自己的拷贝控制成员的一个重要准则是:首先确定这个类是否需要析构函数 。通常,对析构函数的需求比对拷贝构造函数或赋值运算符的需求更明显。如果一个类需要析构函数,那么它几乎肯定也需要拷贝构造函数和拷贝赋值运算符。
考虑一个管理动态内存的类:
|class TextBuffer { public: TextBuffer(const std::string& content = std::string()) : buffer(new std::string(content)), size(0) { } ~TextBuffer() { delete buffer; } // 需要析构函数来释放内存 // 错误:仅有析构函数是不够的 // 还需要拷贝构造函数和拷贝赋值运算符
如果这个类只定义了析构函数而使用合成的拷贝操作,就会出现严重问题。合成的拷贝构造函数和拷贝赋值运算符只是简单地拷贝指针成员,这意味着多个对象可能指向相同的内存。 当这些对象被销毁时,同一块内存会被释放多次,导致未定义行为。
另一个指导原则是:如果一个类需要拷贝构造函数,它几乎肯定也需要拷贝赋值运算符,反之亦然。 例如,假设我们设计一个为每个对象分配唯一序列号的类:
|class SerialNumber { public: SerialNumber() : serial(next_serial++), data(0) { } SerialNumber(const SerialNumber& other) : serial(next_serial++), data(other.data) { } // 生成新的序列号 // 也需要自定义赋值运算符,避免赋值序列号 SerialNumber& operator=(const SerialNumber&
这个类需要拷贝构造函数来为新对象生成独特的序列号,同样也需要拷贝赋值运算符来避免赋值序列号。
有些类从逻辑上不应该被拷贝。例如,iostream类阻止拷贝以避免多个对象读写同一个IO缓冲。对于这样的类,我们必须采用技术手段来阻止拷贝操作。
在新标准中,我们可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝:
|class NoCopyClass { public: NoCopyClass() = default; // 使用合成的默认构造函数 NoCopyClass(const NoCopyClass&) = delete; // 阻止拷贝 NoCopyClass& operator=(const NoCopyClass&) = delete; // 阻止赋值 ~NoCopyClass() = default; // 使用合成的析构函数
删除的函数是声明了但不能以任何方式使用的函数。我们通过在参数列表后加上= delete来指明我们希望将函数定义为删除的。
与= default不同,= delete必须出现在函数第一次声明的时候。这个差异反映了它们不同的本质:默认化的成员只影响编译器生成的代码,而删除的函数则用于阻止某些使用,编译器需要在第一时间知道函数是删除的。
需要注意的是,我们通常不应该删除析构函数。如果析构函数被删除,就无法销毁该类型的对象。编译器将不允许定义该类型的变量或创建该类型的临时对象。
|class NoDestructor { public: NoDestructor() = default; ~NoDestructor() = delete; // 不能销毁此类型的对象 }; NoDestructor nd; // 错误:析构函数被删除 NoDestructor* ptr = new NoDestructor(); // 可以,但无法delete ptr delete ptr; // 错误:析构函数被删除
虽然我们不能定义这种类型的变量或成员,但可以动态分配这种对象。但是,不能释放这些动态分配的对象。
一般来说,管理资源的类必须定义拷贝控制成员。一旦一个类需要析构函数,它几乎肯定也需要拷贝构造函数和拷贝赋值运算符。
在设计这些成员时,我们需要首先决定拷贝一个对象的含义。通常有两种选择:让类的行为像一个值,或者让类的行为像一个指针。
行为像值的类拥有自己的状态。当拷贝一个值类型的对象时,副本和原对象是完全独立的,改变副本不会影响原对象,反之亦然。
行为像指针的类则共享状态。当拷贝这种类的对象时,副本和原对象使用相同的底层数据。改变副本也会改变原对象,反之亦然。
为了实现值类型行为,每个对象都必须拥有一份自己的资源副本。以一个简化的智能指针类为例:
|class SmartPointer { public: SmartPointer(const std::string& content = std::string()) : data(new std::string(content)), ref_count(0) { } // 值类型的拷贝构造函数:拷贝字符串而不是指针 SmartPointer(const SmartPointer& other) : data(
对于值类型的拷贝赋值运算符,我们必须特别小心处理自赋值的情况:
|SmartPointer& SmartPointer::operator=(const SmartPointer& rhs) { auto new_data = new std::string(*rhs.data); // 先拷贝右侧运算对象 delete data; // 释放当前对象的资源 data = new_data; // 从右侧运算对象拷贝数据 ref_count = rhs.ref_count; return *this; }
这种实现方式的关键在于先拷贝右侧运算对象的数据,然后释放左侧运算对象的资源。这样即使是自赋值也能正确工作。
要让类表现得像指针,最简单的方法是使用shared_ptr来管理类中的资源。但有时我们希望直接管理资源,这时可以使用引用计数技术。
引用计数的工作原理是:除了拷贝构造函数之外,每个构造函数都创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。 当创建一个对象时,只有一个对象共享状态,因此将计数器初始化为1。拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器,并递增共享的计数器。 析构函数递减计数器,如果计数器变为0,则销毁状态。拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器,如果左侧运算对象的计数器变为0,则必须销毁状态。
|class SharedPointer { public: SharedPointer(const std::string& content = std::string()) : data(new std::string(content)), ref_count(new std::size_t(1)) { } SharedPointer(const SharedPointer& other)
除了拷贝控制成员,管理资源的类通常还应该定义swap函数。定义swap对于那些将与重排元素的算法一起使用的类特别重要。
如果一个类定义了自己的swap,那么算法将使用类特定的版本,否则将使用标准库定义的swap。虽然我们通常不知道swap是如何实现的,但从概念上很容易理解,标准库的swap需要进行一次拷贝和两次赋值操作。
对于我们的值类型SmartPointer类,标准的swap可能是这样工作的:
|SmartPointer temp = ptr1; // 创建ptr1的临时副本 ptr1 = ptr2; // 将ptr2赋值给ptr1 ptr2 = temp; // 将保存的ptr1副本赋值给ptr2
这种方式会拷贝字符串数据,但实际上这些内存分配都是不必要的。我们更希望swap交换指针,而不是分配字符串的新副本。
我们可以为类定义一个自定义版本的swap来避免这些不必要的拷贝:
|class SmartPointer { friend void swap(SmartPointer& lhs, SmartPointer& rhs); // 其他成员如前所示 }; inline void swap(SmartPointer& lhs, SmartPointer& rhs) { using std::swap; swap(lhs.data, rhs.data); // 交换指针,而不是字符串数据 swap(lhs.ref_count, rhs.ref_count); // 交换int成员 }
我们首先将swap声明为friend,以便它能访问私有数据成员。由于swap是用来优化代码的,我们将其定义为内联函数。
在swap函数的实现中,对每个数据成员调用swap是很重要的。在我们的例子中,数据成员是内置类型,对于内置类型没有特定版本的swap,所以这些swap调用将调用标准库的std::swap。
但是,如果一个类的成员有自己的类型特定的swap函数,那么调用std::swap就是错误的。正确的方式是在每个swap调用中都不加限定符,然后提供一个using声明使得std::swap在当前作用域中可见。
定义了swap的类通常用swap来定义它们的赋值运算符。这些运算符使用了一种称为拷贝并交换的技术:
|SmartPointer& SmartPointer::operator=(SmartPointer rhs) { // 注意参数是按值传递的 swap(*this, rhs); // 交换*this和rhs的内容 return *this; // rhs被销毁,从而删除了原来*this中的内存 }
这个版本的赋值运算符的参数不是引用,而是按值传递。因此rhs是右侧运算对象的一个副本。在赋值运算符的函数体中,我们调用swap来交换rhs和this的数据成员。 这样做将左侧运算对象中原来的指针存储到rhs中,并将rhs中的指针存储到this中。当赋值运算符结束时,rhs被销毁,从而释放了*this曾经使用的内存。
这个技术的有趣之处在于它自动处理了自赋值情况并且是异常安全的。通过在改变左侧运算对象之前拷贝右侧运算对象,它处理自赋值的方式与我们在原始赋值运算符中使用的方法相同。
现代C++引入了移动语义这一重要特性,它能够在某些情况下显著提升性能。移动语义的核心思想是:在对象即将被销毁的情况下,我们可以"窃取"其资源而不是进行昂贵的拷贝操作。
为了支持移动操作,C++11引入了右值引用这一新的引用类型。右值引用使用&&而不是&来声明,它只能绑定到即将被销毁的对象上。
|int number = 42; int& lref = number; // 正确:左值引用绑定到左值 int&& rref = number; // 错误:不能将右值引用绑定到左值 int&& rref2 = number * 2; // 正确:右值引用绑定到右值
右值引用具有一个重要特性:它们只能绑定到即将被销毁的对象。这意味着我们可以安全地从右值引用所绑定的对象中"移动"资源。 左值表达式指向对象的身份,而右值表达式指向对象的值。左值具有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。
需要特别注意的是,变量是左值表达式。即使一个变量被声明为右值引用类型,该变量本身仍然是左值:
|int&& ref_var = 42; // ref_var是右值引用类型的变量 int&& ref_var2 = ref_var; // 错误:ref_var是左值!
虽然我们不能直接将右值引用绑定到左值,但可以通过标准库的move函数将左值显式转换为对应的右值引用类型:
|int&& ref_var3 = std::move(ref_var); // 正确
调用move函数告诉编译器:我们有一个左值,但希望像右值一样处理它。调用move之后,我们承诺除了对这个对象赋值或销毁它之外,不再使用它。
移动构造函数和移动赋值运算符类似于对应的拷贝操作,但它们从给定对象"窃取"资源而不是拷贝资源。
移动构造函数的第一个参数是该类类型的右值引用,任何额外的参数都必须有默认值。除了完成资源移动,移动构造函数还必须确保移后源对象处于可安全销毁的状态:
|class DynamicArray { public: DynamicArray(DynamicArray&& source) noexcept // 移动构造函数 : elements(source.elements), first_free(source.first_free), cap(source.cap) { // 将移后源对象置于可安全销毁的状态 source.elements = source.first_free = source.cap = nullptr; } private: std::string* elements; std::string
这里我们使用了noexcept关键字,它承诺函数不会抛出异常。这对移动操作特别重要,因为标准库容器在某些情况下只有在确定移动操作不会抛出异常时才会使用移动而不是拷贝。
移动赋值运算符执行与析构函数和移动构造函数相同的工作:
|DynamicArray& DynamicArray::operator=(DynamicArray&& rhs) noexcept { if (this != &rhs) { // 检测自赋值 free(); // 释放当前对象的资源 elements = rhs.elements; // 接管rhs的资源 first_free = rhs.first_free; cap = rhs.cap; // 将rhs置于可安全销毁的状态 rhs.elements = rhs.first_free = rhs.cap
编译器合成移动操作的条件比合成拷贝操作更加严格。只有当类没有定义任何自己的拷贝控制成员,并且类的每个非静态数据成员都可以移动时,编译器才会合成移动构造函数或移动赋值运算符。
|// 编译器会为这些类合成移动操作 struct SimpleClass { int value; // 内置类型可以移动 std::string text; // string定义了自己的移动操作 }; struct ComplexClass { SimpleClass member; // SimpleClass有合成的移动操作 };
如果类定义了拷贝构造函数、拷贝赋值运算符或析构函数,编译器就不会合成移动操作。在这种情况下,移动操作会被当作拷贝操作来处理。
移动语义的主要优势在于性能提升,特别是对于管理大量资源的类。考虑一个存储大量数据的容器在重新分配内存时的情况:
|void DynamicArray::reallocate() { auto new_capacity = size() ? 2 * size() : 1; auto new_data = allocator.allocate(new_capacity); // 移动元素而不是拷贝 auto dest = new_data; auto elem = elements; for (size_t i = 0; i
通过使用std::move,我们确保字符串的内容被移动而不是拷贝,从而避免了不必要的内存分配和数据复制。
在实际开发中,我们经常需要编写既能处理左值又能处理右值的函数。现代C++提供了完美转发技术来解决这个问题:
|template<typename T> void process_data(T&& data) { // 万能引用 // 完美转发:保持参数的值类别 internal_process(std::forward<T>(data)); }
万能引用(也称为转发引用)能够绑定到任何类型的参数,而std::forward则确保参数以其原始的值类别(左值或右值)被转发。
在使用移动语义时,应该遵循以下最佳实践:
首先,只有在确信移动操作安全的情况下才使用std::move。移动后的对象必须处于有效但未指定的状态,我们不应该对其值做任何假设。 其次,移动构造函数和移动赋值运算符应该标记为noexcept,除非它们确实可能抛出异常。这样做能让标准库容器更有效地使用移动操作。 最后,如果类定义了移动操作,通常也应该定义对应的拷贝操作,反之亦然。这确保了类在各种使用场景下都能正确工作。
现在让我们通过一些简单的练习题来巩固本章学到的知识。每道题都涵盖了拷贝控制和移动语义的核心概念,请先尝试独立完成,然后再查看答案。
编写一个简单的类,包含拷贝构造函数,并演示拷贝构造函数的调用。
|#include <iostream> using namespace std; class MyClass { private: int* data; public: // 构造函数 MyClass(int value) { data = new int(value); cout << "构造函数被调用" << endl; } // 拷贝构造函数 MyClass(const MyClass
下面的代码有什么问题?如何修复?
|class Resource { public: Resource(int size) : data(new int[size]), size_(size) {} ~Resource() { delete[] data; } private: int* data; int size_; }; int main() { Resource r1(10); Resource r2 = r1;
|#include <iostream> using namespace std; class Resource { public: Resource(int size) : data(new int[size]), size_(size) {} // 需要添加拷贝构造函数(深拷贝) Resource(const Resource& other) : size_(other.size_) { data = new int[size_]; for
为下面的类添加移动构造函数和移动赋值运算符。
|class MyString { public: MyString(const char* str); MyString(const MyString& other); // 拷贝构造函数 ~MyString(); private: char* data_; size_t size_; };
|#include <iostream> #include <cstring> using namespace std; class MyString { public: MyString(const char* str = "") { size_ = strlen(str); data_ = new char[size_ + 1]; strcpy(data_, str); } // 拷贝构造函数 MyString
解释为什么移动比拷贝更高效?在什么情况下应该使用移动?
|#include <iostream> #include <vector> using namespace std; class LargeObject { private: int* data; size_t size; public: LargeObject(size_t s) : size(s) { data = new int[size]; cout << "分配了 " << size << " 个整数" <<
实现一个简单的资源管理类,要求:
|#include <iostream> using namespace std; class Resource { private: int* data; size_t size; public: // 构造函数 Resource(size_t s) : size(s) { data = new int[size]; for (size_t i = 0; i < size; i
拷贝构造函数用于创建一个对象的副本。当使用一个对象初始化另一个对象时,拷贝构造函数会被调用。注意要进行深拷贝,避免多个对象共享同一块内存。
原代码的问题是缺少拷贝构造函数和拷贝赋值运算符。当进行拷贝时,会使用编译器生成的默认拷贝构造函数,它只是简单地复制指针,导致两个对象指向同一块内存。当对象销毁时,同一块内存会被删除两次,导致程序崩溃。解决方案是实现深拷贝。
移动构造函数和移动赋值运算符用于"移动"资源而不是拷贝。它们接受右值引用参数(&&),将源对象的资源"偷取"过来,然后将源对象置为空状态。这样可以避免不必要的拷贝,提高性能。
移动比拷贝高效的原因:
当对象很大或者包含动态分配的资源时,移动可以显著提高性能。对于临时对象(右值),应该使用移动而不是拷贝。
这个例子展示了完整的拷贝控制和移动语义实现。关键点:
noexcept标记移动操作,提高性能