如果一个类中什么成员都没有,简称为空类。
空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成以下6个默认成员函数。
默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数。
class Date{};//空类
对于下面这个类:
class Date
{
public:
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
注意到,对于这个类,如果我们要实例化对象,就必须得调用Init函数来初始化对象,如果忘记初始化就会导致崩溃(出现随机值),因此我们需要有一个方法来保证对象一定会被初始化,使得对象在创建时,信息就会被设置进去。这就引出我们的默认成员函数——构造函数
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有一个合适的初始值,并且在对象整个生命周期内只调用一次。
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
其特征如下:
注意无返回值也不用写void
注意:这里的对应的意思是:如果我们有显示定义构造函数,则编译器会调用我们写的,并不再自己生成,若我们没有显示定义,则编译器则会自己生成并调用。
对于这一点,我们需要知道,虽然构造函数是可以重载的,但是默认的构造函数只能存在一个!
我们需要知道:有3类默认构造函数
无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。
注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数。
//不需要写Init了,调用太麻烦
/*void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}*/
//1.全缺省的带参构造
Date(int year = 1,int month = 1,int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//2.无参构造
Date()
{
_year = 1;
_month = 1;
_day = 1;
}
//3.编译器自动生成的构造函数
//一般采用全缺省的带参构造,非常好用,可以选择性的传参数或不传,但这两种构造函数不能同时存在。
现在我们知道,我们可以显示定义构造函数,并且构造函数也可以重载。
若我们没有写上述的构造函数(即没有显示定义),则编译器会自动生成默认成员函数,下面我们来了解编译器自动生成的默认成员函数到底好不好用呢?
我们通过一段代码来分析一下:
#include
using namespace std;
class Date
{
public:
void Print()
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1; // 编译器将调用自动生成的默认构造函数对d1进行初始化
d1.Print();
return 0;
}
最终运行结果如下:
这样看来,我们编译器自动生成的默认构造函数好像没什么用,因为对象通过生成的构造函数初始化后还是随机值。真是如此吗?
虽然确实是如此,但是是由其特性引起的:
C++把类型分成内置类型(基本类型)和自定义类型。
内置类型就是语言提供的数据类型,如:int/char…,指针等等
自定义类型就是我们使用class/struct/union等自己定义的类型
而默认生成的构造函数他的功能是:
1.对于内置类型不做处理
2.对于自定义类型成员,会去调用他的默认构造函数
例如下面的代码:
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++11打了一个补丁:内置类型成员变量在类中声明时可以给默认值。
我们可以对内置类型做如下处理:
private:
int _year=1;
int _month=1;
int _day=1;
注意:这里不是对内置类型直接进行定义,这里仍旧是成员变量声明,这里相当于给成员变量赋了一个缺省值,如果编译器检测到没有显示定义构造函数时,会调用缺省值进行初始化,若有显示定义则缺省值无任何影响。(这一块是为了弥补默认生成成员函数的缺点所打的补丁)
通过前面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么没呢的?
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
对于我们前面写的日期类,我们在一个函数中实例化对象时,首先先会建立该函数的栈帧,并把该实例化对象通过压栈的方式放入栈帧空间,当函数调用完毕,出了函数作用域时,函数的栈帧空间会销毁,因此实例化对象的空间也会被随之销毁,此过程由编译器完成。
但是,要是遇到这样的场景,情况就不一样了:
在实例化对象时,我们需要用到动态开辟空间,如实现栈时,栈的空间是通过动态开辟来的,因此这种情况就算出了函数作用域,这块空间也不会被销毁,因此析构函数的作用就体现出来了:为了防止动态开辟的空间忘记销毁,我们把需要free的空间写在析构函数里,在对象生命周期结束时,析构函数会由编译器自动调用来帮助我们销毁,这样保证了不会出现内存泄漏的问题。
class Date
{
public:
~Date()
{...}
};
无返回值也不需要加void。
由于析构函数不能重载,因此析构函数只有显示定义的与默认生成的两种。
有效避免了出现内存泄漏问题。
类似地,默认生成析构函数与默认生成构造函数有着相同特点
1.内置类型不做处理
2.自定义类型成员会去调用其析构函数
我们还是用代码来验证其特点:
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;
}
运行结果:
此时我们发现编译器是实实在在的调用了Time类型的析构函数。
在main方法中根本没有直接创建Time类的对象,为什么最后会调用Time类的析构函数?
因为:main方法中创建了Date对象d,而d中包含4个成员变量,其中_year, _month,_day三个是内置类型成员,销毁时不需要资源清理,最后系统直接将其内存回收即可;而_t是Time类对象,所以在d销毁时,要将其内部包含的Time类的_t对象销毁,所以要调用Time类的析构函数。但是:main函数中不能直接调用Time类的析构函数,实际要释放的是Date类对象,所以编译器会调用Date类的析构函数,而Date没有显式提供,则编译器会给Date类生成一个默认的析构函数,目的是在其内部
调用Time 类的析构函数,即当Date对象销毁时,要保证其内部每个自定义对象都可以正确销毁
main函数中并没有直接调用Time类析构函数,而是显式调用编译器为Date类生成的默认析构函数
注意:创建哪个类的对象则调用该类的构造函数,销毁那个类的对象则调用该类的析构函数。
总结:默认生成析构函数会调用自定义类型成员的析构函数完成销毁工作。
先构造的后析构,后构造的先析构
何出此言?
我们前面提到,在函数中,实例化对象时,是要对对象进行压栈的,因此栈帧销毁时,顺其自然的后面压入栈的对象的内存空间先被回收,一直到栈底。
但是要注意static对象的存在,因为static改变了对象的生存作用域,需要等待程序结束时才会析构释放对象。
析构顺序:局部对象(先构造的后析构,后构造的先析构)->静态对象->全局对象
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
#include
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;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 4, 11);
Date d2(d1); // 用已存在的对象d1创建对象d2
Date d3=d1; // 两种方式都行
return 0;
}
都与类名相同,但形参不同。
如何理解传值引发无穷递归调用呢?
我们知道,形参是对实参的拷贝,因此传参时,若使用了传值传参,形参又一次调用拷贝构造函数去拷贝实参…以此类推,显而易见推不下去了…无限递归了。
注意:在后续的自定义类型传参时,如果不用改变对象,则建议都用传引用传参,因为就算传值传参能够成功的话也是会有消耗的。
我们用以下代码来验证
#include
using namespace std;
class Date
{
public:
Date(int year = 0, 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;
};
int main()
{
Date d1(2021, 5, 30);
Date d2(d1); // 用已存在的对象d1创建对象d2
d1.Print();
d2.Print();
return 0;
}
运行结果:
可见就算没有显示拷贝构造代码,拷贝也成功了,那是不是说拷贝构造就没有必要去显示定义了呢?
默认生成拷贝构造函数特点就与前两个不同了:
1.内置类型按照字节方式直接拷贝
2.自定义类型调用其拷贝构造函数完成拷贝。
那这样是不是就更不需要去显示定义了呢?编译器已经把该干的事都干完了呀!
那就大错特错了!这只是冰山一角,我们来看下面的代码:
//我们来实现一个栈,若没有学过也没关系,我们来看解析
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)
{
// CheckCapacity();
_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;
}
总结:
对于无需动态开辟空间的,默认生成拷贝构造就够用(浅拷贝)
但对于需要动态开辟空间的,需要完成显示定义
(深拷贝)
到此,三种基本默认成员函数讲完了,我们用一段代码来分析其中的逻辑过程来巩固一下:
class Date
{
public:
Date(int year, int minute, int day)
{
cout << "Date(int,int,int):" << this << endl;
}
Date(const Date& d)
{
cout << "Date(const Date& d):" << this << endl;
}
~Date()
{
cout << "~Date():" << this << endl;
}
private:
int _year;
int _month;
int _day;
};
Date Test(Date d)
{
Date temp(d);
return temp;
}
int main()
{
Date d1(2022,1,13);
Test(d1);
return 0;
}
对于我们上述实现的日期类,若我们想实现日期的运算,比如计算2023年4月11号的100天后的日期是多少,我们一般会想到用函数来处理,但是,用函数处理太麻烦了,若我们频繁的使用,则代码可读性大大降低,于是我们就想,我们是否能让自定义类型像内置类型一样,进行运算符运算呢?因此,想让编译器知道如何对自定义类型运算,我们使用运算符重载。
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:关键字operator后面接需要重载的运算符符号。
函数原型:返回值类型 operator操作符(参数列表)
注意:
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
// bool operator==(Date* this, const Date& d2)
// 这里需要注意的是,左操作数是this,指向调用函数的对象
bool operator==(const Date& d2)
{
return _year == d2._year
&& _month == d2._month
&& _day == d2._day;
}
//private:
int _year;
int _month;
int _day;
};
放在全局中的形式,但会报错(成员变量是私有)
//bool operator==(const Date& d1, const Date& d2)
//{
// return d1._year == d2._year
// && d1._month == d2._month
// && d1._day == d2._day;
//}
void Test()
{
Date d1(2018, 9, 26);
Date d2(2018, 9, 27);
cout << (d1 == d2) << endl;
//在编译器中,这句代码会被转化成:
//cout<
}
我们以重载“=”来作为参考
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//d1 = d2 = d3;支持连续赋值
Date& operator=(const Date& d2)
{
if(this!=&d2)
{
_year = d2._year;
_month = d2._month;
_day = d2._day;
}
return *this;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 4, 11);
Date d2;
d2 = d1;
return 0;
}
看完以上代码,我们有了个大概的框架后,我们来拆分其格式:
赋值运算符重载格式
- 参数类型:const T&,传递引用可以提高传参效率
- 返回值类型:T&,返回引用可以提高返回的效率, 有返回值目的是为了支持连续赋值
- 检测是否自己给自己赋值
- 返回*this :要复合连续赋值的含义
熟悉完赋值运算符重载的格式后,我们知道,既然作为默认成员函数,那么编译器肯定会在没有显示定义的情况下自动生成一个,下面我们来分析一下。
先说一个结论:赋值运算符只能重载成类的成员函数不能重载成全局函数
原因:由于默认生成赋值运算符重载函数的存在,如果编译器在类内没有发现赋值运算符的成员函数后便自动生成一个,此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。这也侧面说明了默认生成的存在。
那么,此默认赋值运算符存在有什么特点呢?
- 对于内置类型,会完成值拷贝操作(按字节拷贝)
- 对于自定义类型,会调用其赋值运算符重载函数
(与拷贝构造的特点一致)
那么同样的,是不是就代表不需要去显示定义了呢?
不是的,比如遇到以下场景时:
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)
{
// CheckCapacity();
_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;
s2 = s1;
return 0;
}
以上代码是否还正常运行呢?答案是否定的
会有两个严重的问题:
将**const修饰的“成员函数”**称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
class
{
public:
void Display()const
{
...
}
}
我们通过把const加在需要改变的函数之后。
编译器会做如下处理:
那么const成员函数有什么用呢?
请思考下面的几个问题:
Answer:
除了构造,析构,拷贝构造,运算符重载,还有两个默认成员函数。
取地址及const取地址操作符重载函数也是编译器会自动生成的默认成员函数,这两个默认成员函数一般不用重新定义 ,编译器默认生成的就够用。
除了极少可能的特殊场景,这两个都不用自己写。
class Date
{
public :
Date* operator&()
{
return this ;
}
const Date* operator&()const
{
return this ;
}
private :
int _year ; // 年
int _month ; // 月
int _day ; // 日
};
我们先把类的总体框架写出来(先声明成员函数),再一个一个实现:
#pragma once
#include
#include
using namespace std;
class Date
{
public:
//构造函数
Date(int year=1,int month=1,int day=1)
{
_year = year;
_month = month;
_day = day;
}
//运算符重载
bool operator==(const Date& d) const;
bool operator!=(const Date& d) const;
bool operator>(const Date& d) const;
bool operator>=(const Date& d) const;
bool operator<(const Date& d) const;
bool operator<=(const Date& d) const;
//赋值运算符重载
//d1 + 100 || d1+=100
Date operator+(int day) const;
Date& operator+=(int day);
// ++d1;
// d1++;
// 直接按特性重载,无法区分
// 特殊处理,使用重载区分,后置++重载增加一个int参数跟前置构成函数重载进行区分
Date& operator++(); // 前置
Date operator++(int); // 后置
// d1 - 100|| d1-=100
Date operator-(int day) const;
Date& operator-=(int day);
Date& operator--(); // 前置
Date operator--(int); // 后置
// d1 - d2
int operator-(const Date& d) const;
private:
int _year;
int _month;
int _day;
};
首先是构造函数,由于构造函数会被频繁调用,因此直接在类内定义做内联函数,但若只是上述那样实现太过草率,我们还得考虑当传参不小心传错的情况,即日期是否合法,因此我们对构造函数进行了优化:
//频繁调用,构造函数类内定义作为内联
Date(int year=1,int month=1,int day=1)
{
_year = year;
_month = month;
_day = day;
assert(CheckDate());
}
//和构造函数一起会被频繁使用,因此作为内联展开
//获取每个月对应的天数
int GetmonthDay(int year, int month)
{
// 会被频繁调用,避免每次使用都要开辟数组空间,直接用static修饰放在静态区
static int days[13] = { 0,31,28,31,30,31,30,31,31,30,31,30,31 };
int day = days[month];
// 小细节:先判断是不是2月才有必要去判断是不是闰年
if (month==2 && (year % 4 == 0 && year % 10 != 0) || (year % 400) == 0)
{
day += 1;
}
return day;
}
//和构造函数一起会被频繁使用,因此作为内联展开
//检查日期是否合法
bool CheckDate()
{
if (_year >= 1
&& _month >= 1
&& _month < 13
&& _day>0
&& _day <= GetmonthDay(_year, _month))
{
return true;
}
else
return false;
}
以上函数都放在类中定义,编译器会自动识别为内联函数
对于哪些运算符可以重载,我们需要思考的是,哪些运算符对于这个类是有实现意义的
//先实现==, !=可以直接复用
bool Date::operator==(const Date& d) const
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
bool Date::operator!=(const Date& d) const
{
return !(*this == d);
}
//先实现>, >=以及<=可以服用
bool Date::operator>(const Date& d) const
{
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;
}
else
{
return false;
}
}
bool Date::operator>=(const Date& d) const
{
return (*this > d) || (*this == d);
}
bool Date::operator<(const Date& d) const
{
return !(*this >= d);
}
bool Date::operator<=(const Date& d) const
{
return !(*this > d);
}
值得注意的是,我们其实就实现了 == 以及 > 两个函数,而其他函数可以直接复用这两个函数,这样一来就大大提高了效率(前提是两个函数都写对)
同样地,我们需要思考哪些赋值操作是对日期类有意义的,经过整理我们可以实现的有:
//赋值运算符重载
//d1 + 100 || d1+=100
Date operator+(int day) const;
Date& operator+=(int day);
// ++d1;
// d1++;
// 直接按特性重载,无法区分
// 特殊处理,使用重载区分,后置++重载增加一个int参数跟前置构成函数重载进行区分
Date& operator++(); // 前置
Date operator++(int); // 后置
// d1 - 100|| d1-=100
Date operator-(int day) const;
Date& operator-=(int day);
Date& operator--(); // 前置
Date operator--(int); // 后置
// d1 - d2
int operator-(const Date& d) const;
private:
int _year;
int _month;
int _day;
};
Date& Date::operator+=(int day)
{
//day<0代表要减去正的天数,因此复用-=
if (day < 0)
{
*this -= -day;
}
_day += day;
while (_day>GetmonthDay(_year,_month))
{
_day -= GetmonthDay(_year, _month);
_month += 1;
if (_month > 13)
{
_month = 1;
_year += 1;
}
}
return *this;
}
+的左右操作数都不变,因此用传值返回(若传引用出了作用域临时变量会被销毁)
并且+用+=复用,减少消耗(传值返回会有销耗)
Date Date::operator+(int day) const
{
Date ret = *this;
ret += day;
return *this;
}
// ++d1;
// d1++;
// 直接按特性重载,无法区分
// 特殊处理,使用重载区分,后置++重载增加一个int参数跟前置构成函数重载进行区分
// 前置
Date& Date::operator++()
{
*this += 1;
return *this;
}
// 后置
Date Date::operator++(int)
{
Date temp = *this;
*this += 1;
return temp;
}
// d1 - 100
Date Date::operator-(int day) const
{
Date ret = *this;
ret -= day;
return *this;
}
Date& Date::operator-=(int day)
{
if (day < 0)
{
return *this += -day;
}
_day -= day;
while (_day <= 0)
{
_month -= 1;
_day += GetmonthDay(_year, _month);
if (_month < 0)
{
_month = 12;
_year -= 1;
}
}
}
Date& Date::operator--() // 前置
{
*this -= 1;
return *this;
}
Date Date::operator--(int) // 后置
{
Date temp = *this;
*this -= 1;
return temp;
}
// d1 - d2
int Date::operator-(const Date& d) const
{
//大减小,用flag来控制正负
Date max = *this;
Date min = d;
int flag = 1;
//小减大
if (*this < d)
{
max = d;
min = *this;
int flag = -1;
}
int count = 0;
while (max != min)
{
min++;
count++;
}
return count * flag;
}