MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++重写拷贝构造函数的注意事项

2021-12-194.5k 阅读

C++ 重写拷贝构造函数的必要性

在 C++ 中,拷贝构造函数扮演着至关重要的角色。当我们基于已有的对象创建新对象时,拷贝构造函数就会被调用。例如,在函数传参和函数返回对象时,常常会涉及到对象的拷贝。默认情况下,C++ 编译器会为我们生成一个默认的拷贝构造函数。然而,在许多实际场景中,默认的拷贝构造函数并不能满足我们的需求。

考虑如下场景,假设我们有一个类 MyClass,其中包含一个指向动态分配内存的指针成员变量。

class MyClass {
private:
    int* data;
public:
    MyClass(int value) {
        data = new int(value);
    }
    ~MyClass() {
        delete data;
    }
};

如果我们使用默认的拷贝构造函数,例如:

MyClass obj1(10);
MyClass obj2 = obj1;

此时,obj2data 指针会直接复制 obj1data 指针的值,这意味着 obj1obj2data 指针指向同一块内存。当 obj1obj2 其中一个对象被销毁时,这块内存会被释放,那么另一个对象的 data 指针就会变成野指针,从而引发未定义行为。为了避免这种情况,我们需要重写拷贝构造函数,进行深拷贝。

重写拷贝构造函数的基本形式

重写拷贝构造函数时,其参数必须是当前类类型的常量引用。这是因为如果不是引用,在调用拷贝构造函数时会再次触发拷贝构造函数,导致无限递归。

class MyClass {
private:
    int* data;
public:
    MyClass(int value) {
        data = new int(value);
    }
    MyClass(const MyClass& other) {
        data = new int(*other.data);
    }
    ~MyClass() {
        delete data;
    }
};

在上述代码中,我们的重写的拷贝构造函数 MyClass(const MyClass& other) 为新对象分配了一块新的内存,并将 other 对象中 data 所指向的值复制到新分配的内存中。这样,obj1obj2 就拥有了各自独立的内存空间,避免了野指针问题。

注意事项

内存管理相关注意事项

  1. 深拷贝与浅拷贝:如前文所述,在涉及动态分配内存的情况下,必须进行深拷贝。浅拷贝(默认拷贝构造函数的行为)只是简单地复制指针的值,而不是复制指针所指向的内存内容。深拷贝确保每个对象都有自己独立的资源,不会出现多个对象共享同一块动态分配内存的情况。
  2. 释放旧资源:在重写拷贝构造函数时,如果新对象在构造过程中需要分配新的资源,要注意旧对象的资源释放问题。例如,假设我们的 MyClass 类有一个重新分配内存的操作:
class MyClass {
private:
    int* data;
    int size;
public:
    MyClass(int initialSize) {
        size = initialSize;
        data = new int[size];
        for (int i = 0; i < size; ++i) {
            data[i] = i;
        }
    }
    MyClass(const MyClass& other) {
        size = other.size;
        data = new int[size];
        for (int i = 0; i < size; ++i) {
            data[i] = other.data[i];
        }
    }
    ~MyClass() {
        delete[] data;
    }
};

在这个例子中,我们为 MyClass 类添加了一个 size 成员变量来表示数组的大小。重写的拷贝构造函数为新对象分配了合适大小的数组,并复制了数据。在对象销毁时,~MyClass() 函数会正确释放动态分配的数组内存。

类成员类型的影响

  1. 内置类型成员:对于内置类型(如 int, float, char 等)的成员变量,默认的位拷贝通常是足够的。例如:
class SimpleClass {
private:
    int value;
public:
    SimpleClass(int v) : value(v) {}
    SimpleClass(const SimpleClass& other) : value(other.value) {}
};

这里我们显式地为重写了拷贝构造函数,只是简单地复制 value 成员变量的值。因为 int 是内置类型,这种方式是安全有效的。

  1. 自定义类型成员:如果类包含自定义类型的成员变量,且这些自定义类型也有自定义的拷贝构造函数,那么在当前类的拷贝构造函数中,会自动调用这些成员变量的拷贝构造函数。例如:
class InnerClass {
private:
    int data;
public:
    InnerClass(int value) : data(value) {}
    InnerClass(const InnerClass& other) : data(other.data) {}
};

class OuterClass {
private:
    InnerClass inner;
public:
    OuterClass(int innerValue) : inner(innerValue) {}
    OuterClass(const OuterClass& other) : inner(other.inner) {}
};

