章节目录

浅拷贝与深拷贝

本节阅读量:

浅拷贝

由于C++不了解用户定义类的具体语义,因此它提供的默认拷贝构造函数和默认赋值运算符会使用一种称为成员级复制(也称为浅拷贝)的复制方法。这意味着C++会复制类的每个成员(对重载运算符=使用赋值运算符,对拷贝构造函数使用直接初始化)。当类很简单时(例如不包含任何动态分配的内存),这种方式工作得很好。

例如,让我们来看一下Fraction类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#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;
}

编译器为此类提供的默认拷贝构造函数和默认赋值运算符如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#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;
    }
};

请注意,因为这些默认版本已经可以很好地复制这个类,所以在这种情况下,确实没有理由为这些函数编写自定义版本。

然而,在设计处理动态分配内存的类时,成员级(浅层)复制会带来许多麻烦!这是因为复制指针只会复制指针保存的地址,并不会分配任何内存,也不会复制指针所指向的内容!

让我们看一个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <cstring> // for strlen()
#include <cassert> // for assert()

class MyString
{
private:
    char* m_data{};
    int m_length{};
 
public:
    MyString(const char* source = "" )
    {
        assert(source); // 确保source不是空指针

        // 查看 string 的长度
        // 再加一个结尾的终止符
        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; }
};

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

1
2
3
4
5
MyString::MyString(const MyString& source)
    : m_length { source.m_length }
    , m_data { source.m_data }
{
}

请注意,m_data只是source.m_data的指针副本,这意味着它们现在都指向同一块内存。

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <iostream>

int main()
{
    MyString hello{ "Hello, world!" };
    {
        MyString copy{ hello }; // 使用默认的拷贝构造函数
    } // copy 是局部变量, 在这里销毁. 析构函数删除copy内的 m_data, 会导致 hello对象内的 m_data现在是一个悬空指针

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

    return 0;
}

虽然这段代码看起来很无害,但它包含一个潜在问题,会导致程序出现未定义行为!

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

1
    MyString hello{ "Hello, world!" };

这一行是无害的。它会调用MyString构造函数,构造函数分配一些内存,将hello.m_data设置为指向这块内存,然后将字符串“Hello, world!”复制进去。

1
    MyString copy{ hello }; // 使用默认的拷贝构造函数

这一行看起来也很无害,但它实际上是问题的根源!运行这一行时,C++会使用默认拷贝构造函数(因为我们没有提供自己的版本)。这个拷贝构造函数会执行浅拷贝,将copy.m_data初始化为与hello.m_data相同的地址。因此,copy.m_data和hello.m_data现在都指向同一块内存!

1
} // copy 是局部变量, 在这里销毁

当copy超出作用域时,会调用MyString析构函数。析构函数会删除copy.m_data和hello.m_data共同指向的动态分配内存!因此,删除copy时,我们也(无意中)影响了hello。变量copy随后被销毁,但hello.m_data仍然指向已经删除的无效内存!

1
    std::cout << hello.getString() << '\n'; // 这里会导致未定义的行为

现在您可以看到为什么该程序会产生未定义行为:我们删除了hello指向的字符串,却又试图打印那块已经无效的内存。

这个问题的根源是拷贝构造函数执行了浅拷贝。在拷贝构造函数或重载赋值运算符中对指针值进行浅拷贝,几乎总是会带来麻烦。


深拷贝

解决这个问题的一种方法,是对正在复制的任何非空指针进行深拷贝。深拷贝会为副本分配内存,然后复制实际值,使副本位于与源对象不同的内存中。这样,副本和源对象就是相互独立的,不会彼此影响。进行深拷贝需要我们编写自己的拷贝构造函数和重载赋值运算符。

让我们继续展示如何为MyString类完成这一点:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 假设 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);
}

正如您所看到的,这比简单的浅拷贝复杂得多!首先,我们必须检查source是否有值。如果有值,就分配足够的内存来保存该字符串的副本。最后,还必须手动复制字符串。

现在,让我们看看重载赋值运算符。重载赋值运算符稍微复杂一些:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 赋值运算符
MyString& MyString::operator=(const MyString& source)
{
    // 做自赋值检查
    if (this != &source)
    {
        // 执行深拷贝
        deepCopy(source);
    }

    return *this;
}

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

  1. 添加了自我赋值检查。
  2. 返回*this,这样就可以链接赋值运算符。
  3. 需要显式地释放字符串已经持有的任何值(以便在稍后重新分配m_data时不会出现内存泄漏)。这在deepCopy()中处理。

调用重载赋值运算符时,被赋值对象可能已经包含旧值,因此需要确保在为新值分配内存之前先清理旧值。对于非动态分配的变量(大小固定),我们不必担心,因为新值会直接覆盖旧值。然而,对于动态分配的变量,需要在分配新内存之前显式释放旧内存。如果不这样做,代码未必会崩溃,但会发生内存泄漏,每次赋值都会吞掉一部分内存!


类的构造析构与赋值

如果类需要用户定义析构函数、拷贝构造函数或拷贝赋值运算符中的一个,那么它很可能需要全部三个。为什么?如果用户定义这些函数中的任何一个,通常是因为类正在处理动态内存分配。拷贝构造函数和拷贝赋值用于处理深拷贝,析构函数用于释放内存。


更好的解决方案

标准库中有处理动态内存的类,如std::string和std::vector。它们负责管理自己持有的内存,并提供执行适当深拷贝的拷贝构造函数和重载赋值运算符。因此,您可以像使用普通基本变量一样初始化或赋值它们,而不必自己管理内存!这让这些类更易于使用,也更不容易出错,并且您不必花时间编写自己的重载函数!


总结

  1. 默认拷贝构造函数和默认赋值运算符执行浅拷贝,这对于不包含动态分配变量的类很好。
  2. 具有动态分配变量的类需要提供拷贝构造函数和赋值运算符来执行深拷贝。
  3. 与自己进行内存管理相比,优先使用标准库中的类。

21.11 重载赋值运算符

上一节

21.13 重载运算符和函数模板

下一节