前言
在 C++ 面向对象编程中,“类与对象” 是核心基石,而默认成员函数则是编译器为类自动生成的 “隐藏助手”—— 即使你没显式写,它们也在默默工作。本文将从实际开发角度拆解类的 6 个默认成员函数,帮你搞懂 “为什么需要它们”“怎么用” 以及 “踩坑点在哪”。同时友情提示一下:这一篇的深拷贝可以先不管,我是学完后面的回来写博客,所以随便实现了一下,我在 STL 的底层实现的时候会具体细致地讲。
一、什么是默认成员函数?
如果一个类中什么成员都没写(空类),编译器会自动生成 6 个默认成员函数。它们的核心作用是处理对象的 “初始化、清理、拷贝、赋值、取地址” 等基础操作。
空类class Date {};看似空,实则包含以下 6 个默认成员函数:
| 函数类型 | 核心作用 | 显式实现场景 |
|---|---|---|
| 构造函数 | 对象创建时初始化 | 需自定义初始化逻辑(如日期) |
| 析构函数 | 对象销毁时清理资源 | 类内有动态内存(如栈、链表) |
| 拷贝构造函数 | 用已有对象创建新对象(Date d2(d1)) | 涉及资源拷贝(避免浅拷贝) |
| 赋值运算符重载 | 对象间赋值(d2 = d1) | 涉及资源赋值(避免浅拷贝) |
| 普通取地址重载 | 获取普通对象地址 | 几乎不用(默认实现足够) |
const取地址重载 | 获取const对象地址 | 几乎不用(默认实现足够) |
需要注意的是这 6 个中最重要的是前 4 个。最后两个取地址重载不重要,我们稍微了解一下即可。其次就是 C++11 以后还会增加两个默认成员函数,移动构造和移动赋值,这个我们后面再讲解。默认成员函数很重要,也比较复杂,我们要从两个方面去学习:
第一:我们不写时,编译器默认生成的函数行为是什么,是否满足我们的需求。
第二:编译器默认生成的函数不满足我们的需求,我们需要自己实现,那么如何自己实现?
二、构造函数
2.1 构造函数的定义
每次创建对象时,编译器都会自动调用一次构造函数 —— 它的核心不是 “创建对象”(开空间由编译器负责),而是 “给对象的成员变量赋初始值”,避免成员变量出现随机值。
构造函数的本质是要替代我们以前写的Init函数的功能,构造函数自动调用的特点就完美的替代了Init。
2.2 构造函数特性与创建
- 函数名与类名相同。
- 无返回值。(返回值啥都不需要给,也不需要写
void,不要纠结,C++ 规定如此) - 构造函数可以重载(无参、带参、全缺省都可)。
classDate{public:// 1. 无参构造(编译器默认生成的就是类似无参版本)Date(){_year=1900;_month=1;_day=1;}// 2. 带参构造(重载版本,支持自定义初始化)Date(intyear,intmonth,intday){_year=year;_month=month;_day=day;}private:int_year;int_month;int_day;};// 使用示例intmain(){Date d1;// d1实例化时自动调用无参构造Dated2(2024,5,20);// 调用带参构造// Date d3(); // 错误!这是声明函数,不是创建对象return0;}写到这里我们已经发现构造函数的优势了,但是还可以结合我们的缺省值这个知识点,我们可以只写一个来替代我们刚刚写的那两个构造函数,请看全缺省构造函数:
Date(intyear=2025,intmonth=2,intday=20){_year=year;_month=month;_day=day;}瞬间减少了我们的代码量。但是这个全缺省的构造函数和无参的构造函数不能同时出现,因为这样编译器就不知道调用哪一个了。
2.3 编译器默认生成的构造函数
如果类中没有显式定义构造函数,则 C++ 编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
我们先来看看编译器生成的
classDate{public:voidprint(){cout<<_year<<"-"<<_month<<"-"<<_day<<endl;}private:int_year;int_month;int_day;};intmain(){Date d1;// d1实例化时自动调用默认生成的无参构造d1.print();return0;}
我们发现好像编译器生成的默认构造函数,好像啥也没干,还是随机值,那它有啥用?别急,我们继续看
classTime{public:// Time的默认构造Time(){cout<<"Time()被调用"<<endl;_hour=0;_minute=0;_second=0;}private:int_hour,_minute,_second;};classDate{public:voidprint(){cout<<_year<<"-"<<_month<<"-"<<_day<<endl;}private:int_year;int_month;int_day;Time _t;};intmain(){Date d1;// d1实例化时自动调用默认生成的无参构造d1.print();return0;}
我们发现系统生成的默认构造函数会调用 “自定义类型成员”(Time)的默认构造函数。也就是它没给内置类型(int/char等)赋初始值,但是会调用 “自定义类型成员” 的默认构造函数。
注意:
- 再一次强调,无参构造、全缺省构造、编译器默认生成的构造函数,都属于 “默认构造函数”—— 它们的共同点是 “调用时不用传参”。一个类中只能有一个默认构造函数,否则会冲突:
三、析构函数
3.1 析构函数的定义
通过前面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么没的呢?
析构函数与构造函数功能相反,析构函数不是完成对对象本身的销毁,
C++ 规定对象在销毁时(生命周期结束)会自动调用析构函数,完成对象中资源的清理释放工作。析构函数的功能类比我们之前的Destroy功能。
- 为什么析构函数不是完成对对象本身的销毁
因为对象是存在栈帧,只有函数结束,栈帧销毁并释放
3.2 析构函数特性与创建
- 函数名 = ~(有点像取反的意思)类名,无参数、无返回值;
- 一个类只能有一个析构函数(不能重载);
- 若未显式实现,编译器会生成默认析构函数。
3.3 编译器默认析构函数
和默认构造类似,默认析构函数会调用 “自定义类型成员” 的析构函数,而内置类型成员无需清理(内存由编译器回收):
classTime{public:~Time(){cout<<"~Time()被调用"<<endl;}private:int_hour,_minute,_second;};classDate{private:int_year=1970;// 内置类型,默认析构不处理Time _t;// 自定义类型,默认析构调用~Time()};intmain(){Date d;// 程序结束时,输出"~Time()被调用"return0;}3.4 什么时候要显式写析构函数?
- 没有空间申请的类不需要析构函数
- 比如只有内置类型的,因为它们没有在堆上开辟空间,都是在栈上开空间出来,它们出了函数的作用域会自动销毁,把空间还给操作系统。
日期类:
classDate{public://…………………private:int_year;int_month;int_day;};日期类都是内置类型我们就可以不用写
栈类的析构函数
typedefintDataType;classStack{public:// 构造函数:申请动态内存Stack(size_t capacity=4){_array=(DataType*)malloc(sizeof(DataType)*capacity);if(_array==nullptr){perror("malloc失败");return;}_capacity=capacity;_size=0;}// 析构函数:释放动态内存(必须显式写!)~Stack(){if(_array!=nullptr){free(_array);// 释放内存_array=nullptr;// 避免野指针_capacity=0;_size=0;}}private:DataType*_array;// 动态内存指针size_t _capacity;size_t _size;};// 使用示例voidTestStack(){Stack s;// 创建对象,调用构造s.Push(1);s.Push(2);}// s出作用域,自动调用析构,释放内存因为像栈这种它有动态开辟内存在堆上的,系统生成的它不会去清理堆上的,就不够用了,我们要显式写析构函数。
3.5 总结
- 对象生命周期结束,系统会自动调用默认的析构函数。
- 有资源申请的类需要写析构函数。
- 有多个对象需要析构的时候后创建的先析构。因为栈帧后进先出。
四、拷贝构造函数
4.1 拷贝构造函数的定义
用一个已存在的对象创建新对象时(如Date d2(d1)),编译器会自动调用拷贝构造函数。也就是说拷贝构造是一个特殊的构造函数。它的核心是 “复制对象的成员变量”,但要注意 “浅拷贝” 的坑。
4.2 拷贝构造函数特性与创建
- 是构造函数的重载形式,函数名 = 类名;
- 参数必须是 “类类型的引用”(
Date& d); - 若未显式实现,编译器会生成默认拷贝构造(浅拷贝)。
- 引用前面最好加上
const限制。(保护用来拷贝的那个数据)
Date(constDate&d){_year=d._year;_month=d._month;_day=d._day;}- 采用
const引用的原因
可以保护形式参数不被改变。传值传参,拷贝构造函数只是一个中转。我不希望它在函数体内对我的原来的对象造成修改。 - 为什么参数必须是引用?
- 如果参数是传值(
Date(const Date d)),调用拷贝构造时会先复制实参到形参 —— 而复制形参又需要调用拷贝构造,简而言之,传参会调用拷贝构造,调用拷贝构造又要调用拷贝构造,形成 “无穷递归”:具体可以看下面的图
- 如果参数是传值(
classDate{public:// 正确写法:参数是const引用(const避免修改原对象,引用避免递归)Date(constDate&d){_year=d._year;_month=d._month;_day=d._day;}Date(intyear=0,intmonth=1,intday=1){_year=year;_month=month;_day=day;}// 错误写法:参数传值,引发无穷递归// Date(const Date d) {}private:int_year,_month,_day;};intmain(){Date d1;Dated2(d1);return0;}- 调用拷贝构造函数的另外一种方法
第二种调用方法实质上等价于第一种。(但是可读性更高)
Date d1;Date d2=d1;4.3 编译器默认生成拷贝构造函数
与构造函数和析构函数不同,自动生成的拷贝构造函数会管内置类型。
- 若未显式定义拷贝构造,编译器会自动生成拷贝构造函数。
- 自动生成的拷贝构造对内置类型成员变量会完成值拷贝 / 浅拷贝
- 对自定义类型成员变量会调用它的拷贝构造。
我们发现自己不写也会完成,因为编译器生成的会按照字节进行拷贝。 - 情况一:类成员均为内置类型,且不管理资源
例如Date类。编译器生成的拷贝构造函数会完成值拷贝,因此不需要我们显式实现拷贝构造。 - 情况二:类成员包含自定义类型,且该类型已正确实现拷贝构造函数
例如Myqueue类,其内部成员主要是自定义类型Stack的对象。编译器自动生成的拷贝构造函数会调用Stack的拷贝构造函数,因此也不需要我们显式实现Myqueue的拷贝构造。
- 情况三:类管理动态资源
例如Stack类,其成员_a指向了资源。编译器自动生成的拷贝构造函数仅完成值拷贝 / 浅拷贝(仅复制指针值),并不符合我们的需求,因此必须手动实现深拷贝,即对指针所指向的资源也进行完整拷贝。
4.4 浅拷贝的坑:什么时候必须显式写拷贝构造?
像上面的情况三就要自己写了,系统生成的无法完成。我们先看为什么
- 解决办法:深拷贝
深拷贝的核心是 “给新对象重新申请资源”,避免指针指向同一块内存:
classStack{public:// 深拷贝构造Stack(constStack&s){// 给新对象重新申请内存_array=(DataType*)malloc(sizeof(DataType)*s._capacity);if(_array==nullptr){perror("malloc失败");return;}// 复制数据(不是复制指针)memcpy(_array,s._array,sizeof(DataType)*s._size);_capacity=s._capacity;_size=s._size;}// 构造、析构同上...};五、赋值运算符重载
5.1 运算符重载
比如我们如果要比较日期类对象的大小,按照我们现有的知识要去写一个函数,但是我们如果想直接比较呢?d1>d2?所以有了运算符重载。
在 C++ 中,运算符重载是一项强大的特性,它允许我们为自定义类型赋予内置类型般的操作体验,极大提升了代码的可读性与易用性。
函数名字为:关键字operator后面接需要重载的运算符符号。
函数原型:返回值类型 operator 操作符(参数列表)
Date&operator=(constDate&d)- 不能通过连接其他符号来创建新的操作符:比如
operator@ - 重载操作符必须有一个类类型参数
- 用于内置类型的运算符,其含义不能改变,例如:内置的整型 +,不能改变其含义
- 作为类成员函数重载时,其形参看起来比操作数数目少 1,因为成员函数的第一个参数为隐藏的
this .*、::、sizeof、?:、.注意以上 5 个运算符不能重载。
5.2 赋值运算符重载的特性与创建
5.2.1 赋值运算符特性与创建
返回值类型 operator=(参数列表);- 参数类型为
const 类类型&(引用提高效率,const避免修改原对象); - 返回值类型为类类型&(支持连续赋值,如
d3 = d2 = d1); - 必须显式检测 “自己给自己赋值”(如
d1 = d1),避免无意义操作。
classDate{public:Date(intyear=1900,intmonth=1,intday=1){_year=year;_month=month;_day=day;}// 赋值运算符重载Date&operator=(constDate&d){// 1. 检测自己给自己赋值if(this!=&d){// 2. 复制成员变量_year=d._year;_month=d._month;_day=d._day;}// 3. 返回*this,支持连续赋值return*this;//返回d2}private:int_year,_month,_day;};// 使用示例intmain(){Dated1(2024,5,20);Date d2,d3;d3=d2=d1;// 连续赋值,等价于d3.operator=(d2.operator=(d1))return0;}5.2.2 全局函数 vs 成员函数
运算符重载可以通过全局函数或类的成员函数实现,两者的核心区别在于参数列表的隐含性。
若将operator==实现为全局函数,需要显式接收两个Date对象作为参数:
- 【全局函数实现】:
classDate{public:Date(intyear=1900,intmonth=1,intday=1):_year(year),_month(month),_day(day){}// 成员变量需公开(或用后面友元解决封装性问题)int_year,_month,_day;};// 全局函数重载 operator==booloperator==(constDate&d1,constDate&d2){returnd1._year==d2._year&&d1._month==d2._month&&d1._day==d2._day;}voidTest(){Dated1(2024,10,1);Dated2(2024,10,2);cout<<(d1==d2)<<endl;// 调用全局 operator==}缺点:若类的成员变量是私有的,全局函数无法直接访问,需通过 “友元” 声明打破封装(或直接将重载函数写为成员函数)。
- 【成员函数实现】:
若将operator==实现为类的成员函数,编译器会自动将左操作数作为隐含的this指针,因此参数列表只需显式接收右操作数:
classDate{public:Date(intyear=1900,intmonth=1,intday=1):_year(year),_month(month),_day(day){}// 成员函数重载 operator==:左操作数是 this 指向的对象booloperator==(constDate&d2)const{return_year==d2._year&&_month==d2._month&&_day==d2._day;}private:int_year,_month,_day;// 成员变量可私有,保证封装性};voidTest(){Dated1(2024,10,1);Dated2(2024,10,1);cout<<(d1==d2)<<endl;// 调用成员函数 operator==}优点:无需破坏封装性,且逻辑更直观(左操作数的属性可直接访问)。
5.2.3 避坑点:赋值运算符只能是类的成员函数
不能把赋值运算符重载成全局函数 —— 因为编译器会默认生成类内的赋值运算符重载,全局版本会和它冲突:
5.3 编译器默认生成的赋值运算符重载
和拷贝构造一样,编译器默认生成的赋值运算符重载是 “浅拷贝”—— 有动态内存的类必须显式实现深拷贝,否则会重复释放内存:
// 栈类的深拷贝赋值运算符重载classStack{public:Stack&operator=(constStack&s){if(this!=&s){// 检测自赋值// 1. 释放当前对象的旧资源free(_array);// 2. 给当前对象重新申请资源并复制数据_array=(DataType*)malloc(sizeof(DataType)*s._capacity);if(_array==nullptr){perror("malloc失败");return*this;}memcpy(_array,s._array,sizeof(DataType)*s._size);_capacity=s._capacity;_size=s._size;}return*this;}// 构造、析构、拷贝构造同上...};5.3.1 不需要手动实现的情况
情况一:类成员均为内置类型,且不管理资源
例如Date类。编译器生成的赋值运算符会进行值拷贝。
情况二:类成员包含自定义类型,且该类型已正确实现赋值运算符
例如Myqueue类,其成员为Stack对象。编译器会递归调用Stack的赋值运算符,只要Stack已正确实现深拷贝,Myqueue就无需手动实现。
5.3.2 必须手动实现的情况
类管理动态资源(如堆内存、文件句柄等)
例如Stack类,其成员_a指向动态数组。编译器生成的赋值运算符仅进行浅拷贝(复制指针值),会导致:内存泄漏,重复释放,悬空指针等一系列问题。
六、其他 2 个默认成员函数:取地址重载
取地址重载和const取地址重载,编译器默认生成的实现完全满足需求 —— 返回对象的地址(this指针),几乎无需自定义。
七、总结
| 函数类型 | 显式实现场景 | 核心坑点 |
|---|---|---|
| 构造函数 | 需自定义初始化(如日期) | 默认构造只能有一个 |
| 析构函数 | 类内有动态资源(如栈) | 忘记释放资源导致内存泄漏 |
| 拷贝构造函数 | 类内有动态资源(避免浅拷贝) | 参数不用引用引发无穷递归 |
| 赋值运算符重载 | 类内有动态资源(避免浅拷贝) | 忘记检测自赋值、返回*this |
| 取地址重载 | 特殊需求(隐藏真实地址) | 几乎不用自定义 |