OuterClass 的拷贝构造函数中,inner 成员变量的拷贝构造函数会被自动调用,以确保 inner 对象的正确拷贝。

  1. 指针类型成员:除了前文提到的动态分配内存的指针需要深拷贝外,即使指针指向的是栈上的对象或者其他静态分配的对象,也需要谨慎处理。例如,如果指针是一个指向数组的指针,并且该数组的生命周期独立于当前对象,在拷贝构造函数中需要决定如何处理这个指针。一种常见的做法是使新对象的指针指向相同的数组,但要注意在对象销毁时不能释放该数组,因为它不是由当前对象分配的。
class ArrayPointerClass {
private:
    int* array;
public:
    ArrayPointerClass(int* arr) : array(arr) {}
    ArrayPointerClass(const ArrayPointerClass& other) : array(other.array) {}
    ~ArrayPointerClass() {
        // 注意这里不能 delete[] array,因为数组不是由当前对象分配的
    }
};

异常安全性

  1. 异常安全保证级别:在重写拷贝构造函数时,要考虑异常安全问题。C++ 有三种异常安全保证级别:基本保证、强保证和不抛出保证。对于拷贝构造函数,通常应至少提供基本保证,即在发生异常时,不会泄漏资源,并且对象处于一个有效的状态。
  2. 资源分配与异常处理:以我们之前的 MyClass 类为例,在分配内存时可能会抛出异常(例如内存不足)。如果在分配新内存后但还未完成数据复制时抛出异常,可能会导致内存泄漏。为了避免这种情况,可以采用一些技术,比如使用智能指针。
#include <memory>

class MyClass {
private:
    std::unique_ptr<int> data;
public:
    MyClass(int value) : data(std::make_unique<int>(value)) {}
    MyClass(const MyClass& other) {
        try {
            data = std::make_unique<int>(*other.data);
        } catch (...) {
            // 处理异常,例如记录日志等
            throw;
        }
    }
    ~MyClass() = default;
};

在上述代码中,我们使用了 std::unique_ptr<int> 来管理动态分配的内存。std::unique_ptr 会在对象销毁时自动释放其管理的资源,从而提供了基本的异常安全保证。如果在 std::make_unique<int>(*other.data) 时抛出异常,data 不会被赋值,也就不会导致内存泄漏。

与赋值运算符重载的一致性

  1. 拷贝构造函数与赋值运算符重载的关系:拷贝构造函数和赋值运算符重载(operator=)在功能上有相似之处,但它们的应用场景不同。拷贝构造函数用于基于已有的对象创建新对象,而赋值运算符重载用于将一个已存在的对象的值赋给另一个已存在的对象。然而,它们在实现逻辑上应该保持一致性。
  2. 实现一致性的原则:如果在拷贝构造函数中进行了深拷贝,那么在赋值运算符重载中也应该进行深拷贝。同样,如果在拷贝构造函数中处理了特定的资源管理或异常安全问题,赋值运算符重载也应该以相同的方式处理。
class MyClass {
private:
    int* data;
public:
    MyClass(int value) {
        data = new int(value);
    }
    MyClass(const MyClass& other) {
        data = new int(*other.data);
    }
    MyClass& operator=(const MyClass& other) {
        if (this != &other) {
            delete data;
            data = new int(*other.data);
        }
        return *this;
    }
    ~MyClass() {
        delete data;
    }
};

在上述代码中,赋值运算符重载首先检查是否是自赋值(this != &other),然后释放旧的资源,再进行深拷贝并返回当前对象。这种实现方式与拷贝构造函数的深拷贝逻辑保持一致。

继承体系中的拷贝构造函数

  1. 基类拷贝构造函数的调用:当在继承体系中重写拷贝构造函数时,需要注意调用基类的拷贝构造函数。如果不显式调用,编译器会调用基类的默认拷贝构造函数。例如:
class Base {
private:
    int baseValue;
public:
    Base(int value) : baseValue(value) {}
    Base(const Base& other) : baseValue(other.baseValue) {}
};

class Derived : public Base {
private:
    int derivedValue;
public:
    Derived(int base, int derived) : Base(base), derivedValue(derived) {}
    Derived(const Derived& other) : Base(other), derivedValue(other.derivedValue) {}
};

