21-13 浅拷贝与深拷贝

浅拷贝

由于C++对类了解有限,其提供的默认复制构造函数和默认赋值运算符采用了一种称为成员逐项复制(也称浅拷贝shallow copy)的方法。这意味着C++会单独复制类的每个成员(通过重载的赋值运算符operator=进行赋值,并通过复制构造函数进行直接初始化)。当类结构简单(例如不包含任何动态分配的内存)时,这种方式效果良好。

例如,让我们看看我们的 Fraction 类:

#include <cassert>
#include <iostream>

class Fraction
{
private:
    int m_numerator { 0 };
    int m_denominator { 1 };

public:
    // Default constructor
    Fraction(int numerator = 0, int denominator = 1)
        : m_numerator{ numerator }
        , m_denominator{ denominator }
    {
        assert(denominator != 0);
    }

    friend std::ostream& operator<<(std::ostream& out, const Fraction& f1);
};

std::ostream& operator<<(std::ostream& out, const Fraction& f1)
{
	out << f1.m_numerator << '/' << f1.m_denominator;
	return out;
}

编译器为该类提供的默认复制构造函数和默认赋值运算符大致如下所示:

#include <cassert>
#include <iostream>

class Fraction
{
private:
    int m_numerator { 0 };
    int m_denominator { 1 };

public:
    // Default constructor
    Fraction(int numerator = 0, int denominator = 1)
        : m_numerator{ numerator }
        , m_denominator{ denominator }
    {
        assert(denominator != 0);
    }

    // Possible implementation of implicit copy constructor
    Fraction(const Fraction& f)
        : m_numerator{ f.m_numerator }
        , m_denominator{ f.m_denominator }
    {
    }

    // Possible implementation of implicit assignment operator
    Fraction& operator= (const Fraction& fraction)
    {
        // self-assignment guard
        if (this == &fraction)
            return *this;

        // do the copy
        m_numerator = fraction.m_numerator;
        m_denominator = fraction.m_denominator;

        // return the existing object so we can chain this operator
        return *this;
    }

    friend std::ostream& operator<<(std::ostream& out, const Fraction& f1)
    {
	out << f1.m_numerator << '/' << f1.m_denominator;
	return out;
    }
};

请注意,由于这些默认版本完全能够满足复制该类的需求,因此在此情况下我们实在没有必要编写自己的版本。

然而,在设计处理动态分配内存的类时,按成员复制(浅复制)可能会给我们带来很多麻烦!这是因为指针的浅复制仅复制了指针的地址——它既不会分配任何内存,也不会复制被指向的内容!

让我们通过一个示例来理解:

#include <cstring> // for strlen()
#include <cassert> // for assert()

class MyString
{
private:
    char* m_data{};
    int m_length{};

public:
    MyString(const char* source = "" )
    {
        assert(source); // make sure source isn't a null string

        // Find the length of the string
        // Plus one character for a terminator
        m_length = std::strlen(source) + 1;

        // Allocate a buffer equal to this length
        m_data = new char[m_length];

        // Copy the parameter string into our internal buffer
        for (int i{ 0 }; i < m_length; ++i)
            m_data[i] = source[i];
    }

    ~MyString() // destructor
    {
        // We need to deallocate our string
        delete[] m_data;
    }

    char* getString() { return m_data; }
    int getLength() { return m_length; }
};

以上是一个简单的字符串类,它分配内存来存储我们传入的字符串。请注意,我们没有定义复制构造函数或重载赋值运算符。因此,C++ 将提供默认的复制构造函数和默认的赋值运算符,它们执行浅拷贝。复制构造函数大致如下所示:

MyString::MyString(const MyString& source)
    : m_length { source.m_length }
    , m_data { source.m_data }
{
}

请注意,m_data 只是 source.m_data 的浅指针复制,这意味着它们现在都指向同一对象。

现在,请考虑以下代码片段:

#include <iostream>

int main()
{
    MyString hello{ "Hello, world!" };
    {
        MyString copy{ hello }; // use default copy constructor
    } // copy is a local variable, so it gets destroyed here.  The destructor deletes copy's string, which leaves hello with a dangling pointer

    std::cout << hello.getString() << '\n'; // this will have undefined behavior

    return 0;
}

虽然这段代码看似无害,但其中暗藏一个狡猾的问题,会导致程序出现未定义行为!

让我们逐行解析这个示例:

MyString hello{ "Hello, world!" };

这行代码相当安全。它调用MyString构造函数,该函数分配内存,将hello.m_data指向该内存区域,然后将字符串“Hello, world!”复制到其中。

