本篇是类和对象的第二站🚌

主要讲述类的几个默认成员函数,以及操作符重载

本篇博客会涉及到很多之前C++专栏里面提到的知识点,建议连起来观看

感谢你关注慕雪,欢迎来我的寒舍坐坐❄慕雪的寒舍


[TOC]

默认成员函数

当我们创建一个类的时候,即便类里面啥都不放,都会自动生成下面6个默认成员函数

image-20220519181052014

它们都有啥功能呢?且听我一一道来

1.构造函数

众所周周知,当我们写C语言的顺序表、链表等代码的时候,一般都会写一个Init函数来初始化内容。

1
2
3
4
5
6
void Init()
{
a=(int*)malloc(sizeof(int)*4);
size=0;
capa=4;
}

但是这样有一个缺点,就是不够智能,需要我们自己来调用它进行初始化。

于是C++就整出来了一个构造函数来解决这个问题

1.1特性

构造函数:名字和类名相同,创建类对象的时候编译器会自动调用,初始化类中成员变量,使其有一个合适的初始值。构造函数在对象的生命周期中只调用一次

构造函数有下面几个特性:

  1. 函数名和类名相同
  2. 无返回值
  3. 构造函数可以重载
  4. 对象实例化的时候,编译器会自动调用对应的构造函数
  5. 如果你自己不写构造函数,编译器会自己创建一个默认的构造函数

1.2基本使用

下面用一个队列来演示一下构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Queue{
public:
Queue()
{
cout<<"Queue Init"<<endl;//测试是否调用
_a=(int*)malloc(sizeof(int)*4);
_size=0;
_capa=4;
}
void Print()
{
cout<<this<<": ";
cout<<"size: "<<_size<<" ";
cout<<"capa: "<<_capa<<endl;
}
private:
int* _a;
int _size;
int _capa;
};

可以看到,在创建对象q1的时候,编译器就自动调用了类中的构造函数,帮我们初始化了这个队列

image-20220519190214530


除了上面这种最基本的无参构造函数以外,一般写构造函数的时候,我们都会带一个有缺省值的参数,这样可以更好地灵活使用这个队列

1
2
3
4
5
6
Queue(int Capacity=4)
{
_a=(int*)malloc(sizeof(int)*Capacity);
_size=0;
_capa=Capacity;
}

调用这种构造函数也更加灵活,我们可以根据数据类型的长度,来创建不同容量的队列,避免多次realloc造成的内存碎片

1
2
Queue q1;//调用无参的构造函数
Queue q2(100);//调用带参的构造函数

多种构造函数是可以同时存在的,不过!它们需要满足函数重载的基本要求

当你调用一个无参的函数,和一个全缺省的函数的时候,编译器会懵逼!

1
2
3
Queue();
Queue(int Capacity=4);
//这两个函数不构成重载,会报错

正确的重载应该是下面的情况

1
2
Queue();
Queue(int Capacity);

编译器在创建对象的时候,就会智能选择这两个构造函数其中之一进行调用。但是同一个对象只会调用一个构造函数

1.3编译器默认生成的构造函数

上面提到过,如果我们不写构造函数,编译器会自己生成一个。

但测试过以后,你会发现,这个默认生成的构造函数,好像啥事都没有干——或者说,它把_a _b _c 都初始化成了随机值!

image-20220519191815211

实际上,编译器默认生成的构造函数是不会处理内置类型的

  • 内置类型:int、char、float、double……
  • 外置类型:自定义类型(其他的类)

在处理的时候,编译器忽略内置类型;外置类型会调用它的构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Date{
public:
//默认构造函数:不传参就能调用的
//1.全缺省 2.无参 3.编译器自动生成
//可以是半缺省的,但是不实用
Date(int year=2022,int month=2,int day=30)
{
_year=year;
_month=month;
_day=day;
}
void Print()
{
cout<<_year<<"-"<<_month<<"-"<<_day<<endl;
_A.Print();
}
private:
//编译器会自动生成构造函数(如果你没有自己写的话)
//自动生成的构造函数是不会初始化内置类型的
//内置类型:int,char,double等等
int _year;
int _month;
int _day;
//外置类型:自定义类型
//外置类型会调用它自己的默认构造函数
Queue _A;
};

可以看到,编译器调用了自己的构造函数的同时,还调用了外置类型Queue的构造函数,搞定了它的初始化