Derived 类的拷贝构造函数中,我们通过 Base(other) 显式调用了基类 Base 的拷贝构造函数,以确保基类部分的成员变量被正确拷贝。

  1. 虚基类的特殊情况:当存在虚基类时,情况会变得更加复杂。虚基类的子对象由最底层的派生类构造函数初始化。在拷贝构造函数中,也需要确保虚基类子对象被正确拷贝。
class VirtualBase {
private:
    int virtualValue;
public:
    VirtualBase(int value) : virtualValue(value) {}
    VirtualBase(const VirtualBase& other) : virtualValue(other.virtualValue) {}
};

class Intermediate : virtual public VirtualBase {
public:
    Intermediate(int value) : VirtualBase(value) {}
    Intermediate(const Intermediate& other) : VirtualBase(other) {}
};

class Final : public Intermediate {
private:
    int finalValue;
public:
    Final(int virtualValue, int finalValue) : Intermediate(virtualValue), finalValue(finalValue) {}
    Final(const Final& other) : Intermediate(other), finalValue(other.finalValue) {}
};

在上述代码中,VirtualBase 是虚基类,IntermediateFinal 类继承自它。在 Final 类的拷贝构造函数中,通过 Intermediate(other) 间接调用了虚基类 VirtualBase 的拷贝构造函数,保证了虚基类子对象的正确拷贝。

多态与拷贝构造函数

  1. 切片问题:在涉及多态的场景中,使用拷贝构造函数时需要注意切片问题。当通过基类对象拷贝派生类对象时,派生类特有的成员变量会被截断,只保留基类部分的成员变量。
class Shape {
public:
    virtual void draw() const = 0;
    Shape() = default;
    Shape(const Shape& other) = default;
};

class Circle : public Shape {
private:
    int radius;
public:
    Circle(int r) : radius(r) {}
    Circle(const Circle& other) : Shape(other), radius(other.radius) {}
    void draw() const override {
        // 绘制圆形的逻辑
    }
};

void processShape(const Shape& shape) {
    // 这里如果传入的是 Circle 对象,通过拷贝构造函数创建新的 Shape 对象时会发生切片
    Shape newShape = shape; 
}

processShape 函数中,当传入 Circle 对象时,通过 Shape newShape = shape; 进行拷贝构造,newShape 只是一个 Shape 对象,Circle 类特有的 radius 成员变量被截断。 2. 解决切片问题的方法:为了避免切片问题,可以使用指针或引用。例如,使用 std::unique_ptr<Shape> 来管理对象,通过 std::make_unique<Circle> 创建对象,这样在传递和操作对象时可以保持对象的完整类型。

#include <memory>

class Shape {
public:
    virtual void draw() const = 0;
    Shape() = default;
    Shape(const Shape& other) = default;
    virtual ~Shape() = default;
};

class Circle : public Shape {
private:
    int radius;
public:
    Circle(int r) : radius(r) {}
    Circle(const Circle& other) : Shape(other), radius(other.radius) {}
    void draw() const override {
        // 绘制圆形的逻辑
    }
};

void processShape(std::unique_ptr<Shape> shape) {
    std::unique_ptr<Shape> newShape = std::make_unique<Circle>(*dynamic_cast<Circle*>(shape.get()));
    newShape->draw();
}

在上述代码中,processShape 函数接受一个 std::unique_ptr<Shape>,通过 dynamic_castShape 指针转换为 Circle 指针,然后使用 std::make_unique<Circle> 创建一个新的 Circle 对象,从而避免了切片问题。

性能考量

  1. 拷贝构造函数的性能影响:拷贝构造函数的实现方式会对程序性能产生影响。特别是在涉及大量数据拷贝的情况下,如深拷贝大数组或复杂对象结构时,性能开销可能会很大。例如,如果我们的类包含一个大的 std::vector
#include <vector>

class BigDataClass {
private:
    std::vector<int> data;
public:
    BigDataClass(int size) {
        data.resize(size);
        for (int i = 0; i < size; ++i) {
            data[i] = i;
        }
    }
    BigDataClass(const BigDataClass& other) : data(other.data) {}
};

