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

C++拷贝构造函数调用时的浅拷贝与深拷贝

2024-10-244.5k 阅读

一、C++拷贝构造函数基础概念

在C++ 中,拷贝构造函数是一种特殊的构造函数,用于创建一个新对象,该新对象是另一个同类对象的副本。它的定义形式如下:

class ClassName {
public:
    // 拷贝构造函数
    ClassName(const ClassName& other) {
        // 构造逻辑
    }
};

这里的参数是一个对同类对象的常量引用。使用常量引用是为了避免在传递参数时发生不必要的拷贝构造(如果参数不是引用,传递参数时会调用拷贝构造函数,这就陷入了无限递归调用拷贝构造函数的困境),并且保证在函数内部不会修改传入的对象。

拷贝构造函数在以下几种情况下会被调用:

  1. 对象以值传递的方式传入函数
void func(ClassName obj) {
    // 函数体
}

int main() {
    ClassName original;
    func(original); // 这里会调用拷贝构造函数,将original拷贝给形参obj
    return 0;
}
  1. 对象以值传递的方式从函数返回
ClassName func() {
    ClassName temp;
    return temp; // 这里会调用拷贝构造函数,将temp拷贝给调用处接收返回值的对象
}

int main() {
    ClassName result = func();
    return 0;
}
  1. 使用一个对象初始化另一个对象
ClassName original;
ClassName copy(original); // 这里调用拷贝构造函数,用original初始化copy

二、浅拷贝的原理与问题

当我们没有显式定义拷贝构造函数时,C++ 编译器会为我们生成一个默认的拷贝构造函数。这个默认的拷贝构造函数执行的就是浅拷贝。

浅拷贝是指在拷贝过程中,新对象的成员变量值直接复制自源对象的对应成员变量。对于普通数据类型(如int、double等),这种拷贝方式是足够的。但对于指针类型的成员变量,浅拷贝就会带来问题。

假设有如下类定义:

class ShallowCopy {
public:
    int* data;
    ShallowCopy(int value) {
        data = new int(value);
    }
    // 编译器生成的默认拷贝构造函数执行浅拷贝
};

现在,当我们使用默认拷贝构造函数进行对象拷贝时:

int main() {
    ShallowCopy obj1(10);
    ShallowCopy obj2(obj1);

    // obj1.data 和 obj2.data 指向同一块内存
    std::cout << "obj1 data: " << *obj1.data << std::endl;
    std::cout << "obj2 data: " << *obj2.data << std::endl;

    delete obj1.data;
    // 这里obj2.data指向的内存已经被释放,再访问obj2.data会导致未定义行为
    std::cout << "obj2 data after obj1 delete: " << *obj2.data << std::endl; 

    return 0;
}

在上述代码中,obj1 和 obj2 的 data 指针指向同一块动态分配的内存。当 obj1 的 data 指针所指向的内存被释放后,obj2 的 data 指针就成为了悬空指针(dangling pointer)。如果此时再试图通过 obj2.data 访问内存,就会导致程序崩溃或出现未定义行为。

另一个问题是,如果对其中一个对象的指针成员进行修改,会影响到另一个对象。例如:

int main() {
    ShallowCopy obj1(10);
    ShallowCopy obj2(obj1);

    *obj1.data = 20;
    std::cout << "obj2 data after obj1 modification: " << *obj2.data << std::endl; 
    return 0;
}

这里修改了 obj1 的 data 所指向的值,obj2 的 data 所指向的值也跟着改变了,这可能不符合我们的预期。

三、深拷贝的实现与原理

为了避免浅拷贝带来的问题,我们需要实现深拷贝。深拷贝意味着在拷贝过程中,如果对象包含指针成员,会为指针成员重新分配内存,并将源对象指针所指向的内容复制到新分配的内存中。

下面是实现深拷贝的示例代码:

class DeepCopy {
public:
    int* data;
    DeepCopy(int value) {
        data = new int(value);
    }
    // 自定义拷贝构造函数实现深拷贝
    DeepCopy(const DeepCopy& other) {
        data = new int(*other.data);
    }
    ~DeepCopy() {
        delete data;
    }
};

在上述代码中,我们显式定义了拷贝构造函数。在拷贝构造函数中,为新对象的 data 指针重新分配了内存,并将源对象 data 所指向的值复制到新分配的内存中。这样,每个对象都有自己独立的内存空间,不会相互影响。

以下是测试代码:

int main() {
    DeepCopy obj1(10);
    DeepCopy obj2(obj1);

    std::cout << "obj1 data: " << *obj1.data << std::endl;
    std::cout << "obj2 data: " << *obj2.data << std::endl;

    *obj1.data = 20;
    std::cout << "obj1 data after modification: " << *obj1.data << std::endl;
    std::cout << "obj2 data after obj1 modification: " << *obj2.data << std::endl; 

    return 0;
}

在这段测试代码中,修改 obj1 的 data 所指向的值,不会影响到 obj2 的 data 所指向的值,因为它们指向不同的内存区域。同时,当对象析构时,各自释放自己的内存,不会出现悬空指针的问题。

四、深拷贝与浅拷贝在复杂数据结构中的应用

(一)数组作为成员变量

当类的成员变量是数组时,浅拷贝和深拷贝也有不同的表现。

class ArrayClass {
public:
    int* arr;
    int size;
    ArrayClass(int s) : size(s) {
        arr = new int[s];
        for (int i = 0; i < s; ++i) {
            arr[i] = i;
        }
    }
    // 默认拷贝构造函数执行浅拷贝
};

如果使用默认拷贝构造函数(浅拷贝):

int main() {
    ArrayClass obj1(5);
    ArrayClass obj2(obj1);

    // obj1.arr 和 obj2.arr 指向同一块内存
    std::cout << "obj2 arr[0]: " << obj2.arr[0] << std::endl;

    delete[] obj1.arr;
    // obj2.arr 成为悬空指针
    std::cout << "obj2 arr[0] after obj1 delete: " << obj2.arr[0] << std::endl; 

    return 0;
}

实现深拷贝的方式如下:

class ArrayClass {
public:
    int* arr;
    int size;
    ArrayClass(int s) : size(s) {
        arr = new int[s];
        for (int i = 0; i < s; ++i) {
            arr[i] = i;
        }
    }
    ArrayClass(const ArrayClass& other) : size(other.size) {
        arr = new int[other.size];
        for (int i = 0; i < other.size; ++i) {
            arr[i] = other.arr[i];
        }
    }
    ~ArrayClass() {
        delete[] arr;
    }
};

(二)链表作为成员变量

对于链表这种复杂的数据结构,深拷贝和浅拷贝的处理更为复杂。

struct Node {
    int data;
    Node* next;
    Node(int d) : data(d), next(nullptr) {}
};

class LinkedListClass {
public:
    Node* head;
    LinkedListClass() : head(nullptr) {}
    void addNode(int data) {
        Node* newNode = new Node(data);
        if (!head) {
            head = newNode;
        } else {
            Node* current = head;
            while (current->next) {
                current = current->next;
            }
            current->next = newNode;
        }
    }
    // 默认拷贝构造函数执行浅拷贝
};

浅拷贝时,如果直接使用默认拷贝构造函数,新对象的 head 指针会指向原链表的头节点,这就导致两个对象共享同一个链表,修改一个对象的链表会影响另一个对象。

实现深拷贝的链表拷贝构造函数如下:

class LinkedListClass {
public:
    Node* head;
    LinkedListClass() : head(nullptr) {}
    void addNode(int data) {
        Node* newNode = new Node(data);
        if (!head) {
            head = newNode;
        } else {
            Node* current = head;
            while (current->next) {
                current = current->next;
            }
            current->next = newNode;
        }
    }
    LinkedListClass(const LinkedListClass& other) : head(nullptr) {
        if (other.head) {
            head = new Node(other.head->data);
            Node* current = head;
            Node* otherCurrent = other.head->next;
            while (otherCurrent) {
                current->next = new Node(otherCurrent->data);
                current = current->next;
                otherCurrent = otherCurrent->next;
            }
        }
    }
    ~LinkedListClass() {
        while (head) {
            Node* temp = head;
            head = head->next;
            delete temp;
        }
    }
};

在上述深拷贝实现中,我们遍历原链表,为每个节点创建新的节点,并将新节点连接成新的链表,这样新对象和原对象就拥有了独立的链表结构。

五、使用智能指针简化深拷贝

在现代C++ 中,智能指针(如 std::unique_ptrstd::shared_ptr)可以帮助我们简化深拷贝的实现,同时避免内存泄漏问题。

(一)使用std::unique_ptr

#include <memory>

class UniquePtrClass {
public:
    std::unique_ptr<int> data;
    UniquePtrClass(int value) : data(std::make_unique<int>(value)) {}
    UniquePtrClass(const UniquePtrClass& other) {
        if (other.data) {
            data = std::make_unique<int>(*other.data);
        }
    }
};