image-20220519192352946

如果我们去掉Date的构造函数,就能看到下面的情况。Queue成功初始化,但是内置类型的年月日都是随机值

image-20220519192720777

一般情况下一个C++类都需要自己写构造函数,下面这两个情况除外

  1. 类里面的成员都是自定义类型成员(且有自己的构造函数)
  2. 如果还有内置类型成员,声明时给了缺省值

注:只有类在声明变量的时候才可以给缺省值

1
2
3
4
5
6
7
8
//下面的情况就不需要写
class MyS{

private:
Queue q1;//自定义类型
Queue q2;
int a=1;//内置类型声明的时候给了缺省值
};

QQ图片20220504102516


1.4初始化列表

除了上面的方式之外,还有一种构造函数的使用方式为初始化列表

1
2
3
4
5
Date(int year=2022,int month=2,int day=30)
:_year(year),
_month(month),
_day(day)
{}
  • 每个成员变量只能在初始化列表中出现一次
  • 类中包含以下成员必须在初始化列表中进行初始化
    • 引用
    • const成员
    • 自定义类型成员

一般情况下,建议使用初始化列表进行初始化。因为对于自定义类型的成员变量,初始化列表的优先级是高于{ }里面的内容的。

这里还有非常重要的一点!

成员变量在类中声明的顺序就是初始化列表的顺序,而并非初始化列表自己的顺序!

  • 怎么理解呢?看下面这个代码
1
2
3
4
5
6
7
8
9
10
11
12
13
class Date{
public:
Date(int year=2022,int month=2,int day=30)
:_day(day),
_year(year),
_month(month)

{}
private:
int _year;
int _month;
int _day;
};

即便我们把_day放在了初始化列表的首位,但由于它是在最后声明的。所以构造函数走初始化列表的时候,会依据声明顺序,依次初始化年、月、日。

  • 这会引起什么问题?再来看看一个错误示例
1
2
3
4
5
6
7
8
9
10
11
12
class Date{
public:
Date(int year=2022,int month=2,int day=30)
:_day(day),
_year(year),
_month(_day)
{}
private:
int _year;
int _month;
int _day;
};

当我们用上面这个初始化列表的时候,我们本意是想在初始化完_day以后,将_day的值赋给_month。但由于_month的声明顺序在_day之前,所以_month(_day)会先执行,此时的_day尚为随机值,这就导致月份变成随机值了!

这只是一个示例,实际上肯定不会用天数初始化月数,范围不一样

最好的办法,就是声明顺序和初始化列表的顺序保持一致!


1.5 explicit关键字

构造函数不仅可以构造与初始化对象,对于单个参数的构造函数,还具有隐式类型转换的作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Date
{
public:
//正常的构造函数
//Date(int year)
// :_year(year)
// {}

explicit Date(int year)
:_year(year)
{}
private:
int _year;
int _month:
int _day;
};
void TestDate()
{
Date d1(2018);
// 用一个整形变量给日期类型对象赋值
// 实际编译器背后会用2019构造一个无名对象,最后用无名对象给d1对象进行赋值
d1 = 2019;
}

当我们调用赋值的时候,实际上编译器会先用2019构造出一个date类型对象,再调用赋值重载(这里还没有写)赋值给d1。这就是一个隐式类型转换

如果我们用explicit修饰了这个构造函数,那么编译器将不会进行此类隐式类型转换!

1.6规范命名类的成员变量

为了更好的使用构造函数,以及区分类内外的函数类型

一般我们定义类中的成员变量的时候,都会使用一个下划线进行标明_YEAR

在一些地方,你会看到函数名前面也带了一个_,这一般表明该函数是另外一个函数的子函数,同样是用于区分的。

不同人的代码风格不同,你可以选择你自己喜欢的风格,但不能影响我们程序的正常使用

比如下面这种情况,就会影响类的构造了

1
2
3
4
5
6
7
8
9
class Date{
public:
Date(int year=2022)
{
year=year;
}
private:
int year;
};

请问year=year里面的这个year,到底是成员变量,还是构造函数的传参呢?编译器又双懵逼了

实际上,编译器在找year的时候,会先在当前{ }中找,找到了传参的year,就不会去找其他地方的year了。所以这个语句实际上是传参过来的year自己给自己赋值,编译器会报错。

