一、前言
经过上一篇文章的学习,我们知道类中由成员变量和成员函数组成
本章的内容,就围绕类的6个默认成员函数展开讲解
如果一个类中什么都没有,简称为空类
class Date
{
};
然而,空类真的像表面一样什么都没有吗?不是的,编译器会自动生成上面的6个默认成员函数。
用户本身没有去显式实现,编译器会自己生成的成员函数就称为默认成员函数。
二、构造函数
2.1 概念
我们实现一个日期类
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;
};
对于这个Date类,我们可以使用公有成员函数Init来初始化对象
但是如果每次创建一个对象,就要调用一次Init来初始化,未免有点太麻烦了,有没有办法在创建对象的时候就可以进行初始化呢?
构造函数的功能能够实现这个需求,它是一个特殊的成员函数,它的函数名和类名相同,在创建对象时由编译器自动调用,并且只在创建对象时调用一次,其他时候不会调用。
2.2 特性
虽然构造函数名叫构造,但是它并不参与对象的创建,只用来初始化对象。
构造函数主要有以下几个特征:
(1)函数名与类名相同
(2)无返回值
(3)实例化对象时由编译器自动调用对应类的构造函数
(4)构造函数可以重载(你可以定义多种初始化的方法)
例如我定义了两个构造函数,一个不带参数,一个带参数
class Date
{
public:
Date()
{
_year = 1900;
_month = 1;
_day = 1;
}
Date(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;
};
当我实例化对象时不传参,就会调用第一个无参的构造函数;如果带了参数,就会调用第二个。
有人可能会有疑问:为什么不传参时对象的后面不用带括号呢?
下面这种情况你能分得清是在实例化对象还是在声明函数吗?这就是原因
所以,使用无参的构造函数初始化对象时,不用带括号!
(5)如果用户没有在类中显式定义构造函数,则编译器会自动生成一个无参的默认构造函数;如果用户定义了,则编译器不会生成。
例如我们把之前定义的无参构造函数删了,只留下带参的构造函数
这时如果还是无参的去实例化对象,就会报错。
因为已经有了显式定义的构造函数,编译器就不再生成。
那我们把前面显式定义的两个构造函数删了,试一试编译器自动生成的构造函数
这是怎么回事,不是说编译器会自动生成默认构造函数吗?怎么好像并没有什么用?
原来,C++把类型分为了内置类型(基本类型)和自定义类型。内置类型就是语言自己提供的例如int、char等类型,而自定义类型就是我们使用class/struct/union等自己定义的类型。
例如我们在日期类中添加一个自定义类型的成员函数_time
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 _time;
};
创建一个Date类的对象,输出如下:
可以看到,Date类中编译器生成的默认构造函数对自定义类型的变量_time起了作用,去调用了Time类的构造函数。
编译器自动生成的默认构造函数,只对自定义类型起作用,而不处理内置类型。我们的日期类中原本的三个成员变量都是内置类型,所以才会出现随机值。
实际上,这是C++的一个缺陷,所以在C++11中针对内置类型成员不初始化的缺陷打了一个补丁,即内置类型的成员变量在声明时可以给默认值。
(6)无参的构造函数和全缺省的构造函数也认为是默认构造函数,并且默认构造函数只能有一个
例如如下代码:
class Date
{
public:
Date()
{
_year = 1900;
_month = 1;
_day = 1;
}
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year = 1900;
int _month = 1;
int _day = 1;
};
int main()
{
Date d1;
return 0;
}
一个无参的构造函数,一个全缺省的构造函数,那么此时实例化d1时该调用哪个函数呢?
最好就直接把无参的构造函数删了就行了~ 毕竟全缺省的构造函数也能完成无参的功能嘛
三、析构函数
3.1 概念
以前使用C语言写栈或者其他数据结构的时候,每次程序结束前都需要手动Destory一下开辟出的空间,避免内存泄漏,十分的麻烦。
既然C++可以自动初始化,那能不能自动清理内存空间呢?析构函数可以满足你的需求
析构函数与构造函数类似,但是它们的功能大不一样。在一个对象出了作用域被销毁后,编译器就自动调用析构函数,完成对象中资源的清理工作。
3.2 特性
析构函数是特殊的成员函数,主要有以下几个特征:
(1)析构函数名是在类名前加上字符~
(2)析构函数没有参数,也没有返回值
(3)一个类只能有一个析构函数,如果用户没有显式定义,编译器会自动生成默认的析构函数。
(4)在对象生命周期结束时,编译器会自动调用析构函数
我们以栈为例子,写一个析构函数看看
typedef int DataType;
class Stack
{
public:
Stack(size_t capacity = 4)
{
_array = (DataType*)malloc(sizeof(DataType) * capacity);
if (NULL == _array)
{
perror("malloc申请空间失败!!!");
return;
}
_capacity = capacity;
_size = 0;
}
// ...
~Stack() //析构函数
{
if (_array)
{
free(_array);
_array = NULL;
_capacity = 0;
_size = 0;
}
}
private:
DataType* _array;
int _capacity;
int _size;
};
有了析构函数,我们对栈进行各种增删查改后再也不用手动去释放空间了,编译器都会帮你完成。
(5)编译器自动生成的默认析构函数也只对自定义类型的成员有效
因为内置类型的成员变量出了作用域就销毁了,不需要回收空间等操作,而自定义类型的成员则需要额外的清理。
我们还是添加一个Time类型作为例子
可以看到,虽然我们创建的是Date类的对象,但是还是调用了Time类的析构函数。
因为在Date类中有类型为Time的成员变量,在销毁对象d1时要先销毁Time类的成员变量_time
编译器会在Date类中生成一个默认析构函数并调用,这个函数自身再去调用Time类的析构函数。
对于三个int类型的成员变量,因为是内置类型,所以析构函数不作处理。
所以,如果类中只有内置类型的成员变量,没有进行资源申请时,可以不用显式定义析构函数,例如Date类;有资源申请时就一定要写,例如Stack类
四、拷贝构造函数
4.1 概念
我们在创建一个内置类型的变量时,可以用另一个变量去初始化
int b = 1;
int a = b;
对于变量,我们也想这样很方便快捷的去创建一个和已存在对象一模一样的新对象,于是就有了拷贝构造函数。
在用已存在的类类型对象去创建新对象时,编译器就会自动调用拷贝构造函数。
4.2 特性
拷贝构造函数也是特殊的成员函数,主要有以下几个特征:
(1)拷贝构造函数是构造函数的一个重载形式
(2)拷贝构造函数只有一个参数,并且必须是类对象的引用
正确写法:
用法:
为什么形参前要加const呢?
看看下面这种情况
如果有人粗心大意写反了,而我们又没加const,是不会报任何错误的。
并且,如果实参本身就是被const修饰的,而形参又没加const,就会造成权限放大。
为什么形参一定要用引用呢?传值传参不行吗?
如果对于自定义类型的对象,我们在拷贝构造函数中使用传值传参的话,会引发无限递归。
我们知道,传值传参时,形参是实参的拷贝
编译器对于内置类型直接进行浅拷贝,即按字节拷贝;对于更复杂的自定义类型,编译器不敢擅自拷贝,此时就需要调用类的拷贝构造函数。
对于自定义类型为什么不能浅拷贝呢?例如栈这种类型,其内部存放的是指向空间的指针,我们对栈进行拷贝,想要的效果是两个栈各自拥有自己的空间,空间内的元素相同;如果我们对其进行浅拷贝的话,就只是对地址进行了拷贝,两个栈就指向了同一块空间,最后两个栈调用析构函数时就对这块空间清理了两遍,程序会崩溃。
但是如果你的拷贝构造函数中使用了传值传参,那么就会造成死递归,即拷贝构造需要传值传参,但每次传值传参又要调用拷贝构造。
(3)若用户未显式定义,编译器会生成默认的拷贝构造函数。默认的拷贝构造函数只会进行浅拷贝(值拷贝),即按字节拷贝
我们把日期类中显式定义的拷贝构造函数删除,看看编译器生成的默认拷贝构造函数是否有效
由于日期类中只有内置类型的成员变量,所以使用编译器生成的默认拷贝构造也可以
所以,如果类中没有涉及资源申请时,可以选择用编译器生成的默认拷贝构造;一旦涉及到资源申请时,就一定要自己写拷贝构造函数。
五、赋值运算符重载
5.1 运算符重载
概念
我们可以使用大于号、小于号等运算符来比较两个内置类型的变量;但是对于像日期类的对象而言,当我们想比较两个日期时,无法使用这些运算符来进行比较
因此,C++引入了运算符重载
运算符重载是具有特殊函数名的函数,这里要提到关键字operator
函数名:operator + 待重载的运算符
例如我要对相等运算符==进行重载,相等返回true,不相等返回false,那么函数名就是
bool operator==(参数列表)
特性
(1)我们只能在operator后加上已经存在的运算符进行重载,不能凭空创造一个新的操作符
例如你不能写一个operator@并定义它,不存在名为@的运算符
(2)关于运算符重载的参数和参数类型:
首先我们在类中定义运算符重载函数
还是以相等运算符==为例,我们知道它有两个操作数,那么是不是意味着相等运算符的重载函数也要有两个参数呢?
可以看到,报错的原因是参数过多
你是否忘记了一点:成员函数的第一个参数为隐式的this指针?
所以实际上的确有两个参数,但是一个是隐式的this指针,一个是显式的类类型对象
正确的函数如下:
如果在类外定义运算符重载函数呢?
因为类外定义的函数没有隐式的this指针,所以我们需要写两个参数
问题又来了,我们在类外无法访问私有的成员变量啊
这里可以用我们后面会学到的友元解决
至于相等运算符的重载函数用法如下:
编译器会将 d1==d2 转换成 d1.operator==(d2)
因为流插入运算符的优先级较高,我们需要用括号把待比较的对象和运算符括起来
(3)重载后的运算符,其特性应和原本的运算符一样
例如加法运算符+,我们想要实现在一个日期的基础上加上一个天数,获得一个新的日期,就需要对其进行运算符重载
我们来统计一下+的特性有哪些
- 可以+一个负数
- 可以进行连续的+,例如 a + b + c
- 一个变量+一个数,变量本身不会改变
所以在重载的加法运算符函数中,我们需要对形参的正负进行判断,并且要返回日期类的对象保持连续运算的特性,还要用拷贝构造函数创建一个临时的对象来进行运算,避免修改对象本身。
如果天数为负数,我们就可以调用减法运算符的重载函数(虽然这里还没实现)
因为tmp出了作用域就销毁了,所以我们无法传引用返回,只能传值返回tmp的拷贝
(4)重载运算符函数中必须至少有一个类类型的参数
重载运算符本来就是用来运算自定义类型的,如果参数全是内置类型,还有必要重载吗?
(5).* :: sizeof ?: . 这五个运算符不能重载(常在笔试题中出现)
5.2 赋值运算符重载
概念
赋值运算符重载和拷贝构造有点相似,不过一个是针对两个已经实例化好的对象,另一个是在创建对象时用已经存在的对象去初始化。
我们平时可以用一个变量赋值给另一个变量,例如
int a = 1 , b = 2 , c = 3;
a = b = c;
如果想让自定义类型的对象也能够做到这一点,就需要使用赋值运算符的重载函数
赋值运算符的重载函数格式如下:
- 参数类型:const 类名& 参数名,用引用可以减少拷贝,提高传参效率
- 返回值类型:类名&,返回的对象出了作用域不销毁,所以选择传引用返回
- 检测*this是否和形参一样,避免自己给自己赋值
- 返回*this:因为要支持连续赋值,保持运算符的特性
现在,我们可以按照上面的格式写一个日期类的赋值运算符重载函数了
特性
赋值运算符重载是类的6大默认成员函数之一,所以它有着一些独特的特性
(1)赋值运算符只能重载为类的成员函数,不能在类外实现
这是因为,赋值重载函数是默认成员函数,如果我们不实现,编译器就会自己生成一个。
此时如果我们再在类外去实现一个全局的赋值运算符重载,就和编译器在类中实现的赋值重载冲突了,所以赋值重载函数只能在类中实现。
(2)当用户没有显式实现赋值运算符重载时,编译器会生成一个默认的,并逐字节拷贝
对于内置类型的成员变量直接拷贝即可,对于自定义类型的成员变量,则需要调用对应类的赋值运算符重载。
可以看到,在调用Date类的默认赋值重载函数时,对于自定义类型的成员变量_t,会去调用它的赋值运算符重载。
和拷贝构造函数类似的,如果类中没有涉及到资源管理,我们就可以不实现赋值重载;一旦涉及资源管理就必须要自行实现。
5.3 前置++和后置++重载
前置++的重载函数:
Date& operator++()
{
_day += 1;
return *this;
}
this指向的对象在函数结束后不会销毁,所以可以使用传引用返回提高效率
后置++的重载函数:
Date operator++(int)
{
Date tmp(*this);
_day += 1;
return tmp;
}
因为后置++是先使用后+1,要返回未+1之前的旧值,所以需要tmp来保存*this的值
tmp出了作用域后就会销毁,所以只能传值返回
后置++重载函数中的参数int没有实际作用,只是为了与前置++构成函数重载,以便区分
前置++和后置++的使用:
遇到后置++重载函数时,编译器会自动在参数中放一个整型用来匹配函数
六、const成员
如果一个被const修饰的对象去调用一个普通的成员函数,因为this指针没有被const修饰,就会造成权限放大
针对这种情况,我们需要用const对this指针进行修饰,但是this指针是隐式的,该怎么去修饰它呢
只需要在成员函数的后面加上const即可
我们将const修饰的成员函数称为const成员函数, 看上去是修饰成员函数,实际上修饰的是隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改
我们看看下面这段代码
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << "Print()" << endl;
cout << "year:" << _year << endl;
cout << "month:" << _month << endl;
cout << "day:" << _day << endl << endl;
}
void Print() const //void Print(const Date* this)
{
cout << "Print()const" << endl;
cout << "year:" << _year << endl;
cout << "month:" << _month << endl;
cout << "day:" << _day << endl << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024, 3, 2);
d1.Print();
const Date d2(2024, 3, 2);
d2.Print();
return 0;
}
其中,对象d1没有被const修饰,对象d2被const修饰,它们都调用了Print函数
而一个Print函数没有被const修饰,一个被const修饰,函数的调用情况如何呢?
可以看到,d1调用了第一个Print,d2调用了第二个被const修饰的Print
那么问题来了
1.非const对象可以调用const成员函数吗?
我们将第一个Print删除,运行程序
此时两个对象都能调用const成员函数
2.const成员函数内可以调用其他非const成员函数吗?
我们恢复第一个Print,并在第二个Print内调用第一个Print,运行程序
程序正常运行
3.非const成员函数内可以调用其他const成员函数吗?
程序正常运行
如果成员函数内部不需要修改成员变量时,都可以在后面加上const,这样普通对象和const对象都可以调用
七、取地址操作符重载
这两个默认成员函数一般不需要我们定义,编译器会默认生成。
它们起到对普通对象和const对象取地址的作用
class Date
{
public:
Date* operator&()
{
return this;
}
const Date* operator&() const
{
return this;
}
private:
int _year;
int _month;
int _day;
};
完.