【C++】—— 类和对象(中)
文章目录
- 【C++】—— 类和对象(中)
- 前言
- 1. 类的默认成员函数
- 2. 构造函数
- 3. 析构函数
- 4. 拷贝构造函数
- 5. 赋值运算符重载
- 5.1 运算符重载
- 5.2 赋值运算符重载
- 结语
前言
小伙伴们大家好呀,昨天的 【C++】——类和对象(上) 大家理解的怎么样了
今天的内容难度会上升一大截,大家需要做好准备哦
1. 类的默认成员函数
默认成员函数就是用户没有显式实现,编译器会自动生成的成员函数称为默认成员函数。⼀个类,我们不写的情况下编译器会默认生成以下6个默认成员函数,需要注意的是这6个中最重要的是前4个,最后两个取地址重载不重要,我们稍微了解⼀下即可。其次就是C++11以后还会增加两个默认成员函数,移动构造和移动赋值,这个我们后面再讲解。默认成员函数很重要,也比较复杂,我们要从两个方面去学习:
- 我们不写时,编译器默认生成的函数行为是什么,是否满足我们的需求
- 编译器默认生成的函数不满足我们的需求,我们需要自己实现,那么如何自己实现
默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数
我们的学习重点是在前4个,即构造函数,析构函数,拷贝构造函数和赋值运算符重载
2. 构造函数
构造函数的定义:构造函数是一个特殊的成员函数,名字与类名相同, 创建类类型对象时由编译器自动调用,以保证每个数据成员都有一个合适的初始值,并且在对象整个生命周期内只调用一次
我们就拿之前写过的日期类来举个构造函数的例子
class Date
{
public:
Date()// 无参构造函数
{}
Date(int year = 1, int month = 1, int day = 1)// 带参构造函数
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;// 年
int _month;// 月
int _day;// 日
};
注意:构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象
构造函数的特征:
- 函数名与类名相同
- 无返回值(即不需要写void)
- 对象实例化时编译器会自动调用对应的构造函数
- 构造函数可以重载可以写多个,即可以定义多种初始化方式
下面是构造函数的调用的几种方式
void TestDate()
{
Date d1; // 调用无参构造函数 (注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明)
// Date d1();// error
Date d2(2024, 4, 10); // 调用带参的构造函数
}
- 之前说过,如果我们没有写,则C++编译器会自动生成一个默认构造函数(这个构造函数是不需要传参的),反之,如果我们写了,编译器将不再生成
但是,一般情况下我们最好自己去写这个构造函数,因为编译器自动生成的这个默认构造函数的初始化做的并不是很好,比如下面这个代码,我们可以看一下它的初始化:
class Date
{
public:
// 如果我们显式定义了构造函数,编译器将不再生成
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
// 将Date类中构造函数屏蔽后,代码可以通过编译,因为编译器生成了一个无参的默认构造函数
Date d1;// 但是没有初始化,成员变量都是随机值!!!
return 0;
}
- 关于编译器生成的默认构造函数,我们可能会有疑惑,既然编译器自动生成的这个默认构造函数的初始化做的并不是很好,还是要自己去写这个构造函数,为什么会有这个默认构造函数呢,对象调用了编译器生成的默认构造函数,但是对象
_year/_month/_day
,依旧是随机值,也就说在这里编译器生成的默认构造函数并没有什么用
我们要知道的是,C++把类型分成内置类型和自定义类型
举个例子:
class Time
{
public:
Time()
{
cout << "Time()" << endl;
_hour = 0;
_minute = 0;
_second = 0;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
// 内置类型
int _year;
int _month;
int _day;
// 自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
我们发现编译器生成默认的构造函数:
-
对于内置类型成员变量,它没有处理(其实在C++标准中没有规定要不要做处理,所以有些编译器会处理,有些不会处理)
-
对于自定义类型成员变量才会调用他的默认构造函数
再来看看一个两个栈实现队列的例子:
#include<iostream>
using namespace std;
typedef int STDataType;
class Stack
{
public:
Stack(int n = 4)
{
_a = (STDataType*)malloc(sizeof(STDataType) * n);
if (nullptr == _a)
{
perror("malloc申请空间失败");
return;
}
_capacity = n;
_top = 0;
}
// ...
~Stack()
{
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
STDataType* _a;
size_t _capacity;
size_t _top;
};
// 两个Stack实现队列
class MyQueue
{
public:
// 编译器默认生成MyQueue的构造函数调用了Stack的构造,完成了两个成员的初始化
~MyQueue()
{
cout << "~MyQueue()" << endl;
}
private:
Stack pushst;
Stack popst;
};
int main()
{
MyQueue mq;
//Stack st1;
//Stack st2;
return 0;
}
编译器默认生成MyQueue的构造函数调用了Stack的构造,完成了两个成员的初始化
大多数情况实现构造函数都需要我们自己实现(传参构造,内置类型的构造),少数情况类像MyQueue且Stack有默认构造可用和析构,可以不用写,但是构造函数大家应写尽写
这个构造函数不初始化的缺陷造成的种种复杂,所以在C++11 中,针对内置类型成员不初始化的缺陷,这里打了补丁
即:内置类型成员变量在类中声明时可以给默认值(缺省值)
还是举个例子:
class Time
{
public:
Time()
{
cout << "Time()" << endl;
_hour = 0;
_minute = 0;
_second = 0;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
// 这里的内置类型成员变量在类中声明时我们自己给了缺省值
int _year = 2024;
int _month = 10;
int _day = 4;
// 自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
- 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个
注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数,我们可以看出,这些函数都是不传参就可以调用的
像这样:
class Date
{
public:
// 1.无参构造函数
/*Date()
{
_year = 1;
_month = 1;
_day = 1;
}*/
// 2.带参构造函数
/*Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}*/
// 3.全缺省构造函数
/*Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}*/
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
无参构造函数、全缺省构造函数、我们不写构造时编译器默认生成的构造函数,都叫做默认构造函数。但是这三个函数有且只有⼀个存在,不能同时存在。无参构造函数和全缺省构造函数虽然构成函数重载,但是调⽤时会存在歧义。要注意很多同学会认为默认构造函数是编译器默认⽣成那个叫默认构造,实际上无参构造函数、全缺省构造函数也是默认构造,总结⼀下就是不传实参就可以调用的构造就叫默认构造
小结:
- 一般情况下,构造函数都需要我们自己显式的去实现
- 只有少数的情况下可以让编译器自动生成构造函数(比如两个栈实现队列 MyQueue,成员全是自定义类型)
3. 析构函数
析构函数的定义:析构函数与构造函数功能相反,析构函数不是完成对对象本身的销毁,比如局部对象是存在栈帧的,函数结束栈帧销毁,他就释放了,不需要我们管,C++规定对象在销毁时会自动调用析构函数,完成对象中资源的清理释放工作。析构函数的功能类比我们之前Stack实现的Destroy功能,而像Date没有Destroy,其实就是没有资源需要释放,所以严格说Date是不需要析构函数的
析构函数负责对象指向资源的清理,如果对象没有指向资源则不用写析构函数
栈帧销毁后类对象一同销毁,但其指针指向的资源没释放,会造成内存泄漏这时就需要析构函数在栈帧销毁前清理资源
析构函数的特征:
-
析构函数名是在类名前加上字符
~
-
无参数无返回值类型(这里的无参就印证的下面的析构函数不能重载的特性,而无返回值这点与上面的构造函数一致)
-
一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数,析构函数不能重载
-
对象生命周期结束时,C++编译系统系统自动调用析构函数
-
关于编译器自动生成的析构函数,是否会完成一些事情呢?这里直接说结论,自动生成的析构函数做的事与构造函数类似:
- 对于内置类型成员变量,它没有规定要不要处理
- 对于自定义类型成员变量才会调用他的默认析构函数
举个例子:
class Time
{
public:
~Time()
{
cout << "~Time()" << endl;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
// 内置类型
int _year = 1970;
int _month = 1;
int _day = 1;
// 自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
代码运行结果如下:
那这个代码我们在Data里没有写析构函数,为什么会调用Time的析构呢
因为这里的 _year, _month和 _day
三个是内置类型成员,销毁时不需要资源清理,最后系统直接将其内存回收即可,而 _t
是Time类对象,所以在d销毁时,要将其内部包含的Time类的_t对象销毁,所以要调用Time类的析构函数
- 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类,有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类,跟构造函数类似,我们不写编译器自动生成的析构函数对内置类型成员不做处理,其实也不用处理,因为栈帧销毁后会一起销毁。自定类型成员会调用他的析构函数
还是举一个两个栈实现队列的例子:
#include<iostream>
using namespace std;
typedef int STDataType;
class Stack
{
public:
Stack(int n = 4)
{
_a = (STDataType*)malloc(sizeof(STDataType) * n);
if (nullptr == _a)
{
perror("malloc申请空间失败");
return;
}
_capacity = n;
_top = 0;
}
// ...
~Stack()
{
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
STDataType* _a;
size_t _capacity;
size_t _top;
};
// 两个Stack实现队列
class MyQueue
{
public:
// 编译器默认生成MyQueue的构造函数调用了Stack的构造,完成了两个成员的初始化
// 编译器默认生成MyQueue的析构函数调用了Stack的析构,释放的Stack内部的资源
// 显示写析构,也会自动调用Stack的析构
~MyQueue()
{
cout << "~MyQueue()" << endl;
}
private:
Stack pushst;
Stack popst;
};
int main()
{
MyQueue mq;
//Stack st1;
//Stack st2;
return 0;
}
- 自定义类型:没有写析构,生成默认析构,默认析构调用白定义类型的析构
- 内置类型:写了析构调用显示的析构,没写生成默认析构,默认析构对自定义类型不做处理,函数栈帧结束后自动销毁
小结:
-
有资源需要显式清理,就需要写析构,如 Stack
-
有两种场景不需要显式写析构,默认生成即可
-
没有资源需要清理,如:Date
-
内置类型成员没有资源需要清理,剩下的都是自定义类型成员 ,如 : 两个栈实现队列MyQueue
4. 拷贝构造函数
拷贝构造函数的定义:如果⼀个构造函数的第⼀个参数是自⾝类类型的引用,且任何额外的参数都有默认值,则此构造函数也叫做拷贝构造函数,也就是说拷贝构造是⼀个特殊的构造函数
所以我们就可以采用拷贝构造函数的方式创建一个与已存在对象一某一样的新对象
拷贝构造函数的特征:
- 拷贝构造函数是构造函数的一个重载形式(它是一种特殊的构造)
- 拷贝构造函数的参数只有一个且必须是类类型对象的引用,如果使用传值方式编译器直接报错,因为这会引发无穷递归调用
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//Date(const Date d) // 错误写法:编译报错,会引发无穷递归
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;
};
int main()
{
Date d1(2024, 10, 4);
d1.Print();
//下面两种写法是等价的
Date d2(d1);//拷贝构造
d2.Print();
Date d3 = d1;//拷贝构造也可以这样写
d3.Print();
}
为什么必须时拷贝构造函数的参数必须用引用呢?不用引用可以吗?
传值传参:C++规定传值传参必须调用拷贝构造,所以又会调用拷贝构造构造形参,又因为拷贝构造是传值传参这样就会一直调用拷贝构造去构造形参也就形成无限递归,所以传值传参拷贝构造又会调用拷贝构造就相当于递归,并且递归没有结束条件
传引用传参:因为是传引用传参就是给d取别名,所以没有形成拷贝构造也就不会引发无穷递归
正是因为在语法逻辑上这里会形成无穷递归,所以拷贝的参数必须带引用,我讲清楚了吗
- 若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝
举个例子:
class Time
{
public:
Time()
{
_hour = 0;
_minute = 0;
_second = 0;
}
Time(const Time& t)
{
_hour = t._hour;
_minute = t._minute;
_second = t._second;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
// 内置类型
int _year = 1970;
int _month = 1;
int _day = 1;
// 自定义类型
Time _t;
};
int main()
{
Date d1;
Date d2(d1);// 因为Date类并没有显式定义拷贝构造函数,这里编译器会生成一个默认的拷贝构造函数
return 0;
}
注意:在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定义类型是调用其拷贝构造函数完成拷贝的
- 那就会有小伙伴问了,编译器生成的默认拷贝构造函数已经可以完成字节序的值拷贝了,还需要自己显式实现吗?
让我们来看下面的代码:
typedef int DataType;
class Stack
{
public:
Stack(size_t capacity = 10)
{
_array = (DataType*)malloc(capacity * sizeof(DataType));
if (nullptr == _array)
{
perror("malloc申请空间失败");
return;
}
_size = 0;
_capacity = capacity;
}
void Push(const DataType& data)
{
_array[_size] = data;
_size++;
}
~Stack()
{
if (_array)
{
free(_array);
_array = nullptr;
_capacity = 0;
_size = 0;
}
}
private:
DataType* _array;
size_t _size;
size_t _capacity;
};
int main()
{
Stack s1;
s1.Push(1);
s1.Push(2);
s1.Push(3);
s1.Push(4);
Stack s2(s1);
return 0;
}
代码运行结果如下:
哎,系统怎么崩掉了
这里的问题就与上面我们说的浅拷贝联系上了,因为它只有值拷贝,数组会指向跟之前一样的空间,所以最后它会析构两次导致崩溃
想要解决也挺简单的,就是要用深拷贝,深拷贝就是要开一个跟之前一样大的空间,有了两个空间,析构两次也就不会崩了
5. 赋值运算符重载
5.1 运算符重载
想要讲清楚赋值运算符重载,我们首先要了解 一下什么是运算符重载
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似
函数命名:关键字 operator后面接需要重载的运算符符号
函数原型:返回值类型 operator操作符
需要注意的是:
- 不能通过连接其他符号来创建新的操作符:比如operator@
- 重载操作符必须有一个类类型参数
- 用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不能改变其含义
- 作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this
- *(点星) , :: , sizeof ,?: ,. (星) 注意以上5个运算符不能重载
还是那我们熟知的日期类举例子:
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
bool operator==(const Date& d2)// ==
{
return _year == d2._year
&& _month == d2._month
&& _day == d2._day;
}
bool operator>(const Date& d)// >
{
if (_year > d._year)
{
return true;
}
else if (_year == d._year && _month > d._month)
{
return true;
}
else if (_year == d._year && _month == d._month && _day > d._day)
{
return true;
}
return false;
}
bool operator >= (const Date& d)// >=
{
return *this > d || *this == d;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024, 5, 1);
Date d2(2024, 4, 16);
cout << (d1 == d2) << endl;
cout << (d1 > d2) << endl;
cout << (d1 >= d2) << endl;
return 0;
}
代码运行结果如下:
在所有的比较大小的函数,我们已经实现的 ==
,>
和 >=
, 像 <
, <=
和 !=
也差不多
现在我们就来思考一下,实现日期+天数呢,又该怎么实现呢
int GetMonthDay(int year, int month)//获取这个月的天数
{
int monthDayArray[13] = { 0,31,28,31,30,31,30,31,31,30,31,30,31 };
if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)))
{
return 29;
}
else
{
return monthDayArray[month];
}
}
Date& operator+=(int day)
{
_day += day;
while (_day >= GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
_month++;
if (_month == 13)
{
++_year;
_month = 1;
}
}
return *this;
}
这里我们思考一下这个代码能不能实现我们要求的逻辑
我们会发现我们是不是实现的 日期+=日期
的逻辑,为什么这样说呢,我们要实现 日期+日期
的逻辑的话,this指针是不是不应该改变,所以我们就应该这样写
Date operator+(int day)
{
Date tmp(*this);
tmp += day;// 复用+=
return tmp;
}
实现 日期+日期
的逻辑就可以复用 +=
的逻辑,使代码变得更简洁,更简单
5.2 赋值运算符重载
赋值运算符重载的定义:赋值运算符重载是⼀个默认成员函数,用于完成两个已经存在的对象直接的拷贝赋值,这里要注意跟拷贝构造区分,拷贝构造用于⼀个对象拷贝初始化给另⼀个要创建的对象
在形式上,我们得先与第4点讲的拷贝构造做一个对比
int main()
{
Date d1;
Date d2(2024, 10, 4);
Date d3(d2);// 拷贝构造(初始化)
d1 = d2;// 赋值重载(复制拷贝)
return 0;
}
通过上面的比较,我们不难看出,赋值重载和拷贝构造的区别:
- 赋值重载 : 已经存在两个对象之间拷贝
- 拷贝构造 : 一个初始化另一个马上要创建的对象
举个例子:
Date& operator=(const Date& d)
{
_year = d.year;
_month = d.month;
_day = d.day;
return *this;
}
但是如果我们不写,用编译器默认的赋值运算符重载又会发生什么?
这里我们需要知道一个结论:如果类中未涉及到资源管理,赋值运算符是否实现都可以:(比如日期类),一旦涉及到资源管理则必须要实现(比如栈类),是不是与前面讲的默认成员函数很类似
小结:
- 赋值运算符重载是⼀个运算符重载,规定必须重载为成员函数
- 引用返回可以提高效率,有返回值目的是为了支持连续赋值场景
- 没有显式实现时,编译器会自动生成⼀个默认赋值运算符重载,默认赋值运算符重载行为跟默认拷贝函数类似,对内置类型成员变量会完成值拷贝/浅拷贝(⼀个字节⼀个字节的拷贝),对定义类型成员变量会调用他的拷贝构造
结语
- 像Date这样的类成员变量全是内置类型且没有指向什么资源,编译器自动生成的赋值运算符重载就可以完成需要的拷贝,所以不需要我们显示实现赋值运算符重载
- 像Stack这样的类,虽然也都是内置类型,但是_a指向了资源,编译器自动生成的赋值运算符重载完成的值拷贝/浅拷贝不符合我们的需求,所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)
- 像MyQueue这样的类型内部主要是自定义类型Stack成员,编译器自动生成的赋值运算符重载会调用Stack的赋值运算符重载,也不需要我们显示实现MyQueue的赋值运算符重载
这里还有一个小技巧,如果一个类显示实现了析构并释放资源,那么他就需要显示写赋值运算符重载,否则就不需要
这就是今天类和对象的内容,感觉是不是有些难的,所以大家就好好理解
好了,感谢你能看到这里,溜了溜了,我们下期再见吧