1.7初始化列表/函数体/缺省值

在VS2019里面测试了一下运行顺序

  • 缺省值/初始化列表(缺省值会被处理成初始化列表)
  • 函数体内

在下面的测试用例中,我用注释标出了初始化的顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <iostream>
#include <string>
using namespace std;

class inclass {
public:
inclass(int a = -10,const string& info = "dft") :_a(a) {
cout << "init class | " << info << endl;
}
inclass(const inclass& d) {
_a = d._a;
cout << "init class copy" << endl;
}
int _a;
};

class myclass {
public:
myclass(int c)
:_c(c),// 3
// 6 这里给变量_f调用了默认构造函数
_g(5,"_g")// 7
{
_d = 2; // 8
_f = inclass(6,"_f");// 9 _f实际上构造了两次
cout << "init myclass" << endl;
}
private:
int _a = -1;// 1
const int _b = -3;// 2
int _c;
int _d;
int _dd = -5;// 4
inclass _e = inclass(4,"_e"); // 5 初始化列表中直接构造
inclass _f;
inclass _g;
};

int main()
{
myclass b(50);
return 0;
}

运行输出如下,其中第二行的dft是编译器在初始化列表阶段给变量_f调用的默认构造函数(因为inclass的构造函数写了全缺省)

1
2
3
4
5
init class | _e
init class | dft
init class | _g
init class | _f
init myclass

而且这里我们能看出,即便采用 inclass _e = inclass(4,"_e"); 这种形式给自定义类型赋值,最终编译器也会优化成在初始化列表阶段直接调用构造函数;

2.析构函数

和构造函数相对应,析构函数是对象在出了生命周期后自动调用的函数,用来爆破对象里的成员(如进行free操作)

生命周期是离这个对象最近的{ }括号

2.1特性

  • 析构函数名是在类名前加~
  • 无参数,无返回值
  • 一个类只能有一个析构函数
  • 如果你没有自己写,编译器会自动生成一个析构函数

和构造函数一样,编译器自己生成的析构函数不会处理内置类型;会调用外置类型的析构函数

2.2基本使用

析构函数的定义和我们在外部写的Destroy函数一样,主要执行free操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include<iostream>
#include<stdlib.h>
using namespace std;

class Queue{
public:
Queue()
{
cout<<"Queue"<<endl;//测试是否调用
_a=(int*)malloc(sizeof(int)*4);
_size=0;
_capa=4;
}
void Print()
{
cout<<this<<": ";
cout<<"size: "<<_size<<" ";
cout<<"capa: "<<_capa<<endl;
}
~Queue()
{
//析构函数
free(_a);
_a=nullptr;
_size=_capa=0;
cout<<"distory:"<<this<<endl;//测试调用
}
private:
int* _a;
int _size;
int _capa;
};

假设我们在main函数里面定义了两个对象,你能说出q1和q2谁先进行析构函数的调用吗?

image-20220519195337429

可以看到,先调用的是q2的析构函数

image-20220519195559016

因为在底层操作中,编译器会给main函数开辟栈帧

栈遵从后进先出的原则,q2是后创建的,所以在析构的时候会先析构


3.拷贝构造

3.1特性和使用

拷贝构造是一个特殊的构造函数,它的参数是另外一个Date类型。在用已有的类类型对象来创建新对象的时候,由编译器自动调用

因为拷贝的时候我们不会修改d的内容,所以传的是const

另外,我们必须进行传引用调用!

这里补充说明一下,下面的这个函数,在传参的时候,编译器会去调用Date的拷贝构造

1
void func(Date d);

如果你没有写拷贝构造,或者拷贝构造里面不是传引用,编译器会就递归不断创建新的对象进行值拷贝构造,程序就死循环辣

1
2
3
4
5
6
7
8
9
10
//拷贝构造,如果不写的时候,编译器会默认生成一个
//对内置类型进行值拷贝(浅拷贝)
Date(const Date& d)
{
_year=d._year;
_month=d._month;
_day=d._day;
//外置类型会调用外置类型的拷贝构造
Queue b(_A);
}

