← 返回文章列表

C++ 学习(五)|拷贝构造、赋值运算符重载与对象复制——从浅拷贝到深拷贝,理解类的资源管理

本文是《从零开始学 C++》系列第五篇,承接上篇构造与析构函数的对象生命周期知识,聚焦对象复制、赋值与资源管理。讲解拷贝构造、赋值运算符重载,剖析浅拷贝带来的二次释放问题,使用深拷贝实现动态资源隔离。通过日期类实践,拓展比较、加法运算符重载与 const 成员函数,梳理类的六大默认成员函数。对比 C 语言手动管理资源的方式,体会 C++ 面向对象封装思想,为后续 STL 容器、移动语义学习打下基础。

写在前面

上一篇《从零开始学 C++(四):构造函数与析构函数》解决了对象从创建到销毁的完整生命周期问题。 这一篇进一步解决对象之间的核心问题:对象如何复制、如何赋值、如何管理内部资源。

从默认拷贝构造的浅拷贝问题切入,深入讲解拷贝构造函数、赋值运算符重载、深拷贝实现,补充日期类运算符重载、const 成员函数、取地址重载,最后梳理六大默认成员函数,建立类的资源管理完整认知。


一、为什么需要拷贝构造?

1.1 对象复制的本质问题

对于普通内置类型,复制非常直观:

int a = 10;
int b = a;

系统直接复制数值,两个变量各自独立,互不影响。

但对于自定义类对象,复制就没有这么简单。 对于只有普通成员的日期类:

class Date {
private:
    int _year;
    int _month;
    int _day;
};

执行 Date d2 = d1; 时,本质是把成员变量逐个复制,这种场景下看起来没有问题。

但如果类中管理了动态资源,比如:

class Stack {
private:
    int* _a;
    int _capacity;
    int _top;
};

如果只是简单复制指针的值,就会出现:

对象1._a → 地址0x1000 → 同一块动态数组
对象2._a → 地址0x1000 → 同一块动态数组

两个对象共同管理同一块内存资源,析构时就会出现重复释放、内存篡改等一系列问题。

正是为了规范对象复制的行为,C++ 提供了专门的拷贝构造函数。


二、拷贝构造函数

2.1 概念与语法

拷贝构造函数的作用:使用一个已经存在的对象,初始化另一个新对象。

基本格式:

类名(const 类名& 对象名)
{
    // 拷贝逻辑
}

日期类示例:

class Date {
public:
    Date(int year = 2000, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }

    // 拷贝构造函数
    Date(const Date& d)
    {
        _year = d._year;
        _month = d._month;
        _day = d._day;
    }
private:
    int _year;
    int _month;
    int _day;
};

调用场景:

Date d1(2026, 10, 14);
Date d2 = d1;   // 创建新对象d2,用d1初始化,调用拷贝构造

为什么参数必须是 const 引用?

如果写成 Date(Date d) 按值传参,调用拷贝构造时需要先把实参 d1 复制给形参 d,而复制形参又要调用拷贝构造,从而形成无限递归。

因此拷贝构造的参数必须是引用,加上 const 保证不修改原对象,是标准写法。

2.2 默认拷贝构造

如果我们没有手动编写拷贝构造函数,编译器会自动生成一个默认拷贝构造函数,行为是:按成员逐个复制值,也就是浅拷贝。

对于只有 int、double 等内置类型成员、不管理外部资源的简单类,默认拷贝构造完全够用。 但如果类持有动态内存、文件句柄、网络连接等资源,默认浅拷贝就会出现严重问题。


三、浅拷贝与深拷贝

3.1 浅拷贝的问题

默认拷贝构造只复制成员变量本身的值,对于指针成员,只复制指针地址,不复制指针指向的资源,这就是浅拷贝。

以动态栈为例,浅拷贝后两个对象的 _a 指向同一块堆内存:

  • 二次释放:两个对象析构时,同一块内存被 delete 两次,导致程序崩溃;
  • 数据篡改:一个对象修改数组内容,另一个对象的数据同步变化;
  • 野指针:一方释放资源后,另一方指针悬空,访问非法内存。

3.2 深拷贝实现

深拷贝:不只是复制指针地址,而是重新申请独立的内存空间,再把资源内容完整复制过去,让两个对象拥有各自独立的资源。

深拷贝版栈的拷贝构造:

Stack(const Stack& st)
{
    _a = new int[st._capacity];
    memcpy(_a, st._a, sizeof(int) * st._size);
    _capacity = st._capacity;
    _top = st._top;
}

深拷贝后:

st1._a → 地址0x1000 → 数组1
st2._a → 地址0x2000 → 数组2(内容相同,空间独立)

两个对象生命周期完全独立,各自管理自己的资源,析构时分别释放,不会出现重复释放问题。


四、赋值运算符重载

4.1 拷贝构造 vs 赋值:发生时机不同

很多初学者容易混淆二者,核心区别在于发生的阶段不同:

操作类型适用场景调用的函数
拷贝构造创建新对象时,用已有对象初始化新对象拷贝构造函数
赋值运算符两个对象都已存在,用一个对象覆盖另一个对象operator= 赋值运算符重载

简单区分:

  • Date d2 = d1; → 拷贝构造(d2 是正在创建的新对象)
  • d2 = d1; → 赋值运算符重载(d2 是已经存在的对象)

4.2 赋值运算符重载实现

和默认拷贝构造一样,如果不手动重载赋值运算符,编译器也会生成默认版本,行为同样是逐个成员赋值,对于资源类同样造成浅拷贝问题。

赋值运算符重载基本格式:

