13. 拷贝控制

youncyb 发布于 20 天前 82 次阅读 C++


在C++中,拷贝控制(Copy Control)是管理对象的拷贝和赋值的关键机制。通过了解构造函数、拷贝构造、拷贝赋值、移动构造和析构函数等概念,我们可以控制对象在不同情境中的行为,从而优化资源使用并避免潜在的错误。

1. 拷贝控制的基本概念

拷贝控制(copy control) 是一组机制,用于管理对象在创建、销毁、拷贝和赋值操作时的行为。在C++中包含5个核心成员用于拷贝控制:

  • 拷贝构造函数:在创建对象时,使用同类型对象初始化。
  • 拷贝赋值运算符:将对象赋值给一个已经存在的同类型对象。
  • 析构函数:在对象生命周期结束后,释放资源,清理垃圾。
  • 移动构造函数:将资源从一个临时对象移动到另一个持久对象,避免昂贵的拷贝资源。
  • 移动赋值运算符:将资源从一个临时对象移动到另一个持久对象,避免不必要的拷贝。

在定义一个类时,如果没有显示的定义这5个成员函数,C++的编译器会为我们自动合成,但自动合成的版本某些情况下并不能达到我们所希望出现的结果。所以在C++中,拷贝控制对于一个类是必须的,是一种良好的习惯。

拷贝控制的应用场景:动态内存管理、外部资源管理、文件句柄管理等方面,在遇到对象的赋值、销毁、拷贝等操作时,需要显式的对指针、动态内存、外部资源进行处理,避免内存泄露,低性能拷贝情况。

2. 拷贝构造函数

拷贝构造函数定义:构造函数的第一个参数是自身类型的引用(必须是引用类型,否则会无限递归),并且其他成员都有默认值。

class Foo
{
public:
	Foo(const Foo&); // 拷贝构造函数声明	
}

由于拷贝构造函数涉及到隐式的调用,所以拷贝构造函数必须不包含explicit关键字。

Foo f1;
Foo f2 = f1; // 调用f1的拷贝构造函数
void func(Foo f); // 调用f的拷贝构造函数
Foo f3 = "11-22-33"; // 隐式转换后,调用拷贝构造函数
Foo f_arr = {f1, f2}; // 两次拷贝构造,分别是f1和f2
Foo func2()
{
    Foo a(); 
    return a; // 返回非引用对象,调用拷贝构造函数
}
vector<string> a{"1", "2"}; // 大括号列表,调用拷贝构造函数

如果没有定义拷贝构造函数,编译器会自动生成一个。该拷贝构造函数可能是default,也可能是delete。如果是default,会将非static成员拷贝。对内置类型(int、char等)进行直接内存拷贝,对类则调用类的拷贝构造函数。对数组,则会逐一按照上述规则拷贝元素。

3. 拷贝赋值运算符

class Foo
{
public:
    Foo& operator=(const Foo&); // 拷贝赋值运算符
}

3.1 定义拷贝赋值运算符的步骤

  1. 返回类型:返回类型为 ClassName&,表示返回当前对象的引用,以便支持链式赋值操作,例如 a = b = c;
  2. 参数:参数为 const ClassName& other,即引用传递另一个同类型的对象 other,用于从中复制数据。
  3. 自我赋值检查:确保自我赋值时不会发生意外错误(例如释放自身资源)。
  4. 释放已有资源:在执行赋值之前,需要释放当前对象可能持有的资源,避免内存泄漏。
  5. 执行拷贝:将 other 的数据复制到当前对象中。
  6. 返回当前对象的引用:返回 *this
Foo& operator=(const foo& other) {
    if (this != &other) {       // 1. 检查自我赋值
        delete data;            // 2. 释放已有资源
        data = new int(*other.data); // 3. 拷贝数据
    }
    return *this;               // 4. 返回当前对象
}

3.2 拷贝并交换

在之前的学习中,我们知道类可以定义自身的swap函数,也可以调用std::swap函数。std::swap默认实现如下,调用对象的拷贝构造和拷贝赋值运算符,降低了性能。

template <typename T>
void swap(T& lhs, T& rhs) {
    T temp = lhs;  // 调用拷贝构造函数
    lhs = rhs;     // 调用赋值操作符
    rhs = temp;    // 再次调用赋值操作符
}

为了提升性能,我们也可以定义自己的swap函数,直接交换对象的内部成员,避免对象的拷贝。如果对象的内部成员是指针或者内置类型,则会调用std::swap

friend void swap(MyClass &lhs, MyClass &rhs) noexcept {
    using std::swap; // 声明std::swap,但会优先调用对象自身的swap,如果没有,采用调用std::swap,算是C++的语法糖吧
    swap(lhs.size, rhs.size);
    swap(lhs.data, rhs.data);
}

