C++ 类和对象入门(三):拷贝构造、赋值运算符重载和深浅拷贝

🔥 星恒随风: 个人主页 ❄️ 个人专栏: 《指针合集》 | 《C语言基础》 | 《数据结构》 | 《机器学习导论》 | 《前端基础》 | 《python基础》 ✨ 数据即知识,压缩即智能
目录
- [C++ 类和对象入门(三):拷贝构造、赋值运算符重载和深浅拷贝](#C++ 类和对象入门(三):拷贝构造、赋值运算符重载和深浅拷贝)
-
- 前言
- 一、拷贝构造函数是什么?
-
- [1.1 拷贝构造的基本概念](#1.1 拷贝构造的基本概念)
- [1.2 为什么参数必须用引用?](#1.2 为什么参数必须用引用?)
- 二、什么时候会调用拷贝构造?
-
- [2.1 用已有对象初始化新对象](#2.1 用已有对象初始化新对象)
- [2.2 另一种初始化写法](#2.2 另一种初始化写法)
- [2.3 传值传参](#2.3 传值传参)
- [2.4 传值返回](#2.4 传值返回)
- 三、默认拷贝构造会做什么?
-
- [3.1 对 Date 这种类一般够用](#3.1 对 Date 这种类一般够用)
- [3.2 对 Stack 这种类不够用](#3.2 对 Stack 这种类不够用)
- 四、浅拷贝的问题
-
- [4.1 两个对象共用一块资源](#4.1 两个对象共用一块资源)
- [4.2 重复释放会导致程序崩溃](#4.2 重复释放会导致程序崩溃)
- 五、深拷贝怎么写?
-
- [5.1 Stack 的拷贝构造](#5.1 Stack 的拷贝构造)
- 六、一个简单判断技巧
-
- [6.1 什么时候需要自己写拷贝构造?](#6.1 什么时候需要自己写拷贝构造?)
- 七、赋值运算符重载是什么?
-
- [7.1 它和拷贝构造很像,但不是一回事](#7.1 它和拷贝构造很像,但不是一回事)
- [7.2 拷贝构造和赋值的核心区别](#7.2 拷贝构造和赋值的核心区别)
- 八、赋值运算符重载怎么写?
-
- [8.1 Date 的赋值运算符重载](#8.1 Date 的赋值运算符重载)
- [8.2 参数为什么用 const 引用?](#8.2 参数为什么用 const 引用?)
- [8.3 为什么返回 Date&?](#8.3 为什么返回 Date&?)
- [8.4 为什么检查自赋值?](#8.4 为什么检查自赋值?)
- 九、赋值运算符也会遇到深浅拷贝问题
-
- [9.1 Stack 的赋值不能只拷贝指针](#9.1 Stack 的赋值不能只拷贝指针)
- 十、运算符重载的基本认识
-
- [10.1 为什么需要运算符重载?](#10.1 为什么需要运算符重载?)
- [10.2 运算符重载的基本形式](#10.2 运算符重载的基本形式)
- 十一、运算符重载的几个规则
-
- [11.1 不能创造新运算符](#11.1 不能创造新运算符)
- [11.2 有些运算符不能重载](#11.2 有些运算符不能重载)
- [11.3 至少有一个参数是类类型](#11.3 至少有一个参数是类类型)
- [11.4 运算符重载要有意义](#11.4 运算符重载要有意义)
- 十二、本文总结
前言
上一篇我们讲了两个非常重要的默认成员函数:
- 构造函数
- 析构函数
构造函数负责对象创建时初始化。
析构函数负责对象销毁前清理资源。
这一篇继续讲另外两个更容易出问题的默认成员函数:
- 拷贝构造函数
- 赋值运算符重载
这两个函数都和"对象拷贝"有关。
这一篇的目标是讲清楚三件事:
第一,什么时候调用拷贝构造?
第二,什么时候调用赋值运算符重载?
第三,为什么有资源管理的类不能依赖默认浅拷贝?
一、拷贝构造函数是什么?
1.1 拷贝构造的基本概念
拷贝构造函数本质上也是构造函数的一种。
它的作用是:
用一个已经存在的同类型对象,初始化一个新对象。
基本写法:
cpp
class Date
{
public:
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
private:
int _year;
int _month;
int _day;
};
这里:
cpp
Date(const Date& d)
就是拷贝构造函数。
它的参数通常写成:
cpp
const Date& d
也就是当前类类型的常引用。
1.2 为什么参数必须用引用?
错误写法:
cpp
Date(Date d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
这会有问题。
原因是:
传值传参本身就需要拷贝对象,而拷贝对象又要调用拷贝构造。
也就是说:
调用拷贝构造需要传参。
传参又要拷贝。
拷贝又要调用拷贝构造。
于是会形成无穷递归。
所以拷贝构造的参数必须使用引用。
通常还会加 const:
cpp
Date(const Date& d)
这样既避免拷贝,又保证不会修改被拷贝对象。
二、什么时候会调用拷贝构造?
2.1 用已有对象初始化新对象
最典型场景:
cpp
Date d1(2024, 4, 14);
Date d2(d1);
这里 d2 是一个新对象。
它用 d1 来初始化。
所以调用拷贝构造。
2.2 另一种初始化写法
下面这种写法也是拷贝构造:
cpp
Date d3 = d1;
虽然中间出现了 =,但这里不是赋值运算符重载。
因为 d3 是一个正在创建的新对象。
所以这句话的本质是:
用 d1 初始化 d3。
调用的是拷贝构造。

2.3 传值传参
如果函数参数按值接收对象,也会调用拷贝构造。
cpp
void Func(Date d)
{
d.Print();
}
Date d1(2024, 4, 14);
Func(d1);
调用 Func(d1) 时,需要把 d1 拷贝一份给形参 d。
因此会调用拷贝构造。
如果对象比较大,或者拷贝成本比较高,通常更推荐传引用:
cpp
void Func(const Date& d)
{
d.Print();
}
这样不会产生对象拷贝。
2.4 传值返回
函数按值返回对象时,也可能涉及拷贝构造。
cpp
Date Func()
{
Date tmp(2024, 4, 14);
return tmp;
}
现代编译器可能会做返回值优化,但从语法理解上,传值返回和对象拷贝关系很密切。
三、默认拷贝构造会做什么?
3.1 对 Date 这种类一般够用
对于:
cpp
class Date
{
private:
int _year;
int _month;
int _day;
};
成员都是内置类型,没有指向额外资源。
编译器自动生成的拷贝构造会按成员逐个拷贝。
例如:
cpp
Date d2(d1);
会把:
cpp
d1._year -> d2._year
d1._month -> d2._month
d1._day -> d2._day
这对 Date 来说通常是够用的。
所以 Date 类可以不自己写拷贝构造。
3.2 对 Stack 这种类不够用
再看 Stack:
cpp
class Stack
{
private:
int* _a;
size_t _capacity;
size_t _top;
};
如果编译器默认拷贝:
cpp
Stack st2 = st1;
它会把 _a 的值也复制过去。
注意,_a 是一个指针。
复制指针的值,复制的是地址。
结果就是:
cpp
st1._a 和 st2._a 指向同一块空间
这就是浅拷贝。
四、浅拷贝的问题
4.1 两个对象共用一块资源
假设:
cpp
Stack st1;
st1.Push(1);
st1.Push(2);
Stack st2 = st1;
如果使用默认拷贝构造,可能出现:
cpp
st1._a == st2._a
也就是说,两个栈对象指向同一块数组空间。
这会导致两个问题。
第一,一个对象修改数据,另一个对象也受影响。
第二,析构时同一块空间会被释放两次。
4.2 重复释放会导致程序崩溃
当函数结束时,st1 和 st2 都会调用析构函数。
如果它们的 _a 指向同一块空间:
cpp
free(st1._a);
free(st2._a);
同一块空间被释放两次,程序很可能崩溃。
所以对于管理资源的类,默认浅拷贝通常是不够的。

五、深拷贝怎么写?
5.1 Stack 的拷贝构造
对于 Stack,正确思路是:
不仅要拷贝指针变量本身,更要重新申请一块空间,把原空间中的数据复制过去。
示例:
cpp
Stack(const Stack& st)
{
_a = (int*)malloc(sizeof(int) * st._capacity);
if (_a == nullptr)
{
perror("malloc fail");
return;
}
memcpy(_a, st._a, sizeof(int) * st._top);
_top = st._top;
_capacity = st._capacity;
}
这样 st1 和 st2 各自拥有独立空间。
cpp
st1._a != st2._a
但是它们保存的数据内容一样。
这就是深拷贝。

六、一个简单判断技巧
6.1 什么时候需要自己写拷贝构造?
可以先记一个经验规则:
如果一个类需要自己写析构函数释放资源,那么它通常也需要自己写拷贝构造。
原因很简单。
你需要自己写析构,说明这个类内部大概率管理了资源。
既然管理了资源,就要考虑对象拷贝时资源怎么处理。
比如 Stack:
- 构造函数申请资源;
- 析构函数释放资源;
- 拷贝构造必须深拷贝资源。
而 Date:
- 没有动态资源;
- 默认拷贝即可;
- 不需要自己写析构和拷贝构造。
七、赋值运算符重载是什么?
7.1 它和拷贝构造很像,但不是一回事
赋值运算符重载用于:
两个已经存在的对象之间进行赋值。
例如:
cpp
Date d1(2024, 4, 14);
Date d2(2025, 1, 1);
d1 = d2;
这里 d1 和 d2 都已经存在。
所以调用的是赋值运算符重载,而不是拷贝构造。
7.2 拷贝构造和赋值的核心区别
看两行代码:
cpp
Date d2 = d1;
d2 = d1;
第一行:
cpp
Date d2 = d1;
d2 正在创建。
所以是拷贝构造。
第二行:
cpp
d2 = d1;
d2 已经存在。
所以是赋值运算符重载。
八、赋值运算符重载怎么写?
8.1 Date 的赋值运算符重载
对于 Date 类,可以这样写:
cpp
Date& operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
这里有几个细节。
8.2 参数为什么用 const 引用?
cpp
const Date& d
原因有两个:
第一,避免传值导致拷贝。
第二,保证函数内部不会修改右侧对象。
8.3 为什么返回 Date&?
返回引用是为了支持连续赋值:
cpp
d1 = d2 = d3;
这个表达式会从右往左执行。
如果 d2 = d3 返回的是 d2 本身,那么 d1 = d2 才能继续执行。
所以赋值运算符通常返回:
cpp
return *this;
*this 表示当前对象本身。
8.4 为什么检查自赋值?
cpp
if (this != &d)
这是为了处理这种情况:
cpp
d1 = d1;
对于 Date 这种简单类,不检查也通常没问题。
但对于管理资源的类,比如 Stack,自赋值如果处理不当,可能先释放自己的资源,再从自己已经释放的资源里拷贝数据。
所以写赋值运算符时,养成自赋值检查习惯是好的。

九、赋值运算符也会遇到深浅拷贝问题
9.1 Stack 的赋值不能只拷贝指针
对于 Stack:
cpp
st1 = st2;
如果只是简单把 _a 的值复制过去,就又会出现两个栈共用一块空间的问题。
所以 Stack 的赋值运算符也应该做深拷贝。
这和拷贝构造的核心原因一样:
指针成员如果指向资源,不能只复制指针值。
十、运算符重载的基本认识
10.1 为什么需要运算符重载?
C++ 允许我们给自定义类型重新定义某些运算符的含义。
比如:
cpp
Date d1(2024, 4, 14);
Date d2(2024, 4, 25);
我们希望可以直接写:
cpp
d1 < d2
d1 == d2
d1 + 100
d2 - d1
这些写法对内置类型很自然。
对于自定义类型,编译器不知道该怎么比较、怎么加减。
所以我们需要通过运算符重载告诉编译器:
对 Date 类型来说,这个运算符应该怎么工作。
10.2 运算符重载的基本形式
运算符重载函数的名字由两部分组成:
cpp
operator
加上要重载的运算符。
例如:
cpp
operator==
operator+
operator-
operator=
operator++
示例:
cpp
bool operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
当我们写:
cpp
d1 == d2;
编译器会转换成类似:
cpp
d1.operator==(d2);

十一、运算符重载的几个规则
11.1 不能创造新运算符
不能写:
cpp
operator@
因为 C++ 语法中没有 @ 这个运算符。
运算符重载只能重载已有的运算符。
11.2 有些运算符不能重载
常见不能重载的运算符包括:
cpp
.*
::
sizeof
?:
.
11.3 至少有一个参数是类类型
不能通过运算符重载改变内置类型的含义。
例如:
cpp
int operator+(int x, int y)
{
return x - y;
}
这种写法是不允许的。
因为它试图改变两个 int 相加的含义。
C++ 不允许这样做。
11.4 运算符重载要有意义
不是所有运算符都适合给所有类重载。
比如 Date 类重载这些就很有意义:
cpp
<
==
+
-
++
--
但如果你给日期重载:
cpp
operator*
operator/
就比较奇怪。
所以运算符重载的原则是:
让代码更自然,而不是让代码更魔幻。
十二、本文总结
这一篇主要讲了拷贝构造、赋值运算符重载和运算符重载基础。
拷贝构造:
- 用已有对象初始化新对象;
- 参数必须使用当前类类型的引用;
- 常写成
const 类名&; - 传值传参、传值返回可能触发拷贝构造。
赋值运算符重载:
- 用于两个已经存在的对象之间赋值;
- 通常返回当前类类型引用;
- 参数通常用
const 类名&; - 要考虑自赋值问题。
深浅拷贝:
- 没有资源管理的类,默认拷贝通常够用;
- 管理动态资源的类,默认浅拷贝容易出问题;
- 如果写了析构释放资源,通常也要考虑拷贝构造和赋值重载。
运算符重载:
- 用于让自定义类型支持自然的运算符写法;
- 不能创造新运算符;
- 有些运算符不能重载;
- 至少要有一个类类型参数;
- 重载应该符合类型本身的语义。
拷贝构造解决"新对象怎么从旧对象来",赋值运算符解决"已有对象之间怎么赋值",深拷贝解决"资源不能只复制地址"的问题。