类的6个默认成员函数
如果一个类中什么成员都没有,我们简称其为空类。但是空类中真的什么都没有吗?其实不然,任何一个类,即使我们什么都不写,类中也会自动生成6个默认成员函数。
由于篇幅原因,先介绍3个默认成员函数:构造函数,析构函数 和 拷贝构造函数。后续3个在类和对象(三)。
构造函数
构造函数的概念
构造函数:名字与类名相同, 创建类类型对象时由编译器自动调用,保证每个数据成员都有 一个合适的初始值,并且在对象的生命周期内只调用一次。
5个特性要点
1. 自动初始化
构造函数在创建对象时自动调用,不用手动初始化调用。
示例栈初始化:
class Stack {
public:
// 构造函数内初始化栈
Stack() {
_array = new int[4];
_top = 0;
_capacity = 4;
}
private:
int* _array;
int _top;
int _capacity;
};
此时我们用Stack实例化一个对象st,创建时st就已经初始化好了(编译器自动调用的)。不需要我们手动调用初始化。
Stack st; // 自动完成初始化
2. 构造函数的访问权限:
- 通常声明为
public
,以便在类外部创建对象。 - 若声明为
private
,则外部无法直接创建对象(常用于单例模式等高级技巧)。
3. 默认构造函数:不需要传递参数就可以调用的构造函数。默认构造函数只能有一个。
默认构造函数是什么?根据C++标准:
- 无参构造函数:不需要任何参数就能调用的构造函数
- 全缺省构造函数:所有参数都有默认值的构造函数
- 编译器会自动的无参的默认构造函数(隐式)
默认构造函数都不需要传参,所以可以认为,不需要传参的构造函数就是默认构造函数。
易错点:假如我们显示写了两个函数,一个是无参构造函数,一个是全缺省构造函数,如下代码。这两个函数都不需要传参就可以调用。那么我创建一个对象A,请问,
class A {
public:
// 构造函数1:无参
A() { _a = _b = _c = 2; }
// 构造函数2:全缺省参数
A(int a = 0, int b = 0, int c = 0) {
_a = a; _b = b; _c = c;
}
private:
int _a;
int _b;
int _c;
};
这个A对象调用哪一个构造函数?
A obj; // 编译器困惑:
// 可调用 A()
// 也可调用 A(0,0,0)
答案是:报错,编译器不知道选哪一个。所以,默认构造函数只能有一个!!!要么保留无参构造,要么保留全缺省构造。
这样子写也是不行的:
A obj();// ❌ 这不是对象创建,而是函数声明!
答案是:A obj();
这一行不会调用任何构造函数!
此外:
- 如果类中没有显式定义任何构造函数,编译器会自动生成一个无参的默认构造函数。
- 一旦用户显式定义了构造函数(无论是否有参数),编译器将不再生成默认构造函数。
4. 成员初始化规则:
- 内置类型(如
int
,double
,指针
):编译器生成的默认构造函数不会初始化它们(值随机)。 - 自定义类型(如
class
/struct
定义的类型):编译器会调用该类型的默认构造函数初始化。
一般情况下,有内置类型成员,就需要自己写构造函数。编译器生成的默认构造函数不会初始化内置类型,这些内置成员在对象创建时将持有随机垃圾值。
全部都是自定义类型成员,可以考虑让编译器自己生成。
5. C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给缺省值(默认值)。
这些缺省值必须写在类的定义内部(private 或 public 区域)
也就是说,C++11以前,我们声明变量,只能写成:
private: //默认成员初始化器
int _year; //C++11以后,可以写成_year = 1970;
int _month; // _month = 1;
int _day; // _day = 1;
C++11:如果用户未在构造函数中初始化该成员,编译器会使用缺省值初始化。日期类示例:
#include <iostream>
using namespace std;
class Date {
private: // 在 private 区域声明成员并设置缺省值
int _year = 1970; // 内置类型缺省值
int _month = 1; // 初始化月份
int _day = 1; // 初始化日期
public:
// 构造函数1:使用成员缺省值。也是默认构造函数
Date() {
// 不显式初始化则自动使用缺省值
cout << "使用缺省值: " << _year << "-" << _month << "-" << _day << endl;
}
// 构造函数2:部分覆盖缺省值
Date(int year) : _year(year) {
cout << "部分覆盖: " << _year << "-" << _month << "-" << _day << endl;
}
// 构造函数3:完全覆盖缺省值
Date(int year, int month, int day)
: _year(year), _month(month), _day(day) {
cout << "完全覆盖: " << _year << "-" << _month << "-" << _day << endl;
}
};
int main() {
Date d1; // 使用缺省值: 1970-1-1
Date d2(2023); // 部分覆盖: 2023-1-1
Date d3(2024, 6, 5);// 完全覆盖: 2024-6-5
return 0;
}
注意:构造函数的主要任务并不是开空间创建对象,而是初始化对象。
总结
- 函数名与类名相同,构造函数没有返回值
- 对象实例化时编译器自动调用对应的构造函数
- 无参的构造函数、全缺省的构造函数以及我们不写编译器自动生成的无参构造函数都称为默认构造函数,并且默认构造函数只能有一个。
- 有内置类型成员,就需要自己写构造函数;全都是自定义类型,可以考虑让编译器写。
- 支持重载(可以创建多个构造函数
析构函数
析构函数的概念
析构函数:与构造函数功能相反,析构函数负责完成对象的销毁,对象在销毁时会自动调用析构函数,完成类的一些资源清理工作。
析构函数的特性:
1. 析构函数的名字和类名相同,只是在最前面加上 " ~ " 这个符号
2. 如果我们没有写构造函数,编译器会自动创建。在对象生命周期结束时,编译器会自动调用析构函数。一个类有且只有一个析构函数
编译器自动生成的析构函数机制:
1、编译器自动生成的析构函数对内置类型不做处理。
2、对于自定义类型,编译器会再去调用它们自己的默认析构函数。3. 先构造的后析构,后构造的先析构
因为对象是定义在函数中的,函数调用会建立栈帧,栈帧中的对象构造和析构也要符合先进后出的原则。
例子:
#include <iostream>
using namespace std;
class Stack {
public:
// 构造函数:分配内存
Stack() {
data = new int[10]; // 分配10个整数的空间
cout << "分配内存\n";
}
// 析构函数:释放内存
~Stack() {
delete[] data; // 释放内存
cout << "释放内存\n";
}
private:
int* data; // 栈数据指针
};
int main() {
Stack s; // 构造函数自动调用
return 0;
} // 析构函数自动调用
拷贝构造函数
拷贝构造函数的概念
拷贝构造,通俗来讲就是复制一个对象。拷贝构造函数只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
例如如下代码:
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)//构造函数
{
_year = year;
_month = month;
_day = day;
}
//拷贝构造函数 !(我在这里)
Date(const Date& d)
{
_year = date._year; //目标对象成员 = 源对象成员
_month = date._month;
_day = date._day;
}
protected:
int _year;
int _month;
int _day;
};
int main()
{
Date d; //创建日期对象
Date d1(d); //创建d对象的拷贝d1 如何拷贝一个对象d?
}
拷贝构造函数的特性:
1、拷贝构造函数是构造函数的一个重载形式
因为拷贝构造函数的函数名也与类名相同。
2、若未显示定义拷贝构造函数,系统将生成默认的拷贝构造函数
编译器自动生成的拷贝构造函数机制:
1、编译器自动生成的拷贝构造函数对内置类型会完成浅拷贝(值拷贝)。
2、对于自定义类型,编译器会再去调用它们自己的默认拷贝构造函数。
3、编译器自动生成的拷贝构造函数不能实现深拷贝
上面说到,编译器自动生成的拷贝构造函数会对内置类型完成浅拷贝。对于以下这句代码,浅拷贝实际上就是将d1的内容完完全全的复制了一份拷贝给d2,所以说浅拷贝也叫做值拷贝。
Date d2(d1);// 用已存在的对象d1创建对象d2
但某些场景下浅拷贝并不能达到我们想要的效果。例如,栈(Stack)这样的类,编译器自动生成的拷贝构造函数就不能满足我们的需求了:
Stack s1;
Stack s2(s1);// 用已存在的对象s1创建对象s2
代码中,我们的本意是用已存在的对象s1创建对象s2,但编译器自动生成的拷贝构造函数,完成的是浅拷贝,拷贝出来的对象s2将不能满足我们的要求。
举个例子,现有以下栈(Stack)类:
class Stack
{
public:
Stack(int capacity = 4)
{
_ps = (int*)malloc(sizeof(int)* capacity);
_size = 0;
_capacity = capacity;
}
void Print()
{
cout << _ps << endl;// 打印栈空间地址
}
private:
int* _ps;
int _size;
int _capacity;
};
我们可以看到,我们用的是系统自动生成的拷贝构造函数,那么我们拷贝一个已存在的对象。看看以下代码运行结果:
int main()
{
Stack s1;
s1.Print();// 打印s1栈空间的地址
Stack s2(s1);// 用已存在的对象s1创建对象s2
s2.Print();// 打印s2栈空间的地址
return 0;
}
结果打印s1栈和s2栈空间的地址相同,这就意味着,就算在创建完s2栈后,我们对s1栈做的任何操作都会直接影响到s2栈。
这是我们想要的效果吗?显然不是。 我们希望在创建时,s2栈和s1栈中的数据是相同的,但是在创建完s2栈后,我们对s1栈和s2栈之间的任何操作能够互不影响。
而且这种情况下,还会出现对同一块空间释放多次的问题。若我们自己定义的析构函数是正确的情况下,当程序运行结束,s2栈将被析构,此时那块栈空间被释放,然后s1栈也要被析构,就又再次对那一块空间进行释放。
重复释放(致命错误):
main->>s2: 析构函数 s2->>heap: 释放内存(0x1000) main->>s1: 析构函数 s1->>heap: 再次释放同一内存(0x1000) → 崩溃!
所以,这种情况下编译器自动生成的拷贝构造函数就不能满足我们的要求了。
总结一下:
1、像Date这样的类,需要的就是浅拷贝,那么编译器自动生成的拷贝构造函数就够用了,我们不需要自己写。
2、像Stack这样的类,浅拷贝会导致析构两次、程序崩溃等问题,需要我们自己写对应的拷贝构造函数。
加餐:为什么编译器生成的拷贝构造函数会导致指针共享地址?
这是C++中"浅拷贝"行为的直接后果,编译器自动生成的拷贝构造函数执行的是逐成员复制(member-wise copy),如下图,这是执行拷贝构造函数以后,对象s1成员和对象s2成员的内存分布。复制过程:
首先,s2对象的_ps指针复制s1对象的_ps指针,因为指针里面存的是地址,所以把地址完完全全的复制了过来,这两个指针指向同一块空间。
其次,s2对象的_size复制s1对象的_size,因为_size = 0, 所以s2对象的_size也等于0,只是数值相同,地址不相同,指向的不是同一块空间。
最后,s2对象的_capacity复制s1对象的_capacity,_capacity只是数值相同,指向的不是同一块空间。
这就是逐成员复制的过程,所以,浅拷贝本质就是一个数值的完全复制,尤其在指针方面,容易让运行出现问题。有时候我们需要深拷贝来解决问题。
特性 | 浅拷贝(编译器生成) | 深拷贝(我们需要) |
---|---|---|
指针处理 | 直接复制指针值 | 创建新内存并复制数据 |
内存共享 | 是 | 否 |
独立性 | 对象之间相互依赖 | 完全独立 |
析构安全 | 多重释放问题 | 安全释放 |
4. 拷贝构造函数的形参要求 -- const Date& d
//拷贝构造函数
Date(const Date& d)
{
_year = date._year; //目标对象成员 = 源对象成员
_month = date._month;
_day = date._day;
}
- 常量 (const):
- 防止函数内部意外修改源对象 d。
- 允许函数接受常量源对象(如
const Date d1; Date d2(d1);
),提升灵活性。 - 增加代码可读性,明确表达“只读”意图。
- 引用 (&):必须使用引用传递。如果直接传值 (
Date d
),会触发拷贝构造函数本身的无限递归调用导致栈溢出。
//拷贝构造函数 !(我在这里)
Date(const Date& d)
{
_year = date._year; //目标对象成员 = 源对象成员
_month = date._month;
_day = date._day;
}
深入解析:为什么C++拷贝构造函数必须使用引用
必备前缀知识:对函数栈帧的创建和销毁,有一点基础。复习文章:
拷贝构造函数必须使用引用参数的根本原因在于避免无限的递归调用链,这是由C++的参数传递规则决定的。
C++参数传递规则:
当函数参数是自定义类型时:
- 参数按值传递 → 调用 拷贝构造函数
- 参数按引用传递 → 不创建新对象
核心机制解析,为什么传值会发生无限递归?
我们用错误的拷贝构造函数来举例:
//拷贝构造函数代码:
class Date {
public:
// 错误的值传递拷贝构造函数
Date(Date copy) { // 按值传递,copy是参数
_year = copy._year;
_month = copy._month;
_day = copy._day;
}
private:
int _year;
int _month;
int _day;
};
程序崩溃步骤讲解:
步骤1: 创建原始对象
Date d; // 使用默认构造函数创建对象d
步骤2:调用拷贝构造函数
Date d1(d); // 尝试创建d的拷贝d1
编译器调用自己编写的拷贝构造函数(按值传递)。
步骤3:为拷贝构造函数创建参数!!!
我们要创建d的拷贝d1, 由于按值传递,所以拷贝构造函数的代码是这样的:
问题出在拷贝构造函数的形参上面:
我们知道,形参和实参使用的不是同一块内存空间。所以我们知道,Date d1(d)里面的d对象,和拷贝构造函数Date (Date d) 里面的d对象,使用的不是同一块内存空间。也就是说,我们虽然有了Date (Date d) 里面的实参d对象,但是我们还要创建一个d对象作为拷贝构造函数的形参。
如何创建形参? :
根据C++规则:当函数参数是自定义类型且按值传递时,必须调用该类型的拷贝构造函数来创建形参对象。
总结来说,就是:在执行拷贝构造函数体之前(即创建d1之前),我们必须先创建形参copy1,而创建形参copy1需要调用拷贝构造函数。
创建形参copy1的过程:因为形参copy1是Date类型,按值传递,所以要调用另一个拷贝构造函数,也就是说,要创建形参copy2 ...... 这样就形成了循环。
也就是说,为了调用拷贝构造函数来创建d1,我们首先需要创建形参copy1,而创建形参copy1又需要调用拷贝构造函数,为了调用拷贝构造函数需要创建形参copy2 , 而创建形参copy2又需要调用拷贝构造函数,调用构造函数需要创建形参copy3, 而创建形参copy3又需要调用拷贝构造函数......这样就形成了一个无限递归,直至栈空间耗尽。
(ps: 这块知识点确实很绕,但我觉得我表达的已经很清楚了。)