【C++初阶】拷贝构造函数超详解(浅拷贝、深拷贝、拷贝构造 vs 赋值运算符重载、易错点)
系列文章目录
拷贝构造函数超详解(浅拷贝、深拷贝、拷贝构造 vs 赋值运算符重载、易错点)
赋值运算符重载 operator=超详解(深浅拷贝、自赋值判断、连续赋值原理、三法则、完整示例代码)
【C++初阶】const 成员函数 & 取地址运算符重载超详解(六大默认成员函数收官篇)
文章目录
前言
前面我们已经讲完了构造函数(对象出生初始化)和析构函数(对象销毁清资源),今天我们来学习六大默认成员函数的第三个 ——拷贝构造函数。
什么是拷贝构造?通俗来说,就是用一个已经存在的对象,快速 “克隆” 出一个一模一样的新对象。就像复印文件、复制 U 盘文件,不用重新从头创建,直接照着已有对象生成副本。
C++编译器虽然会自动生成拷贝构造函数,但针对不同情况下,会有不同结果:
- 简单类(全是内置类型,比如Date日期类,全是
int类型),编译器自动生成的拷贝就能用; - 资源类(持有堆内存,比如Stack栈类,需要开辟空间资源),编译器自动生成的默认拷贝会直接导致程序崩溃,必须手动实现深拷贝。
一、什么是拷贝构造函数?
1. 通俗理解
把类比作 “蛋糕模具”,对象就是 “烤好的蛋糕”。
- 普通构造函数:用原材料从头做一个新蛋糕;
- 拷贝构造函数:拿一个现成蛋糕,直接复刻出一个一模一样的新蛋糕。
拷贝构造函数是特殊的构造函数,专门用于:用同类型的一个已有对象,去初始化一个新对象。
2. 标准语法格式
class 类名
{
public:
// 拷贝构造函数:第一个参数必须是 同类型的const引用
类名(const 类名& 源对象)
{
// 拷贝成员变量的逻辑
}
};
以日期类Date为例,完整写法:
#include <iostream>
using namespace std;
class Date
{
public:
// 普通构造函数(全缺省)
Date(int year = 1, 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;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
3. 提问:为什么第一个参数必须是引用?(重点)
因为:拷贝构造函数如果传值调用,会引发无穷递归,直接编译报错。
错误示例:
#include <iostream>
using namespace std;
class Date
{
public:
// 普通构造函数(全缺省)
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
// 编译直接报错:非法的复制构造函数,第一个参数不应是Date
Date(Date d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};

递归原理详解:
- 当我们调用
Date d2(d1)时,要把d1传给形参d; - C++ 规定:自定义类型传值传参,必须调用拷贝构造函数来生成形参副本;
- 于是为了生成形参
d,又要调用拷贝构造函数; - 新的拷贝构造又要传值,又要生成新副本…… 无限循环下去,永远停不下来。
就像 “复印一份文件,复印前必须先复印一份原稿”,永远卡在第一步。
结论:拷贝构造第一个参数必须是引用,通常加const修饰(保证不修改源对象,更安全)。
二、拷贝构造的 3 种调用场景
场景 1:用已有对象初始化新对象
这是最常见的场景,有两种写法,本质完全一样:
// Date日期类
class Date{
// 略
}
int main()
{
Date d1(2026, 6, 10);
// 写法1:括号形式
Date d2(d1);
// 写法2:赋值形式(注意:这是拷贝构造,不是赋值重载!)
Date d3 = d1;
d1.Print();
d2.Print();
d3.Print();
return 0;
}
高频错误:Date d3 = d1; 是定义 + 初始化,调用拷贝构造;不是两个已有对象赋值,这点后面会详细区分。
场景 2:自定义类型传值传参
函数参数是自定义类型,且采用传值传递时,会调用拷贝构造生成形参副本。
// Date日期类
class Date{
// 略
}
// 传值传参:会调用拷贝构造
void Func1(Date d)
{
d.Print();
}
int main()
{
Date d1(2026, 6, 10);
Func1(d1); // 实参d1拷贝给形参d,调用拷贝构造
return 0;
}
如果改成传引用传参,就不会调用拷贝构造,能减少拷贝开销,提高效率。这也是为什么类对象推荐传引用的原因。
场景 3:函数传值返回对象
函数返回值是自定义类型,且采用传值返回时,会生成临时对象,调用拷贝构造。
// Date日期类
class Date{
// 略
}
// 传值返回:会调用拷贝构造生成临时对象
Date Func2()
{
Date tmp(2024, 7, 5);
return tmp; // 返回tmp,拷贝构造生成临时对象
}
int main()
{
Date ret = Func2(); // 临时对象拷贝给ret
return 0;
}
拓展:现代编译器会做返回值优化(RVO),减少不必要的拷贝,但语法逻辑上依然符合拷贝构造的规则。
三、编译器默认拷贝构造:浅拷贝
和构造、析构一样,如果我们不手动写拷贝构造函数,编译器会自动生成一个默认拷贝构造函数。
1. 默认拷贝构造的行为规则
分两种成员变量处理:
- 内置类型成员(int、char、指针等):执行值拷贝 / 浅拷贝,也就是逐字节把源对象的值复制过去;
- 自定义类型成员(类里嵌套其他类对象):自动调用这个成员自身的拷贝构造函数。
2. 什么时候默认拷贝够用?
如果一个类的成员全是内置类型,且没有指向外部资源(比如堆内存),那么默认拷贝构造完全够用,不需要我们手动写。
典型例子就是经常举例用的Date类:
class Date
{
private:
int _year; // 内置类型
int _month; // 内置类型
int _day; // 内置类型
};
三个成员都是普通 int,没有指针、没有堆内存,浅拷贝完全没问题,编译器自动生成的拷贝构造就能完美工作。
四、深浅拷贝大坑:Stack 类崩溃经典案例
这是拷贝构造最核心、最容易出 Bug的部分。
1. 先看问题:浅拷贝为什么会崩溃?
我们用Stack类举例:栈类里有一个指针_a,指向堆上开辟的数组空间。
如果我们不写拷贝构造,用编译器默认的浅拷贝:
#include <iostream>
#include <cstdlib>
#include <cstring>
using namespace std;
typedef int STDataType;
class Stack
{
public:
// 构造函数:申请堆内存
Stack(int n = 4)
{
_a = (STDataType*)malloc(sizeof(STDataType) * n);
if (nullptr == _a)
{
perror("malloc申请空间失败");
exit(1);
}
_capacity = n;
_top = 0;
}
// 析构函数:释放堆内存
~Stack()
{
cout << "~Stack() 释放内存" << endl;
free(_a);
_a = nullptr;
_capacity = 0;
_top = 0;
}
// 尾部插入函数
void Push(STDataType x)
{
_a[_top++] = x;
}
private:
STDataType* _a; // 指向堆内存的指针
size_t _capacity;
size_t _top;
};
int main()
{
Stack st1;
st1.Push(1);
st1.Push(2);
// 使用默认拷贝构造:浅拷贝
Stack st2 = st1;
return 0;
// 程序崩溃!!!
}

崩溃原因:
通过查看监视窗口,我们可以看到
浅拷贝只拷贝了指针的值,没有拷贝指针指向的资源:
st1._a指向地址0x000002800e4bab80的堆内存;- 浅拷贝后,
st2._a也等于0x000002800e4bab80,两个对象的指针指向同一块堆内存; - 函数结束时,先析构
st2:执行free(_a),释放0x000002800e4bab80这块内存; - 再析构
st1:再次执行free(_a),对已经释放的内存重复释放,导致程序直接崩溃。
2. 解决方案:深拷贝
既然浅拷贝只拷指针不行,那我们就重新开辟一块同样大的堆内存,把数据完整复制过去,让两个对象各自拥有独立的资源,这就是深拷贝。
手动实现深拷贝构造函数
#include <iostream>
#include <cstdlib>
#include <cstring>
using namespace std;
typedef int STDataType;
class Stack
{
public:
// 构造函数:申请堆内存
Stack(int n = 4)
{
_a = (STDataType*)malloc(sizeof(STDataType) * n);
if (nullptr == _a)
{
perror("malloc申请空间失败");
exit(1);
}
_capacity = n;
_top = 0;
}
// 深拷贝构造函数
Stack(const Stack& st)
{
// 1. 重新开辟一块独立的堆内存
_a = (STDataType*)malloc(sizeof(STDataType) * st._capacity);
if (nullptr == _a)
{
perror("malloc申请空间失败!!!");
return;
}
// 2. 把源对象的数据完整拷贝过来
memcpy(_a, st._a, sizeof(STDataType) * st._top);
// 3. 拷贝其他成员
_top = st._top;
_capacity = st._capacity;
}
// 析构函数:释放堆内存
~Stack()
{
cout << "~Stack() 释放内存" << endl;
free(_a);
_a = nullptr;
_capacity = 0;
_top = 0;
}
// 尾部插入函数
void Push(STDataType x)
{
_a[_top++] = x;
}
private:
STDataType* _a; // 指向堆内存的指针
size_t _capacity;
size_t _top;
};
int main()
{
Stack st1;
st1.Push(1);
st1.Push(2);
// 使用深拷贝构造
Stack st2 = st1;
return 0;
}
再运行:

st1._a和st2._a指向不同的堆内存;- 两个对象析构时各自释放自己的内存,互不干扰;
- 程序正常运行。
3. 嵌套类的情况:不用写拷贝构造
我们经常做题的同学应该会接触过用两个栈实现队列的题目。
这是我的解题过程:
那为什么我在这里面没有给MyQueue类写拷贝构造函数,依旧可以正常运行呢?
原因是:编译器给MyQueue生成的默认拷贝构造,会自动调用 Stack 的拷贝构造;只要Stack自己实现了深拷贝,MyQueue的拷贝就是安全的。
解题小技巧:如果一个类显式实现了析构并释放资源,那么它就需要显式写拷贝构造;否则就不需要。
五、新手最容易搞混:拷贝构造 vs 赋值运算符重载
很多人分不清 “拷贝构造” 和 “赋值重载”,这里一句话讲透:
拷贝构造:创建新对象时,用已有对象初始化它
赋值重载:两个对象都已经存在,把一个的值赋给另一个。
代码对比:
int main()
{
Date d1(2026, 6, 10);
// 1. 拷贝构造:d2是新创建的对象
Date d2 = d1;
Date d3(2025, 1, 1);
// 2. 赋值重载:d3已经存在,把d1的值覆盖给d3
d3 = d1;
return 0;
}
赋值运算符重载也是六大默认成员函数之一,我们下一篇会详细讲解,这里先记住核心区别即可。
六、高频易错点汇总
易错点 1:指针参数的构造不是拷贝构造
// 这是普通构造函数,不是拷贝构造!
Date(Date* d)
{
_year = d->_year;
}
拷贝构造要求第一个参数是同类型对象的引用,传指针的只是普通构造,不算拷贝构造。
易错点 2:返回局部对象的引用(野引用)
Date& Func2()
{
Date tmp(2024, 7, 5);
return tmp; // 错误:返回局部对象的引用
}
局部对象tmp在函数结束时就销毁了,返回的引用就成了 “野引用”(类似野指针),访问会导致未定义行为。
总结
下一篇我们将讲解赋值运算符重载,敬请关注,谢谢!
更多推荐


所有评论(0)