🏖️作者:@malloc不出对象
⛺专栏:C++的学习之路
👦个人简介:一名双非本科院校大二在读的科班编程菜鸟,努力编程只为赶上各位大佬的步伐🙈🙈
目录
- 前言
- 一、类的6个默认成员函数
- 二、构造函数
- 2.1 特性
- 三、析构函数
- 3.1 特性
- 四、拷贝构造函数
- 4.1 特性
- 4.2 构造函数与析构函数的调用顺序
- 五、运算符重载
- 六、友元
- 6.1 友元函数
- 6.2 友元类
- 七、赋值运算符重载
- 7.1 赋值运算符与运算符重载的关系
- 7.2 赋值运算符重载 VS 拷贝构造函数
- 八、const成员函数
- 九、取地址及const取地址操作符重载
前言
本篇文章讲解的是类的六大默认成员函数,它们的特性以及注意点非常多,学起来是有一定难度的,但只要我们认真一点相信什么困难都是可以克服的orz~
一、类的6个默认成员函数
Q:如果一个类中什么成员都没有,简称为空类。空类中真的什么都没有吗?
并不是,任何类在什么都不写时,编译器会自动生成6个默认成员函数。
默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数。
class Date {};
下面就让我们一起来了解一下这6个函数的特性吧!!
二、构造函数
概念:构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。
下面我们来看看日期类的例子:
#include <iostream>
using namespace std;
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;
};
int main()
{
Date d1;
d1.Init(2022, 11, 5);
d1.Print();
Date d2;
d2.Init(2022, 11, 6);
d2.Print();
return 0;
}
对于上面的日期类Date,可以通过Init共有函数对对象设置日期,但如果每次创建对象时都要通过该函数设置信息,未免有点麻烦。那能否在创建对象时,就将信息设置进去呢?为此C++之父就设计出了构造函数来解决这一问题。
2.1 特性
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
其特征如下:
1.函数名与类名相同。
2.无返回值。
3 对象实例化时编译器自动调用对应的构造函数。
4.构造函数可以重载。
注:构造函数的无返回值就是类名后面直接跟形参列表,void不算做无返回值!!!构造函数可以重载的意思就是可以写多个构造函数,提供多种初始化方式。
下面我们就来利用构造函数解决上面日期类出现的问题:
#include <iostream>
using namespace std;
class Date
{
public:
// 初始化对象
// 无参构造函数
Date()
{
_year = 2023;
_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;
};
int main()
{
Date d1; // 1.调用无参构造函数,对象实例化时会自动调用对应的构造函数
d1.Print();
Date d2(2022, 11, 5); // 2.调用有参构造函数
d2.Print();
return 0;
}
我们来看看结果:
我们通过调试带大家感受一下这个过程:
通过上述的分析想必大家都知道了构造函数的用法,其实上述的两个构造参数可以合并成一个缺省构造函数,它也是以后我们最推荐的写法,下面我们一起来看看这段代码:
#include <iostream>
using namespace std;
class Date
{
public:
// 初始化对象
// 无参构造函数
Date()
{
_year = 2023;
_month = 1;
_day = 1;
}
// 有参构造函数,与无参构造函数构成函数重载
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
// 缺省构造函数
Date(int year = 2023, 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(2022, 11, 5);
d1.Print();
Date d2(2022, 11);
d2.Print();
Date d3(2022);
d3.Print();
Date d4;
d4.Print();
Date d5();
d5.Print();
return 0;
}
首先我想问一下这段代码存在哪些错误?
那么该如何解决这些问题呢?
我们实际上只需要定义一个缺省构造函数就可以解决这些问题了,因为无参和有参构造函数都是与缺省构造函数发生冲突了才产生的错误,我们一起来看看修改后的结果:
在以后的学习过程中,我们也是推荐采用缺省构造函数的方式来初始化对象,这样既可以初始化对象了又很好的利用了缺省参数的特性来方便我们初始化,可谓是一举俩得嘿嘿!!
我们来看看下面一组例子,为什么右图定义一个有参构造函数就发生了报错呢?并且报错信息为没有合适的默认构造函数可用???
首先我们在开头就已经讲到过,任何一个类中都有默认的6大成员函数,它们就是天选之子!!对于构造函数来说,只要发生了对象实例化就一定会调用构造函数,而如果我们没显示的定义构造函数的话就会自动的调用默认构造函数如左图所示,它是没有任何问题的。而对于右图来说只要类中实现了任意一种构造函数,此时编译器就不会自动生成一个默认构造函数了!!!换而言之我在右图中显式的定义了这个构造函数,那么在实例化对象时我就会去默认调用这个构造函数,但右图明显的未调用这个有参构造函数,而是无参构造函数,,所以编译器提示未找到合适的默认构造函数。
总结:如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义了任意一种构造函数,编译器将不再生成这个默认的无参构造函数,那么在对象实例化时默认调用的构造函数就是用户显式定义的构造函数(其他构造函数都不行)!!!
接下来我想问大家一个问题:在类中没有显式定义构造函数,此时自动生成的无参默认构造函数中的内容是什么???我们来看一个例子:
d对象调用了编译器生成的无参默认构造函数,但是d对象中_year/_month/_day成员变量依旧是随机值,也就说在这里编译器生成的默认构造函数其实并没有什么用??
解答:这其实是C++之父设计的不合理的地方。在C++中把类型分成内置类型(基本类型)和自定义类型,内置类型就是语言提供的数据类型,如:int/char…,自定义类型就是我们使用class/struct/union等自己定义的类型。默认生成的构造函数对内置类型成员不做处理,而对自定义类型的成员进行处理。对于上述_year、_month以及_day都是内置类型成员,默认构造函数不会对它们进行处理,所以它们打印的是随机值。
注意事项:默认生成的构造函数对内置类型成员不做处理,而对于自定义类型的成员,会去调用它的默认构造函数。默认构造函数:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数都可以认为是默认构造函数。
接下来我们就来看看默认构造函数对自定义类型的处理:
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;
}
首先我们的对Date类进行了实例化对象,它一定会调用构造函数,在Date类中我们没有显式的定义一个构造函数,所以它会生成一个默认的构造函数,但是它只对自定义类型成员进行处理,我们的_t是Time类的一个实例化对象,此时它又会调用构造函数,Time类中显式的定义了一个无参构造函数,所以会一定会调用这个无参构造函数,结果会打印出Time()。
这个设计在当初本来就是有一点不合理的,于是C++之父在C++11中针对内置类型成员不初始化的缺陷又打了补丁,即:内置类型成员变量在类中声明时可以给默认值。
其实通过这些例子我们知道其实到最后使用最多的情况还是自己写构造函数,不用自己定义构造函数的情况还是比较少的。当然了即使这个地方设计的不那么尽如意,大家根据环境的不同来判断要不要自定义写构造函数是最靠谱的。
另外关于构造函数其实还有一点内容没讲完,我放在了下一篇文章给大家讲解。
三、析构函数
通过前面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么没呢的?
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的,而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
3.1 特性
学完了构造函数,学习析构函数就非常的轻松了,因为析构函数很多特性是与构造函数相反的!!
析构函数是特殊的成员函数,其特征如下:
1.析构函数名是在类名前加上字符 ~。
2.无参数无返回值类型。
3.一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数(与默认构造函数的特性相似,都是只对自定义类型成员处理,而不对内置类型成员处理)。 注意:析构函数不能重载。
4.对象生命周期结束时,C++编译系统系统自动调用析构函数。
下面我们一起来看看析构函数的例子:
#include <iostream>
#include <assert.h>
using namespace std;
class Stack
{
public:
// 初始化栈
Stack(int capacity = 4)
{
cout << "Stack()" << endl;
_a = (int*)malloc(sizeof(int) * capacity);
if (nullptr == _a)
{
perror("malloc申请空间失败");
return;
}
_capacity = capacity;
_size = 0;
}
void Push(int x)
{
_a[_size++] = x;
}
bool Empty()
{
return _size == 0;
}
int Top()
{
return _a[_size - 1];
}
// 销毁栈
~Stack() // 析构函数
{
cout << "~Stack()" << endl;
free(_a);
_a = nullptr;
_size = _capacity = 0;
}
private:
// 成员变量
int* _a;
int _size;
int _capacity;
};
int main()
{
Stack st(4);
st.Push(1);
st.Push(2);
st.Push(3);
st.Push(4);
return 0;
}
我们知道动态申请的空间在使用完之后需要进行释放,如果不释放会造成内存泄露,有时候我们经常可能会把这项事情忽略掉,那么在C++中我们显式的在类中定义一个析构函数就能很好的解决这个问题,它会在对象声明周期结束时自动调用,这样就达到了空间释放的功能!!妈妈再也不怕出现内存泄露了🙈🙈
我们通过调试让大家清楚整个过程:
关于析构函数的第三点特性析构函数未显式定义时生成默认的析构函数,这里我不再进行测试了,大家下来可以自行去尝试一下与构造函数是差不多的orz~
四、拷贝构造函数
在现实生活中,可能存在一个与你一样的自己,我们称其为双胞胎。那在创建对象时,可否创建一个与已存在对象一某一样的新对象呢?
答案是可以的,在C++中借助拷贝构造函数可以完成这个任务。拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
4.1 特性
拷贝构造函数也是特殊的成员函数,其特征如下:
1.拷贝构造函数是构造函数的一个重载形式。
2.拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。
3.在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定义类型是调用其拷贝构造函数完成拷贝的。
我们来看一个例子,我们想创建一个新的对象d2它与d1对象内容一样:
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 2023, int month = 2, int day = 25)
{
_year = year;
_month = month;
_day = day;
}
Date(Date d) // 将d1对象中的成员变量赋值给d2对象的成员变量,由此便完成了d1对象的拷贝
{
_year = d._year;
_month = d._month;
_day = d._day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
Date d2(d1); // 将要拷贝的对象作为实参进行传递
return 0;
}
从上面注释的分析来说理论上这个拷贝构造函数是可行的,但为什么编译器会报错而且会出现无限递归调用拷贝构造函数的情况呢?
首先调用函数是不是要先进行传参操作,这个形参为自定义类型,那么我们知道自定义类型拷贝时需要去调用拷贝构造函数,而拷贝构造函数也是一个函数,它也是先要进行传参的,此时的形参还是为自定义类型,所以又会去调用拷贝构造函数,所以这样就引发了无穷递归调用。
这个地方确实是有点难懂的,但其实把思路理清了是非常好想的一件事,我们可以看看下图的分析:
我们来看一个例子让大家理清函数调用与拷贝构造函数之间的关联:
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 2023, int month = 2, int day = 25)
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& d)
{
cout << "调用了拷贝构造函数" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
private:
int _year;
int _month;
int _day;
};
void Func1(Date d)
{
cout << "Func1()" << endl;
}
void Func2(Date& d)
{
cout << "Func2()" << endl;
}
int main()
{
Date d1;
Func1(d1);
Func2(d1);
return 0;
}
通过这个例子想必我已经讲清楚了这段代码中函数调用传参与拷贝构造函数之间的关联。
4.2 构造函数与析构函数的调用顺序
下面我们再来看一个比较难的例题,如果这个例题你能很好的消化或者独立做出来,那么对于引用、构造函数、析构函数、拷贝构造函数以及函数调用这部分的理解会更进一步:
#include <iostream>
using namespace std;
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;
}
构造函数与析构函数的调用顺序:
调用构造函数的顺序:基类(父类)构造函数、对象成员构造函数、派生类本身的构造函数。
调用析构函数的顺序:派生类本身的析构函数、对象成员析构函数、基类(父类)构造函数。
我们来看一组关于全局对象与静态对象的例子,大家想想看答案会是什么?
#include<iostream>
using namespace std;
class A
{
public:
A()
{cout << "A的构造函数" << endl;}
~A()
{cout << "A的析构函数" << endl;}
};
class B
{
public:
B()
{cout << "B的构造函数" << endl;}
~B()
{cout << "B的析构函数" << endl;}
};
class C
{
public:
C()
{cout << "C的构造函数" << endl;}
~C()
{cout << "C的析构函数" << endl;}
};
class D
{
public:
D()
{cout << "D的构造函数" << endl;}
~D()
{cout << "D的析构函数" << endl;}
};
C c;
int main()
{
static D d;
A a;
B b;
return 0;
}
通过上图我们发现全局对象比静态局部变量先构造,再其次是构造静态局部对象?还是说跟静态局部对象的位置有关系?
我们发现静态局部对象跟局部成员对象的的构造顺序是一致的,先实例化就先进行构造,但是静态成员的析构不是按照局部成员对象的析构顺序来的,它与全局对象构成栈的关系,先构造的后析构。
下面我们再来看看全局静态对象与全局对象的构造与析构顺序:
从上图我们可以发现,静态全局对象与全局对象的构造析构顺序与定义的先后顺序有关!!
关于构造函数与析构函数的调用顺序有些知识点我们现阶段还未学到,到了后面我们再来进行详谈。
我们只需要记住,在一般情况下,调用析构函数的次序正好与调用构造函数的次序相反:最先被调用的构造函数,其对应的(同一对象中的)析构函数最后被调用,而最后被调用的构造函数,其对应的析构函数最先被调用。简单的来说,先构造的后析构,后构造的先析构。 我们根据栈的性质(后进先出)来记住是最好不过的!!
拷贝构造函数的典型应用场景:拷贝一份已存在的对象去构造一个新对象、函数参数类型为自定义类类型对象、函数返回值类型为自定义类类型对象。
这道题涉及到的知识点还是很多的,相对来说还是较难的,但是我们只要吸收了对我们的收获是很大的!!实际上这道题如果使用引用作为函数参数类型、做返回值类型的话能减少很多的拷贝工作,所以根据实际场景,能用引用就尽量使用引用。
关于拷贝构造函数它其实还有其他的实现方式,下面我们一起来看看:
#include <iostream>
using namespace std;
class Date
{
public:
// 缺省构造函数
Date(int year = 2023, int month = 2, int day = 25)
{
_year = year;
_month = month;
_day = day;
}
// 拷贝构造函数
Date(const Date& d) // 这里我们一般使用const修饰参数,这是为了防止写错赋值对象的情况,例如:写成d._year = _year,这样得出的答案就是随机值了
{
_year = d._year;
_month = d._month;
_day = d._day;
}
// 构造函数
Date(Date* d)
{
_year = d->_year;
_month = d->_month;
_day = d->_day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
Date d2(d1);
Date d3 = d1; // 写成'='的形式也是可以的,‘=’是一个赋值运算符,后续我们会提到六大默认函数之一赋值重载函数,但在这里用'='就表示拷贝构造,在文章后面我会将两者进行对比
Date d4(&d1); // 写成传指针的构造函数之后,我们需要传对象的地址,这样其实是没有写成引用拷贝对象这么直观的并且使用起来也没引用这么方便
Date d5 = &d1;
return 0;
}
注意:拷贝构造函数的参数写成指针也能实现,但是这个函数就不再是拷贝构造函数了,而是构造函数。拷贝构造函数的引用一般要用const关键字修饰,这样可以避免将两个参数赋值行为写反!!
接下来我们来探究一下拷贝构造函数的第三个特性:为什么自定义类型一定要调用其拷贝构造函数来完成拷贝呢??
class Stack
{
public:
// 初始化栈
Stack(int capacity = 4)
{
cout << "Stack()" << endl;
_a = (int*)malloc(sizeof(int) * capacity);
if (nullptr == _a)
{
perror("malloc申请空间失败");
return;
}
_capacity = capacity;
_size = 0;
}
void Push(int x)
{
_a[_size++] = x;
}
bool Empty()
{
return _size == 0;
}
int Top()
{
return _a[_size - 1];
}
// 销毁栈
~Stack()
{
cout << "~Stack()" << endl;
free(_a);
_a = nullptr;
_size = _capacity = 0;
}
private:
// 成员变量
int* _a;
int _size;
int _capacity;
};
int main()
{
Stack st;
st.Push(1);
st.Push(2);
st.Push(3);
st.Push(4);
Stack st2(st); // 将st对象拷贝给st2对象,未显式定义拷贝构造函数,默认的拷贝构造函数对象按内存存储按字节序完成拷贝
return 0;
}
我们来看看结果:
我们发现程序已经崩溃了,这是为什么?
要想解决这个问题我们只能自己去实现这个拷贝构造函数进行拷贝,之前的内置类型拷贝也被叫做浅/值拷贝,它是按照一字节一字节来进行拷贝的,而对于自定义类型拷贝是去调用它的成员函数拷贝构造/赋值重载的,它也被叫做深拷贝!!!
下面我们就来简单实现一下这个深拷贝,通过上述例子我们知道值拷贝不适用于指向同一块空间的拷贝,那么我们就将两者分别指向不同的独立的空间,这样就不会发生上面的问题了:
Q:什么情况下需要我们实现拷贝构造函数?
如果类中没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,自己实现了析构函数释放空间,则拷贝构造函数是一定要写的,否则就是浅拷贝(按照字节序方式直接进行拷贝)。
我们再来谈一个题外话,为什么内置类型不会出现这种异常的情况呢?
因为内置类型是很简单的类型,编译器是完全能够驾驭它的拷贝方式的,而自定义类型可以认为是多样性的,编译器不能驾驭它的拷贝方式,由此就交给了拷贝构造函数去完成这项任务。
五、运算符重载
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:关键字operator
后面接需要重载的运算符符号。
函数原型:返回值类型 operator操作符(参数列表)
注意:
- 不能通过连接其他符号来创建新的操作符:比如
operator@
- 重载操作符必须有一个自定义类类型参数
- 用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不能改变其含义。
- 作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的
this
指针。.*
::
sizeof
?:
.
注意以上5个运算符不能重载。.*
是极少出现的一种运算符重载
为什么会出现运算符重载?以及为什么增强了代码的可读性?
运算符重载的出现就是为了解决自定义类型无法直接使用运算符来进行运算的问题。假设我们用函数来实现各项运算符的功能,但使用函数我们就需要取各种各样的函数名,这样在调用的时候存在名字的规范性问题,而使用运算符重载我们不需要命名函数就可以直接使用运算符了。例如要实现俩个自定义对象的比较,我们使用 d1 < d2即可,这样是不是代码的可读性大大提高了!!!
下面我们就来针对日期类进行各项运算符重载的讲解:
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 2023, int month = 2, int day = 26)
{
_year = year;
_month = month;
_day = 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._month;
}
int main()
{
Date d1(2023, 2, 25);
Date d2(2023, 2, 26);
cout << (d1 == d2) << endl; // 使用运算符会自动转换成去调用对应的运算符重载函数,operator==(d1,d2)
operator==(d1, d2); // 显式调用了==运算符重载函数,但是它的可读性不高,这样写和我们自定义一个函数实现==的功能然后去调用这个函数差不多,这就体现不出运算符重载的优势所在了
return 0;
}
从上图我们知道operator==
函数就是两个日期类对象的比较运算符重载函数,但是我们可以看到如果我们的operator==
函数是定义在类外面的也就是全局的,我们要想使用Date
类中的成员变量就必须将其放开到公有域中,但是这样写的话C++的封装性就体现不出来了。
这里有几种解决方式,下面我给出最简单的方式就是将operator==
函数写在Date
类中,但是我们不能直接复制粘贴到类中就完事了,我们来看看现象:
为什么这里编译器提示函数参数太多呢?难道我们不是利用两个对象来做比较的吗?
这是因为此时我们的
operator==
函数已经在类中了,它是一个成员函数,既然是成员函数那么参数列表中一定隐含了一个this指针,所以这里我们只需要传递一个对象就好了!!
下图为正确的表示形式:
operator==: 比较两个日期类对象是否相等
bool operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
operator<: 判断第一个日期类对象是否小于第二个日期类对象
bool operator<(const Date& d)
{
return _year < d._year
|| (_year == d._year && _month < d._month)
|| (_year == d._year && _month == d._month && _day < d._day);
}
有了上面两个运算符重载函数接下来其他的运算符重载函数都可以进行复用,不用我们一个个的去实现了。
operator<=: 判断第一个日期类对象是否小于或等于第二个日期类对象
bool operator<=(const Date& d)
{
// 复用d1对象的operator<以及operator==运算符重载函数
return (*this < d) || (*this == d);
}
operator>: 判断第一个日期类对象是否大于第二个日期类对象
bool operator>(const Date& d)
{ // 复用operator<=运算符重载函数
return !(*this <= d);
}
operator>=: 判断第一个日期类对象是否大于或等于第二个日期类对象
bool operator>=(const Date& d)
{
return !(*this < d);
}
operator!=: 判断第一个日期类对象是否不等于第二个日期类对象
bool operator!=(const Date& d)
{
return !(*this == d);
}
operator+=:求第一个日期加上第二个日期得到的日期,返回的是两者加上之后得到的新的日期对象
// 由于每个月的天数是不确定的,所以我们要获取每个月的天数
int GetMonthDay(int year, int month)
{
// static修饰数组避免频繁创建
static 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 < 0的情况
if (day < 0)
{
return *this -= -day;
}
_day += day;
while (_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
++_month;
if (_month == 13)
{
++_year;
_month = 1;
}
}
return *this;
}
+=
运算符会修改变量的值,返回的是加上之后新的日期对象。出了函数的作用域*this
对象还存在,所以我们可以传引用返回日期类对象,这样可以减少拷贝提高程序效率。
operator+:求第一个日期加上第二个日期表示的日期对象,返回的是原来的日期对象
Date operator+(int day)
{
Date ret(*this);
ret += day;
return ret;
}
不能传引用返回,因为ret对象是局部变量出了函数作用域会被销毁。
知道了日期对象+day的写法,对于日期对象-day就很简单了。
operator-=: 求第一个日期减去第二个日期得到的日期,返回的是两者相减之后得到的新的日期对象
Date& operator-=(int day)
{
// 处理 day < 0的情况
if (day < 0)
{
return *this += -day;
}
_day -= day;
while (_day <= 0)
{
--_month;
if (_month == 0)
{
--_year;
_month = 12;
}
_day += GetMonthDay(_year, _month);
}
return *this;
}
operator-: 求第一个日期减去第二个日期表示的日期对象,返回的是原来的日期对象
Date operator-(int day)
{
Date ret(*this);
ret -= day;
return ret;
}
下面我们来验证一下代码的正确性:
operator++: 前置++与后置++
前置++和后置++都是一元运算符,为了让前置++ 与 后置++ 能正确重载,C++规定:后置++重载时多增加一个int类型的参数,但调用函数时该参数不用传递,编译器自动传递。
前置++
Date& operator++()
{
*this += 1;
return *this;
}
前置++:返回+1之后的新结果。出了函数作用域*this指向的对象还存在,此时我们可以传引用减少拷贝。
后置++
Date operator++(int) // int参数仅仅是为了占位跟前置重载区分,传不传参都无所谓
{
Date tmp(*this);
*this += 1;
return tmp;
}
后置++是先使用后+1,所以需要返回+1之前的旧值。我们先将原值拷贝一份给tmp,*this指向的对象+1之后返回tmp;tmp为临时变量那么不能传引用返回。
注意:在内置类型中前置++与后置++的区别不大,但是在自定义类型中需要使用++时,我们建议使用前置++,因为后置++需要多调用两次拷贝构造函数!!
operator- -: 前置- -与后置- -
前置- -和后置- -跟前置++与后置++是一样的道理,这里我就不再对它们进行解释了。
前置- -
Date operator--()
{
*this += 1;
return *this;
}
后置–
Date operator--(int)
{
Date tmp(*this);
*this += 1;
return tmp;
}
日期-日期
我们的思路是让一直让小的日期++直到它们两者相等。
int operator-(const Date& d)
{
Date max = *this;
Date min = d;
int flag = 1;
if (*this < d)
{
max = d;
min = *this;
flag = -1;
}
int n = 0;
while (min != max)
{
n++;
min++;
}
return n * flag;
}
operator<< 与 operator>> : 输入与输出流重载
我们经常见到cout <<
用来输出内容,cin >>
用来输入内容,但你真的明白它实现的机理吗?下面就让我们一起来探究一下吧:
cin
是头文件istream
中的对象,cout
是头文件ostream
的对象;>>
表示流提取运算符,<<
表示流插入运算符。注:istream
与ostream
都是类。
Q:为什么C++要重载流插入、流提取运算符呢?
在C语言中printf其实是只能打印内置类型内容的,之所以能打印结构体成员是因为结构体成员是自由的不受任何限制;对于C++而言结构体/类中的成员变量是私有属性的,所以就要按照C++的语法规定来重载这个运算符。cin与cout为什么能够自动识别类型?就是因为标准库已经将内置类型全部构造完成了,所以它能够直接进行使用,而对于自定义类型来说,编译器并没有帮我们完成,我们需要自己重载流插入、流提取运算符。
题外话:为什么经常有人说printf的效率要比cout稍微高一些?
因为使用cout会涉及大量的函数调用并且C++为了保持与C语言的兼容性,它们的流需要保持同步!!
自定义类型需要运算符重载才能进行使用:
既然编译器没帮我们实现自定义类型的重载,那么同样的我们就自己来构造运算符重载函数!!
operator<<: 流提取日期类对象
// 以下代码放在Date类中
void operator<<(ostream& out)
{
out << _year << "年" << _month << "月" << _day << "日" << endl;
}
我们将这段代码放在Date类中作为一个成员函数,但是我们的this指针会默认占据参数的第一个位置,这也就导致了与我们平时使用的习惯相斥使用起来特别的别扭,那么我们该怎么使cout对象作为第一个参数呢?
我们可以试着将它放在全局中进行函数重载,第一个参数位置就为cout
对象了,为了保证它可以连续输出多项内容,所以它的返回值要成ostream
类,我们一起来看看:
虽然成功的完成了任务,但是我们将类中私有成员变量放到了公有域中,这样封装性又不好了。
这里我们有两种方法:一种是定义一个在public中的函数来接收私有成员变量的值,我们直接使用对象调用这个函数就能使用私有属性的成员变量了;另外一种方式是借助友元函数来实现。下面我们讲解第二种方式,同时也顺便把友元的知识一并全部讲完。
六、友元
友元提供了一种突破封装的方式,有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不宜多用。
友元分为:友元函数和友元类。
6.1 友元函数
友元函数:友元函数可以直接访问类的私有成员,它是定义在类外部的普通函数,不属于任何类,但需要在类的内部声明,声明时需要加friend关键字。 形象的来讲,就是在类中声明这个函数是友好的,把这个函数当成朋友充分信任它,让它能访问类中的私有成员。
operator>>: 流提取日期对象
实现了operator<<
重载定义这个重载函数就非常简单了,下面我们一起来看看:
istream& operator>>(istream& in, Date& d)
{
in >> d._year >> d._month >> d._day;
return in;
}
6.2 友元类
友元类的所有成员函数都可以是另一个类的友元函数,都可以访问另一个类中的非公有成员。
- 友元关系是单向的,不具有交换性。
比如上述Time类和Date类,在Time类中声明Date类为其友元类,那么可以在Date类中直接
访问Time类的私有成员变量,但想在Time类中访问Date类中私有的成员变量则不行。- 友元关系不能传递。
如果C是B的友元, B是A的友元,则不能说明C时A的友元- 友元关系不能继承。在继承位置再给大家详细介绍。
我们来看一个例子:
#include <iostream>
class Time
{
friend class Date; // 声明日期类为时间类的友元类,则在日期类中就可以直接访问Time类中的私有成员变量
public:
Time(int hour = 0, int minute = 0, int second = 0)
: _hour(hour)
, _minute(minute)
, _second(second)
{}
void Print()
{
std::cout << _hour << "时" << _minute << "分" << _second << "秒" << std::endl;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
: _year(year)
, _month(month)
, _day(day)
{}
void SetTimeOfDate(int hour, int minute, int second)
{
// 直接访问时间类私有的成员变量
_t._hour = hour;
_t._minute = minute;
_t._second = second;
_t.Print();
}
void Print()
{
std::cout << _year <<"年" << _month << "月" << _day << "日" << std::endl;
}
private:
int _year;
int _month;
int _day;
Time _t;
};
int main()
{
Date d1(2023, 3, 1);
d1.Print();
Time t1(23, 34, 20);
t1.Print();
d1.SetTimeOfDate(10, 24, 0);
return 0;
}
在Date类中使用_Time类中非公有成员完成了对_Time类非公有成员值的修改!!
关于友元暂时就讲到这里了,后续我们在遇到一些特殊场景再来详谈。
七、赋值运算符重载
赋值运算符重载格式
- 参数类型:
const T&
,传递引用可以提高传参效率- 返回值类型:
T&
,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值- 检测是否自己给自己赋值
- 返回
*this
:要复合连续赋值的含义
赋值运算符重载既是默认成员函数又是运算符重载!!
函数重载:支持函数名相同,参数不同的函数可以同时使用。
运算符重载:自定义类型对象可以使用运算符。
operator=:赋值运算
// 第一种形式,对于单次赋值
void operator=(const Date& d)
{
if (*this != d) // 避免出现同一个对象进行赋值操作,如d1 = d1这种无意义的赋值操作
{
_year = d._year;
_month = d._month;
_day = d._day;
}
}
我们知道赋值是可以连续复合进行赋值的,例如:在内置类型中可以这样int x1 = x2 = x3,来进行连续赋值,对于赋值运算符来说是从右往左结合的,也就是x2 = x3进行赋值操作之后这里其实会产生一个临时变量,之后再将这个临时变量赋值给x1,,那么我们在实现复合连续赋值重载函数时就需要将对象进行返回,由此就写成了下面的形式:
Date& operator=(const Date& d)
{
if (*this != d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
对于这个日期类对象赋值来说,实际上不需要定义也可以达到目的,因为编译器会默认生成一个赋值重载函数,它与内置类型赋值采取的都是值拷贝的形式,这里未涉及空间资源的申请,所以不会有任何问题产生。
Q:赋值运算符重载到底什么时候需要我们自己去定义呢?
像拷贝构造函数一样,如果该类需要写析构函数,那么就需要写赋值运算符重载函数;如果该类不需要写析构函数,那么就不需要写赋值运算符重载。
我们把赋值运算符写在类外,此时它为一个全局赋值运算符重载函数,我们看看它能不能实现赋值功能:
Date& operator=(Date& d1, Date& d2)
{
if (&d1 != &d2)
{
d1._year = d2._year;
d1._month = d2._month;
d1._day = d2._day;
}
}
为什么赋值运算符只能重载成类的成员函数而不能重载成全局函数?
赋值运算符如果不显式实现,编译器会生成一个默认的(因为它是六大默认成员函数之一)。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。
在《C++ prime》这本书中也特意提及到了这个语法项:
7.1 赋值运算符与运算符重载的关系
关系:
自定义类型使用运算符都需要重载,除了赋值运算符和&运算符重载(默认六大成员函数)!!!
关于运算符重载这里只讲了一部分,有些特殊一点的运算符(例如:[]它也是运算符)重载还没讲到过,以后碰见了我们再来谈吧!
7.2 赋值运算符重载 VS 拷贝构造函数
我们来看看下面这个例子:
#include <iostream>
class Date
{
public:
Date(int year = 2003, int month = 10, int day = 24)
{
std::cout << this << ": Date(int, int, int)" << std::endl;
_year = year;
_month = month;
_day = day;
}
bool operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
bool operator!=(const Date& d)
{
return !(*this == d);
}
Date& operator=(const Date& d)
{
std::cout << this << ": Date& operator=(const Date& )" << std::endl;
if (*this != d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
Date(Date& d)
{
std::cout << this << ": Date(Date& d)" << std:: endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
void Print()
{
std::cout << _year << "年" << _month << "月" << _day << "日" << std::endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 2, 27);// 构造
d1.Print();
Date d2 = d1; //拷贝构造
d2.Print();
Date d3(d2); //拷贝构造
d3.Print();
d3 = d1; //赋值重载
d3.Print();
Date d4 = d3 = d2; // 赋值重载 + 构造拷贝
d4.Print();
return 0;
}
从上图我们知道拷贝构造函数会构造出一个新的对象,这个新的对象与拷贝对象的内容一致;赋值运算符重载函数没有新的对象生成,两边使用的都是已经实例化过的对象,将右边对象的内容赋给左边对象,两者内容一致!!
我们还可以通过调试来检测一下我们的推测:
结论:赋值运算符和拷贝构造函数最大区别即是赋值运算符重载没有新的对象生成,而拷贝构造函数会生成新的对象(拷贝构造函数是构造函数的一种特殊重载函数,构造函数和拷贝构造函数一致,都有新的对象生成)。拷贝构造函数是将老对象的数据成员一一赋值给新的对象数据成员的一种构造函数;赋值运算符重载函数是将右边已经存在对象的数据成员一一赋值给左边另一个已经存在对象数据成员的一种操作符重载函数!!
其实说白了,只有在两边对象都已经存在的情况下使用=
才是赋值运算符重载,其他情况下都是拷贝构造函数!!!
八、const成员函数
将const
修饰的“成员函数”称之为const
成员函数,const
修饰类成员函数,实际是修饰该成员函数隐含的this
指针,表明在该成员函数中不能对类的任何成员进行修改。
我们来看下面一个例子,大家认为会打印出什么结果呢?
class A
{
public:
void Print() const
{
cout << _a << endl;
}
private:
int _a = 20;
};
int main()
{
const A aa;
aa.Print();
return 0;
}
通过上图我们发现这段代码竟然报错了,这是为何?
这是因为我们的类A对象aa使用了const修饰,那么在调用成员函数时编译器会隐式的将&aa ==> const A*传递给成员函数Print的形参,但是成员函数Print默认是用A* this来接收实参,所以此时就发生了权限放大的问题,我们在之前讲过对于指针和引用来说权限放大是不行的。
那么该做如何修改呢?在C++中为了解决这个问题使用了const修饰成员函数,在成员函数后加上const表示它是一个const成员函数:
下面这种权限缩小的方式也是可行的:
内部不改变成员变量的成员函数最好加上const,const对象和普通对象都可以调用!!只能修饰成员函数因为它有this指针
下面有几个问题我们一起来看一下:
1.const对象可以调用非const成员函数吗? 不可以
2.非const对象可以调用const成员函数吗? 可以
3.const成员函数内可以调用其它的非const成员函数吗?
4.非const成员函数内可以调用其它的const成员函数吗?
第一二个问题我们已经解决了,接下来我们看看剩下的问题:
Q:const成员函数内可以调用其它的非const成员函数吗?
Q:非const成员函数内可以调用其它的const成员函数吗?
九、取地址及const取地址操作符重载
这两个默认成员函数一般不用重新定义 ,编译器默认会自动生成。
operator&: 取地址操作符重载
Date* operator&()
{
return this;
}
const operator&: const取地址操作符重载
const Date* operator&() const
{
return this;
}
如果这两个函数不写也没有什么问题,编译器生成也够用。如果你不想让别人拿到类对象的地址就可以像下面这样写。
Date* operator&()
{
return nullptr;
}
const Date* operator&() const
{
return nullptr;
}
本篇文章的内容就到这里了,这篇文章有一定的难度,特别是细节方面需要大家去认真琢磨,最后如果文章有任何疑问或者错处,欢迎大家评论区一起交流orz~🙈🙈