在这个 BigDataClass 的拷贝构造函数中,data 成员变量是一个 std::vector<int>,当进行拷贝时,std::vector 的拷贝构造函数会被调用,将 other.data 的所有元素复制到新的 data 中。如果 size 很大,这将是一个相对耗时的操作。 2. 优化策略:为了优化性能,可以考虑以下几种策略。一是使用移动语义(C++11 引入),如果对象的资源可以安全地转移而不是复制,可以通过移动构造函数来避免不必要的拷贝。二是在某些情况下,可以采用写时拷贝(Copy - on - Write,COW)技术。例如,对于 BigDataClass,可以在拷贝构造函数中不立即复制数据,而是共享数据,只有在数据需要修改时才进行复制。

#include <vector>
#include <memory>

class BigDataClass {
private:
    std::shared_ptr<std::vector<int>> data;
    bool isShared;
public:
    BigDataClass(int size) {
        data = std::make_shared<std::vector<int>>(size);
        for (int i = 0; i < size; ++i) {
            (*data)[i] = i;
        }
        isShared = false;
    }
    BigDataClass(const BigDataClass& other) : data(other.data), isShared(true) {}
    void modifyData(int index, int value) {
        if (isShared) {
            data = std::make_shared<std::vector<int>>(*data);
            isShared = false;
        }
        (*data)[index] = value;
    }
};

在上述代码中,BigDataClass 使用 std::shared_ptr<std::vector<int>> 来管理数据。在拷贝构造函数中,新对象和原对象共享 data,并将 isShared 设置为 true。当调用 modifyData 函数修改数据时,如果 isSharedtrue,则创建一份新的数据副本,从而实现写时拷贝,提高了性能。

与模板的结合使用

  1. 模板类中的拷贝构造函数:在模板类中,拷贝构造函数的重写同样需要遵循上述的注意事项。例如,我们有一个简单的模板类 MyTemplate
template<typename T>
class MyTemplate {
private:
    T value;
public:
    MyTemplate(const T& v) : value(v) {}
    MyTemplate(const MyTemplate<T>& other) : value(other.value) {}
};

在这个模板类中,拷贝构造函数 MyTemplate(const MyTemplate<T>& other) 会根据 T 的类型来决定如何拷贝 value。如果 T 是自定义类型且有自定义的拷贝构造函数,那么会调用 T 的拷贝构造函数。 2. 模板参数推导与拷贝构造函数:C++17 引入了类模板参数推导(CTAD),这也会影响拷贝构造函数的使用。例如:

template<typename T>
class MyTemplate {
private:
    T value;
public:
    MyTemplate(const T& v) : value(v) {}
    MyTemplate(const MyTemplate<T>& other) : value(other.value) {}
};

MyTemplate obj(10);
MyTemplate newObj = obj; // 使用类模板参数推导

在上述代码中,MyTemplate obj(10); 会根据传入的参数 10 推导 TintMyTemplate newObj = obj; 则会调用 MyTemplate<int> 的拷贝构造函数。在编写模板类的拷贝构造函数时,要考虑 CTAD 可能带来的影响,确保代码在不同的推导场景下都能正确工作。

多线程环境下的注意事项

  1. 线程安全问题:在多线程环境中,重写拷贝构造函数需要考虑线程安全。如果类的成员变量涉及共享资源,例如共享的静态变量或动态分配的共享内存,拷贝构造函数可能会引入数据竞争。
class SharedResourceClass {
private:
    static int sharedValue;
public:
    SharedResourceClass() {}
    SharedResourceClass(const SharedResourceClass& other) {
        // 这里如果多个线程同时调用拷贝构造函数,可能会导致数据竞争
        ++sharedValue; 
    }
    ~SharedResourceClass() {
        --sharedValue;
    }
};

int SharedResourceClass::sharedValue = 0;

在上述代码中,SharedResourceClass 类有一个静态成员变量 sharedValue。在拷贝构造函数中对 sharedValue 进行递增操作,如果多个线程同时调用拷贝构造函数,可能会导致 sharedValue 的值出现不一致的情况,这就是数据竞争。 2. 同步机制:为了避免数据竞争,可以使用同步机制,如互斥锁(std::mutex)。

#include <mutex>

class SharedResourceClass {
private:
    static int sharedValue;
    static std::mutex mtx;
public:
    SharedResourceClass() {}
    SharedResourceClass(const SharedResourceClass& other) {
        std::lock_guard<std::mutex> lock(mtx);
        ++sharedValue; 
    }
    ~SharedResourceClass() {
        std::lock_guard<std::mutex> lock(mtx);
        --sharedValue;
    }
};

