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 容器、智能指针、移动语义的重要基础。