系列文章目录

构造函数超详解(特性+类型+初始化方式+易错避坑)

析构函数超详解(误区、语法、调用时机、析构顺序)

拷贝构造函数超详解(浅拷贝、深拷贝、拷贝构造 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;
};

在这里插入图片描述
递归原理详解:

  1. 当我们调用 Date d2(d1) 时,要把d1传给形参d
  2. C++ 规定:自定义类型传值传参,必须调用拷贝构造函数来生成形参副本;
  3. 于是为了生成形参d,又要调用拷贝构造函数;
  4. 新的拷贝构造又要传值,又要生成新副本…… 无限循环下去,永远停不下来。

就像 “复印一份文件,复印前必须先复印一份原稿”,永远卡在第一步。

结论:拷贝构造第一个参数必须是引用,通常加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. 默认拷贝构造的行为规则

分两种成员变量处理:

  1. 内置类型成员(int、char、指针等):执行值拷贝 / 浅拷贝,也就是逐字节把源对象的值复制过去;
  2. 自定义类型成员(类里嵌套其他类对象):自动调用这个成员自身的拷贝构造函数。

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; 
    // 程序崩溃!!!
}

在这里插入图片描述
崩溃原因:
通过查看监视窗口,我们可以看到
在这里插入图片描述

浅拷贝只拷贝了指针的值,没有拷贝指针指向的资源:

  1. st1._a 指向地址 0x000002800e4bab80的堆内存;
  2. 浅拷贝后,st2._a 也等于 0x000002800e4bab80,两个对象的指针指向同一块堆内存;
  3. 函数结束时,先析构st2:执行free(_a),释放0x000002800e4bab80这块内存;
  4. 再析构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._ast2._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在函数结束时就销毁了,返回的引用就成了 “野引用”(类似野指针),访问会导致未定义行为。


总结

下一篇我们将讲解赋值运算符重载,敬请关注,谢谢!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