int SharedResourceClass::sharedValue = 0;
std::mutex SharedResourceClass::mtx;

在改进后的代码中,我们使用了 std::lock_guard<std::mutex> 来自动管理互斥锁的锁定和解锁。在拷贝构造函数和析构函数中,通过锁定互斥锁,确保了对 sharedValue 的操作是线程安全的。

与智能指针的结合

  1. 智能指针作为成员变量:当类的成员变量是智能指针时,重写拷贝构造函数需要考虑智能指针的特性。例如,std::unique_ptr 是独占所有权的智能指针,而 std::shared_ptr 是共享所有权的智能指针。
#include <memory>

class SmartPtrClass {
private:
    std::unique_ptr<int> uniquePtr;
    std::shared_ptr<int> sharedPtr;
public:
    SmartPtrClass(int value) : uniquePtr(std::make_unique<int>(value)), sharedPtr(std::make_shared<int>(value)) {}
    SmartPtrClass(const SmartPtrClass& other) : sharedPtr(other.sharedPtr) {
        if (other.uniquePtr) {
            uniquePtr = std::make_unique<int>(*other.uniquePtr);
        }
    }
};

在上述代码中,对于 std::unique_ptr<int> 类型的 uniquePtr 成员变量,我们在拷贝构造函数中进行了深拷贝,为新对象创建了一个新的 std::unique_ptr 并复制了值。而对于 std::shared_ptr<int> 类型的 sharedPtr 成员变量,由于 std::shared_ptr 本身就是共享所有权的,我们直接复制 sharedPtr,这会增加引用计数。 2. 使用智能指针管理资源与拷贝构造函数的协同:智能指针的使用可以简化资源管理,同时也影响拷贝构造函数的实现。例如,在处理动态分配的数组时,使用 std::unique_ptr<int[]> 可以自动释放数组内存。

#include <memory>

class ArraySmartPtrClass {
private:
    std::unique_ptr<int[]> array;
    int size;
public:
    ArraySmartPtrClass(int initialSize) : size(initialSize) {
        array = std::make_unique<int[]>(size);
        for (int i = 0; i < size; ++i) {
            array[i] = i;
        }
    }
    ArraySmartPtrClass(const ArraySmartPtrClass& other) : size(other.size) {
        array = std::make_unique<int[]>(size);
        for (int i = 0; i < size; ++i) {
            array[i] = other.array[i];
        }
    }
};

在这个 ArraySmartPtrClass 类中,std::unique_ptr<int[]> 管理动态分配的数组。拷贝构造函数为新对象分配新的数组并复制数据,同时利用 std::unique_ptr<int[]> 的自动释放机制,确保在对象销毁时数组内存被正确释放。

调试与测试拷贝构造函数

  1. 调试技巧:在调试拷贝构造函数时,可以使用打印语句、断点等工具。例如,在拷贝构造函数中添加打印信息,以便了解何时调用以及参数的值。
class DebugClass {
private:
    int value;
public:
    DebugClass(int v) : value(v) {
        std::cout << "Constructor called with value: " << value << std::endl;
    }
    DebugClass(const DebugClass& other) : value(other.value) {
        std::cout << "Copy constructor called, copied value: " << value << std::endl;
    }
};

通过这种方式,在程序运行时可以直观地看到拷贝构造函数的调用情况。 2. 测试方法:编写单元测试来验证拷贝构造函数的正确性。例如,可以使用 Google Test 框架。

#include <gtest/gtest.h>

class TestClass {
private:
    int data;
public:
    TestClass(int value) : data(value) {}
    TestClass(const TestClass& other) : data(other.data) {}
    int getData() const {
        return data;
    }
};

TEST(CopyConstructorTest, BasicCopy) {
    TestClass obj1(10);
    TestClass obj2 = obj1;
    EXPECT_EQ(obj2.getData(), 10);
}

在上述测试代码中,我们创建了一个 TestClass 类,并编写了一个单元测试 BasicCopy 来验证拷贝构造函数是否正确地复制了数据。通过单元测试,可以确保拷贝构造函数在各种情况下都能正确工作。

综上所述,重写 C++ 拷贝构造函数需要综合考虑内存管理、类成员类型、异常安全、与其他操作符的一致性、继承体系、多态、性能、多线程、与智能指针的结合以及调试测试等多个方面的问题。只有全面、细致地处理这些注意事项,才能编写出健壮、高效且正确的代码。