在上述代码中,std::unique_ptr 自动管理内存的释放。在拷贝构造函数中,当原对象的 data 存在时,为新对象的 data 重新分配内存并复制值。

(二)使用std::shared_ptr

#include <memory>

class SharedPtrClass {
public:
    std::shared_ptr<int> data;
    SharedPtrClass(int value) : data(std::make_shared<int>(value)) {}
    // 由于std::shared_ptr的拷贝构造函数已经实现了正确的引用计数处理,这里无需额外实现
};

std::shared_ptr 内部使用引用计数来管理对象的生命周期。当进行拷贝时,引用计数会增加,多个 std::shared_ptr 可以共享同一块内存,当最后一个指向该内存的 std::shared_ptr 被销毁时,内存才会被释放。这在一定程度上简化了深拷贝的实现,因为我们无需手动处理内存的分配和释放。

六、深拷贝与浅拷贝在性能方面的考量

(一)浅拷贝的性能优势

浅拷贝相对简单,只是简单地复制对象的成员变量的值。对于包含大量成员变量且成员变量为基本数据类型的对象,浅拷贝的性能优势明显。因为它不需要额外的内存分配和数据复制操作,只需要进行简单的赋值操作。例如:

class SimpleClass {
public:
    int a;
    double b;
    long c;
    // 默认浅拷贝构造函数即可满足需求
};

在这种情况下,使用浅拷贝构造函数(编译器自动生成的默认版本)会非常高效,因为它只是对 a、b、c 这几个基本数据类型变量进行简单的赋值。

(二)深拷贝的性能劣势

深拷贝通常需要更多的时间和空间。在深拷贝过程中,对于指针类型的成员变量,需要重新分配内存并复制数据。如果对象包含复杂的数据结构,如大型数组或链表,深拷贝的开销会非常大。例如,对于前面提到的包含大型数组的 ArrayClass 类:

class ArrayClass {
public:
    int* arr;
    int size;
    ArrayClass(int s) : size(s) {
        arr = new int[s];
        for (int i = 0; i < s; ++i) {
            arr[i] = i;
        }
    }
    ArrayClass(const ArrayClass& other) : size(other.size) {
        arr = new int[other.size];
        for (int i = 0; i < other.size; ++i) {
            arr[i] = other.arr[i];
        }
    }
    ~ArrayClass() {
        delete[] arr;
    }
};

size 很大时,深拷贝构造函数中的内存分配和数据复制操作会消耗大量的时间和内存。而且,如果频繁进行深拷贝操作,会导致大量的内存分配和释放,这可能会导致内存碎片问题,进一步影响性能。

然而,在某些情况下,深拷贝是必要的,即使存在性能开销。比如,我们需要确保对象之间的数据独立性,避免修改一个对象影响另一个对象。所以,在选择深拷贝还是浅拷贝时,需要综合考虑对象的性质、应用场景以及对数据独立性的要求等因素。

七、总结浅拷贝与深拷贝的选择原则

  1. 当对象只包含基本数据类型成员变量时:可以使用浅拷贝,因为浅拷贝已经能满足需求,而且性能更高。编译器生成的默认拷贝构造函数(执行浅拷贝)就足以应对这种情况。
  2. 当对象包含指针类型成员变量且希望多个对象共享同一块内存时:浅拷贝是合适的选择。但需要注意管理共享内存的生命周期,避免悬空指针等问题。例如,在实现引用计数机制时,可能会用到浅拷贝。
  3. 当对象包含指针类型成员变量且需要保证对象之间数据独立性时:必须使用深拷贝。深拷贝可以确保每个对象都有自己独立的内存空间,修改一个对象不会影响其他对象。这在大多数需要对象独立性的场景中是必要的,比如在多线程环境中,对象可能会被不同线程访问和修改,深拷贝可以避免数据竞争问题。
  4. 考虑性能因素:如果对象很大且拷贝操作频繁,浅拷贝可能更具性能优势,前提是共享内存不会带来问题。如果对象的深拷贝开销过大,可以考虑使用写时复制(Copy - On - Write,COW)技术,它结合了浅拷贝的性能优势和深拷贝的数据独立性,在实际数据需要修改时才进行深拷贝。

总之,在C++ 编程中,深入理解浅拷贝和深拷贝的原理、实现及适用场景,对于编写高效、健壮的代码至关重要。通过合理选择浅拷贝或深拷贝,我们可以在满足程序逻辑需求的同时,优化程序的性能和资源利用。