返回类型 operator符号(参数列表)
{
    // 实现逻辑
}

完整的深拷贝版赋值运算符:

Stack& operator=(const Stack& st)
{
    // 自赋值保护:防止自己赋值给自己
    if (this != &st)
    {
        // 先释放自身旧资源
        delete[] _a;

        // 申请新空间 + 拷贝内容
        _a = new int[st._capacity];
        memcpy(_a, st._a, sizeof(int) * st._size);
        _capacity = st._capacity;
        _top = st._top;
    }
    return *this;
}

两个关键细节:

  • 为什么返回引用? 赋值支持链式写法:a = b = c;,执行顺序为 a = (b = c)。因此赋值运算必须返回当前对象的引用,才能支持链式赋值,同时避免返回值拷贝开销。
  • 为什么判断 this != &st? 如果出现自赋值 st1 = st1;,没有判断的话会先释放自己的资源,再访问时就会访问已经释放的内存。自赋值保护是赋值运算符重载的标准范式。

五、日期类完整实践:运算符重载扩展

前面用栈讲清了资源复制,这里用日期类补充更多运算符重载的用法,理解运算符重载的通用逻辑。

5.1 相等与比较运算符重载

重载 == 判断两个日期是否相等:

bool operator==(const Date& d) const
{
    return _year == d._year
        && _month == d._month
        && _day == d._day;
}

重载 ` 有了 == 和 、=,也可以对应重载;C++20 也有三路比较运算符进一步简化。

5.2 日期加法运算符重载

重载 + 实现日期加天数:

Date operator+(int days) const
{
    Date res = *this;
    res._day += days;

    // 日期进位处理(简化示例)
    while (res._day > 当月天数)
    {
        res._day -= 当月天数;
        res._month++;
        if (res._month > 12)
        {
            res._month = 1;
            res._year++;
        }
    }
    return res;
}

完整实现需要结合月份天数、闰年判断进行进位处理,这里重点展示运算符重载的结构。


六、const 成员函数

如果对象是 const 限定的:

const Date d(2026, 10, 14);

那么普通成员函数是不能调用的。

原因:成员函数隐含了 this 指针参数:

  • 普通成员函数:Date* const this,指向非常量对象;
  • const 对象的 this 是 const Date*,类型不匹配。

因此需要const 成员函数,保证函数内部不会修改成员变量:

void Print() const
{
    cout << _year << "-" << _month << "-" << _day << endl;
}

const 成员函数可以被普通对象和 const 对象共同调用,只要是只读访问的函数,都应该声明为 const 成员函数。


七、取地址运算符重载

普通情况下,编译器会自动生成默认的取地址运算符重载,所以我们直接对对象取地址 &d 是可以正常工作的:

Date d;
cout << &d << endl;  // 调用默认 operator&,返回对象地址

我们也可以自定义取地址运算符:

Date* operator&()
{
    return this;
}

对应还有 const 版本的取地址重载。实际开发中极少自定义取地址运算符,默认行为通常就能满足需求。


八、默认成员函数总览

到这里,可以完整梳理一个类的六大默认成员函数,即使我们不写,编译器也会按规则生成对应版本:

默认成员函数核心作用
构造函数对象创建时完成初始化
析构函数对象销毁时释放内部资源
拷贝构造函数用已有对象初始化新对象
赋值运算符重载已有对象之间赋值覆盖
取地址运算符重载获取普通对象的地址
const 取地址运算符重载获取 const 对象的地址

这六个函数构成了类对象生命周期与交互的基础框架。如果类管理了动态资源,拷贝构造、赋值运算符、析构函数通常都需要自己手动实现深拷贝版本。


九、从 C 到 C++:资源管理的封装思想

对比 C 和 C++ 实现栈的差异,可以直观看到面向对象封装带来的变化。

C 语言风格:

typedef struct Stack {
    int* a;
    int top;
    int capacity;
} ST;

void STInit(ST* ps);
void STDestroy(ST* ps);
// ... 操作函数

资源靠独立的 Init/Destroy 函数手动管理,依赖程序员自觉调用。

C++ 面向对象风格:

class Stack {
public:
    Stack();
    ~Stack();
    Stack(const Stack&);
    Stack& operator=(const Stack&);
    // ... 操作接口
private:
    int* _a;
    int _top;
    int _capacity;
};

资源的初始化、销毁、复制、赋值都和对象生命周期绑定,由类自动管理,程序员只需要使用对象,不需要手动跟踪资源生命周期。

这就是面向对象封装思想在资源管理上的体现:把数据和操作数据的方法封装在一起,把资源的生命周期和对象的生命周期绑定在一起。


十、写在最后

本篇围绕对象复制与资源管理,解决了三个核心问题:

  • 创建新对象时怎么复制? → 拷贝构造函数,参数必须是 const 引用,默认浅拷贝,有资源要深拷贝。
  • 已有对象之间怎么赋值? → 赋值运算符重载,先释放旧资源,再拷贝新资源,加自赋值保护,返回引用支持链式调用。
  • 管理动态资源怎么办? → 深拷贝,让每个对象拥有独立的资源,避免二次释放、野指针问题。

同时补充了日期类比较、算术运算符重载,const 成员函数、取地址重载,梳理了六大默认成员函数。

结合上一篇构造析构,C++ 对象的完整生命周期已经形成:

创建 → 构造函数初始化 → 使用 → 拷贝/赋值 → 析构函数释放资源

这也是 C++ 的核心特点之一:它不仅提供类与封装,更要求开发者理解对象背后的资源如何管理、如何复制、如何转移,这也是后续学习 STL 容器、智能指针、移动语义的重要基础。