MyString copy{ hello }; // use default copy constructor

这行代码看似无害,实则正是问题的根源!当执行此行时,C++会调用默认的复制构造函数(因为我们未提供自定义版本)。该复制构造函数将执行浅拷贝操作,将copy.m_data初始化为与hello.m_data相同的地址。结果导致copy.m_data和hello.m_data现在都指向同一块内存区域!

} // copy gets destroyed here

当 copy 脱离作用域时,系统会调用 MyString 的析构函数处理 copy。该析构函数会删除 copy.m_data 和 hello.m_data 共同指向的动态分配内存!因此,删除 copy 时我们(无意间)也影响了 hello。变量 copy 被销毁后,hello.m_data 却仍指向已被删除(无效)的内存区域!

std::cout << hello.getString() << '\n'; // this will have undefined behavior

现在你应该明白为什么这个程序会出现未定义行为了。我们删除了hello所指向的字符串,现在却试图打印已不再分配的内存值。

问题的根源在于复制构造函数执行的浅拷贝——在复制构造函数或重载的赋值运算符中对指针值进行浅拷贝,几乎总是会引发问题。


深度复制

解决此问题的方案之一是对所有非空指针进行深度复制。深度复制deep copy会为副本分配独立内存,再复制实际值,使副本与源对象存在于不同的内存空间中。这样副本与源对象便完全分离,彼此不会产生任何影响。实现深度复制需要我们自行编写复制构造函数和重载的赋值运算符。

下面我们以 MyString 类为例演示具体实现:

// assumes m_data is initialized
void MyString::deepCopy(const MyString& source)
{
    // first we need to deallocate any value that this string is holding!
    delete[] m_data;

    // because m_length is not a pointer, we can shallow copy it
    m_length = source.m_length;

    // m_data is a pointer, so we need to deep copy it if it is non-null
    if (source.m_data)
    {
        // allocate memory for our copy
        m_data = new char[m_length];

        // do the copy
        for (int i{ 0 }; i < m_length; ++i)
            m_data[i] = source.m_data[i];
    }
    else
        m_data = nullptr;
}

// Copy constructor
MyString::MyString(const MyString& source)
{
    deepCopy(source);
}

如你所见,这比简单的浅拷贝要复杂得多!首先,我们必须检查源对象是否包含字符串(第11行)。若存在,则分配足够内存来存储该字符串的副本(第14行)。最后,我们需要手动复制字符串(第17和18行)。

现在来实现重载的赋值运算符。重载的赋值运算符稍显复杂:

// Assignment operator
MyString& MyString::operator=(const MyString& source)
{
    // check for self-assignment
    if (this != &source)
    {
        // now do the deep copy
        deepCopy(source);
    }

    return *this;
}

请注意,我们的赋值运算符与复制构造函数非常相似,但存在三个主要区别:

  • 我们添加了自我赋值检查。
  • 返回 *this 以支持赋值运算符的链式调用。
  • 需要显式释放字符串已占用的内存(避免后续重新分配 m_data 时发生内存泄漏)。此操作在 deepCopy() 中处理。

当重载的赋值运算符被调用时,目标项可能已包含先前值,必须确保在分配新值内存前完成清理。对于非动态分配的变量(固定大小),无需处理——新值会直接覆盖旧值。但动态分配的变量必须在分配新内存前显式释放旧内存。否则代码虽不会崩溃,但每次赋值都会产生内存泄漏,持续蚕食可用空间!


三原则

还记得三原则吗?若类需要用户定义析构函数、复制构造函数或复制赋值运算符,则很可能需要同时实现三者。原因在于:当我们手动定义这些函数时,通常涉及动态内存分配。复制构造函数与复制赋值运算符用于处理深拷贝,而析构函数则负责释放内存。


更优解

标准库中处理动态内存的类(如std::string和std::vector)会自行管理内存,并重载了能正确执行深拷贝的复制构造函数与赋值运算符。因此无需自行管理内存,只需像初始化基础变量那样直接初始化或赋值即可!这使得这些类更易于使用、更少出错,且无需耗费时间编写重载函数!


总结

  • 默认复制构造函数和默认赋值运算符执行浅拷贝,对于不含动态分配变量的类而言已足够。
  • 包含动态分配变量的类需要具备执行深拷贝的复制构造函数和赋值运算符。
  • 优先使用标准库中的类,而非自行实现内存管理。
posted @ 2026-01-25 22:50  游翔  阅读(4)  评论(0)    收藏  举报