浅拷贝与深拷贝

浅拷贝

由于 C++ 对自定义类的内部细节知之甚少,其提供的默认复制构造函数与默认赋值运算符均采用“按成员拷贝”(memberwise copy,又称浅拷贝)的方式。
这意味着编译器会逐个复制类的每个成员:重载的 operator= 使用赋值运算符,复制构造函数则采用直接初始化。
当类结构简单(例如不含任何动态分配内存)时,这种做法完全可行。

下面以 Fraction 类为例:

#include <cassert>
#include <iostream>

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

public:
    // 默认构造函数
    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:
    // 默认构造函数
    Fraction(int numerator = 0, int denominator = 1)
        : m_numerator{ numerator }
        , m_denominator{ denominator }
    {
        assert(denominator != 0);
    }

    // 可能的隐式复制构造函数实现
    Fraction(const Fraction& f)
        : m_numerator{ f.m_numerator }
        , m_denominator{ f.m_denominator }
    {
    }

    // 可能的隐式赋值运算符实现
    Fraction& operator=(const Fraction& fraction)
    {
        // 自赋值保护
        if (this == &fraction)
            return *this;

        // 执行拷贝
        m_numerator = fraction.m_numerator;
        m_denominator = fraction.m_denominator;

        // 返回当前对象以支持链式赋值
        return *this;
    }

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

由于这些隐式版本在本例中已能正确工作,故无需自行编写。

然而,在设计需要管理动态内存的类时,按成员(浅)拷贝将带来严重隐患!
原因在于,对指针的浅拷贝仅复制其地址,既不分配新内存,也不复制所指向的内容。

请看下例:

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

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

public:
    MyString(const char* source = "")
    {
        assert(source); // 确保 source 非空

        // 计算字符串长度,并留一个字符存放终止符
        m_length = std::strlen(source) + 1;

        // 分配等长缓冲区
        m_data = new char[m_length];

        // 将参数字符串复制到内部缓冲区
        for (int i{ 0 }; i < m_length; ++i)
            m_data[i] = source[i];
    }

    ~MyString() // 析构函数
    {
        // 释放字符串所占内存
        delete[] m_data;
    }

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

这是一个简单的字符串类,为传入的字符串分配内存。注意我们尚未定义复制构造函数或重载赋值运算符,因此编译器会提供执行浅拷贝的默认实现。其复制构造函数形如:

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 }; // 使用默认复制构造函数
    } // copy 为局部变量,在此处销毁。析构函数删除了 copy 的字符串,使 hello 持有悬垂指针

    std::cout << hello.getString() << '\n'; // 将导致未定义行为

    return 0;
}

尽管代码看似无害,却暗藏致命缺陷,会导致未定义行为!

逐行解析:

  1. MyString hello{ "Hello, world!" };
    调用构造函数,分配内存,令 hello.m_data 指向该内存,并复制字符串。

  2. MyString copy{ hello }; // 使用默认复制构造函数
    看似无害,却是问题根源!由于未提供自定义复制构造函数,编译器使用默认浅拷贝,将 copy.m_data 初始化为与 hello.m_data 相同地址。于是两者指向同一块内存。

  3. } // copy 在此处被销毁
    copy 离开作用域,调用 MyString 析构函数,释放 copy.m_data 与 hello.m_data 共同指向的内存。copy 被销毁后,hello.m_data 成为指向已释放内存的悬垂指针。

  4. std::cout << hello.getString() << '\n'; // 未定义行为
    我们删除了 hello 指向的字符串,却试图访问并打印已释放的内存,因而产生未定义行为。

问题的根源在于复制构造函数执行了浅拷贝——在复制构造函数或重载赋值运算符中对指针做浅拷贝几乎总会带来麻烦。

深拷贝

解决之道是对任何非空指针执行深拷贝。深拷贝先为目标分配内存,再复制实际内容,使副本与源对象位于不同内存区域,互不干扰。为此需自行编写复制构造函数及重载赋值运算符。

以下演示如何为 MyString 类实现深拷贝:

// 假设 m_data 已初始化
void MyString::deepCopy(const MyString& source)
{
    // 先释放当前字符串所占用内存
    delete[] m_data;

    // m_length 非指针,可安全浅拷贝
    m_length = source.m_length;

    // m_data 为指针,若非空则需深拷贝
    if (source.m_data)
    {
        // 为副本分配内存
        m_data = new char[m_length];

        // 执行复制
        for (int i{ 0 }; i < m_length; ++i)
            m_data[i] = source.m_data[i];
    }
    else
        m_data = nullptr;
}

// 复制构造函数
MyString::MyString(const MyString& source)
{
    deepCopy(source);
}

显然,深拷贝远比浅拷贝复杂!首先需确保源对象含字符串(第 11 行),若有,则分配足够内存(第 14 行),最后手动复制字符串(第 17–18 行)。

接下来实现重载赋值运算符,其处理略为繁琐:

// 赋值运算符
MyString& MyString::operator=(const MyString& source)
{
    // 检查自赋值
    if (this != &source)
    {
        // 执行深拷贝
        deepCopy(source);
    }

    return *this;
}

注意赋值运算符与复制构造函数相似,但有三大区别:

  1. 加入自赋值检查。
  2. 返回 *this 以支持链式赋值。
  3. 需先显式释放字符串原有内存(防止重新分配时内存泄漏)。此操作已在 deepCopy() 中完成。

调用重载赋值运算符时,被赋值对象可能已含旧值,故在分配新内存前务必清理旧资源。对于非动态分配(固定大小)变量,新值直接覆盖旧值即可;而对动态分配变量,则必须显式释放旧内存,否则不会崩溃,但会反复泄漏内存!

三之法则(Rule of Three)

还记得三之法则吗?若类需自定义析构函数、复制构造函数或复制赋值运算符之一,则往往三者皆需。原因如上:当涉及动态内存时,复制构造函数与复制赋值运算符需执行深拷贝,析构函数则负责释放内存。

更优方案

标准库中管理动态内存的类,如 std::string 与 std::vector,已封装所有内存管理细节,并提供了正确执行深拷贝的复制构造函数与赋值运算符。只需像使用基本类型一样初始化或赋值即可!这些类更简洁、更少错误,也免去了自行编写重载函数的麻烦。

总结

  • 默认复制构造函数与默认赋值运算符执行浅拷贝,适用于不含动态分配变量的类。
  • 含动态分配变量的类必须提供执行深拷贝的复制构造函数与赋值运算符。
  • 优先使用标准库中的类,而非自行管理内存。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"