🔥🔥本章重内容
类与对象(中)
1. 类的6个默认成员函数
一个类中默认又6个成员函数。
默认成员函数又分为一下三种。
默认成员函数就是不需要我们自己写,系统也会自动生成,自动调用。
2.构造函数
2.1概念
比如我们现在要实现一个栈。
之前我们实现栈的思想:
- 存放栈的结构体
- 栈的初始化
- 栈顶插入
- 出栈
…
最后销毁栈
但是在使用栈的时候,我们有时候就会忘记初始化与销毁
如果没有初始化编译器就会报错,但是如果没有销毁那我们的程序就会又内存泄漏,内存泄漏是很严重的问题,编译器也不会报错。
C++中的构造函数与析构函数就帮我们解决了这个问题。
2.2 特性
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
其特征如下:
- 函数名与类名相同。
- 无返回值。
- 对象实例化时编译器自动调用对应的构造函数。
- 构造函数可以重载。
#include <iostream>
using namespace std;
class Date
{
public:
//无参的构造函数
Date()
{
}
//带参数的构造函数
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
//d1与d2是Date创建的对象
//调用无参的构造函数
Date d1;
//调用带参数的构造函数
Date d2(2023,5,4);
return 0;
}
我们可以看到就算我们不去调用构造函数,编译器会帮我们初始。
5. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
class Date
{
public:
//把之前写的构造函数删掉,编译器也会帮我们生成
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
可以看到编译器帮我们生成的默认构造在初始化 内置类型(char,short,int,double……) 时数据不做处理。
- C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:int/char…,自定义类型就是我们使用class/struct/union等自己定义的类型。自定义类型会自动调用它自己的构造函数。
#include <iostream>
using namespace std;
class Stack
{
public:
Stack(int capacity = 4)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a == NULL)
{
perror("malloc fail");
return;
}
_top = 0;
_capacity = capacity;
}
private:
int* _a;
int _top;
int _capacity;
};
class MyQueue
{
private:
Stack _pushst;
Stack _popst;
};
int main()
{
MyQueue q1;
return 0;
}
比如上面这段代码,MyQueue类中都是类类型,Stack会自动调用它的构造函数,结果如下。
我们发现如果类中的数据类型都是类类型,那我们就可以不用给当前这个类写构造函数。
注意:C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给默认值。
#include <iostream>
using namespace std;
class Stack
{
public:
private:
int* _a = nullptr;
int _top = 0;
int _capacity = 0;
};
class MyQueue
{
private:
Stack _pushst;
Stack _popst;
};
int main()
{
MyQueue q1;
return 0;
}
所以有些情况下我们也可以考虑在类中声明时给内置类型成员缺省值,对对象进行初始化。
7. 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数。
class Date
{
public:
Date()
{
_year = 2023;
_month = 5;
_day = 6;
}
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
return 0;
}
但是这样写是正确的吗?
之前的C++入门那一章我们就有说过,这样写会导致运行时出现调用不明确的结果。
所以这样写,程序不能通过。
构造函数的调用
class A
{
public:
A(int a = 0, int b = 0)
{
_a = a;
_b = b;
}
private:
int _a;
int _b;
};
class B
{
public:
B(int b = 0)
{
_b = b;
}
private:
int _b;
};
int main()
{
//多个参数的调用方法
A a1(1, 2);
A a2 = { 1,2 };
A a({ 1,2 });
//这个虽然看起来怪,但它是为后面隐式类型转换做准备的
//单个参数的调用方法
B b(1);
B b = 1;
return 0;
}
3.析构函数
3.1 概念
通过前面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么没呢的?
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
3.2 特性
析构函数是特殊的成员函数,其特征如下:
- 析构函数名是在类名前加上字符 ~。
- 无参数无返回值类型。
- 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载
- 对象生命周期结束时,C++编译系统系统自动调用析构函数。
class Stack
{
public:
//构造函数
Stack()
{
cout << "Stack()" << endl;
_a = (int*)malloc(sizeof(int) * 4);
if (_a == NULL)
{
perror("malloc fail");
return;
}
_top = 0;
_capacity = 4;
}
//析构函数
~Stack()
{
cout << "~Stack()" << endl;
free(_a);
_a = NULL;
_top = _capacity = 0;
}
private:
int* _a;
int _top;
int _capacity;
};
int main()
{
Stack st1;
return 0;
}
构造函数与析构函数都被自动调用了。
构造函数是在创建时调用,析构函数是在程序结束时调用。
- 关于编译器自动生成的析构函数,是否会完成一些事情呢?下面的程序我们会看到,编译器生成的默认析构函数,对自定类型成员调用它的析构函数。
把上面代码中我们自己写的析构函数注释掉,测试一下系统自动生成的析构函数是否可以实现我们想要的结果。
class Stack
{
public:
//构造函数
Stack()
{
cout << "Stack()" << endl;
_a = (int*)malloc(sizeof(int) * 4);
if (_a == NULL)
{
perror("malloc fail");
return;
}
_top = 0;
_capacity = 4;
}
void Push(int x)
{
if (_top == _capacity)
{
int* tmp = (int*)realloc(_a, _capacity * 2);
if (tmp == NULL)
{
perror("realloc fail");
return;
}
_capacity *= 2;
_a = tmp;
free(tmp);
tmp = NULL;
}
_a[_top++] = x;
}
析构函数
//~Stack()
//{
// cout << "~Stack()" << endl;
// free(_a);
// _a = NULL;
// _top = _capacity = 0;
//}
private:
int* _a;
int _top;
int _capacity;
};
int main()
{
Stack st1;
st1.Push(1);
return 0;
}
当程序走到最后,我们在堆区动态申请的空间还没有被释放,其它变量都是栈区上的变量,程序结束后会自动释放。
所以当我们有动态申请的空间时必须得自己写析构函数。
- 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类
4. 拷贝构造函数
4.1 概念
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
我们在调用函数时如果传递的实参是类,那我们就需要调用拷贝构造。传参可以看作用实参去初始化形参。
4.2 特征
拷贝构造函数也是特殊的成员函数,其特征如下
- 拷贝构造函数是构造函数的一个重载形式
- 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。
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(2003, 4, 7);
Date d2(d1);
return 0;
}
这里一定要写成引用,因为传递类类型会调用拷贝构造。
如果没有写成引用,就会导致程序死循环,当然C++不允许这样的写法,所以编译器会报错。
对于传参C++规定
内置类型直接拷贝
自定义类型必须调用拷贝构造完成
我们来举例证明:
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& d)
{
cout << "Date(const Date& d)" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
private:
int _year;
int _month;
int _day;
};
void fun1(Date d)
{
cout << "void fun1(Date d)" << endl;
}
int main()
{
Date d1;
fun1(d1);
return 0;
}
我们在拷贝构造函数与fun1()函数中都写了一段输出到控制台的代码,只需要看我们在调用fun1()函数时会不会调用拷贝构造函数
可以看到是先调用拷贝构造再进入fun1(),所以我们说自定义类型必须调用拷贝构造完成,也证明了如果我们把拷贝构造函数的参数写成Date 就会造成死循环,一直调用拷贝构造,当然C++不会允许有这样的代码,所以写出这样的代码,编译器会报错。
-
若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
-
编译器默认生成的拷贝构造,浅拷贝对于Date类是没有影响的可以正常使用,那拷贝构造函数还需要自己显式实现吗?
第三点已经证明了,浅拷贝对于Date类是没有影响的。
那编译器生成的拷贝构造能否完成Stack类的拷贝构造呢?
class Stack
{
public:
Stack(int capacity = 4)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a == NULL)
{
perror("malloc fail\n");
return;
}
_top = 0;
_capacity = capacity;
}
//使用编译器生成的拷贝构造
private:
int* _a;
int _top;
int _capacity;
};
int main()
{
Stack s1;
Stack s2(s1);
return 0;
}
可以发现s1,s2中_a的地址是相同的,那如果我们改变s1中_a 的元素,s2也会跟着改变。
所以我们不能完全依赖编译器的拷贝构造函数。
当我们有向堆区申请空间时,拷贝构造就需要自己来完成。
那Stack的深拷贝构造怎么写?
Stack(const Stack& s)
{
_a = (int*)malloc(sizeof(int) * s._capacity);
if (_a == NULL)
{
perror("malloc fail\n");
return;
}
memcpy(_a, s._a, sizeof(int) * s._top);
_capacity = s._capacity;
_top = s._top;
}
- 拷贝构造函数典型调用场景:
- 使用已存在对象创建新对象
- 函数参数类型为类类型对象
- 函数返回值类型为类类型对象
为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用尽量使用引用。
5.赋值运算符重载
5.1 运算符重载
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:关键字operator后面接需要重载的运算符符号。
函数原型:返回值类型 operator操作符(参数列表)
例如:void operator=(const Date& d)
注意:
- 不能通过连接其他符号来创建新的操作符:比如operator@
- 重载操作符必须有一个类类型参数
- 用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不 能改变其含义作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this
- .* :: sizeof ?: . 注意以上5个运算符不能重载。
全局的operator>
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;
};
bool operator>(const Date& x1, const Date& x2)
{
if (x1._year > x2._year)
{
return true;
}
else if (x1._year == x2._year && x1._month > x2._month)
{
return true;
}
else if (x1._year == x2._year && x1._month == x2._month && x1._day > x2._day)
{
return true;
}
return false;
}
int main()
{
Date d1(2023,5,11);
Date d2;
d1 > d2;//相当于operator>(d1,d2)
return 0;
}
可以看到当这两条语句转换成汇编后,他们的第层实现是一样的。
所以:d1 > d2;相当于operator>(d1,d2)
如果程序像上面代码那样写的话,程序的封装性就不能保证了。
我们可以把这个函数写到类中,或者使用友元函数,我们在写程序时尽量少的使用友员函数,友员函数下一章会讲。
把operator>写到类中
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
bool operator>(const Date& x)
{
if (_year > x._year)
{
return true;
}
else if (_year == x._year && _month > x._month)
{
return true;
}
else if (_year == x._year && _month == x._month && _day > x._day)
{
return true;
}
return false;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 5, 11);
Date d2;
d1 > d2;//相当于d1.operator>(d2)
d1.operator>(d2);
return 0;
}
d1 > d2;相当于d1.operator>(d2),这里与上面代码大致相同。
5.2 赋值运算符重载
-
赋值运算符重载格式
- 参数类型:const T&,传递引用可以提高传参效率
- 返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
- 检测是否自己给自己赋值
- 返回*this :要复合连续赋值的含义
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
Date& operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 5, 11);
Date d2;
Date d3;
d3 = d2 = d1;
d1 = d1;
return 0;
d1 = d1;
这里this就是d1的地址,d又是d1的引用,所以我们可以直接用地址来判断是否要赋值。
this在这个函数是一个局部变量,存放的是d1的地址,但是我们返回的是*this,出了函数后它还存在,所以我们可以用引用返回。
- 赋值运算符只能重载成类的成员函数不能重载成全局函数
// 赋值运算符重载成全局函数,注意重载成全局函数时没有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;
}
原因:赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现
一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值
运算符重载只能是类的成员函数。
提问:看下面这段代码我们来判断它是调用哪种函数呢?
A.拷贝构造函数
B.赋值函数
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
cout << "Date(int year = 1, int month = 1, int day = 1)" << endl;
_year = year;
_month = month;
_day = day;
}
Date(Date& d)
{
cout << "Date(Date& d)" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
Date& operator=(Date& d)
{
cout << "Date& operator=(Date & d)" << endl;
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 5, 11);
Date d2 = d1;
//A.拷贝构造函数
//B.赋值函数
return 0;
}
可以看到我们只调用了,构造与拷贝构造,没有调用赋值函数
为什么会调用拷贝构造函数呢? 拷贝构造函数的概念;
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
而赋值是对已存在对象进行拷贝
- 用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。 注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。
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;
};
int main()
{
Date d1(2023, 5, 11);
Date d2(2023, 5, 12);
d1 = d2;
return 0;
}
默认生成的赋值运算可以完成Date类的拷贝。
但它不能完成Stack的拷贝
class Stack
{
public:
Stack(int capacity = 4)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a == NULL)
{
perror("malloc fail\n");
return;
}
_top = 0;
_capacity = capacity;
}
Stack(const Stack& s)
{
_a = (int*)malloc(sizeof(int) * s._capacity);
if (_a == NULL)
{
perror("malloc fail\n");
return;
}
memcpy(_a, s._a, sizeof(int) * s._top);
_capacity = s._capacity;
_top = s._top;
}
~Stack()
{
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
int* _a;
int _top;
int _capacity;
};
int main()
{
Stack s1;
Stack s2;
s1 = s2;
return 0;
}
赋值前s1与s2 中_a 的地址:
赋值后的地址:
如果运行起来程序还会异常终止。这是因为s1与s2,_a的地址相同,在程序结束时会调用两次析构函数,_a被释放了两次导致的错误
所以我们总结出:如果类中涉及到资源管理则必须要实现。没有涉及到资源管理,赋值运算符就可以不实现。
5.3 前置++和后置++重载
我们这里写一个简短的代码,只是为了展示**前置++和后置++**该怎么区分。
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//这里的++没有检测_day的天数是否需要向月进位。
Date& operator++()
{
_day += 1;
return *this;
}
//为了区分前置++与后置++
//C++规定:后置++重载时多增加一个int类型的参数,但调用函数时该参数不用传递,编译器自动传递
Date operator++(int)
{
Date temp(*this);
_day += 1;
return temp;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 5, 31);
Date d2(2023, 5, 12);
++d1;
return 0;
}
C++规定:后置++重载时多增加一个int类型的参数,但调用函数时该参数不用传递,编译器
自动传递
5.4输入>>,输出<<重载
我们知道要想输出一个类,我们之前只能写一个输出函数Print()
但在学习了运算符重载后我们可以把**<<,>>**这两个操作符重载了。
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//拷贝构造编译器生成的就可以完成
void operator<< (ostream& out)
{
out << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 5, 13);
cout << d1;
return 0;
}
但是这样写编译器会报错,这是为什么呢?
其实我们之前写的所有类成员函数
被调用后都会转换成 类名.成员函数()
操作符左边为类名,右边为成员函数
但我们调用时写的会被转换成cout.operator(),但我们想要的是d1.operator()
所以我们不能这样写,要把它写在类外
这张图中可以看出cin,cout的类型分别是什么。
如果我们把<<,>>的函数重载写到类外,就要考虑怎么输出类里面的成员变量
- 把类成员写出共有的,但我们不推荐这样写,会破会程序的封装性。
- 在类里面写GetYeart(),GetMonth(),GetDay(),这三个函数,然后用d1调用他们
- 在类外定义然后写类里声明一个友员函数就可以访问类里的成员变量了
我们用第三种方法:
class Date
{
friend ostream& operator<<(ostream& out, Date& d);
friend istream& operator>>(istream& in, Date& d);
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//拷贝构造编译器生成的就可以完成
void operator<< (ostream& out)
{
out << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
ostream& operator<<(ostream& out, Date& d)
{
out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
return out;
}
istream& operator>>(istream& in, Date& d)
{
in >> d._year >> d._month >> d._day;
return in;
}
int main()
{
Date d1(2023, 5, 13);
cin >> d1;
cout << d1 << endl;
return 0;
}
这样程序就可以正常运行了
6.const成员
将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
我们调用类中的成员函数时,一般都是 对象名.成员函数。
那如果我们的对象是一个const的对象,在调用函数时,函数形参中的this指针是由编译器加上的,那我们应该把const放在哪里呢?
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//如果我们不需要改变this指向的成员变量就在函数后加上const
void Print()const
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
const Date d1(2023, 5, 11);
d1.Print();
return 0;
}
由于我们不能在形参中写this指针,所以C++就规定了将const放在函数名后括号的后面。
当然this指针的指向也是不可以改变的 , 上图右边完整的形参应该是,const Date * const this。防止大家混淆,上图就没有写完整。
总结:如果我们写的成员函数不会修改this指针指向的内容我们就要加const,这样就算是const的对象也可以访问这个函数了,还可以保护this指向的内容不被改变.
请思考下面的几个问题:
- const对象可以调用非const成员函数吗?
- 非const对象可以调用const成员函数吗?
- const成员函数内可以调用其它的非const成员函数吗?
- 非const成员函数内可以调用其它的const成员函数吗
答:1.不可以 2.可以 3.不可以 4.可以
我们只需要记住权限可以缩小但不可以扩大就能掌握const的传值与赋值了
7.取地址及const取地址操作符重载
这两个默认成员函数一般不用重新定义 ,编译器默认会生成。
class Date
{
public:
Date* operator&()
{
return this;
}
const Date* operator&()const
{
return this;
}
private:
int _year = 1; // 年
int _month = 1; // 月
int _day = 1; // 日
};
int main()
{
Date d1;
const Date d2;
cout << &d1 << endl;
cout << &d2 << endl;
return 0;
}
这两个函数是是构成重载的,
第一个参数是 Date* const this
第二个参数是 const Date* const this
这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容!