每一个不曾起舞的日子都是对生命的辜负
C++之类和对象(中)
本节目标
- 1. 类的6个默认成员函数
- 2. 构造函数
- 3. 析构函数
- 4. 拷贝构造函数
- 5. 赋值运算符重载
- 6. 日期类实现
- 7. const成员函数
- 8. 取地址及const取地址操作符重载
1. 类的6个默认成员函数
如果一个类中什么成员都没有,简称为空类。空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成以下6个默认成员函数。
默认成员函数:又称特殊成员函数,用户没有显式实现,编译器会生成的成员函数称为默认成员函数。即:我们不写,编译器就会自己生成一个;我们自己写了,编译器就不会生成。(隐含的意思:对于有些类,需要我们自己写;对于另外一些类,编译器默认生成就可以用)
class Date {};
即:(前四个极其重要)
- 构造函数
- 拷贝函数
- 析构函数
- 赋值重载
- 普通对象取地址
- const对象取地址
但对于我们的代码来说,有的时候编译器默认的函数不能满足我们的要求,因此有时候就需要我们自己去写默认成员函数,那么什么时候需要自己写呢?什么时候编译器默认的函数就够用了呢?接下来详细介绍一下默认成员函数。
2. 构造函数
2.1 概念
对于以下Date类:
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, 7, 5);
d1.Print();
Date d2;
d2.Init(2022, 7, 6);
d2.Print();
return 0;
}
对于Date类,可以通过 Init 公有方法给对象设置日期,但如果每次创建对象时都调用该方法设置信息,未免有点麻烦,有的时候甚至容易忘记初始化导致在Push的时候出现错误,那能否在对象创建时,就将信息设置进去呢?
有了这个需求,构造函数就应运而生。构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。
2.2 特性
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫做构造,但是构造函数的主要任务并不是空间创造对象,而是初始化对象。
构造函数具有以下特征:
- 函数名与类名相同。
- 无返回值。
- 对象实例化时编译器自动调用对应的构造函数。(自己写构造函数就会调用自己写的那个)
- 构造函数可以重载。(提供多个构造函数,多个初始化方式)
- 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
- 带参构造函数
class Date
{
public:
//带参构造函数
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 d2(2015, 1, 1); // 调用带参的构造函数
return 0;
}
这样就是带参的构造函数,建立对象时直接初始化。
- 无参构造函数
class Date
{
public:
//无参构造函数
Date()
{
_year = 1;
_month = 1;
_day = 1;
}
void Print()
{
cout << _year << "--" << _month << "--" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d2;
// 注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明
return 0;
}
需要注意的是对象d2在创建时不能加括号,因为构造函数不需要参数。
- 缺省构造函数
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;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d2;// 注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明
d2.Print();
return 0;
}
即仍然可以通过我们第一节学习的缺省参数来定义这个构造函数,此方式无疑是极其方便的。
- 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数。(即:不传参的构造函数就是默认构造函数)
上述三种方法演示之后,还要说明的就是构造函数的第四个特征,可以进行重载,即我们将方式1和方式2的
Date
构造函数放在一起是没问题的;但是对于第三个缺省参数来说,其与第二个例子构成重载的话,就会产生二义性,因为当我们不传递参数时,我们并没有办法区分调用哪个函数,因此,要避免这种情况。同时也对应了上面的条款:默认构造函数只能有一个
2.3 构造函数实现栈Stack的初始化
对于栈来说,初始化是必要的,但是我们在创建栈的时候有可能忘记调用其初始化,因此在C++中,我们可以通过构造函数对其自动的初始化,接下来我们看看具体的代码:
class Stack
{
public:
Stack(int capacity = 4)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a == nullptr)
{
perror("malloc fail");
exit(-1);
}
_top = 0;
_capacity = capacity;
}
void Push(int x)
{
//……
_a[_top++] = x;
}
private:
int* _a;
int _top;
int _capacity;
};
int main()
{
Stack s;
s.Push(1);
return 0;
}
我们在类中增加了Stack类的构造函数,因此就可以进行自动初始化。
当我们进行调试观察时,我们观察这几个值。我们知道this指针实际上就是对象的指针,因此我们观察我们定义的对象s的地址和this指针的值是相同的。其次,这是在调试中经过了
_a[_top++] = x
,可以看出,s对象里的属性均被初始化了,并且也将1插入了这个栈。
对于上面的第五个特性(如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。)来说,我们先看一下这样初始化的结果:
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;
}
private:
//内置类型
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.Print();
return 0;
}
我们发现,即便其初始化,但初始化的结果仍然是很尴尬的数字,事实上,这是C++设计时的一个缺陷。但是对于这样的初始化来讲,一点用都没有吗?
在这之前先来区分一下内置类型和自定义类型:
- 内置类型:int 、double、char 等
- 自定义类型:struct 、class类以及其他自定义结构体
接下来看看有自定义类型的Date
类:
class A
{
public:
A()
{
_a = 0;
cout << "A()构造函数" << endl;
}
private:
int _a;
};
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;
}
private:
//内置类型
int _year;
int _month;
int _day;
//自定义类型
A _aa;
};
int main()
{
Date d1;
d1.Print();
return 0;
}
我们发现,编译器中默认的构造函数将A类的_aa进行了合理的初始化。因此,默认的构造函数也有一定的意义。
关于编译器生成的默认成员函数,很多童鞋会有疑惑:不实现构造函数的情况下,编译器会生成默认的构造函数。但是看起来默认构造函数又没什么用?d对象调用了编译器生成的默认构造函数,但是d对象
解答:C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:`int/char...`,自定义类型就是我们使用`class/struct/union`等自己定义的类型,看看下面的程序,就会发现编译器生成默认的构造函数会对自定类型成员`aa`调用的它的默认构造函数。因此这里就解答了构造函数需要自己写的情况:类中的成员变量包含内置类型的时候构造函数需要自己写 。_year/_month/_day
,依旧是随机值。也就说在这里编译器生成的默认构造函数并没有什么用??对上述讨论的问题继续进行一个补充,如果A类中将我们自己写的构造函数去掉,这时在Date类中
_aa
初始化的值会发生什么情况?
答:对于这个情况,当在Date类中给_aa初始化时,会调用其内部的函数,但是内部函数仍然是编译器默认的的构造函数,不会对其进行我们想要的初始化,因此这样也是不符合需求的。
2.4 初始化列表的粗略提及(具体在类和对象下讲解)
但如果我们仍然想通过这样去将成员变量中的内置类型和自定义类型按照默认构造的方式去解决,其实也是可以的,这就涉及到了初始化列表的操作 :(这里我们进行演示,具体将会在类和对象(下)去详细介绍)
class Stack
{
public:
Stack(int capacity = 4)
{
cout << "Stack(int capacity = 4)" << endl;
_a = (int*)malloc(sizeof(int) * capacity);
if (_a == nullptr)
{
perror("malloc fail");
exit(-1);
}
_top = 0;
_capacity = capacity;
}
~Stack()
{
cout << "~Stack()" << endl;
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
int* _a;
int _top;
int _capacity;
};
class MyQueue {
public:
// 初始化列表
MyQueue(size_t capacity = 8)
:_popST(8)
, _pushST(8)
, _size(0)
{
_size = 0;
}
void push(int x)
{
//_pushST.Push(x);
}
private:
Stack _pushST;
Stack _popST;
size_t _size;
};
int main()
{
MyQueue q;
return 0;
}
初始化之前:
执行之后:
可以看见即便是内置类型的_size也可以被初始化。
2.5 补丁
我们抛开2.4初始化列表(就当没提到过),对于这里的内置类型和自定义类型的初始化来说,实际上是设计C++的大佬考虑不周最后得出的,确实是一个很奇怪的规则,因此,在后续,又增添了一些内容,即:
- C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给默认值。
通关只将内置类型在类中赋值,实际上不是初始化,而是属于缺省的范畴,通过这样的缺省,就可以将内置类型也同时初始化。
具体看一下:(因为只是增添了_size = 0的情况,为避免冗余这里直接展示图片)
-
初始化之前:
-
初始化之后:
通过这样的缺省值就可以合理的解决内置类型的初始化问题。
此外,如果我们将
Stack
中的构造函数删掉,那么在MyQueue
中是不会初始化_pushST
和_popST
的,但是我们可以在Stack中给成员变量一个缺省值,这样同样可以将其通过MyQueue
将Stack中定义的类进行初始化。(_a
也可以直接malloc
,但是它不是直接开辟,而是一个声明,在调用的时候才会开辟。但这样其实不太好,因为有微小的概率会malloc
失败却没有检查。)
3. 析构函数
3.1 概念
通过前面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么没呢的?
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
即通过析构函数,我们可以避免忘记调用StackDestory
的情况,析构函数自动就可以销毁开辟的空间。
3.2 特性
析构函数是特殊的成员函数,其特征如下:
- 析构函数名是在类名前加上字符
~
。(~在位运算中的作用是按位取反) - 析构函数没有参数没有返回类型(因此其不像构造函数一样能进行重载)。
- 一个类只能有一个析构参数。若未显示定义,系统会自动生成默认的析构函数。注:析构函数不能重载。
- 对象生命周期结束时,C++编译系统自动调用析构函数。
- 注:对于析构函数来说,后定义的先销毁。
3.3 析构函数实现栈Stack的销毁
相信大家可以看到,这正与构造函数的初始化形成了一对,一个初始化,一个销毁。所以我们利用上面的构造函数的代码的类里加上一个析构函数即可:
class Stack
{
public:
Stack(int capacity = 4)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a == nullptr)
{
perror("malloc fail");
exit(-1);
}
_top = 0;
_capacity = capacity;
}
~Stack()
{
free(_a);
_a = nullptr;
}
void Push(int x)
{
//……
_a[_top++] = x;
}
private:
int* _a;
int _top;
int _capacity;
};
int main()
{
Stack s;
s.Push(1);
s.Push(2);
s.Push(3);
return 0;
}
当我们调试到return 0;
的下面,打开监视窗口,发现s成员变量中的_a已经被销毁,这就是析构函数自动发生了作用。
当然,析构函数不写也会默认生成,这与上面的构造函数是一样的,其特性也是一样的,可以销毁自定义类型的空间,但是内置类型的空间却不能被销毁。 因此上述需要将_a销毁我们采用的是自己定义的析构函数。
接下来看看能够自动销毁的例子:
class Stack
{
public:
Stack(int capacity = 4)
{
cout << "Stack(int capacity = 4)" << endl;
_a = (int*)malloc(sizeof(int)*capacity);
if (_a == nullptr)
{
perror("malloc fail");
exit(-1);
}
_top = 0;
_capacity = capacity;
}
~Stack()
{
cout << "~Stack()" << endl;
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
int* _a;
int _top;
int _capacity;
};
class MyQueue {
public:
void push(int x)
{
//_pushST.Push(x);
}
private:
Stack _pushST;
Stack _popST;
};
// 面向需求:编译器默认生成就可以满足,就不用自己写,不满足就需要自己写
// Date Stack的构造函数需要自己写
// MyQueue构造函数就不需要自己写,默认生成就可以用
// Stack的析构函数,需要我们自己写
// MyQueue Date就不需要自己写析构函数,默认生成就可以用
int main()
{
MyQueue q;
return 0;
}
- 调试完成初始化:
- 调试完成程序结束:(main函数结束程序才结束,注意下面的黄色箭头的位置)
我们可以清晰的看见,无论是初始化还是销毁,
MyQueue
定义的对象q
的自定义成员变量中的内容就如之前所说的,都被初始化和销毁。
再举个例子:
关于编译器自动生成的析构函数,是否会完成一些事情呢?下面的程序我们会看到,编译器生成的默认析构函数,对自定类型成员调用它的析构函数。
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修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
先看看正确实例:
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)//构造函数
{
_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;
};
int main()
{
Date d1(2022, 9, 22);
//拷贝一份d1
Date d2(d1); //拷贝构造,拷贝初始化
return 0;
}
4.2 特征(深拷贝、浅拷贝)
拷贝构造函数也是特殊的成员函数,其特征如下:
-
拷贝构造函数是构造函数的一个重载形式。
-
拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用
为什么传值会无穷递归呢?下面就来看看:
对于编译器来说,传值的错误是可以识别的,但只说了不能,而没有说为什么不能。(注:
const
在下面解释中加不加是无所谓的,但是习惯加上const
防止因错误赋值发现不了错误)
我们通过
Func1
和Func2
比较传值和传引用(注:此时类中的拷贝构造函数是正确的形式,即:是传引用),发现在调用Func1
时同时也调用了拷贝构造函数,而Func2
却没有。Func1
调用拷贝构造实际上是在传参的时候调用的,我们常说,对于传值,参数是变量的一份临时拷贝,因此在这里就显示出了在main
函数中对于调用Func1
传的d1
,于是进入Func1
函数时,参数d
就对d1
进行了拷贝,因此会调用内部的拷贝构造函数;而对于Func2
来说,传引用并不是像传参一样需要拷贝,第一篇文章提到过,这个传引用的参数只是传的变量的别名,他们是同一个值,地址也相同,因此在调用Func2
时不会进行拷贝,也就不会引发类中的拷贝构造函数的执行。
那举了这个例子之后,我们就知道了二者之间的区别,那我们看看传值的代码是如何调用的:(注:此代码是错误的,编译器也会报错)
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
Date(Date d)//演示这里是错误的
{
cout << "拷贝构造" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2022, 9, 22);
Date d2(d1);
return 0;
}
我们知道,传值会进行拷贝,那么看看拷贝d1
到d2
的逻辑:
通过这个逻辑不难发现,每一次调用这个拷贝构造函数之后都会将传的值拷贝到拷贝构造函数的参数,而这个过程又会进入到下一层拷贝构造函数,就这样永无止境。因此,我们说,传值会引发无穷的递归调用。
此外,既然传值不行,传引用可以,那我们传地址是否可以呢,是否通过指针来访问这个参数?
答案当然是可以的,但是这样就不能叫做拷贝构造函数了而是叫做拷贝函数,此外里面赋值时,也需要将
.
变成->
。虽然说是可以的,但在这里我还是想记录一下我当时思考的过程:在了解拷贝会发生死递归之后,但是又看见指针传就不会死递归,我就产生了一个这样的疑惑:传参是拷贝值产生死递归,用指针不也是传地址,这个地址不也算是参数吗?然后拷贝这个地址,这样不也会发生死递归吗,为什么还可以呢?当然,经过询问这个问题和考虑之后,我明白无论什么类型的指针,它都算是一个内置类型,因为指针的值无论什么类型他都是一个地址,而对于这些默认函数来说,是不会对内置类型进行操作的,直接拷贝就可以,因此这个错误的想法就这么被推翻了。此外呢,我也是怀疑当初设计中出现的缺陷(缺陷:默认函数不能改变内置类型)是否与这个有关,这确实是个值得细品的问题。当然了,内置类型还是按照正常缺省的方法进行初始化才是最简单的方式。
- 若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
class Stack
{
public:
Stack(int capacity = 4)
{
cout << "Stack(int capacity = 4)" << endl;
_a = (int*)malloc(sizeof(int)*capacity);
if (_a == nullptr)
{
perror("malloc fail");
exit(-1);
}
_top = 0;
_capacity = capacity;
}
~Stack()
{
cout << "~Stack()" << endl;
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
void Push(int x)
{
// ....
// 扩容
_a[_top++] = x;
}
private:
int* _a;
int _top;
int _capacity;
};
// 需要写析构函数的类,都需要写深拷贝的拷贝构造 Stack
// 不需要写析构函数的类,默认生成的浅拷贝的拷贝构造就可以用 Date/MyQueue
class MyQueue {
public:
void push(int x)
{
_pushST.Push(x);
}
private:
Stack _push
Stack _popST;
size_t _size = 0;
};
int main()
{
Stack st1;
st1.Push(1);
st1.Push(2);
// 1 2
Stack st2(st1);
st2.Push(10);
st1.Push(3);
// 1 2 10
MyQueue q1;
MyQueue q2(q1);
return 0;
}
注意:在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的(类似于memcpy),而自定义类型是调用其拷贝构造函数完成拷贝的。
当我们建立完栈之后,将栈st1拷贝给st2通过拷贝构造进行拷贝,并且通过 q1给q2拷贝,我们发现仍然可以将值拷贝过去,然而事实上,这样的拷贝我们发现,对于一些值
_top
和_capacity
来说刚刚好,但是对于_a来说,是行不通的,因为我们不仅将值拷贝了过去,事实上,根本算不上拷贝,因为他们的地址都是一样的,当我们运行结束,每一个类对应的每一个对象都会通过析构函数销毁,然而因为地址相同的变量却销毁了两次,因此在析构函数执行的时候就会出现错误导致程序崩溃。对于这种可以直接拷贝值的却不能拷贝其他类似于
_a
这样的变量,我们称之为浅拷贝。
因此,上述的默认拷贝构造函数行不通我们就需要自己创建一个新的拷贝构造函数,被称为深拷贝:
class Stack
{
public:
Stack(int capacity = 4)
{
cout << "Stack(int capacity = 4)" << endl;
_a = (int*)malloc(sizeof(int)*capacity);
if (_a == nullptr)
{
perror("malloc fail");
exit(-1);
}
_top = 0;
_capacity = capacity;
}
// st2(st1)
Stack(const Stack& st)//只多了这个
{
cout << "Stack(const Stack& st)" << endl;
_a = (int*)malloc(sizeof(int)*st._capacity);
if (_a == nullptr)
{
perror("malloc fail");
exit(-1);
}
memcpy(_a, st._a, sizeof(int)*st._top);
_top = st._top;
_capacity = st._capacity;
}
~Stack()
{
cout << "~Stack()" << endl;
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
void Push(int x)
{
// ....
// 扩容
_a[_top++] = x;
}
private:
int* _a;
int _top;
int _capacity;
};
// 需要写析构函数的类,都需要写深拷贝的拷贝构造 Stack
// 不需要写析构函数的类,默认生成的浅拷贝的拷贝构造就可以用 Date/MyQueue Myqueu中的_pushST和_popST实际上调用了Stack的构造函数,这与下面的赋值运算符重载是一样的。
class MyQueue {
public:
void push(int x)
{
_pushST.Push(x);
}
private:
Stack _pushST;
Stack _popST;
size_t _size = 0;
};
int main()
{
Stack st1;
st1.Push(1);
st1.Push(2);
// 1 2
Stack st2(st1);
st2.Push(10);
st1.Push(3);
// 1 2 10
MyQueue q1;
MyQueue q2(q1);
return 0;
}
对于此代码,比浅拷贝的例子只多了Stack(const Stack& st)
,但这样却可以解决上述的问题:
通过这样,他们的地址就不再相同,我们就可以解决最终析构销毁的错误,从而程序顺利结束。
注意:类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数是一定要写的(即必须写深拷贝),否则就是浅拷贝。
- 拷贝构造函数典型调用场景:
- 使用已存在对象创建新对象
- 函数参数类型为类类型对象
- 函数返回值类型为类类型对象
需要写析构函数的类,都需要写深拷贝的拷贝构造 比如 Stack
不需要写析构函数的类,默认生成的浅拷贝的拷贝构造就可以用 Date/MyQueue
对于内置类型的拷贝就不需要写拷贝构造,因为不会调用析构函数。
5. 赋值运算符重载
5.1 运算符重载
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:关键字operator后面接需要重载的运算符符号。
函数原型:返回值类型 operator操作符(参数列表)
举个例子,我们知道对于内置类型:int、 float、double、char都可以通过运算符(+、-、*、/)直接进行计算、也可以通过比较运算符(>、<、=)进行比较,那么对于类呢?事实上是不能直接通过这些运算符进行比较和运算的。因此,赋值运算符重载因此诞生。
先来看一下下面的代码:(实际上是错误的)
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(2022, 9, 21);
Date d2(2022, 9, 22);
cout << (d1 == d2) << endl;
return 0;
}
直接进行比较是错误的,因为只有内置类型才有这样操作,那么我们可以采用operator
关键字进行操作。(如下图:)
但我们发现,通过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& d1, const Date& d2)//这个叫等于重载,一个-叫赋值重载,在5.2讲解
{
return d1._year == d2._year
&& d1._month == d2._month
&& d1._day == d2._day;
}
private:
// 基本类型(内置类型)
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2022, 9, 21);
Date d2(2022, 9, 22);
cout << (d1 == d2) << endl;
return 0;
}
然而事实却不尽人意,当我们将这样的operator==函数添加进类之后,出现了这样的状况:
参数太多?这是什么意思呢?
对于运算符重载中的
==
来说,这种比较运算符是对两个参数进行操作的,即==
的左面一个,右面一个。我们发现,有两个参数d1
和d2
,但是别忘了,还有一个隐含的this指针,这样的三个参数的数量多于我们需要的两个参数,因此我们应该让括号里面只剩下一个参数,我们就需要删掉一个,但删掉了之后,如何进行比较?我们知道this指针是在类中存放的,而对应的,可以理解成operator这个关键字会承载这个this指针,而之后的在main函数中的d1==d2
在编译时会转换成d1.operator == (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& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
private:
// 基本类型(内置类型)
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2022, 9, 21);
Date d2(2022, 9, 22);
cout << (d1 == d2) << endl;// d1 == d2 会转换成d1.operator == (d2)
cout << (d1.operator==(d2)) << endl;
return 0;
}
因此,这样就实现了类中对象之间大小的比较(在这个比较中,是判断是否相等的比较,相等为1,不相等为0)。当然这里也会有一些细节问题,一是在cout
的时候应该将==的两侧加上括号,这是优先级的问题;然后就是对于const Date& d
,这样写的好处之前提到过,是为了更好的维护代码。
上述描述了相等运算符重载的方式,还有其他的比较运算符,事实上他们的处理都是一样的,仅仅在函数逻辑中有所不同。那么我们再展示几个:
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
bool operator==(const Date& d2)
{
return _year == d2._year
&& _month == d2._month
&& _day == d2._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;
}
return false;
}
// d1 >= d2
bool operator>=(const Date& d)//*this是d1,传的d就是d2的别名
{
return *this > d || *this == d;
}
//…… <<= !=
private:
// 基本类型(内置类型)
int _year;
int _month;
int _day;
};
上述多添加了>
和>=
两种,对于>来说没有什么亮点,因为其与==的形式类似,而对于>=来说,可以直接按逻辑写,当然也可以向我们上面的方式写,这种方式就是直接复用了>
和==
两个重载函数,通过这个>=
可以形象的展示出this指针在其中发挥的作用。
需要特别注意的是:
不能通过连接其他符号来创建新的操作符:比如operator@
重载操作符必须有一个类类型参数(对于内置类型不需要运算符重载)
用于内置类型的运算符,其含义不能改变,例如:内置类型+,不能改变其含义(与上一条关联性强)
作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this{单操作数(比如++)不需要传参,因为有this指针已经是一个参数了}
.* :: sizeof ?: .
注意以上5个运算符不能重载。这个经常在笔试选择题中出现。(第一个基本上遇不到,第二个到第五个解释:域作用限定符、sizeof、选择、成员对象结构体访问符号)
5.2 赋值运算符重载
上面已经提到,对于operator==(Date d)
是等于重载,而对于赋值重载,是满足d1=d2即将d2的值赋值给d1,因此也就是operator=(Date d)
。
// d1 = d2 // 有的运算符不需要区分左右,有的需要,+不需要,-需要,=需要,==不需要,……
void operator=(const Date& d) //&:避免拷贝构造
{
_year = d._year;
_month = d._month;
_day = d._day;
}
即通过这样简单的方式就可以将一个值赋值给另一个值,比如d1=d2,实际上此函数的参数d就是d2的别名,d1是operator所对应的this指针。
然而,对于赋值操作,不仅仅只有这样类型的赋值。我们知道,对于普通变量来说,可以进行像这样的链式赋值:
int i, j; i = j = 10;//链式赋值,因此日期类也要满足这种赋值
如果我们直接仍用上面的函数对类类型进行这样的操作:
我们知道上述的
d0 = d1 = d2
是先进行d1 = d2
的运算,再将这个整体运算的结果也就是左值d1赋值给d0,然而对于这样的void operator=(Date d)
,d1 = d2
并没有实际的返回值,因此出现了这样的报错,因此我们需要将其改成返回类型为Date
。
修正后的赋值运算符重载:
Date& operator=(const Date& d) //&避免拷贝构造
{
_year = d._year;
_month = d._month;
_day = d._day;
return *this;
}//传值传参是一次拷贝构造,传返回值也是一次拷贝构造,返回this不在,但是*this在,因为*this是d1
这样就可以解决链式访问的问题了。但在这里,我们还需要思考一些问题:
传值传参是一次拷贝构造,传返回值也是一次拷贝构造,因此为了提升性能我们采用传引用返回。那么为什么返回*this能传引用呢?
这就是第二个问题,我们知道this是类中隐藏的变量,出了类之后,this不会存在,但是
*this
是存在的,对于d1=d2
来说,*this
事实上就是d1,d1是在TestDate
中定义的类,出了内部成员函数之后仍然会返回TestDate
,因此*this
可以返回(对于一般的整式结果,整体的值都是左值)。第三个问题:我们知道,
*this
返回代表着d1=d2
这整体的结果,那d1=d2
,返回d(即返回d2)是不是也可以呢?实际上是可以的,但由于我们传参的时候d已经被const修饰,返回值的类型没有被const修饰,这就有了一个权限放大的错误,如果参数不用const修饰,就可以返回,或者将返回值类型也变成const Date& operator(const Date& d)
。如果我们真的如第三个问题所说,将返回值变成了那样,这仍然会造成问题:
即不能这样操作。因此我们采用的仍是返回
*this
。
下面展示上述的完整代码:
Date.h
#pragma once
class Date
{
public:
int GetMonthDay(int year, int month)//获取日期对应的天数
{
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(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
//检查日期是否合法,公元前除外
if (!(year >= 1
&& (month >= 1 && month <= 12)
&& (day >= 1 && day <= GetMonthDay(year, month))))
{
cout << "非法日期" << endl;
}
}
// d1 = d2 // 有的运算符不需要区分左右,有的需要,+不需要,-需要,=需要,==不需要,……
Date& operator=(const Date& d) //&避免拷贝构造,返回d,权限放大
{
_year = d._year;
_month = d._month;
_day = d._day;
return *this;
}//传值传参是一次拷贝构造,传返回值也是一次拷贝构造,返回this不在,但是*this在,因为*this是d1
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
//Date AddDay(int day)//3.这样也可以加天,但是可读性不如operator
//{}
//Date JiaTian(int day)//这是啥,运算符重载的意义是可读性。
//{}
private:
int _year;
int _month;
int _day;
};
Test.cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include<iostream>
using namespace std;
#include"Date.h"
void TestDate1()//2.由于是函数,内部变量在栈中存储,函数调用结束会在栈帧自动销毁,
//因此不需要析构函数的功能,即日期类不用写析构函数
{
Date d1;
Date d2(2022, 10, 8);
Date d3(2022, 2, 40);
Date d4(2022, 2, 29);
d3.Print();
d4.Print();
Date d5(d2);
d5.Print();
//d5 + 100;
//d5.AddDay(100);
//d5.JiaTian(100);//加密代码?
}
void TestDate2()
{
Date d0;
Date d1;
Date d2(2022, 10, 8);
d0.Print();
d0 = d1 = d2;// 赋值重载 (复制拷贝)已经存在两个对象之间的拷贝
d0.Print();
Date d3(d2); // 拷贝构造(初始化)一个初始化另一个马上要创建的对象
Date d4 = d2;//这是拷贝构造还是赋值重载? 拷贝构造,因为是初始化
int i, j;
i = j = 10;//链式赋值,因此日期类也要满足这种赋值
}
int main()
{
TestDate2();
return 0;
}
5.3 默认的赋值运算符重载
我们知道对于默认的6个成员函数都有一个共性,即当我们不写这样的函数时系统会默认生成一个这样的函数,前面的构造、拷贝、拷贝构造都已经展示过,下面将我们的赋值运算符重载函数去掉,让系统自动生成一个赋值运算符重载函数再运行会是什么样的结果:
因此,默认的赋值运算符重载函数,仍然可以进行对日期类的拷贝,事实上,其与拷贝构造的默认函数是相同的。 但这里就会和系统默认拷贝构造出现相同的问题,即我们malloc的空间,或者说存在调用析构函数的成员变量的类是否也可以使用默认赋值运算符重载呢?下面我们引入Stack类,观察利用默认的运算符重载之后Stack成员变量的变化:
class Stack
{
public:
Stack(int capacity = 4)
{
cout << "Stack(int capacity = 4)" << endl;
_a = (int*)malloc(sizeof(int)*capacity);
if (_a == nullptr)
{
perror("malloc fail");
exit(-1);
}
_top = 0;
_capacity = capacity;
}
~Stack()
{
cout << "~Stack()" << endl;
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
void Push(int x)
{
// ....
// 扩容
_a[_top++] = x;
}
private:
int* _a;
int _top;
int _capacity;
};
void TestStack()//析构两次,且内存泄漏
{
Stack st1;
st1.Push(1);
st1.Push(2);
Stack st2(10);
st2.Push(10);
st2.Push(20);
st2.Push(30);
st2.Push(40);
st1 = st2;
}
int main()
{
TestStack();
return 0;
}
赋值前:
赋值后:
我们通过打印发现,析构了两次,并且发生了内存泄漏(st1 的 _a 指向一块空间, St2 的 _a 指向另一块空间,使用赋值重载让 st2 的 _a 指向st1 的 _a,所以就内存泄露了),因此像需要析构的类默认的运算符重载就不满足其要求。即需要析构的类就需要自己去写。
对于栈类来说,既然里面的
_a
包括地址也会完全拷贝,因此像这样的我们就需要自己写,要避免这种情况,我们就需要在st1 = st2过程中重新让_a指向另一个地址,从而避免同一个地方析构两次,即free掉原空间,再开辟空间进行赋值:
Stack& operator=(const Stack& st)
{
free(_a);
_a = (int*)malloc(sizeof(int) * st._capacity);
if (_a == nullptr)
{
perror("malloc fail");
exit(-1);
}
memcpy(_a, st._a, sizeof(int) * st._top);
_capacity = st._capacity;
return *this;
}
类中加上这个自定义运算符重载之后就可以完好的运行:
此时又会有一个问题,如果我们直接st1 = st1的话,free掉的地址实际上还是我们所需要的,这样就会出现问题,结果就是_中的内容会变成随机值:
但这种赋值本身就是毫无意义的,因此我们只需要在运算符重载中判断是否是自己给自己赋值即可:
Stack& operator=(const Stack& st)
{
if (this != &st)
{
free(_a);
_a = (int*)malloc(sizeof(int) * st._capacity);
if (_a == nullptr)
{
perror("malloc fail");
exit(-1);
}
memcpy(_a, st._a, sizeof(int) * st._top);
_capacity = st._capacity;
return *this;
}
}
此外,还有一个Queue类:(基于Stack所写的)
class MyQueue {
public:
void push(int x)
{
_pushST.Push(x);
}
private:
Stack _pushST;
Stack _popST;
size_t _size = 0; // 这里不是初始化,给的缺省值
};
在这里我们直接总结:Queue类中默认生成的赋值运算符重载函数就可以对需要析构的_pushST
和_popST
进行像Stack类中那样的赋值,实际上是Queue类中的这两个成员变量借助了Stack类中的自定义运算符重载函数,因此可以调用,从这里可以看出,其与拷贝构造函数的方式是一样的。(事实上,四个重要的默认成员函数都是这样)
5.4 赋值运算符重载的注意事项
上面大部分已经介绍过,在这里进行总结
-
赋值运算符重载格式:
- 参数类型:const T&,传递引用可以提高传参效率
- 返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
- 检测是否自己给自己赋值
- 返回*this :要复合连续赋值的含义
-
赋值运算符只能重载成类的成员函数不能重载成全局函数
原因:赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。
-
用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。
-
注意:如果类中未涉及到资源管理,赋值运算符是否实现都可以;一旦涉及到资源管理则必须要实现。
对于运算符重载,上面所描述的都是大于1个操作数的,即+、-、=等,但仍有单操作数的运算符,比如++ ,因此我在这里提及一下,下一篇仍然算是类和对象中,但主题是日期类的实现,在日期类中将会把类和对象中剩下的部分讲完。
6. 总结四个重要的默认构造函数
对于此类和对象中篇,到这里已经很多了,通过最上面的目标,已经完成了前四个最重要的默认成员函数,由于篇幅过长,在这里就要对此进行收尾,剩下的内容将以续的形式描述。然后嘞,我们在这里总结一下最重要的四个默认成员函数吧:
- 构造和析构很像:内置类型不处理,自定义类型处理;
- 拷贝构造和赋值运算符重载很像:内置类型和自定义类型都处理。