和构造、析构不同的是,编译器自己生成的拷贝构造终于有点用了

  • 它会对内置类型进行按内存存储的字节序完成拷贝,这种称为值拷贝(又称浅拷贝
  • 对外置类型会调用它的构造函数

3.2外置类型拷贝问题

但是!如果你使用了外置类型,该类型中包含malloc的时候,编译器默认生成的构造函数就不能用辣!

因为这时候,编译器默认生成的拷贝构造会进行值拷贝,拷贝完了之后,就会出现q1和q2指向同一个空间的情况。修改q2会影响q1,free的时候多次释放同一个空间会报错,不符合我们的拷贝构造的要求

image-20220519205945585

注意注意,malloc不行的原因是,数据是存在堆区里面,拷贝的时候,q2的_a得到的是一个地址,而不是拷贝了新的数据内容。

如果你在类里面定义了一个int arr[10]数组,这时候拷贝构造就相当于memcpy,是可以完成拷贝的工作的。

如何解决这个问题呢?我们需要使用深拷贝

这里我还没有学到那个地方,后续写深浅拷贝的博客的时候,再来填上这个坑

黑马16分钟视频速成完毕,前来填坑

3.3深拷贝

3.3.1new和delete

这里先给大家从C语言转到C++,讲解一下new和delete关键字,它们分别对应malloc和free

非常简单!比malloc的使用简单多了!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main()
{
int*p1=new int;//开辟一个int类型的空间
int*p2=new int(10);//开辟一个int类型的空间,并初始化为10
int*p3=new int[10];//开辟10个int类型的空间
//注意后两个的括号区别!

delete p1;//销毁p1指向的单个空间
delete p2;//同上

//delete p3;//销毁p3指向的第一个空间,不能用于数组
delete[] p3;//销毁p3指向的数组

return 0;
}

怎么样?是不是超级简单!

好得很

3.3.2深拷贝实现

在上面写道过,编译器会自动生成拷贝构造函数,完成值拷贝工作。但是队列的代码里面包含堆区的空间,需要我们正确释放。这时候就需要自己写一个拷贝构造完成深拷贝👇

1
2
3
4
5
6
7
8
//拷贝构造
Queue(const Queue& q)
{
_a=new int[q._capa];//注意解引用
memcpy(_a, q._a, q._capa*sizeof(int));//拷贝内容
_size=q._size;
_capa=q._capa;
}

用下面这个队列的代码来测试深拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include<iostream>
#include<stdlib.h>
#include<string.h>
using namespace std;

class Queue{
public:
Queue()
{
cout<<"Queue Init"<<endl;//测试是否调用
//_a=(int*)malloc(sizeof(int)*4);
_size=0;
_capa=4;
_a=new int[_capa];
}
//拷贝构造
Queue(const Queue& q)
{
cout<<"Queue Copy"<<endl;
_a=new int[q._capa];
memcpy(_a, q._a, q._capa*sizeof(int));
_size=q._size;
_capa=q._capa;

}
void Set()
{
for (int i = 0; i < _capa; i++)
{
_a[i] = i + 1;
}
}
void Print()
{
cout<<"this:"<<this<<" ";
cout<<"_a:"<<_a<<" ";
cout<<"size: "<<_size<<" ";
cout<<"capa: "<<_capa<<endl;
for(int i=0;i < _capa;i++)
{
cout<<_a[i]<<" ";
}
cout<<endl;
}
~Queue()
{
//析构函数
//free(_a);
delete[] _a;
_a=nullptr;
_size=_capa=0;
cout<<"distory:"<<this<<endl;
}
private:
int* _a;
int _size;
int _capa;
};

int main()
{
Queue q1;
q1.Set();
q1.Print();
cout<<endl;
Queue q2=q1;
q2.Print();

cout<<endl;
return 0;
}

3.3.3深拷贝效果

先注释掉Queue的拷贝构造函数析构函数(不然会报错)

看一看,发现在不写拷贝构造函数的时候,q2和q1的_a指向了同一个地址

image-20220520181924194

取消析构函数的注释,可以看到两次释放同一片空间,发生了报错

image-20220520181921299

如果我们把写好的深拷贝构造加上,就不会出现这个问题

image-20220520182240029

当你加上给_a里面初始化一些数据,以及打印_a数据的函数后,就可以看到,不仅q2的_a有了自己全新的地址,其内部的值也和q1一样了

image-20220520183601995

这样写出来的拷贝构造,即便把队列中的int* _a修改为char*或者其他类型,都能正确完成拷贝工作

image-20220528175529916

这里有一个小点哈,就是打印char* _a的地址的时候,咱需要用printf而不是cout,因为cout会把_a直接当作字符串打印了,效果就变成了下面这样

image-20220520184316356

用printf来控制输出格式为%x即可

1
printf("_a:%x ",_a);

4.运算符重载

4.1定义

在讲解赋值运算符重载之前,我们可以来认识一下完整的运算符重载

C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。

函数名为:关键字 operator运算符,如operator=

函数原型:返回值类型 operator操作符(参数列表),如Date operator=();

下面有几点注意:

  • 重载操作符必须有一个自定义类型的操作数(即操作符重载对内置类型无效)
  • 不能通过其他符号来创建新的操作符
  • 对于类类型的操作符重载,形参比操作数少一个传参(因为有一个默认的形参this指针)
  • 这5个操作符是不能重载的:.*::sizeof? :.

4.2基本使用

以下是在全局定义的操作符重载,用于判断日期是否相等

1
2
3
4
5
6
bool operator==(const Date& d1, const Date& d2)
{
return d1._year == d2._year;
&& d1._month == d2._month
&& d1._day == d2._day;
}

当我们在main函数中使用d1==d2的时候,编译器就会自动调用该操作符重载

当然,你也可以自己来传参使用,如if(operator==(d1,d2))

但是这样非常不方便,和调用一个而普通函数没啥区别,压根算不上操作符重载。所以我们一般是在类里面定义操作符重载的

QQ图片20220519235547

当我们把它放入类Date中间,就需要修改成下面这样

1
2
3
4
5
6
bool operator==(const Date& d2)
{
return _year == d2._year;
&& _month == d2._month
&& _day == d2._day;
}

编译器在调用的时候,会优化成下面这样

1
2
bool operator==(Date* this, const Date& d2)
//显示调用为 d1.operator==(d2);

而在main里面使用的时候,这个重载后的操作符和原本的使用方法完全相同

1
2
3
Date d1(2022,6,1)
Date d2(2022,5,1)
d1==d2;//自动调用操作符重载d1.operator==(d2);

后续会以日期类为样板,实现更多的操作符重载


4.3赋值运算符重载

因为每一个类都有不同的成员,编译器不可能智能的进行赋值操作。这时候就需要我们自己写一个赋值运算符重载来进行赋值操作了

以日期类为例,赋值操作其实就是把内置类型成员一一赋值即可

1
2
3
4
5
6
7
8
9
Date& operator=(const Date& d){
if(this != &d)//避免自己给自己赋值
{
_year=d._year;
_month=d._month;
_day=d._day;
}
return *this;
}

编写赋值重载代码的时候,需要注意下面己点:

  • 返回值和参数类型(注意要引用传参,不然会调用拷贝构造)
  • 检测是否自己给自己赋值(避免浪费时间)
  • 因为返回的是*this,出了函数后没有销毁,所以可以用传引用返回
  • 一个类如果没有显式定义赋值运算符重载,编译器也会自己生成一个,完成对象按字节序的值拷贝(浅拷贝)。

如果类中有自定义类型,编译器会默认调用它的赋值运算符重载。

4.4深拷贝

同样的,对于使用了动态内存管理的成员变量而言,在进行赋值重载的时候,也需要考虑:原有维护的空间是否足够保存新的数据?

  • 如果长度足够,我们可以考虑擦除原有数据,并将新数据拷贝上去。
  • 如果长度不够,我们需要重新申请一片新空间,并释放原有空间。

下面是一个需要深拷贝的场景的代码示例。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
Queue &operator=(const Queue &q)
{
cout << "Queue& operator=" << endl;
if(q._capa > _capa)
{
delete _a; // 删除原有数据
_a = new int[q._capa];//申请新空间
_capa = q._capa;
}
// 拷贝数据
memcpy(_a, q._a, q._capa * sizeof(int));
_size = q._size;
}

尝试用下面的代码调用赋值重载

1
2
3
4
5
6
Queue q3;
q3.Set();
q3.Print();

q1 = q3;
q1.Print();

可以看到,因为q1原有长度足够存放,所以它没有申请新的空间,且q1中的内存地址和q3也不一样,符合我们深拷贝的预期

1
2
3
4
5
this:0x7fff1f9ba7f0 _a:0x561ca4ace300 size: 0 capa: 4
1 2 3 4
Queue& operator=
this:0x7fff1f9ba7d0 _a:0x561ca4ace2c0 size: 0 capa: 4
1 2 3 4

自拷贝判断

但是,上面实现的这个赋值重载有一个严重的问题:没有进行自拷贝判断!假设我们让q1=q1

函数一进来,会把q1的内容拷贝给q1自己,这部分其实就有点浪费,没有意义!而如果有人不小心把这里面的capa判断写错了,变成>=的判断的话,那问题就更大了!

  • 因为q1自己和自己的capa肯定是相同的
  • 所以程序会进入if的分支,将原有空间删除,申请新空间
  • 再进行拷贝。

可这样一操作,q1原本的空间就被销毁了,原有保存的数据都被错误清空了!!!后续的memcpy也是在拷贝个寂寞。不仅浪费了时间,还搬起石头砸自己的脚。

结果就是:我调用了q1=q1的自赋值,成功的在不侵入你这个对象的情况下把对象里面的内容清空了……

1
2
3
4
5
6
7
8
9
10
11
12
13
Queue &operator=(const Queue &q)
{
cout << "Queue& operator=" << endl;
if(q._capa >= _capa) // error
{
delete _a; // 删除原有数据
_a = new int[q._capa];//申请新空间
_capa = q._capa;
}
// 拷贝数据
memcpy(_a, q._a, q._capa * sizeof(int));
_size = q._size;
}

用上面这个错误代码进行自赋值的错误情况测试,会发现q1中的内容已经变成了未初始化的随机值。

1
2
3
Queue& operator=
this:0x7ffd92744130 _a:0x55e199ce92c0 size: 0 capa: 4
1578736873 5 0 0

所以,这里不仅capa的判断条件不能写错,还一定要加上自赋值判断!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Queue &operator=(const Queue &q)
{
cout << "Queue& operator=" << endl;
if(this == &q)//判断地址是否相等
{
return *this;
}
if(q._capa > _capa)
{
delete _a; // 删除原有数据
_a = new int[q._capa];//申请新空间
_capa = q._capa;
}
// 拷贝数据
memcpy(_a, q._a, q._capa * sizeof(int));
_size = q._size;
return *this;
}

当然,这个简单的demo其实也有些不合理的地方,比如如果我的传入的对象capa比当前已有capa小很多呢?虽然这种情况也是当前capa大于传入的capa,不需要申请新空间,但会造成严重的空间浪费

  • q1的capa是10000
  • q2的capa是10
  • 执行q1=q2,上述赋值重载代码不会对q1的空间进行操作,而是直接拷贝q2的内容到q1已有空间中。
  • 假设在这之后有很长一段时间q1都没有被插入新的值
  • 此时就出现了严重的空间浪费(q1申请了大量空间却没有使用)

所以,这个demo只是用来给你演示不写自拷贝(或者说是自赋值)检测可能出现的问题,实际场景可能更加复杂。

自拷贝判断会影响性能吗?

之前在B站看到了一个视频,up的观点是不应该写自拷贝判断,因为它是一个没有必要的判断,应该从程序本身不进行自拷贝操作来解决这个问题。

其中评论区的讨论主要在于,自拷贝判断根本不会有多大的性能损耗,但不写自拷贝判断可能产生的问题更多

自拷贝会影响性能是肯定的,我们要关注的应该是它是否利大于弊。

而且,你不能妄想于整个项目,所有参与的程序员都能做到按规范编写,不会不小心写出自拷贝的问题。对于代码规范和静态代码检测而言,不写自拷贝判断都是一个不允许的情况。

因此,加上自拷贝肯定是个更优解,因为加上是“准没错”,但不加上字拷贝导致出现其他的bug,恐怕大伙就要来找你麻烦了……毕竟在某种程度上,这算是一个比较低级的错误。

我还看到了一个有趣的评论:与其有时间思考这个问题,还不如给他加上省事。不动脑子的方法才是最好的方法。

4.5拷贝构造和赋值重载的调用问题

当赋值操作符和拷贝构造同时存在的时候,什么时候会调用赋值,什么时候会调用拷贝构造呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Date& operator=(const Date& d)
{
cout<<"赋值重载"<<endl;
if(this != &d)//避免自己给自己赋值
{
_year=d._year;
_month=d._month;
_day=d._day;
}
return *this;
}
Date(const Date& d)//拷贝构造
{
cout<<"拷贝构造"<<endl;
_year=d._year;
_month=d._month;
_day=d._day;
}

在这拷贝构造和赋值重载两个函数中添加cout进行打印提示,可以看到:

  • 如果对象在之前已经存在,就会调用赋值重载
  • 如果是一个全新的变量在定义的时候初始化,就调用的是拷贝构造

image-20220520125218089

5.const成员

5.1用const修饰类的成员函数

将const修饰的类成员函数称之为const成员函数,const修饰类成员函数,实际修饰的是该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。

基本的修饰方法如下,在函数的括号后加const即可

1
2
3
4
void Print()const
{
cout<<_year<<endl;
}

实际修饰的是该函数隐含的this指针

this指针本身是Date*const类型的,修饰后变为const Date* const类型

1
2
3
4
void Print(const Date* const this)
{
cout<<_year<<"-"<<_month<<"-"<<_day<<endl;
}

①实例-权限问题

这么说好像有点迷糊,我们用实例来演示一下为什么需要const修饰成员函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Date{
public:
Date(int year=2022,int month=2,int day=30)
{
_year=year;
_month=month;
_day=day;
}
void Print()
{
cout<<_year<<"-"<<_month<<"-"<<_day<<endl;
}
private:
int _year;
int _month;
int _day;
};

假设我们需要在函数中调用Print函数,在main中是可以正常调用的

1
2
3
4
5
6
int main()
{
Date d1(2022,5,10);
d1.Print();
return 0;
}

但当你用一个函数来进行这个操作的时候,事情就不一样了

1
2
3
4
5
6
7
8
9
10
11
12
void TEST(const Date& d)
{
d.Print();//d.Print(&d) -->const Date*
}
int main()
{
Date d1(2022,5,10);
d1.Print();//d1.Print(&d1) -->Date*
TEST(d1);

return 0;
}

这时候我们进行了引用调用,因为在TEST中我们不会修改d1的内容,所以用const进行了修饰

  • 这时候TEST中的d.Print()函数调用,传入的是const Date*指针,指针指向的内容不能被修改
  • main中的d1.Print();函数调用,传入的是Date*指针

于是就会发生权限冲突问题👇

image-20220520122333913

这时候如果我们在函数后面加了const,就可以避免此种权限放大问题。这样不管是main函数还是TEST函数中对Print()函数的调用,就都可以正常打印了!

总结一下:

  • const对象不可以调用非const成员函数(权限放大)
  • 非const对象可以调用const成员函数(权限缩小)
  • const成员函数内不可以调用其他非const成员函数(权限放大)
  • 非const成员函数可以独调用其他const成员函数(权限缩小)

好得很


②什么时候需要使用?

众所周周知,const修饰指针有下面两种形式

  • *之前修饰,代表该指针指向对象的内容不能被修改(地址里的内容不能改)
  • *之后修饰,代表该指针指向的对象不能被修改(指向的地址不能改)

this指针本身就是类型名* const类型的,它本身不能被修改。加上const之后,this指向的内容,既类里面的成员变量也不能被修改了。

知道了这一点后,我们可以合理的判断出:只要是需要修改类中成员变量的函数,就不需要在()后面加const修饰

如果一个函数中不需要修改成员变量,就可以加const进行修饰

注意:如果你用了声明和定义分离的写法,那么声明和定义的函数都需要加上const修饰


③出错提醒

这里有一点需要提醒的是,如果你对某一个函数进行了const修饰,那么这个函数里面包含的其他类里面的函数,都需要进行const修饰。不然就会报错

image-20220522165158500

出现该报错的情况如下

image-20220522165423213

这个情况也提醒我们,不能在const修饰的函数中,调用非const修饰的成员函数


5.2取地址及对const取地址重载

最后两个默认成员函数,编译器会自动生成。这两个函数一般都不需要重载,毕竟返回的本身就是一个this指针,没有什么奇怪的地方

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Date{ 
public :
Date* operator&()
{
return this ;
}

const Date* operator&()const
{
return this ;
}
private :
int _year ;
int _month ;
int _day ;
};

只有特殊情况,我们需要让&只获取特定内容的时候,才需要手动重载这两个函数

6.构造,析构顺序

下面这个代码是一个很好的示例(22.12.31)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>
#include <string>
using namespace std;

class A{
public:
    A(int count)
    {
        c = count;
        cout << c << " init this:: " << this << endl;
    }

    ~A()
    {
        cout << c << " des this:: " << this << endl;
    }
private:
    int c;
};

a1(1);//全局

int main()
{
    A* p = new A(2);//堆区
    a2(3);//栈区
    static A a3(4);//静态区

    cout << "#####" << endl;
    delete p;//堆区被主动释放,肯定是第一个析构的
}

最终打印的结果如下

1
2
3
4
5
6
7
8
9
1 init this:: 00D4E464
2 init this:: 0160F558
3 init this:: 0133F9D8
4 init this:: 00D4E46C
#####
2 des this:: 0160F558
3 des this:: 0133F9D8
4 des this:: 00D4E46C
1 des this:: 00D4E464

总结:

  • 构造顺序和写代码的运行顺序一致
  • 析构时候,堆区若手动delete,那么肯定是按delete的顺序析构的
  • 自动析构的时候,遵循栈-静态-全局的顺序析构

日期类的实现

类和对象第一站🚌中提到过,在项目协作的时候,我们一半要用定义和声明分离的形式来些一个项目。

下面就让我们用日期类来演示这样的操作

在类中定义的函数会被默认设置为内联,我们的目标就是:短小函数在.h中定义,长函数在.h中声明,在.cpp中定义

至于源码和解析嘛……大家直接来我的gitee仓库看吧!【传送门】

注释写的很详细了⏲有啥问题可以在下面留言哦

QQ图片20220424132540

特殊:对<<和>>的重载

这里的<<和>>主要是在使用cin和cout的时候需要使用

①简单了解io

cplusplus网站上,你可以看到下面这一副图。在使用cin和cout的时候,我们其实分别调用了不同头文件的内容。

  • cin:istream
  • cout、cerr、clog:ostream

image-20220522171448448

实际上,流是一个类型的对象,这个对象完成了输入和输出的操作

流操作是系统GUI支持的(了解一下就行,我也不懂)

在cout的定义中,你可以看到,实际上cout为了完成自动识别类型进行输出操作的工作,对各种类型进行了操作符重载operator<<

image-20220522172134846

显然,这部分重载中不包含自定义类型,所以我们需要来仿照这里的函数,进行重载操作

这里涉及到了友元函数,在类和对象的下一篇博客中我会写道。不过现在你只需要知道,友元函数是某一个类的朋友,目的是在类外访问类里面的成员变量

友元函数可以直接访问类的私有成员,它是定义在类外部的普通函数,不属于任何类,但需要在类的内部声明,声明时需要加friend关键字。

②实现

最后实现的效果如下,头文件中在最前面进行声明

image-20220522172618691

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//这两个是友元函数(因为需要在类外面访问类里面的成员变量)
//注意cin和cout的不同实现
//因为我们没有完全展开std namepace,所以写这个函数的时候需要自己指定std::

//返回值为ostream是为了保证多次cout
std::ostream& operator<<(std::ostream& out, const Date& d)
{
out << d._year << "-" << d._month << "-" << d._day << endl;
return out;
}

//cin使用的是istream
std::istream& operator>>(std::istream& in, Date& d)
{
int year,month,day;
in >> year >> month >> day;
//这里应该添加一个对日期的正确性判断
if(year>=0
&&(month>=1&&month<=12)
&&(day>=1&&day<=d.GetMonthDay(year,month)) )
{//判断日期正确性
d._year=year;
d._month=month;
d._day=day;
}
else
{
cout<<"Date err!"<<endl;
exit(0);//日期错误直接终止程序
}

return in;
}

程序运行的效果如下,和我们直接使用cout、cin是一样的!

image-20220522172755451

当你写了一个离谱日期后,程序也会进行正确的报错

image-20220522172914345

③疑惑解答

你可能会想,干嘛用友元啊,直接在类里面定义这个函数重载不就可以了?

之所以在外头定义该函数,是因为类里面定义的函数,默认会带有一个隐含的this指针传参,作为操作符的左操作数

然后你的函数使用就得变成下面这样😱

1
d1<<cout;

虽然也能跑起来并完成工作,但这样写也太怪了!

结语

最后的最后,今天是5月20日,用下图给大家送上祝福😂

QQ图片20220520134450