另一种拷贝赋值运算符,其采用拷贝并交换(copy-swap)的思想,其有2个优点,1个缺点:

  • 优点:可以处理自赋值情况
  • 优点:异常安全的
  • 缺点:发生了一次拷贝构造
  1. 该函数的参数 rhs 是按值传递的,也就是说,在进入函数时,编译器会通过调用拷贝构造函数来创建 rhs 的副本。

  2. 函数内部使用 swap 交换 *this 和 rhs 的数据成员(通常是指针或其他资源)。

  3. 函数返回 *this,而 rhs 在离开作用域时被销毁,其持有的资源(如指针)会被自动释放。

public:    
	// 自定义 swap 函数
    friend void swap(MyClass &lhs, MyClass &rhs) noexcept {
        using std::swap;
        swap(lhs.size, rhs.size);
        swap(lhs.data, rhs.data);
    }

    // Copy-and-swap 赋值运算符
    MyClass& operator=(MyClass rhs) { // 参数采用值传递,调用拷贝构造函数
        swap(*this, rhs);
        return *this;
    }

private:
    int size;
    int* data;

4. 析构函数

析构函数也是成员函数,没有返回值,也没有参数。

class Foo
{
public:
    ~Foo(); // 析构函数
}

对于class type的对象,则调用类自身的析构函数进行对象销毁,对于内置类型,则什么都不做,因为内置类型没有析构函数,对于智能指针,则会调用其析构函数。如果隐式的销毁一个指针,如离开局部作用域,不会销毁指针所指向的对象。对于指针所指向的对象,必须显式的释放。

析构函数的触发场景

  1. 变量离开作用域
{
    Foo f(); // 当变量f离开作用域,触发析构函数
}

  1. 对象被销毁,其成员也会触发析构函数
class Foo
{
private:
	string var1;    
}
// 当Foo被销毁时,会触发var1的销毁,即string的析构函数
  1. 容器被销毁,其元素也会触发析构函数

  2. 动态分配的对象,当使用delete p, delete[] arr进行销毁时,会触发对象的析构函数

  3. 临时对象的销毁

5. 移动构造函数与移动赋值运算符

当类不能同时被多个对象持有(IO类、unique_ptr),并且需要支持构造和赋值等运算,则必须定义移动构造函数和移动赋值运算符,将资源的管理权限从一个对象转移到另一个对象,而源对象则可以安全无忧的销毁。

常见使用场景:

  • 函数返回值:在返回临时对象时,使用移动构造函数来避免拷贝。

    MyClass createMyClass() {
        MyClass obj;
        return obj; // 使用移动构造函数,而不是拷贝
    }
    
  • std::vector 等容器重新分配空间或操作元素时,移动语义被广泛应用以提高性能。

    std::vector<MyClass> vec;
    vec.push_back(MyClass()); // 使用移动构造函数
    
  • 对象赋值:在赋值右值对象时,使用移动赋值运算符来避免深拷贝。

    MyClass a;
    a = MyClass(); // 使用移动赋值运算符
    

5.1 左值引用与右值引用

为了支持移动操作,C++11引入了一种新标准:右值引用,通过&&符号绑定到右值。

左值:具有持久内存地址的对象,可以对其取地址并进行修改。

  • 返回左值引用的函数
  • 左值表达式:赋值、下标访问、解引用、前置递增/递减运算符
  • 变量表达式

右值:没有持久内存地址的临时值,通常是计算表达式的结果。

  • 返回非引用类型的函数
  • 右值表达式:算术、关系运算符、位运算符、后置递增/递减运算符(后置会生成临时对象并返回,之后再++)

通过左右值的定义可以看出,右值存在2个特点,意味着右值引用的代码可以独立的接管对象资源。

  • 临时对象
  • 该对象没有其他用户

由于变量表达式返回左值,所以右值引用不能绑定到右值引用类型。

int &&v1 = 40; // 正确
int &&v2 = v1; // 错误,v1是一个左值

为了将左值变成右值,C++11提供了std::move函数,在调用该函数后,按照约定,我们便不可再使用源对象的值,但可以销毁源对象,或者为源对象重新赋值。

#include <utility>
int &&r1 = 40; // 正确
int &&r2 = std::move(&&r1); // 正确
std::cout << r1 << std::endl; // 能运行,但不推荐

5.2 移动构造函数

  • 不分配新资源,转移资源所有权
  • 将源对象的成员设置为NULL状态,避免销毁时释放资源
  • noexcept,由于移动不会涉及资源分配,所以一般来说不会发生异常,声明该关键字,可以优化标准库对该对象的使用,例如vector<Myclass>,如果移动构造函数不包含noexcept,当遇到扩容时,则会优先调用拷贝构造函数,避免拷贝到一半时,发生异常,导致vector无法恢复。
class MyClass {
public:
    MyClass(MyClass&& other) noexcept {
        // 转移资源的所有权
        data = other.data;
        other.data = nullptr; // 避免原对象在析构时释放资源
        std::cout << "Move constructor called" << std::endl;
    }

private:
    int* data;
};

5.3 移动赋值运算符

  • 处理自赋值
  • 释放当前对象的资源
  • 转移资源所有权
  • 将源对象关于资源的成员设置为空,避免资源被其释放
