0.关注博主有更多知识
C++知识合集
目录
1.类的默认成员函数
2.构造函数和析构函数基础
3.构造函数进阶
4.析构函数进阶
5.拷贝构造函数
6.运算符重载
7.日期类
7.1输入&输出&友元函数
8.赋值运算符重载
9.const成员函数
9.1日期类完整代码
10.取地址重载
1.类的默认成员函数
事实上一个空类它并不是什么都没有,它会有6个默认成员函数。默认成员函数指的是当我们没有显式的实现时编译器自动生成的成员函数。现在的任务不是去理解什么是默认成员函数,现在需要理解的是空类并不是什么都没有就够了。
以下是6个默认成员函数的示意图:
2.构造函数和析构函数基础
我们现在可以使用简单的类定义一个栈:
#include <cstdlib>
using namespace std;
class Stack
{
public:
void Init(int capacity = 4)
{
_a = (int*)malloc(sizeof(int)*capacity);
_top = 0;
_capacity = capacity;
}
void Push(int in)
{
// 暂时不考虑扩容...
_a[_top++] = in;
}
void Destroy()
{
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
int* _a;
int _top;
int _capacity;
};
在说明我的目的之前先介绍一个全新的头文件<cstdlib>,这个头文件实际上与C语言库的<stdlib.h>没有区别,这是C++的升级手法,C++嫌弃C语言的原生库,因为那样会带来命名冲突,所以索性将C语言的库封装进C++的std命名空间中。其中在原有的<stdlib.h>中加上一个"c"作为前缀,表示该头文件是C++头文件,再将其后缀".h"去掉,表示该头文件封装在std命名空间中。
接下来进入主题。定义了一个类之后,其成员变量需要被初始化,所以我们定义了一个名为Init的成员函数;当不需要使用该对象时或对象销毁时,我们希望释放它所动态开辟的空间,所以我们定义了一个名为Destroy的成员函数。那么假设某个程序员的记性很差,在定义Stack对象时忘记初始化,在对象销毁时忘记释放动态开辟的空间,那么就会造成无法向栈push元素,还会造成内存泄漏。实际上C++的祖师爷考虑到了这个问题,提出了一种名为构造函数的成员函数,其目的就是为了定义对象时自动初始化;还提出了一种名为析构函数的成员函数,其目的就是在对象销毁时自动释放动态开辟的资源(例如malloc、new出来的空间或者fopen打开的文件)。
前面说过,构造函数和析构函数属于6个默认成员函数,也就是说我们不显式的实现这两个函数,编译器自动帮我们生成。也就是说在上面的Stack类当中是存在构造函数和析构函数的,只不过是编译器隐式生成,我们看不到。
构造函数的定义:
1.构造函数是特殊的成员函数,其目的是为了初始化对象(不是对象创建时开辟空间,是初始化对象当中的数据),构造函数即使是一个特殊的成员函数,但是它依然持有this指针(没有this指针的成员函数称为静态成员函数)
2.定义构造函数的规则:
1)函数名与类名相同
2)无返回值:这个"无返回值"不是指构造函数的返回类型为void,而是指在定义构造函数时不写返回类型
3)类实例化对象时,由编译器自动调用对应的构造函数
4)构造函数可以传参
5)构造函数可以重载(因为可以传参)
那么我们对上面的Stack类进行修改:
#include <cstdlib>
using namespace std;
class Stack
{
public:
Stack(int capacity = 4)// 构造函数
{
_a = (int*)malloc(sizeof(int)*capacity);
_top = 0;
_capacity = capacity;
}
void Push(int in)
{
// 暂时不考虑扩容...
_a[_top++] = in;
}
void Destroy()
{
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
int* _a;
int _top;
int _capacity;
};
我们在实例化Stack对象的时候就可以这么玩了:
int main()
{
Stack s1;// 自动调用构造函数,不传参,使用缺省参数
// Stack s1();// 不传参为什么不这样写?
Stack s2(100);// 传参,其容量为100
return 0;
}
我们注意到了,当要对构造函数进行传参时,直接在对象名后面更上参数即可。那么为什么不能像注释中所说的那样定义不传参给构造函数的对象呢?我们换一种视角看它:
Stack func();
这是一个函数声明!不是定义对象!
析构函数的定义:
1.析构函数也是一个特殊的成员函数,目的是释放对象当中动态开辟的资源,它也有this指针
2.定义析构函数的规则:
1)函数名和类名相同,但是要在前面加一个"~"。"~"的愿意是按位取反,析构函数加一个"~"是为了与构造函数区分、对立
2)无返回值,与构造函数相同
3)对象即将销毁时,由编译器自动调用析构函数
4)析构函数不能带参数(除了默认的this指针)
5)不能重载
上面的一段代码是存在内存泄露的,我们对Stack类、主函数做出修改,以证明对象销毁时编译器自动调用析构函数:
#include <cstdlib>
#include <iostream>
using namespace std;
class Stack
{
public:
Stack(int capacity = 4)// 构造函数
{
_a = (int*)malloc(sizeof(int)*capacity);
_top = 0;
_capacity = capacity;
}
void Push(int in)
{
// 暂时不考虑扩容...
_a[_top++] = in;
}
~Stack()
{
free(_a);
_a = nullptr;
_top = _capacity = 0;
cout << "~Stack" << endl;
}
private:
int* _a;
int _top;
int _capacity;
};
int main()
{
Stack s1;// 自动调用构造函数,不传参,使用缺省参数
Stack func();
Stack s2(100);// 传参,其容量为100
return 0;
}
在这里再插一段题外话,上面的s1和s2和哪个对象先销毁,哪个对象后销毁?答案是s2先销毁,s1后销毁。我们是在main函数定义的对象,就是说这两个对象遵循入栈和出栈顺序,其中s2后进,那它就应该先出。
3.构造函数进阶
前面说过,如果构造函数没有显式定义,那么编译器会为我们默认生成一个。编译器默认生成的构造函数是无参的构造函数,还是以Stack类为例子,观察该构造函数的作用:
#include <cstdlib>
#include <iostream>
using namespace std;
class Stack
{
public:
void Push(int in)
{
// 暂时不考虑扩容...
_a[_top++] = in;
}
~Stack()
{
free(_a);
_a = nullptr;
_top = _capacity = 0;
cout << "~Stack" << endl;
}
private:
int* _a;
int _top;
int _capacity;
};
int main()
{
Stack s1;
return 0;
}
在监视窗口中,我们发现,编译器默认生成的构造函数似乎什么作用也没起,Stack类的所有成员都没有被初始化,甚至空间都没有动态开辟。事实上这不代表默认构造函数没有用,只不过作用不在这里。
编译器默认生成的构造函数的作用:对内置类型不处理,自动调用自定义类型的默认构造函数(默认构造函数指的是无参的、全缺省的、编译器默认生成的构造函数中的其中一个,它们三个只能同时存在一个)。
在C++中,内置类型指的是C++的原生类型,即我们熟知的int、double、char、指针类型等等;自定义类型指的是我们通过struct或者class定义出的类类型。如果我们在上面的Stack类当中再增加一个自定义类型的对象,就能看到编译器默认生成的构造函数的效果:
#include <cstdlib>
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)// 默认构造
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
class Stack
{
public:
void Push(int in)
{
// 暂时不考虑扩容...
_a[_top++] = in;
}
~Stack()
{
free(_a);
_a = nullptr;
_top = _capacity = 0;
cout << "~Stack" << endl;
}
private:
int* _a;
int _top;
int _capacity;
Date _d;
};
int main()
{
Stack s1;
return 0;
}
编译器默认生成的构造函数的使用场景:在我们定义类时,如果成员变量都是自定义类型的,那么就不需要显式地定义构造函数,因为编译器默认生成的构造函数会自动调用自定义类型的默认构造函数。也就是说,当编译器默认生成的构造函数足够我们使用时,就不需要显式地定义构造函数。
如果我们定义的类当中存在自定义类型和内置类型,那么编译器默认生成的构造函数是不够用的,此时我们必须显式地定义构造函数,那么对于自定义类型的初始化要通过初始化列表,不过在这篇博客中不会提到。除了初始化列表初始化自定义类型的方式,在C++11当中打了一个补丁,即内置类型成员变量可以给缺省值,构造函数(无论哪种构造函数)会使用该缺省值初始化成员:
#include <cstdlib>
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)// 默认构造
{
_year = year;
_month = month;
_day = day;
}
~Date()
{
cout << "~Date()" << endl;
}
private:
int _year;
int _month;
int _day;
};
class Stack
{
public:
void Push(int in)
{
// 暂时不考虑扩容...
_a[_top++] = in;
}
private:
// 内置类型成员变量可以给出缺省值
int* _a = (int*)malloc(sizeof(int)*4);
int _top = 0;
int _capacity = 4;
Date _d;
};
int main()
{
Stack s1;
return 0;
}
需要注意的是,内置类型给定缺省值之后并不代表在类中定义了变量,它仅仅是一个声明。同理,如果类中全是内置类型的成员变量,那么它们都可以使用缺省值。
4.析构函数进阶
如果我们没有显式定义析构函数,编译器也会生成一个无参的默认析构函数。与构造函数一样,编译器自动生成的默认析构函数会自动调用自定义类型的析构函数:
#include <cstdlib>
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)// 默认构造
{
_year = year;
_month = month;
_day = day;
}
~Date()
{
cout << "~Date()" << endl;
}
private:
int _year;
int _month;
int _day;
};
class Stack
{
public:
void Push(int in)
{
// 暂时不考虑扩容...
_a[_top++] = in;
}
private:
int* _a;
int _top;
int _capacity;
Date _d;
};
int main()
{
Stack s1;
return 0;
}
编译器默认生成的析构函数的使用场景:如果定义类时,类中没有进行任何动态资源的申请,那么析构函数可以不写。
5.拷贝构造函数
如果我们没有显式定义拷贝构造函数,那么编译器会自动生成一个拷贝构造函数。拷贝构造函数的本质也是构造函数,其目的也是为了初始化,不过拷贝构造函数是将已存在的同类类型对象拷贝给正在初始化的对象:
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)// 默认构造函数
{
_year = year;
_month = month;
_day = day;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
int main()
{
Date d1(2023, 4, 26);
Date d2(d1);// 使用d1对象作为初始化数据,初始化d2对象
return 0;
}
拷贝构造函数的显式定义:
1.拷贝构造函数是构造函数的一个重载
2.拷贝构造函数的参数只能有一个,并且必须是类类型对象的引用
3.如果拷贝构造函数的参数不是类类型对象的引用,而是一个普通的、传值的类类型对象形参,那么编译器会阻止这种行为,因为该行为会引发无穷递归
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)// 默认构造函数
{
_year = year;
_month = month;
_day = day;
}
Date(Date& d)// 拷贝构造
{
_year = d._year;
_month = d._month;
_day = d._day;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
接下来解析为什么构造函数不能传值调用:如果拷贝构造函数是以传值传参的方式进行调用,那么拷贝构造的函数的参数就是一个局部对象,在调用过程中,实参就会拷贝给该局部对象,那么此时涉及到拷贝,就要调用拷贝构造函数,而拷贝构造函数是以传值方式调用的......如此往复就会触发无穷递归
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)// 默认构造函数
{
_year = year;
_month = month;
_day = day;
}
Date(Date d)// 拷贝构造
{
_year = d._year;
_month = d._month;
_day = d._day;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
另外,还建议拷贝构造的参数是类类型对象的常引用,因为不排除程序员的失误写出下面这段代码:
Date(Date& d)// 拷贝构造
{
_year = d._year;
_month = d._month;
d._day = _day;// 程序员的失误把赋值方向弄反了
}
所以我们将其修改成:
Date(const Date& d)// 建议使用类类型对象的常引用
{
_year = d._year;
_month = d._month;
//d._day = _day;// 报错,常量不能被修改
_day = d._day;
}
编译器默认生成的拷贝构造的作用:
1.对内置类型逐字节进行拷贝:例如本小节中的第一份代码,我们没有显式定义拷贝构造,那么编译器就会默认生成一个拷贝构造。而Date类中恰好都是内置类型,所以直接逐字节拷贝
2.对于自定义类型则是调用自定义类型的拷贝构造完成拷贝:如果类中的成员存在自定类型的对象,那么该成员对象通过它的拷贝构造来完成拷贝。例如下面这个例子:
#include <cstdlib>
using namespace std;
class Stack
{
public:
void Push(int in)
{
// 暂时不考虑扩容...
_a[_top++] = in;
}
private:
int* _a = (int*)malloc(sizeof(int)*4);
int _top = 0;
int _capacity = 4;
};
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)// 默认构造函数
{
_year = year;
_month = month;
_day = day;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
Stack _st;
};
int main()
{
Date d1(2023,4,27);
Date d2(d1);
return 0;
}
、
在此例中,Date类中定义了一个Stack类型的对象,Date的构造函数没有显式初始化该对象,那么该对象就会调用自己默认构造。d1对象拷贝正在实例化的d2对象时,d1对象中的内置类型部分逐字节拷贝给d2对象,d1对象中的Stack类型部分调用Stack类型的拷贝构造拷贝给d2对象中Stack类型对象。Stack类当中的成员都是内置类型,而且没有显式定义拷贝构造,所以编译器默认生成的构造函数会逐字节地拷贝这些内置类型变量。
我们注意到,使用编译器默认生成的拷贝构造进行拷贝时,会将指针变量当中保存的地址也按字节拷贝过去,这就会导致在某些场景下发生崩溃:
#include <cstdlib>
using namespace std;
class Stack
{
public:
~Stack()
{
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
int* _a = (int*)malloc(sizeof(int)* 4);
int _top = 0;
int _capacity = 4;
};
int main()
{
Stack st1;
Stack st2(st1);
return 0;
}
发生崩溃的原因就在于重复析构。因为Stack类中的_a成员变量获得了一块在堆上的空间的地址,又因为Stack类没有显式定义拷贝构造,所以st1拷贝st2时是逐字节拷贝的(Stack类的成员都是内置类型),所以st2中的_a和st1中的_a都指向了同一块空间:
逐字节拷贝的过程称为浅拷贝。浅拷贝只适用于不需要显式定义析构函数的场景。
那么像上面会引发重复析构的解决方案就是使用深拷贝,其作用就是要让某些需要动态申请资源的成员拥有一块独立的空间,这个过程是由程序员去控制的,我们将上面引发崩溃的代码修改一下:
#include <cstdlib>
#include <cstring>
using namespace std;
class Stack
{
public:
~Stack()
{
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
Stack()// 需要注意,拷贝构造是构造一个重载形式,所以定义了拷贝构造编译器就不会生成默认构造
{}
Stack(const Stack& st)
{
_a = (int*)malloc(sizeof(int)* 4);// 拥有一块独立的空间
memcpy(_a, st._a, st._capacity);// 再逐字节拷贝
// 不需要深拷贝的直接逐字节拷贝
_top = st._top;
_capacity = st._capacity;
}
private:
int* _a = (int*)malloc(sizeof(int)* 4);
int _top = 0;
int _capacity = 4;
};
两条规则希望读者牢记于心:
1.不需要显式定义析构函数的类类型使用浅拷贝即可满足要求
2.需要显式定义析构函数的类类类型需要使用深拷贝才可以满足要求
拷贝构造的调用并不只是我们显式去调用才会发生,也有可能在某些场景中隐式发生拷贝而调用拷贝构造:
1.传值传参时调用拷贝构造
2.传值返回时调用拷贝构造
6.运算符重载
说明一点,这里介绍的运算符重载不是类的默认成员函数中的赋值运算符重载,赋值运算符重载在后面将会介绍。
运算符重载也需要区别于函数重载,运算符的重载与函数重载不是一个概念。
什么是运算符重载?当我们要比较内置类型对象的大小时,我们只需要使用运算符">"、"<"、"=="等等诸如此类的运算符即可;想要对内置类型对象进行计算时,使用"+"、"-"、"*"等等诸如此类的运算符即可。那么对于自定义类型来说,则需要调用函数,我们以日期类为例:
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)// 默认构造函数
{
_year = year;
_month = month;
_day = day;
}
public:// 注意这里是公有
int _year;
int _month;
int _day;
};
bool isMore(const Date& d1, const Date& d2)
{
if (d1._year > d2._year)
{
return true;
}
else if (d1._year == d2._year && d1._month > d2._month)
{
return true;
}
else if (d1._year == d2._year && d1._month == d2._month && d1._day > d2._day)
{
return true;
}
return false;
}
int main()
{
Date d1(2023, 4, 27);
Date d2(2024, 4, 27);
// 想要比较这两个对象的大小,必须通过函数
bool ret = isMore(d1, d2);
return 0;
}
确实,我们通过这样的方式确实能够比较两个对象的大小,但是这不是C++,这是C语言。C++提供了一种名为运算符重载的机制,通俗的来说,它的作用就是让我们可以将运算符的名字作为函数名,我们这样修改上面的代码:
bool operator>(const Date& d1, const Date& d2)
{
if (d1._year > d2._year)
{
return true;
}
else if (d1._year == d2._year && d1._month > d2._month)
{
return true;
}
else if (d1._year == d2._year && d1._month == d2._month && d1._day > d2._day)
{
return true;
}
return false;
}
int main()
{
Date d1(2023, 4, 27);
Date d2(2024, 4, 27);
// 想要比较这两个对象的大小,必须通过函数
bool ret = d1 > d2;
//bool ret = operator>(d1, d2);//效果与上面等价,但是不会这么用
return 0;
}
是的,运算符重载也是一个函数,其函数声明的格式为[返回类型 opeartor操作符(参数列表)]。但是,上面的代码也是不是C++风格的代码,如果我们将Date类的成员修改为私有成员,那么就会喜提以下报错信息:
解决这个问题的方法之一便是将该函数作为Date类的成员函数,但是我们又会额外获得一些报错信息:
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)// 默认构造函数
{
_year = year;
_month = month;
_day = day;
}
bool operator>(const Date& d1, const Date& d2)
{
if (d1._year > d2._year)
{
return true;
}
else if (d1._year == d2._year && d1._month > d2._month)
{
return true;
}
else if (d1._year == d2._year && d1._month == d2._month && d1._day > d2._day)
{
return true;
}
return false;
}
private:
int _year;
int _month;
int _day;
};
原因在于类中的成员函数是有一个隐藏的this指针,所以上面的函数看上去有两个参数,实际上有三个参数。运算符重载的原则是:根据运算符的性质来确定函数参数,例如"=="运算符就是个二元运算符,所以它需要两个参数。那么我们再将其修改一下:
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)// 默认构造函数
{
_year = year;
_month = month;
_day = day;
}
bool operator>(const Date& d)
{
if (_year > _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;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 4, 27);
Date d2(2024, 4, 27);
// 想要比较这两个对象的大小,必须通过函数
bool ret = d1 > d2;
//bool ret = d1.operator>(d2);//编译器自动转换,也可以这样显式调用
return 0;
}
以上就是运算符重载的基本用法,后面还有更多的代码、更多的场景使用运算符重载。那么在关于运算符重载,C++给出了以下几点要求:
1.不能通过"operator"连接其他符号来创造新的运算符,例如[operator@]就是错误的用法
2.运算符重载的函数参数必须有一个类类型参数,即必须有自定义类型,否则C++就会认为我们正在尝试修改C++默认定义的运算规则,例如下面的例子就是错误的用法:
int operator+(int x, int y)// C++:以下犯上?
{
return x - y;
}
int main()
{
return 0;
}
3.当运算符重载作为类的普通成员函数时,其显式的形参不是真正参数个数,还需要考虑this指针
4.运算符重载的参数与操作数是一一对应的,第一个参数代表左操作数,第二个参数代表右操作数。这是C++规定的行为
5.有五个运算符不能被重载,即"::"、"sizeof"、"?:"、"."和".*",".*"这个运算符我们没有见过,我也从来没有见过,别关注它。
7.日期类
我们运用已有的知识来封装一个日期类,日期类要支持">"、">="、"=="、"!="等等这些比较,还需要支持"+"、"+="、"-"、"-="等等这些运算。我们首先搭好一个框架,用来明确我们将要实现什么功能:
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)// 默认构造函数
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << ":" << _month << ":" << _day << endl;
}
bool operator>(const Date& d);
bool operator== (const Date& d);
bool operator>=(const Date& d);
bool operator<(const Date& d);
bool operator<=(const Date& d);
bool operator!=(const Date& d);
Date& operator+=(int day);
Date operator+(int day);
Date& operator-=(int day);
Date operator-(int day);
Date& operator++();
Date& operator++(int);
Date& operator--();
Date& operator--(int);
int operator-(const Date& d);
private:
int _year;
int _month;
int _day;
};
实现operator>:
其实这个函数已经实现过了,具体的逻辑就是先比较年份,如果年份大,那么日期就大;如果年份相同,就比较月份,如果月份大,那么日期九大;如果年份、月份都相同,再比较日,日大则日期大;如果都不满足,那就是不大于
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;// 不大于
}
实现operator==:
这个逻辑也非常简单,年份、月份、日三者都相等即为相等
bool operator== (const Date& d)
{
if (_year == d._year && _month == d._month && _day == d._day)
{
return true;
}
return false;
}
实现operator>=:
一定要抛弃固有思维!这里可以复用函数!
bool operator>=(const Date& d)
{
return *this > d || *this == d;
}
实现operator<:
这里也可以复用代码
bool operator<(const Date& d)
{
if (*this >= d)// 如果 >= ,那就不小于
{
return false;
}
return true;// 不然就是小于
}
实现operator<=:
都是代码复用
bool operator<=(const Date& d)
{
if (*this > d)//如果>就不是<=
{
return false;
}
return true;
}
实现operator!=:
bool operator!=(const Date& d)
{
if (*this == d)
{
return false;
}
return true;
}
实现operator+=:
这里就比较复杂了。我们重载加法运算符的目的是计算当前日期N天后是什么日期。我们的思路是这样的:直接让天数相加,如果天数超过了该月的天数,那么月就进位;如果月超过了12,那么就让年进位:
那么这里涉及到关于月份的计算,那么就需要考虑闰年和平年,我们将获取某年某月的天数封装成一个函数(可以作为类的私有成员):
int getMonthDay(int year, int month)
{
int day[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
// 如果是2月又是闰年
if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)))
{
return 29;
}
return day[month];
}
此时再实现operator+=的逻辑:
Date& operator+=(int day)
{
_day += day;// 直接让天数相加
while (_day > getMonthDay(_year, _month))//如果天数大于本月最大天数
{
_day -= getMonthDay(_year, _month);
++_month;// 月份++
if (_month == 13)// 如果月份超过12月
{
++_year;
_month = 1;
}
}
return *this;
}
注意这里使用的是引用返回,因为"*this"得到的是对象,对象出了该函数作用域还没有销毁,就可以返回引用。 至于为什么要有返回值,就是另外一种使用场景,即连续赋值场景,不排除有这样的应用场景:
int main()
{
Date d1(2022, 4, 28);
Date d2(d1 += 12);// d1+=12的返回值作为已存在的对象拷贝给d2
(d2 += 20).Print();// d2+=20的返回值作为已存在的对象调用成员函数
return 0;
}
实现operator+:
与"operator+="不同的是,"operator+"并不会修改对象的内容(x+3并不会修改x的值),所以这里需要特殊处理一下,但是我们仍然可以复用函数
Date operator+(int day)
{
Date tmp(*this);// tmp为*this的拷贝
tmp += day;
return tmp;
}
实现operator-=:
具体的算法分析过程就不展示了,最终实现的逻辑如下:
Date& operator-=(int day)
{
_day -= day;// 天数先相减
while (_day <= 0)// 当天数不合法的时候
{
--_month;
if (_month == 0)
{
--_year;
_month = 12;
}
_day += getMonthDay(_year, _month);
}
return *this;
}
实现operator-:
与实现operator+一样,复用函数:
Date operator-(int day)
{
Date tmp(*this);
tmp -= day;
return tmp;
}
写到这里,日期类差不多就算完成了,但是我们不排除有些人写出下面这样的代码:
int main()
{
Date d1(2023, 4, 28);
d1 += -2000;
d1.Print();
Date d2(2023, 4, 28);
d2 -= -2000;
d2.Print();
return 0;
}
所以要进一步修改operator+=和operator-=(operator+和operaor-都是复用,不需要修改):
Date& operator+=(int day)
{
if (day < 0)// 如果天数是负数
{
return *this -= abs(day);// 取day的绝对值
}
_day += day;// 直接让天数相加
while (_day > getMonthDay(_year, _month))//如果天数大于本月最大天数
{
_day -= getMonthDay(_year, _month);
++_month;// 月份++
if (_month == 13)// 如果月份超过12月
{
++_year;
_month = 1;
}
}
return *this;
}
Date& operator-=(int day)
{
if (day < 0)// 如果天数是负数
{
return *this += abs(day);
}
_day -= day;// 天数先相减
while (_day <= 0)// 当天数不合法的时候
{
--_month;
if (_month == 0)
{
--_year;
_month = 12;
}
_day += getMonthDay(_year, _month);
}
return *this;
}
实际上写到这里,日期类已经实现的很不错了,但还不够完美,我们需要日期类能够支持前置++和后置++,我们给出的函数声明是这样的:
Date& operator++();// 前置++
Date operator++(int);// 后置++
首先我们需要知道,"++"是一个单操作数的运算符,而我们要实现前置++和后置++光靠一个参数是区分不开的,所以C++规定,前置++的运算符重载只需要有一个参数(上面没写是因为作为类的成员函数,有this指针),后置++的运算符要有两个参数,第二个参数规定了必须是int类型,它没有什么实际意义,目的就是为了与前置++区分,构成函数重载。返回值的设计也有讲究,对象调用前置++运算符重载后,其值立马发生改变;后置++运算符重载被调用后,其对象的值并没有立马改变,而是要"延迟"一会。我们看它们的具体实现:
Date& operator++()// 前置++
{
*this += 1;
return *this;// 轻轻松松
}
Date operator++(int)// 后置++
{
Date tmp(*this);
*this += 1;
return tmp;
}
我们再实现一个前置--和一个后置--:
Date& operator--()// 前置--
{
*this -= 1;
return *this;
}
Date operator--(int)// 后置--
{
Date tmp(*this);
*this -= 1;
return tmp;
}
综上,我们可以发现,无论是++还是--,只要是后置,那么空间的开销一定比前置大(后置比前置多了两次拷贝),所以在C++中,能用前置++或者前置--就尽量用。
我们不排除在出初始化日期类对象时,有人写出下面这样的代码:
int main()
{
Date d1(2023, 4, 99);
d1 += -2000;
d1.Print();
Date d2(2023, 4, 2023);
d2 -= -2000;
d2.Print();
return 0;
}
这样的初始化数据是不合理的,所以在初始化的时候就应该阻止这样的行为,即在构造函数内存添加一些条件:
Date(int year = 1, int month = 1, int day = 1)// 默认构造函数
{
if (year >= 1 && (month >= 1 && month <= 12) && (day >= 1 && day <= getMonthDay(year, month)))
{// 只有日期合法才进行初始化
_year = year;
_month = month;
_day = day;
}
else
{
cout << "日期初始化数据不合法!" << endl;
exit(1);// 直接让该程序退出
}
}
需要注意,因为知识有限,现阶段我们把构造函数内的赋值语句称为"初始化",实际上这不是初始化。具体内容在类和对象下的博客中讲解。
上面的运算符都是"日期+天数"的形式,现在我们有新需求,即计算两个日期的差(即计算两个日期相差几天),很明显,这也是一个"-"运算符重载,不过现在我们实现的与上面已定义的要构成函数重载,即参数不一样:
int operator-(const Date& d)
{
// 假设*this > d
Date max = *this;
Date min = d;
int flag = 1;// 那么*this - d计算的结果就是正数
if (*this < d)// 假设错误
{
max = d;
min = *this;
flag = -1;
}
int n = 0;
while (min != max)
{
++n;
++min;// 小的日期一直++,直到与max相等。++多少次就是相差多少天
}
return n;
}
7.1输入&输出&友元函数
我们的日期类已经初步完工,并且能够打印出正确的测试结果(部分测试):
int main()
{
Date d1(2023, 5, 22);
Date d2(2015, 4, 29);
(d1 + 30).Print();// d1 + 30
d1.Print();// d1 不变
(d2 - 88).Print();// d2 - 88
d2.Print();// d2 不变
cout << (d1 - d2) << endl;
return 0;
}
同时我们注意到,想要按照我们的格式输出Date对象时,需要调用Print成员函数,这种方法不是不可以,而是太low,不符合C++的使用意愿。我们在前面提到过输入流与输出流,即cout对象和cin对象,还有流插入(<<)运算符和流提取(>>)运算符。那么cout是一个对象,那么它就有对应的类,即ostream类,cin对应istream类:
并且流插入和流提取作为运算符,不难猜测ostream类和istream类对其进行了运算符重载:
因为运算符重载是一个函数,不同的参数类型可以构成函数重载,如上图。所以在我们使用cout和cin时,就能理解为什么传入不同类型的参数就可以"自动识别类型",其原因就在于函数重载。也就是说,我们想要cout或cin对我们的Date类生效,只需要再添加一个重载函数即可:
class Date
{
public:
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
ostream& operator<<(ostream& out, const Date& d)
{
out << d._year << ":" << d._month << ":" << d._day;
return out;
}
istream& operator>>(istream& in, Date& d)
{
in >> d._year >> d._month >> d._day;
return in;
}
这两个函数可不能作为Date类的成员函数,因为我们说过运算符重载的函数参数与操作符左右操作数是有对应关系的。如果将这两个函数作为成员函数,那么this指针就占用了第一个参数的位置,也就相当于让对象去做操作符的左操作数,使用输出或输入时就有可能得这么写"d1 << cout"或者"d1 >> cin",这显然是不合理的。
关键点在于这两个函数并不能通过编译,因为我们在类外访问了类的私有成员。一种解决方案是将类成员的"private"属性修改成"public",不过这么干就不是C++程序员了;另一种方案是使用友元函数,在这里简单介绍一下友元函数的作用:能够在类外访问类的私有成员,用法就是将函数声明写进类的任意位置,再在声明前面加上friend关键字。我们就可以这么操作:
class Date
{
public:
// 友元函数声明
friend ostream& operator<<(ostream& out, const Date& d);
friend istream& operator>>(istream& in, Date& d);
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
ostream& operator<<(ostream& out, const Date& d)
{
out << d._year << ":" << d._month << ":" << d._day;
return out;// 返回值是为了连续输出的场景
}
istream& operator>>(istream& in, Date& d)
{
in >> d._year >> d._month >> d._day;
return in;// 返回值是为了连续输入的场景
}
int main()
{
Date d1, d2;
cin >> d1 >> d2;// 连续输入
cout << d1 << " " << d2 << endl;// 连续输出
return 0;
}
此时operator<<和operator>>是作为普通全局函数的,并且上面所有有关日期类的大代码都是在一个源文件中实现的。我想表达的是,如果在头文件中定义了全局函数,那么该头文件被多个源文件包含时会产生链接错误:
// Date.h
#pragma once
#include <iostream>
using namespace std;
class Date
{
public:
friend ostream& operator<<(ostream& out, const Date& d);
friend istream& operator>>(istream& in, Date& d);
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
ostream& operator<<(ostream& out, const Date& d)
{
out << d._year << ":" << d._month << ":" << d._day;
return out;// 返回值是为了连续输出的场景
}
istream& operator>>(istream& in, Date& d)
{
in >> d._year >> d._month >> d._day;
return in;// 返回值是为了连续输入的场景
}
// main.cpp
#include "Date.h"
int main()
{
// 哪怕不使用头文件定义的函数,也会报错
return 0;
}
// func.cpp
#include "Date.h"
造成链接错误的原因是这样的:头文件中定义的函数被多个源文件包含,预处理的时候就会将头文件的内容展开,展开之后多个源文件就有了一模一样的函数定义,在各个源文件编译时就会生成符号表,函数就会被修饰进,这些符号表之间就会有相同的函数,这就会导致链接器在检查时发现有多个重定义的函数。解决方案是将函数定义为static函数,或者定义为内联函数,static修饰函数时能够修改其外部链接属性,也就说将该函数在符号表当中"隐藏"起来,而内联函数则是直接不进符号表,所以链接器在检查时就查不到重定义了。
其实不需要有头文件的介入,只要在多个源文件中定义相同的函数,都会产生链接错误:
// func.cpp
void func()
{}
// main.cpp
void func()
{}
int main()
{
return 0;
}
而修改成static函数或者内联函数就不会报错。
8.赋值运算符重载
赋值运算符重载其实也是运算符重载,只不过赋值运算符特殊的地方在于它是类的6个默认成员函数之一,即我们写编译器会自动生成。那么赋值的作用就是完成拷贝,它与拷贝构造的区别在于:拷贝构造是用一个已存在的同类类型对象初始化另一个正在创建的对象;赋值运算符重载是已经存在的对象之间的拷贝关系。
那么先抛开编译器自动生成的运算符重载,先考虑如何自己实现一个赋值运算符重载:
class Student
{
public:
Student(char* name = "", int id = 0, int age = 0)
{
_name = name;
_id = id;
_age = age;
}
void operator=(const Student& s)// 要赋值过来的对象不改变,所以使用常引用
{
_name = s._name;
_id = s._id;
_age = s._age;
}
private:
char* _name;
int _id;
int _age;
};
int main()
{
Student s1("张三", 123456, 21);
Student s2;
s2 = s1;// 已存在的对象之间的赋值
return 0;
}
这样看似实现了赋值的功能,但实际上可能会存在这样的代码:
int main()
{
Student s1("张三", 123456, 21);
Student s2;
Student s3;
s3 = s2 = s1;// void operator=()
return 0;
}
我们实现的赋值运算符重载没有返回值,所以不能够适用于连续赋值的场景。思路其实很简单,设计一个返回值即可。但是难点在于,返回值如何设计?是返回s1还是返回s2?返回值是返回引用还是传值返回?
赋值运算符重载的返回值设计:
1.原则一:返回值返回赋值符号的左操作数
2.原则二:返回值设计为引用返回,记住,不能是常引用。因为返回引用可以减少一次拷贝构造和减小空间开销
将原则一与原则二牢记于心,我们就可以解释以下代码:
class Student
{
public:
Student(char* name = "", int id = 0, int age = 0)
{
_name = name;
_id = id;
_age = age;
}
Student& operator=(const Student& s)// 要赋值过来的对象不改变,所以使用常引用
{
_name = s._name;
_id = s._id;
_age = s._age;
//return s;// 如果返回右操作数,返回类型就要设计为常引用
return *this;// 返回右操作数,明智之举
}
private:
char* _name;
int _id;
int _age;
};
int main()
{
Student s1("张三", 123456, 21);
Student s2;
Student s3;
s3 = s2 = s1;// operator=有返回值就可以连续赋值
// 假设Student支持++操作
//(s3 = s2 = s1)++;// 返回值为普通引用就可以支持++操作
return 0;
}
以上便是实现一个最简单的赋值运算符重载,现在来介绍编译器默认生成的赋值运算符重载。实际上赋值运算符重载与拷贝构造一样,我们不写编译器会自动生成,它们都是对内置类型完成逐字节拷贝,对自定义类型调用它们的赋值运算符重载或拷贝构造。要不要显式写赋值运算符重载与要不要显式写拷贝构造一样,如果该类不需要显式写析构函数,那么就不需要显式定义赋值运算符重载。不过,赋值运算符重载的显式定义要考虑的情况比构造函数多,我们以Stack类为例:
#include <cstdlib>
#include <cstring>
using namespace std;
class Stack
{
public:
~Stack()
{
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
Stack(int capacity = 4)
{
_a = (int*)malloc(sizeof(int)* capacity);
_top = 0;
_capacity = capacity;
}
Stack(const Stack& st)// 拷贝构造->用于初始化
{
_a = (int*)malloc(sizeof(int)* st._capacity);
memcpy(_a, st._a, st._capacity);
_top = st._top;
_capacity = st._capacity;
}
Stack& operator=(const Stack& st)
{
// 这样写就完了吗?
_a = (int*)malloc(sizeof(int)* st._capacity);
memcpy(_a, st._a, st._capacity);
_top = st._top;
_capacity = st._capacity;
return *this;
}
private:
int* _a;
int _top;
int _capacity;
};
Stack类需要写析构函数,所以我们显式定义拷贝构造和赋值运算符重载,使得对象与对象之间拥有独立的堆空间,这个思路是正确的,但是我们忽略了一点,即赋值是已存在对象之间的赋值,我们不得不防止有以下的情况发生:
int main()
{
Stack s1(10000);
Stack s2;
s1 = s2;// 内存泄露
return 0;
}
这段代码会造成很严重的内存泄漏,即使运行过程中没有任何错误。原因就在于s1对象本在堆上拥有了10000个int类型的空间,而"s1 = s2"直接让s1的_a换了一个指向,那么以前的10000个int类型空间何去何从?
所以赋值运算符重载内部必须要对以前拥有的空间作处理,我们修改一下赋值运算符重载:
Stack& operator=(const Stack& st)
{
free(_a);// 将以前的空间释放掉
_a = (int*)malloc(sizeof(int)* st._capacity);
memcpy(_a, st._a, st._capacity);
_top = st._top;
_capacity = st._capacity;
return *this;
}
还没有结束,也不排除有的人写出这样的代码:
int main()
{
Stack s1(10000);
Stack s2;
s1 = s1;// 自我赋值
return 0;
}
这段代码是很危险的,因为我们的赋值运算符重载内部第一件事就是释放掉原有的空间,那么此时赋值符号两边都是同一个对象,这就会导致拷贝未定义的数据。因为s1对象创建之后如果执行了push操作,就会向该空间内添加有效数据(我们的例子没写push)
这种自我赋值的语法编译器是允许的,我们是不能阻止的,我们可以这么修改赋值运算符重载:
Stack& operator=(const Stack& st)
{
if (this != &st)// 如果赋值符号两边不是一个对象才允许赋值
{
free(_a);// 将以前的空间释放掉
_a = (int*)malloc(sizeof(int)* st._capacity);
memcpy(_a, st._a, st._capacity);
_top = st._top;
_capacity = st._capacity;
}
return *this;
}
在这里再讲一个冷知识,我们看下面的代码:
int main()
{
Stack s1(10000);
Stack s2;
// 这两个"="有什么区别?
Stack s3 = s1;
s2 = s1;
return 0;
}
这段代码乍一看似乎都是赋值运算符重载被调用,实际上不是。s3对象正在创建的时候将s1拷贝给它,所以"Stack s3 = s1"发生一次拷贝构造;"s2 = s1"是两个已存在的对象之间的赋值,是一次赋值,即调用赋值运算符重载。
9.const成员函数
对象不可能只有普通对象,还有const对象,以上面的日期类为例,const对象调用成员函数是调不动的:
int main()
{
Date d1(2023, 5, 1);
d1.Print();
const Date d2(2023, 4, 29);
d2.Print();// 调用失败
return 0;
}
原因在于this指针。如我们对d2对象取地址,那么它的指针类型为"const Date*",而Print作为成员函数,其this指针的类型为"Date* const";所以编译器在隐式传递对象指针的时候,d2的指针是没有匹配的this指针类型的,所以调用不了成员函数。也可以理解为"const *"类型的指针不能传递给"* const"类型的指针,因为这是一次权限放大。
解决方案为在成员函数的参数列表之后加上const关键字:
这个const的含义修饰的是this指针指向的内容,即将this指针原本的"Date* const"修改为"const Date* const"类型。像这样的函数我们称为const成员函数,const成员函数任何对象都可以调用(因为权限可以被平移和缩小):
int main()
{
Date d1(2023, 5, 1);
d1.Print();// 非const对象能调用
const Date d2(2023, 4, 29);
d2.Print();// const对象也能调用
return 0;
}
当然,如果我们不想让const对象和非const共用一个成员函数,我们完完全全可以重载一下成员函数:
实际上在类外定义const对象的场景是比较少的,const对象通常都在成员函数中定义。我们拿日期类的某一成员函数举例:
int operator-(const Date& d)// 这里是一个const对象
{
Date max = *this;
Date min = d;
int flag = 1;
//if (*this < d)// 这么调是可以的,因为this指针的类型为 Date* const
if (d > *this)// 这么写是错的,因为d为const对象,调用的>运算符重载没有对应的this指针类型
{
max = d;
min = *this;
flag = -1;
}
int n = 0;
while (min != max)
{
++n;
++min;
}
return n;
}
所以在改进日期类之前,先说明一下const成员函数的定义规则:
1.需要被const对象调用的成员函数要加const
2.成员函数被调用时,其this指针的对象不发生改变时,可以加const
3.如果成员函数的声明和定义分离,那么声明和定义都要有const
4.如果想让非const对象调用非const成员函数,const成员函数调用const成员函数,可以单独定义一个非const成员函数和一个const成员函数
9.1日期类完整代码
了解const成员函数之后,那么最终版本的日期类就可以写出来了:
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)// 默认构造函数
{
if (year >= 1 && (month >= 1 && month <= 12) && (day >= 1 && day <= getMonthDay(year, month)))
{// 只有日期合法才进行初始化
_year = year;
_month = month;
_day = day;
}
else
{
cout << "日期初始化数据不合法!" << endl;
exit(1);// 直接让该程序退出
}
}
void Print() const// const对象专用
{
cout << _year << ":" << _month << ":" << _day << endl;
}
void Print()// 非const对象专用
{
cout << _year << ":" << _month << ":" << _day << endl;
}
bool operator>(const Date& d) const//*this不改变,可以加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;
}
return false;// 不大于
}
bool operator== (const Date& d) const//*this不改变,加const
{
if (_year == d._year && _month == d._month && _day == d._day)
{
return true;
}
return false;
}
bool operator>=(const Date& d) const//*this不改变,加const
{
return *this > d || *this == d;
}
bool operator<(const Date& d) const
{
if (*this >= d)
{
return false;
}
return true;
}
bool operator<=(const Date& d) const
{
if (*this > d)//如果>就不是<=
{
return false;
}
return true;
}
bool operator!=(const Date& d) const
{
if (*this == d)
{
return false;
}
return true;
}
Date& operator+=(int day)// *this改变,不能加const
{
if (day < 0)// 如果天数是负数
{
return *this -= abs(day);// 取day的绝对值
}
_day += day;// 直接让天数相加
while (_day > getMonthDay(_year, _month))//如果天数大于本月最大天数
{
_day -= getMonthDay(_year, _month);
++_month;// 月份++
if (_month == 13)// 如果月份超过12月
{
++_year;
_month = 1;
}
}
return *this;
}
Date operator+(int day) const//*this不改变,加const
{
Date tmp(*this);// tmp为*this的拷贝
tmp += day;
return tmp;
}
Date& operator-=(int day)
{
if (day < 0)// 如果天数是负数
{
return *this += abs(day);
}
_day -= day;// 天数先相减
while (_day <= 0)// 当天数不合法的时候
{
--_month;
if (_month == 0)
{
--_year;
_month = 12;
}
_day += getMonthDay(_year, _month);
}
return *this;
}
Date operator-(int day) const
{
Date tmp(*this);
tmp -= day;
return tmp;
}
Date& operator++()// 前置++
{
*this += 1;
return *this;// 轻轻松松
}
Date operator++(int)// 后置++
{
Date tmp(*this);
*this += 1;
return tmp;
}
Date& operator--()// 前置--
{
*this -= 1;
return *this;
}
Date operator--(int)// 后置--
{
Date tmp(*this);
*this -= 1;
return tmp;
}
int operator-(const Date& d) const// *this不改变,加const
{
Date max = *this;
Date min = d;
int flag = 1;
//if (*this < d)
if (d > *this)
{
max = d;
min = *this;
flag = -1;
}
int n = 0;
while (min != max)
{
++n;
++min;// 小的日期一直++,直到与max相等。++多少次就是相差多少天
}
return n;
}
private:
int _year;
int _month;
int _day;
int getMonthDay(int year, int month)
{
int day[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
// 如果是2月又是闰年
if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)))
{
return 29;
}
return day[month];
}
// 友元函数的声明可以出现在类中的任意位置
friend ostream& operator<<(ostream& out, const Date& d);
friend istream& operator>>(istream& in, Date& d);
};
10.取地址重载
我们直到,取地址(&)也是个运算符。如果我们没有重载取地址运算符,那么对象的地址是取不到的。C++考虑到了这一点,将取地址运算符重载归类成了类的默认成员函数,即我们不写,编译器自动生成,并且生成两个版本,非const对象取地址重载和const对象取地址重载:
#include <iostream>
using namespace std;
class Coords//坐标类
{
public:
Coords(int x = 0, int y = 0)
{
_x = x;
_y = y;
}
private:
int _x;
int _y;
};
int main()
{
Coords c1;
const Coords c2;
// 编译器自动生成的取地址重载
cout << &c1 << " " << &c2 << endl;
return 0;
}
如果我们想要显式定义取地址重载,我们可以这么写:
class Coords//坐标类
{
public:
Coords(int x = 0, int y = 0)
{
_x = x;
_y = y;
}
Coords* operator&()
{
return this;
}
const Coords* operator&() const
{
return this;
}
private:
int _x;
int _y;
};
实际上显式定义这两个取地址重载没什么意义,现实当中应用的场景几乎没有,如果硬要说一种应用场景,那可能就是老六面试官让你设计一个无法对对象取地址的类:
#include <iostream>
using namespace std;
class Coords//坐标类
{
public:
Coords(int x = 0, int y = 0)
{
_x = x;
_y = y;
}
Coords* operator&()
{
// 这里也可以写断言、异常之类的,反正取地址就终止
return nullptr;
}
const Coords* operator&() const
{
return nullptr;
}
private:
int _x;
int _y;
};
int main()
{
Coords c1;
const Coords c2;
cout << &c1 << " " << &c2 << endl;
return 0;
}