类和对象(中)
1.类的6个默认成员函数
如果一个类中什么成员都没有,简称为空类。
空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成以下6个默认成员函数。
默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数。
2.构造函数
2.1概念
对于以下Date类:
# include<iostream>
using namespace std;
class Date
{
public:
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print() // void Print(Date* this) 编译后
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1,d2;
d1.Init(2024, 5, 26);// d1.Init(&d1, 2024, 5, 26); // 编译后
d2.Init(2077, 5, 26);// d2.Init(&d2, 2024, 5, 26);
d1.Print(); // 2024-5-26
// d1.Print(&d1)
d2.Print(); // 2077-5-26
// d2.Print(&d2);
return 0;
}
对于Date类,可以通过 Init 公有方法给对象设置日期,但如果每次创建对象时都调用该方法设置
信息,未免有点麻烦,那能否在对象创建时,就将信息设置进去呢?
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。
2.2特性
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
其特征如下:
-
函数名与类名相同。
-
无返回值。
-
对象实例化时编译器自动调用对应的构造函数。
-
构造函数可以重载。
我们来看例子:
# include<iostream>
using namespace std;
class Date
{
public:
// 构造函数——在对象构建时调用的函数,这个函数完成初始化工作
// [要注意这个函数只完成实例对象的初始化,不参与对象的构造,那是编译器的工作]
Date(int year, int month, int day) // 构造函数的名和类名相同
{
_year = year;
_month = month;
_day = day;
}// 构造函数不要返回值
// 构造函数也支持重载
Date() // 无参构造函数
{
_year = 0;
_month = 0;
_day = 0;
}
// 这个函数按需使用
void Init(int year, int month, int day)// void Init(Date* this, int year, int month, int day)
{
_year = year; // 如果我们设置的成员变量名称不加_或者m 这里就不好理解
_month = month;
_day = day;
}
void Print() // void Print(Date* this) 编译后
{
cout << _year << "-" << _month << "-" << _day << endl;
// cout << this->_year << "-" << this->_month << "-" << this->_day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024, 5, 26); // 直接给实例对象传参,由自动调用的构造函数完成初始化。
Date d2(2077, 5, 26);
Date d3; // 通过函数重载实现,调用无参构造函数
// 注意这里不能Date d3();
d1.Print(); // 2024-5-26
// d1.Print(&d1);
d2.Print(); // 2077-5-26
// d2.Print(&d2);// 是通过隐含的this指针实现的
d3.Print(); // 0 - 0 - 0
return 0;
}
- 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
// 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
# include<iostream>
using namespace std;
class Time
{
public:
Time()
{
_hour = 0;
_min = 0;
_second = 0;
cout << "Time()" << endl;
}
private:
int _hour;
int _min;
int _second;
};
class Date
{
public:
// 这里我们没有显示定义构造函数,编译器会生成无参默认构造函数
// 一旦用户定义了显式构造函数。那么编译器将不会生成无参构造函数
// 即使只定义了带参构造函数,编译器也不会生成无参构造函数
// 想要无参构造函数,要自己再定义一个
//Date(int year, int month, int day)
//{
// _year = year;
// _month = month;
// _day = day;
//}
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print() // void Print(Date* this) 编译后
{
cout << _year << "-" << _month << "-" << _day << endl;
// cout << this->_year << "-" << this->_month << "-" << this->_day << endl;
}
private:
int _year;
int _month;
int _day;
Time _t;
};
int main()
{
Date d1; // 调用编译器生成的无参默认构造函数,
d1.Print();// -858993460--858993460--858993460
// 我们发现,即使d1调用了编译器的默认构造函数,打印出来还是随机值,看起来这个构造函数什么都没做一样
// 但是实际上,这个无参默认构造函数是有做事情的
// 我们给Date一个自定义的成员变量,_t
// d1.Print();的结果如下:
//Time()
//-858993460--858993460--858993460
// 这个说明了一个现象
// 默认生成的无参构造函数(语法坑:双标)
// 1. 针对内置类型的成员变量不会做处理
// 2. 针对自定义类型的成员变量,调用它的构造函数初始化
return 0;
}
- 关于编译器生成的默认成员函数,很多人会有疑惑:不实现构造函数的情况下,编译器会生成默认的构造函数。但是看起来默认构造函数又没什么用?d对象调用了编译器生的默认构造函数,但是d对象_year/_month/_day,依旧是随机值。也就说在这里编译器生成的默认构造函数并没有什么用??
解答:C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:int/char…,自定义类型就是我们使用class/struct/union等自己定义的类型,看看下面的程序,就会发现编译器生成默认的构造函数会对自定类型成员_t调用的它的默认成员函数。
总结:
默认生成的无参构造函数(语法坑:双标)
-
针对内置类型的成员变量不会做处理
-
针对自定义类型的成员变量,调用它的构造函数初始化
-
一旦用户定义了显式构造函数。那么编译器将不会生成无参构造函数
即使只定义了带参构造函数,编译器也不会生成无参构造函数
想要无参构造函数,要自己再定义一个
注意: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 = 1970;
int _month = 1;
int _day = 1;
// 自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
但是这样也不够好,我们可以用之前学习的缺省参数——全缺省参数
- 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数
# include<iostream>
using namespace std;
class Date
{
public:
带参构造函数
//Date(int year, int month, int day)
//{
// _year = year;
// _month = month;
// _day = day;
//}// 构造函数不能有返回值
构造函数也支持重载
//Date() // 无参构造函数
//{
// _year = 0;
// _month = 0;
// _day = 0;
//}
// 更好的方式
// 构造函数——全缺省
Date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
注意,有了全缺省的构造函数,不能再有无参构造函数,编译的时候会产生歧义
//Date() // 无参构造函数
//{
// _year = 0;
// _month = 0;
// _day = 0;
//}
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print() // void Print(Date* this) 编译后
{
cout << _year << "-" << _month << "-" << _day << endl;
// cout << this->_year << "-" << this->_month << "-" << this->_day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1; // 调用默认构造函数
// 1. 自己实现的无参构造函数
// 2. 自己实现的全缺省构造函数
// 3. 编译器自动生成的默认构造函数
// 这三个的特点都是无参,不传参数
// 因此这三个只能存在一个
Date d2(2024, 5, 27);
d1.Print();// 0-0-0
d2.Print();// 2024-5-27
return 0;
}
总结:
默认构造函数一共有三种:
- 自己实现的无参构造函数
- 自己实现的全缺省构造函数
- 编译器自动生成的默认构造函数
这三种默认构造函数只能存在一种
3.析构函数
3.1 概念
通过前面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么没呢的?
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
3.2 特性
析构函数是特殊的成员函数,其特征如下:
-
析构函数名是在类名前加上字符 ~。
-
无参数无返回值类型。
-
一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载
-
对象生命周期结束时,C++编译系统系统自动调用析构函数
来看一段代码:
// 析构函数——在实例对象的生命周期结束的时候,会自动调用析构函数
# include<iostream>
using namespace std;
class Date
{
public:
// 构造函数——全缺省 [一种默认构造函数]
Date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
void Print() // void Print(Date* this) 编译后
{
cout << _year << "-" << _month << "-" << _day << endl;
}
~Date()
{
cout << "析构函数" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
// 析构函数,会在这两个对象生命周期结束之后自动调用,完成清理工作,不是完成对其的销毁
Date d1; // 调用默认构造函数
Date d2(2024, 5, 27);
// 执行代码,会执行两次析构函数
//析构函数
//析构函数
//注意这个析构,先析构d2 在析构d1,因为是在栈区的变量
return 0;
}
在上述代码中要注意:析构,先析构d2 在析构d1,因为是在栈区的变量
而有了析构函数和构造函数的存在,我们来感受一下它们的使用。
这里我们来写一个Stack类,这个Stack类,不在需要初始化和销毁的接口,由构造函数和析构函数代替了。
// 实现Stack类——使用构造和析构函数
# include<iostream>
using namespace std;
class Stack
{
public:
// 构造函数
Stack(int n = 10)
{
_a = (int*)malloc(sizeof(int) * n);
if (_a == NULL)
{
perror("Stack()malloc()");
exit(-1);
}
cout << "malloc:" << _a << endl;
_size = 0;
_capacity = n;
}
// ....... 栈的接口
// 析构函数
~Stack()
{
free(_a);
cout << "free:" << _a << endl;
_a = NULL;
_size = _capacity = 0;
}
private:
int* _a;
int _size;
int _capacity;
};
int main()
{
Stack s1;
Stack s2;
// 注意先析构s2 在析构s1。因为两个局部变量在栈区上 ,后进先出
//malloc:000001FC0C62BEF0
//malloc : 000001FC0C636160
//free : 000001FC0C636160
//free : 000001FC0C62BEF0
return 0;
}
- 关于编译器自动生成的析构函数,是否会完成一些事情呢?下面的程序我们会看到,编译器生成的默认析构函数,对自定类型成员调用它的析构函数。
# include<iostream>
using namespace std;
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类生成的默认析构函数
// 注意:创建哪个类的对象则调用该类的析构函数,销毁那个类的对象则调用该类的析构函数
总结:
默认生成的析构函数(语法坑:双标)
-
针对内置类型的成员变量不会做处理
-
针对自定义类型的成员变量,调用它的析构函数
-
一旦用户定义了显式析构函数。那么编译器将不会生成默认析构函数
- 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类。
4.拷贝构造函数
4.1概念
在现实生活中,可能存在一个与你一样的自己,我们称其为双胞胎
那在创建对象时,可否创建一个与已存在对象一某一样的新对象呢?
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用
4.2特征
拷贝构造函数也是特殊的成员函数,其特征如下:
-
拷贝构造函数是构造函数的一个重载形式。
-
拷贝构造函数的参数只有一个且必须是类 类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。
我们来看一个例子:
// 拷贝构造函数
# include<iostream>
using namespace std;
class Date
{
public:
// 构造函数
Date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
// 这样无法编译通过,因为这里会造成递归拷贝,会无穷递归下去
Date(Date d) // 构造函数的重载函数
{
_year = d._year;
_month = d._month;
_day = d._day;
}
// 析构函数
~Date()
{
cout << "析构函数" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024, 5, 27);
// 如果我们想创建一个跟d1一模一样的对象,我们可以怎么做呢?
//Date d2(2024, 5, 27);// 这样可以,但是肯定不好,因为d1一变,我们还要手动更改d2
// 这个时候我们就可以用到拷贝构造
Date d2(d1); // 传d1进去,// 这里会报错
return 0;
}
为什么会造成无穷递归呢?
// 这样无法编译通过,因为这里会造成递归拷贝,会无穷递归下去
Date(Date d) // 构造函数的重载函数
{
_year = d._year;
_month = d._month;
_day = d._day;
}
int main()
{
// Date d2(d1);
}
因为我们这里是传值给拷贝构造函数,我们知道,传值调用的形参就是实参的一个临时拷贝,既然是拷贝,我们就要创建一个跟实参一个类型的值,那实参是什么类型的值呢,是Date,相当于要把实参d1 拷贝给 形参d,那d的构造又需要调用拷贝构造函数,那又要传参,又要拷贝实参,又要构造形参,又要调用拷贝构造函数。无限循环下去。
如图所示:
解决方法是什么呢?
其实就是让形参是实参的别名就好了。
// 拷贝构造函数
# include<iostream>
using namespace std;
class Date
{
public:
// 构造函数
Date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
这样无法编译通过,因为这里会造成递归拷贝,会无穷递归下去
//Date(Date d)
//{
// _year = d._year;
// _month = d._month;
// _day = d._day;
//}
// 我们使用别名来解决这个问题
Date(const Date& d) // 传参的过程相当于 Date& d = d1;
{
_year = d._year;
_month = d._month;
_day = d._day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
// 析构函数
~Date()
{
cout << "析构函数" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024, 5, 27);
// 如果我们想创建一个跟d1一模一样的对象,我们可以怎么做呢?
//Date d2(2024, 5, 27);// 这样可以,但是肯定不好,因为d1一变,我们还要手动更改d2
// 这个时候我们就可以用到拷贝构造
Date d2(d1); // 传d1进去
Date d3 = d1; // 这个也是拷贝构造。
d2.Print();
d3.Print();
return 0;
}
上面这个代码的运行结果
- 若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
class Time
{
public:
Time()
{
_hour = 1;
_minute = 1;
_second = 1;
}
Time(const Time& t)
{
_hour = t._hour;
_minute = t._minute;
_second = t._second;
cout << "Time::Time(const Time&)" << endl;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
// 基本类型(内置类型)
int _year = 2024;
int _month = 5;
int _day = 28;
// 自定义类型
Time _t;
};
int main()
{
Date d1;
// 用已经存在的d1拷贝构造d2,此处会调用Date类的拷贝构造函数
// 但Date类并没有显式定义拷贝构造函数,则编译器会给Date类生成一个默认的拷贝构造函数
Date d2(d1);
return 0;
}
- 编译器生成的默认拷贝构造函数已经可以完成字节序的值拷贝了,还需要自己显式实现吗?当然像日期类这样的类是没必要的。那么下面的类呢?验证一下试试?
// 涉及内存资源管理的Stack的拷贝构造
# include<iostream>
using namespace std;
class Stack
{
public:
// 构造函数
Stack(int n = 10)
{
_a = (int*)malloc(sizeof(int) * n);
if (_a == NULL)
{
perror("Stack()malloc()");
exit(-1);
}
cout << "malloc:" << _a << endl;
_size = 0;
_capacity = n;
}
// ....... 栈的接口
// 析构函数
~Stack()
{
free(_a);
cout << "free:" << _a << endl;
_a = NULL;
_size = _capacity = 0;
}
private:
int* _a;
int _size;
int _capacity;
};
int main()
{
// 浅拷贝问题
Stack s1;
Stack s2(s1);
return 0;
}
上面我们的Stack类中,没有显式实现的拷贝构造,因此我们在main函数中的拷贝构造 的使用,用的都是编译器生成的默认的函数。这里用的是浅拷贝。因此造成了c++中比较经典的一个问题——浅拷贝问题
我们来分析一下代码为何会崩溃。
- 首先就是我们在调用拷贝构造的时候,我们是将s1对象一个一个字节拷贝到s2中的,这就意味着我们两个对象所存储的数组指针_a是一样的,也就是说指向的数组都是同一个空间的数组
- 这个时候我们的代码还不会崩溃,并且也确实成功完成了拷贝的任务
- 但是问题会出现在析构函数上,我们知道在析构函数中,我们要完成对栈这个类对象的资源清理工作,我们要在析构函数中释放掉数组的空间,由于s1 s2存储在栈区,我们先释放掉s2的数组空间,但是紧接着我们要释放掉s1的数组空间,但是s1和s2指向的数组是同一个数组,这就会造成会同一个空间的重复释放,这就会导致代码崩溃
因此对于这种涉及到内存资源管理的类,我们需要自己实现深拷贝的拷贝构造
但是这里有人会说,那我不自己实现析构函数不就不会报错了吗?
因为我们的析构函数中存在对数组空间的释放,但是不释放就会造成内存泄漏的问题,释放了就会造成浅拷贝问题。
- 拷贝构造函数典型调用场景:
- 使用已存在对象创建新对象
- 函数参数类型为类类型对象
- 函数返回值类型为类类型对象
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;
}
5.赋值运算符重载
5.1 运算符重载
为什么要搞运算符重载呢?
其实很简单,就是因为内置类型,我们在使用运算符的时候。编译器知道怎么去比较,但是如果我们自己定义了一个类,在用内置的运算符,编译器就不知道怎么比较了,因此,为了能够方便的比较我们自己定义的类对象,我们需要对运算符重载
在之前我们是通过自定义函数,来解决这个问题的,但是这个解决方式也没有运算符重载好。因为可读性不是很好。
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:关键字operator后面接需要重载的运算符符号。
函数原型:返回值类型 operator操作符(参数列表)
注意:
-
不能通过连接其他符号来创建新的操作符:比如operator@
-
重载操作符必须有一个类类型参数
-
用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不能改变其含义
-
作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this指针形参
-
.* :: sizeof ?: .
注意以上5个运算符不能重载。这个经常在笔试选择题中出现。注意是 **.**不能重载,不是 (解引用)
我们来看一段代码:
// 运算符重载
# include<iostream>
using namespace std;
class Date
{
public:
// 默认构造函数
Date(int year = 0, int month = 0, int day = 0)
{
_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;
};
// 运算符有几个操作数, operator重载的函数就有几个参数
bool operator==(const Date& d1, const Date& d2)
{
return d1._year == d2._year
&& d1._month == d2._month
&& d1._day == d2._day;
}
int main()
{
Date d1(2024, 5, 27);
Date d2(d1);
// 如果我们相对Date类的 对象 d1 d2 进行比较
// 我们可以通过定义函数实现
//IsDateEqual(d1, d2); // 可读性不好
// 为了让可读性更好,c++推出了运算符重载
d1 == d2;// 编译过后会变成, operator==(d1, d2)
// 如果不对== 进行重载,那么这里就会编译出错
operator==(d1, d2); // 等价d1 == d2 ,但是不推荐这样写,这样可读性又下降了
if (d1 == d2)
cout << "相等" << endl;
return 0;
}
上述的写法不好,因为我们为了实现运算符重载,我们牺牲了Date类中成员变量的私有性。把成员变量变成公有的,才能让运算符重载函数访问到其成员变量。
要如何解决这个问题呢?
这里其实可以用友元解决,这个后面学习
我们还可以通过把运算符重载函数写进类中来解决
# include<iostream>
using namespace std;
class Date
{
public:
// 默认构造函数
Date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& d) // 拷贝构造函数
{
_year = d._year;
_month = d._month;
_day = d._day;
}
// 运算符重载
// 我们知道成员函数会自带this指针形参,因此这里要修改函数的形参个数。
// 我们知道谁调用这个运算符重载函数,那么this指针就指向谁
// d1 == d2;
// d1.operator==(d2);
bool operator==(const Date& d) // bool operator==(Date* this, const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
// d1 > d2
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;
}
else
return false;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024, 5, 27);
Date d2(d1);
d1 == d2; // 编译后变成 d1.operator==(d2)
if (d1 > d2)
cout << "大于" << endl;
else
cout << "其他" << endl;
return 0;
}
5.2赋值运算符重载
- 赋值运算符重载格式
- 参数类型:const T&,传递引用可以提高传参效率
- 返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
- 检测是否自己给自己赋值
- 返回*this :要复合连续赋值的含义
来看代码:
// 赋值运算符重载
# include<iostream>
using namespace std;
class Date
{
public:
// 默认构造函数
Date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
// 拷贝构造函数
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
// 运算符重载
// d1 == d2 -> d1.operator==(d2);
bool operator==(const Date& d) // bool operator==(Date* this, const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
// 赋值运算符重载
//d2 = d1 -> d2.operator(&d2, d1)
Date& operator=(const Date& d) // 其实这里不用引用传参也不会报错,但是使用引用传参效率更高
// void operator(Date* this, const Date d)
{
// 自己给自己赋值是没有意义的
if (this == &d) // d是引用,d的地址和this相同就说明是自己给自己赋值
return *this;
_year = d._year;
_month = d._month;
_day = d._day;
return *this;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024, 5, 27);
Date d2 = d1; //注意这里这个=不是赋值运算符,而是拷贝构造
Date d3;
d3 = d1;
d3.Print();// 2024-5-27
d3 = d3; // 自己赋值给自己
//我们的=除了要实现单个的赋值,还要能实现连续的赋值
d2 = d3 = d1;// 先执行d3 = d1,也就是d3.operator=(d1), 其返回值得是d3 才可以
d2.Print();
return 0;
}
在来看看拷贝构造和 赋值运算符重载的区别:
- 赋值运算符只能重载成类的成员函数不能重载成全局函数
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
int _year;
int _month;
int _day;
};
// 赋值运算符重载成全局函数,注意重载成全局函数时没有this指针了,需要给两个参数
Date& operator=(Date& left, const Date& right)
{
if (&left != &right)
{
left._year = right._year;
left._month = right._month;
left._day = right._day;
}
return left;
}
// 编译失败:
// error C2801: “operator =”必须是非静态成员
编译出错的原因是:
赋值运算符如果不显式实现,编译器会生成默认的赋值运算符重载。此时我们类外边在定义一个赋值运算符重载,就会和编译器的产生冲突。因此,赋值运算符重载只能是类的成员函数
- 用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。
// 如果没有显式实现 拷贝构造 和 赋值运算符重载,编译器会生成默认的
# include<iostream>
using namespace std;
class Date
{
public:
// 默认构造函数
Date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
拷贝构造函数
//Date(const Date& d)
//{
// _year = d._year;
// _month = d._month;
// _day = d._day;
//}
赋值运算符重载
d2 = d1 -> d2.operator(&d2, d1)
//Date& operator=(const Date& d) // 其实这里不用引用传参也不会报错,但是使用引用传参效率更高
// // void operator(Date* this, const Date d)
//{
// // 自己给自己赋值是没有意义的
// if (this == &d) // d是引用,d的地址和this相同就说明是自己给自己赋值
// return *this;
// _year = d._year;
// _month = d._month;
// _day = d._day;
// return *this;
//}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024, 5, 10);
Date d2(2024, 5, 28);
d1 = d2; // 即使我们类中屏蔽了赋值运算符重载,这里不会报错
// 因为我们不实现的时候,编译器会生成拷贝构造和 operator=
// 会完成按字节的值拷贝(浅拷贝)。
// 也就是说有些类,比如这个日期类,我们不需要去实现拷贝构造和赋值运算符重载。
// 但是涉及到 内存管理 的类 我们就需要去实现拷贝构造和赋值运算符重载
d1.Print();// 2024-5-28
d2.Print();// 2024-5-28
Date d3(d1); // 屏蔽了拷贝构造函数,也不会报错,这里调用了编译器生成的默认拷贝构造函数
Date d4 = d3;
d3.Print();// 2024-5-28
d4.Print();// 2024-5-28
return 0;
}
- 值拷贝(浅拷贝):将对象按照一个字节一个字节的传过去。
注意: 只有六个成员函数我们不显式实现,编译器会自动生成。
拷贝构造函数和 赋值运算符函数就是其中之二,其他运算符编译器在没有显式实现的时候不会默认生成
但是这样就产生了一个问题:
- 我们还需要去自己实现拷贝构造函数和赋值运算符重载吗?编译器不是已经帮我们实现了吗?
答案当时是需要的,因为除了我们的日期类,还有许多类我们会涉及到**(内存的资源管理)**。
我们来看一段代码来感受一下:
// 涉及内存资源管理的Stack的拷贝构造和赋值运算符重载
# include<iostream>
using namespace std;
class Stack
{
public:
// 构造函数
Stack(int n = 10)
{
_a = (int*)malloc(sizeof(int) * n);
if (_a == NULL)
{
perror("Stack()malloc()");
exit(-1);
}
cout << "malloc:" << _a << endl;
_size = 0;
_capacity = n;
}
// ....... 栈的接口
// 析构函数
~Stack()
{
free(_a);
cout << "free:" << _a << endl;
_a = NULL;
_size = _capacity = 0;
}
private:
int* _a;
int _size;
int _capacity;
};
int main()
{
// 浅拷贝问题
Stack s1;
Stack s3(30);
s1 = s3;
return 0;
}
上面我们的Stack类中,没有显式实现的赋值运算符重载,因此我们再main函数中的赋值运算符的使用,用的都是编译器生成的默认的函数。这里用的是浅拷贝。造成了浅拷贝问题
前面我们已经知道了拷贝构造函数造成浅拷贝的分析和原理了
对于赋值运算符也是同样的道理,我们将s3赋值给s1用的是浅拷贝,s1和s3的_a数组指针,指向的都是同一个数组空间,在调用析构函数的时候,也会造成对同一个空间的重复释放,代码崩溃.
我们来看图片来更好的理解一下:
因此对于这种涉及到内存资源管理的类,我们需要自己实现深拷贝的赋值运算符重载
但是这里有人会说,那我不自己实现析构函数不就不会报错了吗?
因为我们的析构函数中存在对数组空间的释放,但是不释放就会造成内存泄漏的问题,释放了就会造成浅拷贝问题。
5.3前置++和后置++的重载
直接来看代码:
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
// 前置++:返回+1之后的结果
// 注意:this指向的对象函数结束后不会销毁,故以引用方式返回提高效率
Date& operator++()
{
_day += 1;
return *this;
}
// 后置++:
// 前置++和后置++都是一元运算符,为了让前置++与后置++形成能正确重载
// C++规定:后置++重载时多增加一个int类型的参数,但调用函数时该参数不用传递,编译器自动传递
// 注意:后置++是先使用后+1,因此需要返回+1之前的旧值,故需在实现时需要先将this保存一份,然后给this + 1
// 而temp是临时对象,因此只能以值的方式返回,不能返回引用
Date operator++(int)
{
Date temp(*this);
_day += 1;
return temp;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d;
Date d1(2022, 1, 13);
d = d1++; // d: 2022,1,13 d1:2022,1,14
d = ++d1; // d: 2022,1,15 d1:2022,1,15
return 0;
}
6.日期类的实现
实际上完整的完善的日期类,我们应该让声明和定义分离,并且还要优化代码。这里为了方便,我们就不让声明和定义分离了。
// 实现一个完善的日期类
# include<iostream>
using namespace std;
class Date
{
public:
int GetMonthDay(int year, int month)
{
// 给13 的原因是为了刚好让下标对上月份
static int monthday[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
// 给static的原因是 每次访问的数组都不变并且都是这个数组,那每次访问都要开辟,不如直接放静态区去
// 闰年的2月份 是 29天
if (month == 2 && (year % 4 == 0 && year % 100 != 0) || year % 400 == 0)
monthday[2] = 29;
else
monthday[2] = 28;
int day = monthday[month];
return day;
}
// 默认构造函数
Date(int year = 0, int month = 0, int day = 0)
{
// 对传进来的年 月 日 进行判断,是否合法,合法才构造
if (year >= 0 && month > 0 && month < 13 && day <= GetMonthDay(year, month))
{
_year = year;
_month = month;
_day = day;
}
else
{
cout << "非法日期" << endl;
}
}
// 拷贝构造函数 (其实日期类这种不涉及内存资源管理的类,不需要我们显式实现拷贝构造,但是实现了也没问题)
// Date(Date* this, const Date& d)
Date(const Date& d) // 一定要有别名 & ,不然会无限递归
{
_year = d._year;
_month = d._month;
_day = d._day;
}
// 析构函数
~Date() // 其实类似日期类这种类,是不需要我们去编写显式析构函数的
{
cout << " 析构函数" << endl;
}
// 运算符重载
//d1 < d2 编译器处理后 d1.operator<(&d1, d2)
bool operator<(const Date& d)// bool operator(Date* this, 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;
else
return false;
}
// d1 == d2 编译器处理后 d1.operator==(&d1, d2)
bool operator==(const Date& d)// bool operator==(Date* this, const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
// d1 <= d2 编译器处理 d1.operator<=(&d1, d2)
bool operator<=(const Date& d) // bool operator<=(Date* this, const Date& d)
{
// 这里我们采用复用上面的代码来实现,以减少代码重复
return *this < d || *this == d; // *this 解引用就是 d1
// (*this).operator<(&(*this), d) || (*this).operator==(&(*this), d);
}
// d1 > d2 编译器处理后 d1.operator>(&d1, d2)
bool operator>(const Date& d)
{
// 不再采取之前那种代码,代码重复性太强,并且一旦成员变量改变,代码也要跟着大量修改
// 我们采取函数复用
return !(*this <= d); // 只要*this 不<= d 那就是> d
// 编译器处理后 !((*this).operator<=(&(*this), d))
}
// d1 >= d2 编译器处理 d1.operator>=(&d1, d2)
bool operator>=(const Date& d)// bool operator>=(Date* this, const Date& d)
{
// 也采用函数复用。复用前面已经实现的 > 和 == 的运算符重载函数
return *this > d || *this == d;
}
// d1 != d2 -> d1.operator!=(&d1, d2)
bool operator!=(const Date& d)// bool operator!=(Date* this, const Date& d)
{
// 函数复用
return !(*this == d);
}
d1 + 10(天数) -> d1.operator+(&d1, 10)
//Date operator+(int day) // Date operator+(Date* this, int day)
这里不能使用引用返回,因为拷贝构造的ret在函数结束之后就会销毁
//{
// Date ret = *this; // 拷贝构造一个d1 ,这里等价于 ret(*this)
// ret._day += day;
// // 对day进行判断是否合法,合法就输出,不合法要进位
// while (ret._day > GetMonthDay(ret._year, ret._month))// 有可能不止进一次位,所以给一个循环
// {
// ret._day -= GetMonthDay(ret._year, ret._month);
// ret._month++;
// // 要注意月份是否合法
// if (ret._month == 13)
// {
// ret._year++;
// ret._month = 1;
// }
// }
// return ret;
//}
//d1 += 10 -> d1.operator+=(&d1, 10)
Date& operator+=(int day)// 这里可以使用引用返回,因为this指向的本来就是外面的d1,这个函数结束之后,d1不会销毁
{
// 如果day是负数。要处理
if (day < 0)
{
return *this -= -day; // 加负数 相当于 减正数
}
// += 和 + 的区别是, + 不改变d1本身,但是+=要改变d1本身
_day += day;
while (_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
_month++;
// 判断月份是否合法
if (_month == 13)
{
_year++;
_month = 1;
}
}
return *this; // 返回的是d1本身
}
// 上面我们对+的重载和 对+=的重载的代码重复性很高,那我们就可以考虑采用复用
// d1 + 10
Date operator+(int day)
{
Date ret(*this); // 拷贝构造d1
ret += day; // 复用我们实现的+=的重载
// ret.operator+=(day)
return ret;
}
d1 - 10(天数) -> d1.operator-(&d1, 10)
//Date operator-(int day)
//{
// Date ret = *this; // 拷贝构造一个d1
// ret._day -= day;
// // 判断-=day之后的 day是否合法,合法就返回,不合法就要退位
// while (ret._day <= 0)
// {
// ret._month--; // 先让月份退到上一个月
// // 判断月份是否合法
// if (ret._month == 0)
// {
// ret._month = 12;
// ret._year--;
// }
// ret._day += GetMonthDay(ret._year, ret._month); // + 上一个月份的天数,看看是否_day是否合法
// }
// return ret;
//}
// d1 -= 10 -> d1.operator(&d1, 10)
Date& operator-=(int day) // Date& operator(Date* this, int day)
{
// 判断day是否是负数
if (day < 0)
{
return *this += -day; // 减负数 相当于 加正数
}
// -= 改变的是d1自己,也就是this指针指向的对象
_day -= day;
// 判断_day是否合法,不合法要退位,直至合法
while (_day <= 0)
{
// 先让月份退一位
_month--;
// 判断月份是否合法
if (_month == 0)
{
_month = 12;
_year--;
}
_day += GetMonthDay(_year, _month); // += 上一个月份的天数,看看是否_day是否合法
}
return *this; // 返回自身
}
// 对-的重载采用代码复用,减少对代码的重复性,提升维护性
// d1 - 10
Date operator-(int day)
{
Date ret(*this); // 拷贝构造d1
ret -= day; // 函数复用
return ret; // 返回ret的时候要创建一个临时变量, 会调用一次拷贝构造
}
// ++d1 -> d1.operator++(&d1)
Date& operator++() // Date& operator(Date* this)
{
// ++也可以像之前一样,每次调用+1天,+完之后判断day是否需要进位,月是否需要进位,年是否需要进位
// 但是为了提升类的维护性,和减少代码重复度,我们采取复用
*this += 1;// ++d1就是让自己去 + 1
return *this;
}
// d1++ -> d1.operator++(&d1, 0)
Date operator++(int) // 加int是为了代表是后置++,如果不加就是默认前置++
{
// 前置++和后置++的区别就是 前置++返回+之后的 后置++返回+之前的
Date tmp = *this;
*this += 1;
return tmp; // 返回+之前的
}
//--d1
Date& operator--()
{
*this -= 1; // 函数复用
return *this;
}
//d1--
Date operator--(int)
{
// 前置-- 要返回--之后的 后置-- 要返回-之前的
Date tmp(*this);
*this -= 1;
return tmp;
}
// 赋值运算符重载 operator=
// d1 = d2 -> d1.operator(&d1, d2)
Date& operator=(const Date& d)
{
// 防止自己给自己赋值
if (this != &d) // this指向的地址 d是别名,d的地址和this指针相等就说明是自己给自己赋值
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
// 日期 - 日期 d1 - d2
// 我们让小的日期++ 直至 == 大的日期, + 了多少次,就差了多少天
int operator-(const Date& d)
{
int flag = 1;
// 默认大的是 this指针指向的 小的是 d
Date max = *this; // 拷贝构造
Date min = d; // 拷贝构造
if (max < min)
{
// 走到这里说明 默认的情况是错的
min = *this;
max = d;
flag = -1; // 说明是小的 - 大的 最后应该是个负数
}
int n = 0;
while (max > min) // 让小日期一直加 直至 == 大日期
{
++min;
++n;
}
// 加了多少次,n就是多少,n就是相差的天数
return n * flag;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024, 5, 28);
Date d2 = d1; // 等价于 d2(d1)
cout << (d1 < d2) << endl; // 0
cout << (d1 == d2) << endl;// 1
cout << (d1 > d2) << endl;// 0
cout << (d1 != d2) << endl;// 0
cout << (d1 <= d2) << endl;// 1
cout << (d1 >= d2) << endl;// 1
// 是否要重载一个运算符,看的是这个运算符是否对该类的对象有意义
// 比如日期 + 日期没有意义, 但是日期 - 日期有意义,是两日期相隔的天数
// 比如 日期 + 天数 有意义,是多少天之后, 日期 - 天数有意义 是多少天之前
// 而有+ 就有 += , 有-就有-= , 并且日期 * 日期 和 日期 / 日期是没有意义的
Date d3 = d1 + 10; // 将d1 + 10后的日期 拷贝构造到d3对象
d3.Print();// 2024-6-7
Date d4(d1 + 100);
d4.Print();// 2024-9-5
Date d5(d1 + 1000);
d5.Print();// 2027-2-22
Date d6(d1 - 1000);
d6.Print();// 2021-9-1
Date d7(d1 -= 1000);
d7.Print();// 2021-9-1
Date d8(d1++);
d8.Print(); // 2021-9-1
Date d9(++d1);
d9.Print();//2021-9-3
Date d10(d1--);
d10.Print();// 2021-9-3
Date d11(--d1);
d11.Print();//2021-9-1
cout << d3 - d1 << endl; // 1010
cout << d1 - d3 << endl;// -1010
return 0;
}
中间两次调用析构函数是因为,在日期 - 日期函数中,我们创建了两个Date类的局部变量,函数栈帧结束后要销毁变量。调用了析构函数
7.const成员
将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改
我们来看一段代码:
// const成员
# 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(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
// 由于this指针是隐含的 所以不能这样加 void Print(const Date* this)
// 因此我们使用const修饰成员函数, 实际上修饰的是 *this, 也就是this指针指向的对象
void Print() const // 编译后 -> void Print(const Date* this)
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
void f(const Date& d)
{
d.Print();// 编译无法通过 编译后 -> d.Print(&d)
// 因为这里的&d 是const Date*类型, 但是Print函数的this形参是 Date* 类型
// 涉及到了权限放大的问题,就无法编译通过
}
int main()
{
Date d1;
f(d1);
return 0;
}
这里可以在回忆一下const修饰的用法
-
const Date p1;*
-
Date const p2;*
-
Date const p3;*
第一第二种const都在*的左边,修饰的都是指针指向的对象,也就是不能修改指针指向的对象。
第三种 const在* 的右边,修饰的是指针本身,也就是不能让修改指针本身指向其他地方
上面代码属于是对象调用成员函数,那我们再来看看成员函数之间的相互调用,是否也有const之间的关系
来看代码:
// 成员函数之间调用
# 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(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
void f1()// void f1(Date* this)
{
f2(); // 编译后 this->f2(this);
// 这里的*this是可读可写的,传进去的*this要变成const Date类型,只能读
// 属于权限缩小,不会报错
}
void f2() const // void f2(const Date* this)
{}
void f3()// void f3(Date* this)
{
}
void f4() const // void f4(const Date* this)
{
f3(); // this->f3(this) 会报错
// 这里的*this 只能读不能改, 但是传给f3之后会变成可读可写的
// 属于权限放大,因此会报错
}
void Print() const // 编译后 -> void Print(const Date* this)
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
return 0;
}
看了上面两段代码之后,请思考下面的几个问题:
-
const对象可以调用非const成员函数吗?【不可以】
-
非const对象可以调用const成员函数吗?【可以】
-
const成员函数内可以调用其它的非const成员函数吗?【不可以】
-
非const成员函数内可以调用其它的const成员函数吗?【可以】
注意:
成员函数之间调用,实际上就是this指针指向的对象在调用。
总结:
只要成员函数中不需要修改成员变量,最好都加上const修饰成const成员函数
不然有const类型的对象传进来就会报错,因为权限缩小了。上面我们也有讲。
如图所示:
8.取地址及const取地址操作符重载
这两个默认成员函数一般不用重新定义 ,编译器默认会生成。
//取地址及const取地址操作符重载
# 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* operator&()
{
cout << "取地址操作符重载" << endl;
return this;
}
// 取地址操作符重载
const Date* operator&() const
{
cout << "const取地址操作符重载" << endl;
return this;
}
void Print() const // 编译后 -> void Print(const Date* this)
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1, d2;
const Date d3, d4;
cout << &d1 << endl;
//取地址操作符重载
//00000008B33CF528
cout << &d2 << endl;
//取地址操作符重载
//00000008B33CF558
//cout << &d3 << endl; // 这里不会调用我们实现的&重载函数,因为d3是const Date类 权限放大了。
仍然获得了d3的地址是因为调用了编译器默认生成的const取地址操作符重载函数
000000A4BA3AF568
// const取地址操作符重载函数我们也可以自己实现
cout << &d4 << endl; // 这里调用的就是我们自己实现的
//const取地址操作符重载
//00000008B33CF5B8
return 0;
}
这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容!