class MyClass {
public:
    MyClass& operator=(MyClass&& other) noexcept {
        if (this != &other) { // 防止自赋值
            delete data;      // 释放当前对象的资源

            // 转移资源的所有权
            data = other.data;
            other.data = nullptr; // 避免原对象在析构时释放资源
        }
        std::cout << "Move assignment operator called" << std::endl;
        return *this;
    }

private:
    int* data;
};

5.4 合成的移动操作

编译器也会为类合成移动相关的函数,但其满足条件更加苛刻:

  • 只定义了拷贝相关的函数,没有定义移动相关的函数,则编译器不会合成移动相关的函数
  • 类的非static成员不包含const和引用类型

如果类定义了移动操作,则类也应该定义自己的拷贝操作,否则拷贝操作默认是delete,原因如下:

1. 歧义和一致性问题:移动语义旨在优化对象的资源管理(例如,减少不必要的拷贝),而拷贝语义则侧重于复制对象。如果定义了移动操作,但拷贝操作使用编译器自动生成的默认实现,可能会导致语义不一致或意外的行为。因此,C++ 选择将拷贝操作删除,以防止混淆。

2. 选择性定义拷贝和移动操作:如果一个类定义了移动构造函数或移动赋值运算符,通常意味着拷贝和移动的语义有很大不同,简单的默认拷贝操作可能不适合该类。因此,C++ 要求开发者显式地定义自己的拷贝操作,以确保拷贝行为与类的设计和移动语义一致。

如果类只定义了拷贝构造函数,没有定义移动构造函数,std::move会调用拷贝构造函数,std::move(x)返回Foo&&,但由于没有移动构造函数,所以Foo&被隐式转换为const Foo &,所以会调用拷贝构造函数。

class Foo {
public:
    Foo() = default;               // 默认构造函数
    Foo(const Foo&);               // 拷贝构造函数
    // 其他成员函数的定义
    // 未定义移动构造函数
};

Foo x;          // 默认构造,调用 Foo() = default;
Foo y(x);       // 拷贝构造,调用 Foo(const Foo&),因为 x 是左值
Foo z(std::move(x));  // 拷贝构造,因为没有定义移动构造函数

5.5 移动迭代器

移动迭代器 是 C++ 中的一种特殊类型的迭代器,它允许在遍历容器时将元素的所有权通过移动语义转移到另一个容器或对象中,而不是通过复制。使用移动迭代器可以显著提高处理临时对象或具有大内存的对象的效率,特别是在需要从一个容器向另一个容器转移资源时。

std::vector<std::string> source = {"apple", "banana", "orange"};
std::vector<std::string> destination;

// 使用 std::move_iterator 将元素移动
std::copy(std::make_move_iterator(source.begin()),
          std::make_move_iterator(source.end()),
          std::back_inserter(destination));

6. 防止拷贝与移动

如果我们不希望对象被拷贝,如io对象,可以将拷贝构造函数和拷贝赋值运算符定位为delete:

class Foo
{
public:
    Foo(const Foo&) = delete;
    Foo& operator=(const Foo&) = delete;
}

Foo f1();
Foo f2 = f1; // 错误,没有拷贝赋值运算符

当类满足以下条件,其拷贝构造函数也是delete状态:

  • 某个类成员的拷贝构造函数是delete或者private
  • 某个类成员的类型是const或者引用
  • 某个类成员的析构函数是delete或者private

7. 引用限定符

在 C++ 中,引用限定符 是用于成员函数声明中的特殊符号,表示该成员函数只能被某些特定类型的对象调用。引用限定符的两种形式是:

  1. &(左值引用限定符):限定成员函数只能被左值对象调用。
  2. &&(右值引用限定符):限定成员函数只能被右值对象调用。
class MyClass {
public:
    void print() & {
        std::cout << "Called on lvalue" << std::endl;
    }

    void print() && {
        std::cout << "Called on rvalue" << std::endl;
    }
};

MyClass obj;
obj.print();  // 调用 print() &

MyClass().print();  // 调用 print() &&

为什么需要引用限定符

引用限定符的引入有助于区分左值和右值对象的行为。通常情况下,左值引用适合不可修改的持久对象操作,而右值引用适合优化临时对象或可以安全地进行移动操作。

例如:

  • 左值引用限定函数 & 通常用于那些希望保留对象数据的函数,比如修改对象的状态。
  • 右值引用限定函数 && 则通常用于临时对象或执行资源移动操作,减少不必要的拷贝。

规则

  1. 引用限定符必须匹配调用的对象类型:如果对象是左值,必须调用带有 & 限定符的成员函数;如果对象是右值,则调用带有 && 限定符的成员函数。
  2. 具有相同参数列表的成员函数必须全部带有引用限定符:如果定义了某个函数的 & 版本,那么必须为该函数提供 && 版本,确保不会产生二义性。

应用场景

  • 防止无意的临时对象修改:你可以通过引用限定符限制成员函数只能修改持久对象,而不能修改临时对象。
  • 性能优化:右值引用可以用于移动操作,避免不必要的拷贝,提高性能。