文章目录
这篇文章呢我们接着上一篇的内容,继续C++类和对象的学习。
1. 类的6个默认成员函数
通过上一篇文章的学习,我们知道如果一个类中没有成员变量,也没有成员函数,啥也没有,那我们把它叫做空类。
即如果一个类中什么成员都没有,简称为空类。
比如:
class Date
{
};
那现在问大家一个问题:空类中真的什么都没有吗?
🆗,其实并不是的。
对于任何一个类来说,它们都有6个默认成员函数,即使是空类。
默认成员函数:即用户没有显式实现,编译器自动生成的成员函数称。
那这6个默认成员函数都是什么呢?
大家先简单了解一下,接下来我们会一一学习。
2. 构造函数
2.1 构造函数的引出
通过上一篇文章的学习,相信大家已经有能力能够写一个简单的类了。
那现在有这样一个类:
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;
};
那对于一个类来说,我们实例化出来对象之后一般会对其进行一个初始化:
如果有时候不初始化直接用可能就会出现问题,但是有时候我们可能会忘记初始化,直接就对对象进行一些操作了。
再举个大家可能经历过的例子:
比如我们写了一个栈的类,然后用该类创建一个对象,对象创建好之后我们就迫不及待地往栈里放数据了,上去直接调用压栈的成员函数,哐哐哐数据就搞进去了。
但是一运行发现程序崩溃了,最后吭哧吭哧去调试发现没有对创建出来的栈进行初始化,空间都没开呢,就放数据了。
有可能忘了不说,每次创建一个对象都要初始一次,好像也有点麻烦。
那针对上面提到的这种情况呢,C++呢就提供了一种方法帮助我们解决这个问题:
那就是我们接下来要学的——构造函数。
有了构造函数,我们每创建完一个对象,就不用手动去调用Init函数进行初始化了,因为在创建对象时编译器会自动去调用构造函数对对象进行初始化。
那构造函数到底是个啥呢?
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。
那接下来我们就来详细地认识一下构造函数。
2.2 构造函数的特性
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
其特征如下:
- 构造函数的函数名与类名相同
也就是说定义好一个类,它的构造函数的函数名就确定好了,跟当前类的类名是相同的。
- 构造函数无返回值
要注意这里说的无返回值不是说返回类型是
void
,而是根本就不写返回类型。
比如上面我们定义的那个Date类,如果要写它的构造函数就应该是这样的:
- 对象实例化时编译器自动调用对应的构造函数
有了构造函数我们初始化对象就不用再手动初始化了,实例化一个对象时编译器会自动调用其对应的构造函数。
- 构造函数可以重载
🆗,构造函数可以重载,那是不是就意味着一个类可以有多个构造函数,那也就是说,该类创建的对象可以有多种初始化方式。
那不能光说不练啊,现在已经认识了构造函数了,那我们练习一下呗,就给上面的Date类写一下构造函数:
Date()
{
}
首先看这是不是就是一个构造函数啊,当然是,没有返回值,并且函数名和类名相同嘛。
但是我们说构造函数是用来初始化对象的,那啥也不写是不是没意思啊,写点东西吧:
Date()
{
_year = 1;
_month = 1;
_day = 1;
}
这样,我们把年月日都初始化成1。
那我们来试一下,刚才不初始化都打印出来随机值了,那现在有构造函数不是说会自动初始化吗,行不行啊,验证一下:
哦豁,可以啊,这次我们并没有调用初始化函数,但是打印出来不是随机值,而是我们在构造函数中给定的初值,说明我们实例化对象的时候确实自动调用构造函数进行初始化了。
那这样的话我们每次创建Date类的对象初值都是1 1 1,如果我们想每次都按照自己的想法给对象进行初始化呢?能做到吗?
是不是可以啊。
上面提到的构造函数的第4条特性是啥?
是不是构造函数可以重载啊,那我们重载一下给参数不就行了。
这样的话我们不知道初始化给什么初值的时候就可以调用无参的构造函数,自己想指定初值的话调用有参数的传参不就行了。
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
这样是不是就搞定了。
这是不是就达到我们想要的效果了。
但是要注意,调用无参构造函数的时候我们不要写成这样Date d1();
即后面不要加括号。
这样的话编译器会报一个警告,大家看这样写的话是不是可能会被认为是一个函数声明啊,是吧。
一个返回类型为Date,函数名为d1,无参的函数声明是不是也长这样啊。
那大家再来思考一下:
这两个构造函数有没有必要分开写,或者说,能不能一个函数就搞定了。
🆗,当然是可以的,怎么做呢?
上一篇文章刚学的——缺省参数
是不是可以这样写:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
用这一个是不是就行了啊:
我们不传参,就用默认的,传了,就用我们传的。
另外,还有一个需要注意的点:
我们现在呢,实现了一个带缺省值的构造函数,那大家思考一下,这两个构造函数可以同时存在吗?
那要告诉大家的是,首先在语法上,它们两个是可以存在的,因为它们构成重载嘛,但是,我们现在再去运行程序:
报错了,为什么?
原因在于,我们这里是不是调用了无参的构造函数啊,d1我们创建时没传参嘛,但是上面这两个构造函数是不是都适用于无参的情况啊,所以编译器就不知道该调那个了,就报错了。
那我们把d1的创建注释掉呢?
就不报错了,好吧,这是需要大家注意的一个地方。
那除了上面这些,其实构造函数还有一些其它的特性:
- 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
哦豁,那也就是说,构造函数不一定非要我自己写,如果我们自己没有定义构造函数,编译器会自动生成一个。只不过是无参的嘛。
那现在把我们自己定义的构造函数全部注释掉:
我们发现确实没问题,编译通过了。
将Date类中构造函数屏蔽后,代码可以通过编译,因为编译器生成了一个无参的默认构造函数
那特性还说了,如果我们自己定义的有,编译器就不再生成了:
这个大家好理解,我们上面自己写的无参构造函数把_
year、 _month、 _day
全部初始化为1,打印出来确实是全1。
那编译器会自动生成的话,我们以后是不是就不用自己写构造函数了?
我们把自己写的构造函数屏蔽掉,然后直接运行:
欸!这~怎么回事嘛?
不是说有自动生成的构造函数嘛,怎么还是随机值啊。
这编译器自动生成的默认构造函数怎么没用啊?
什么原因呢?
🆗,这个地方呢,大家可以认为是我们的祖师爷设计的不好的一个地方,或者说是一个失误。
具体是这样的:
C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:int/char...
(包括各种指针类型),自定义类型就是我们使用class/struct/union
等自己定义的类型
而编译器自动生成的构造函数不会对内置类型进行处理,对于自定义类型会处理,怎么处理?会去调用该自定义类型对应的默认构造函数
所以,刚才为什么打印出来是随机值?
因为我们Date类中的成员变量都是int,是内置类型,但是编译器自动生成的构造函数不会处理内置类型,所以还是随机值。
那我们来看这样的场景:
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;
int _month;
int _day;
// 自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
大家看这里的Date类与上面那个有什么区别,是不是它的成员变量里既有内置类型又有自定义类型啊。
但是我们现在并没有给Date类写构造函数,那我们在main函数里直接拿Date去创建一个对象,它自然就会去调用编译器自动生成的构造函数,那内置类型不做处理,我们不是还有一个自定义类型Time _t;
呢,我们说对于自定义类型,编译器会自动去调用它对应的默认构造函数。
那我们在Time
类的默认构造函数里面故意加了一个打印:
如果运行会打印,就说名编译器自动调用了:
🆗,是不是调了啊。
那说到底内置类型呢?这样的话内置类型不写构造函数就没法初始化了吗?
🆗,我们的祖师爷呢在后来也发现了这个问题,并在C++11中针对内置类型不初始化的缺陷打了一个补丁。
即非静态成员变量在类中声明的时候可以给缺省值。
这样如果我们不写构造函数,内置类型的初始化就会按给定的缺省值进行初始化。
- 无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数,默认构造函数只能有一个
所以这里想告诉大家的是,不要认为默认构造函数就是我们不写编译器自动生成的那个,除了这个之外,我们自己定义的无参的构造函数,或者全缺省的构造函数,都可以认为是默认构造函数。
为什么说只能有一个呢,因为我们调用这些构造函数是不是都不用传参啊,那这样如果同时存在多个的话,编译器就不知道到底该调哪个了。
这个问题我们上面也有提到过的。
3. 析构函数
3.1 析构函数的引出
首先我们来回顾一个问题:
我们在之前数据结构的学习中,在学到栈的时候,有一个与栈相关的非常经典的题目——括号匹配问题。
链接: link
不知道大家做过这个题没有,只不过当时我们用的栈是用C语言写的,那现在我们也可以用C++的类实现了。
但是这道题里有一个比较恶心的点,是什么呢?
来看一下我们C语言写出来的代码,我们进行判断之后,需要return的地方可能有好几处,但是呢,每次return之前,其实最好都要去调用一下StackDestroy
把我们动态开辟的空间给销毁一下,但是我们可能很容易会忘掉导致内存泄漏。
那现在我们学了C++,有没有什么好的办法可以帮助我们解决这个问题呢?
可不可以像上面的构造函数自动初始化一样自动对对象中的资源进行清理呢?
那当然是有的,就是我们接下来要学习的析构函数
析构函数:
其与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
解释一下,我们用这样一个类来举例:
typedef int DataType;
class Stack
{
public:
//构造函数
Stack(size_t capacity = 4)
{
_array = (DataType*)malloc(sizeof(DataType) * capacity);
if (NULL == _array)
{
perror("malloc申请空间失败!!!");
return;
}
_capacity = capacity;
_size = 0;
}
void Push(DataType data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
// 其他方法...
private:
DataType* _array;
int _capacity;
int _size;
};
是一个栈类,并且我们已经写好了构造函数。
那我们上面说 析构函数不是完成对对象本身的销毁,而是完成对象中资源的清理工作 是什么意思呢?
🆗,我们拿这个类去实例化栈对象:
int main()
{
Stack s;
s.Push(1);
s.Push(2);
return 0;
}
大家思考一下,这里的对象s需要我们自己去销毁吗?
是不是不需要啊,因为s是定义在栈区上的局部变量,程序结束,它是不是就随着main函数的栈帧自动销毁了啊。
那析构函数的作用是啥呢?
完成对象中资源的清理工作,什么意思?
🆗,像栈这样的对象,它里面是不是有在堆上动态开辟的空间啊,那经过C语言的学习我们都知道,这些空间是不是需要我们手动去释放的啊,否则可能会导致内存泄漏。
所以说,析构函数就是来帮我们干这件事情的。
那析构函数到底是个啥,又怎么用呢?
3.2 析构函数的特性
和构造函数一样,析构函数也是一个特殊的成员函数,其特征如下:
- 析构函数名是在类名前加上字符 ~
也就是说,一个类定义好之后,它的析构函数的函数名也是确定的,即在类名前面加上
“~”
。
~
是啥,在C语言中是不是按位取反啊,表示它的功能和构造函数是相反的。
- 无返回值且无参数
和构造函数一样,析构函数也是没有返回值的,并且析构函数还没有参数。
- 对象生命周期结束时,C++编译系统自动调用析构函数
析构函数起作用的关键就在这里,对象声明周期结束时编译器会自动调用析构函数对对象的资源进行清理。
- 析构函数不能重载
注意析构函数不能重载,因为它连参数都没有,何谈重载。
那了解到这里,我们就可以尝试写一个析构函数来练练手了:
就给我们刚才那个栈类写一个析构函数吧。
~Stack()
{
free(_array);
_array = NULL;
_capacity = 0;
_size = 0;
}
那是不是很简单啊,就是释放我们在堆上开辟的空间嘛。
然后呢,它没有返回值,没有参数
那就写好了,那测试一下呗:
为了方便看出来是否自动调用了析构函数,我们可以在加一个打印:
此时我们的main函数里并没有显式的调用~Stack
函数:
然后我们运行:
是不是自动调用了啊。
- 一个类只能有一个析构函数。若未显式定义,编译器会自动生成默认的析构函数
这一点呢和构造函数一样,如果我们自己不写析构函数,则编译器会自动生成默认的析构函数。
然后说一个类只能有一个析构函数,我们上面说了析构函数不能重载,所以肯定只能有一个了。
那编译器默认生成的析构函数有什么特点呢?
和编译器默认生成的构造函数一样,内置类型成员不处理,当然如果全是内置类型的成员变量也不需要处理,比如上面写的Date类。
那同样,对于自定义类型,会自动调用其对应的析构函数。
举个栗子:
typedef int DataType;
class Stack
{
public:
Stack(int capacity = 4)
{
_array = (DataType*)malloc(sizeof(DataType) * capacity);
if (NULL == _array)
{
perror("malloc申请空间失败!!!");
return;
}
_capacity = capacity;
_size = 0;
}
void Push(DataType data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
// 其他方法...
//析构函数
~Stack()
{
cout << "~Stack" << endl;
free(_array);
_array = NULL;
_capacity = 0;
_size = 0;
}
private:
DataType* _array;
int _capacity;
int _size;
};
class Date
{
private:
// 基本类型(内置类型)
int _year;
int _month;
int _day;
// 自定义类型
Stack _s;
};
int main()
{
Date d;
return 0;
}
这里我们没有给Date显式定义析构函数,那d声明周期结束时,就会调用编译器自己生成的默认析构函数,那里面的内置类型不做处理,当然也不用处理,关键在于自定义类型
Stack _s;
申请的资源需要清理,那我们看编译器自己生成的默认析构函数会不会调用Stack
类的析构函数:
🆗,是不是调了啊。
- 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类
4. 拷贝构造函数
4.1 概念
我们再来看上面写的这个Date类:
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;
};
那现在我们用这个类创建一个对象d1:
int main()
{
Date d1;
return 0;
}
然后大家思考一下,如果我们现在想在创建一个对象,让这个对象和d1一样,或者说是d1的一份拷贝,应该怎么搞?
🆗,那经过了上面的学习,我们现在创建一个对象一般都直接用构造函数对其进行初始化,想初始化什么值传参就行了。
那现在我们想创建一个和d1一样的新对象,是不是可以用d1去初始化创建出来的新对象啊。
怎么做,是不是把构造函数的参数类型设置成类对象的类型就行了。
那这其实就是我们接下来要学的拷贝(复制)构造函数。
拷贝构造函数:
只有单个形参的构造函数,该形参是对本类 类型对象的引用(一般常用const修饰),在我们用已存在类的类型对象创建新对象(对象的拷贝)时由编译器自动调用。
接下来我们来更加详细的认识一下它:
4.2 特性
拷贝构造函数也是一种特殊的成员函数,其特征如下:
- 拷贝构造函数是构造函数的一个重载形式
因为我们刚才上面说了嘛,它的作用其实也是用来初始化对象的,只不过参数类型指定了是我们当前类的类型嘛。
所以它算是构造函数的一种重载形式。
那我们先自己来尝试实现一下它好吧:
Date(Date d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
这样是不是就行了啊。
然后要创建一个和d1一样的对象,是不是这样:
int main()
{
Date d1;
Date d2(d1);
return 0;
}
直接把d1作为参数初始化d2,然后我们构造函数的参数类型正好是Date 嘛,可以接收,然后把d1的成员变量一个一个赋给d2不就搞定了嘛。
但是呢,我们发现:
这样写编译器直接就报错了,还没运行就报错了。
那相信大家刚才也注意到上面的概念了,在拷贝构造函数的概念中其实就指明了说它的参数类型应该是类对象的引用。
确实,我们这样修改之后就可以了。
那这里为啥非得是引用呢?我们来看拷贝构造函数的第2条特性:
- 拷贝构造函数的参数只有一个且必须是当前类同类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用
那为啥直接传值就会引发无穷递归呢?
结合这张图给大家解释一下:
ps:图中还在形参前加了const,大家可以先不管,后面会解释。
大家想一下,首先我们这里是用已有的类对象去创建一个相同的新对象(类对象的拷贝),所以会调用拷贝构造函数,那要调用函数是不是要先传参啊,而传值调用传的是啥(形参是实参的一份临时拷贝),是不是传的实参的拷贝,那要拷贝实参,是不是又是一个类对象的拷贝啊,那既然是类对象的拷贝,就又要调用拷贝构造函数,那就又需要传参,一传参就会再次调用拷贝构造函数,那这样是不是就陷入一个死递归了。
所以这里不能直接传对象,而是要传对象的引用(别名):
我们传对象的引用还需要拷贝实参吗,是不是就不用了,所以也就不会出现上面的问题了。
这时我们再运行程序:
不就达到我们想要的效果了吗。
另外呢,对一个对象进行拷贝构造也可以这样写:
直接用“
=
”也可以,这样也是拷贝构造。
除此之外,大家是不是还注意到:
上面一开始拷贝构造函数的概念中说它的形参一般用const修饰:
为什么要加个const呢?
其实很容易理解,大家想形参d是用来干嘛的,是用来初始化我们新创建的对象的,那我们肯定不希望形参d被修改,所以加个const修饰:
这样我们如果不小心写反了啥的是不是就直接报错了。
所以,正确的实现应该是这样的:
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
另外,加const还有什么好处呢?
大家想,如果我们不加const,但传过来的参数是const修饰的,这样的话是不是根本就接收不了啊,这个问题我们之前也讲了,是不是属于权限放大了,是不行的。
但是如果我们加了const,传过来的不管是否加了const是不是都可以接收啊。
所以呢:
这里一般加上const会比较好。
- 若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数 拷贝对象 按内存存储字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
🆗,那我们上面说了拷贝构造函数是构造函数的一种重载形式,那其实就也属于是构造函数了,那构造函数我们不写的话编译器不是会自动生成嘛,那拷贝构造函数是不是也具有这样的特性呢?
是的,对于拷贝构造函数来说,若未显式定义,编译器也会生成默认的拷贝构造函数。
那默认生成的拷贝构造函数是什么样的?我们来研究一下:
我们刚才不是对Date类实现了一个拷贝构造函数嘛,先我们现在把它屏蔽调:
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 Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
那这次我们自己没写拷贝构造,我们看看编译器自动生成的能不能帮助我们完成拷贝构造:
int main()
{
Date d1;
d1.Print();
Date d2(d1);
d2.Print();
Date d3 = d1;
d3.Print();
return 0;
}
我们运行程序:
欸,是不是可以啊。
那既然编译器自动生成的拷贝构造函数就可以帮助我们完成类对象的拷贝了,那我们还需要自己写吗?
那为了解决这个问题,我们再来看这样一个类:
typedef int DataType;
class Stack
{
public:
Stack(int capacity = 4)
{
_array = (DataType*)malloc(sizeof(DataType) * capacity);
if (NULL == _array)
{
perror("malloc申请空间失败!!!");
return;
}
_capacity = capacity;
_size = 0;
}
void Push(DataType data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
// 其他方法...
~Stack()
{
cout << "~Stack" << endl;
free(_array);
_array = NULL;
_capacity = 0;
_size = 0;
}
private:
DataType* _array;
int _capacity;
int _size;
};
还是我们之前用过的这个栈Stack类,大家看它的成员变量是不是也都是内置类型啊,前面提到过指针也属于内置类型嘛。
那对于Stack这个类,我们也是没写拷贝构造函数的,那编译器自动生成的能不能完成下面这样的拷贝呢?
int main()
{
Stack s1;
s1.Push(1);
s1.Push(2);
s1.Push(3);
Stack s2(s1);
return 0;
}
这里是把s1拷贝给s2,我们运行一下:
但是呢,嗯??? 一运行发现我们的程序挂掉了。
为什么会这样呢,刚才Date类不也都是内置类型,为啥就没事呢?
大家有没有注意到我们上面的特性3,后面的一句话是:
默认的拷贝构造函数 拷贝对象 按内存存储字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
在这里其实就是对逐个成员变量依次进行拷贝,里面存的是啥就把啥拷过去。
那原因其实就出现在这里,我们来对比一下Date和Stack这两个类进行的拷贝:
首先对于Date类来说,进行这样的浅拷贝有没有问题啊。
是不是没问题啊,一共12个字节的内容,直接拷贝过去就行了嘛。
但是,对于Stack类来说呢?
我们还是这样进行浅拷贝的话:
大家看出来有什么问题了吗?
这样拷贝过后两个栈对象是不是指向同一块堆上的空间啊。
这样会有什么问题呢?
我们在st1中入栈几个数据,st2里面是不是就也有数据了(因为它俩用的是同一块空间),然后如果我们再用st2去入栈数据,此时st1的_size
前面是不是已经++
过了,但是st1的_size
前面是不是还是0,这样st2入的数据是不是就把之前st1入的数据给覆盖掉了。
除此之外,还会有什么问题。
st1生命周期结束析构一次,st2生命周期结束析构一次,是不是会对一块空间析构两次啊。
那大家先思考一下,这里st1和st2谁先进行析构啊?
简单解释一下:
这里是st2先析构,我们知道st1和st2都是在栈上的(栈区)
,那栈区之所以叫栈区也是有些讲究的,它在这个地方栈帧的建立也是遵循先进后出的这个顺序的,即后定义的会先进行析构。
所以这里会有什么问题呢?
🆗,st2先析构,那堆上的这块空间就被释放了,但是接下来st1也会进行它的析构,而此时虽然st1还保留了这块空间的地址,但是这块空间已经被释放,所以st1就是个野指针了。
所以为什么程序崩溃了,就是我们这里对野指针进行free了。
所以:
在编译器生成的默认拷贝构造函数中,内置类型是按照浅拷贝进行拷贝的,浅拷贝在某些场景下是适用的(比如上面的Date类),但是在有些场景下是会出问题的(比如这里的Stack类)。
那总结一下就是:
类中如果没有涉及资源申请时,拷贝构造函数我们自己写不写都可以(因为默认生成的就可以搞定);一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝,就会出现问题。
所以说:
对于Stack这样存在资源申请的类,我们是需要自己去写拷贝构造函数的,那浅拷贝不行,这里我们应该怎么实现呢?
🆗,那要完成这种类的拷贝就需要我们实现一个深拷贝。
那深拷贝呢我们后面会专门去讲,这里我们先来简单的试一下:
那刚才Stack进行浅拷贝为什么不行,是不是导致两个栈对象指向了同一块空间了。
所以我们深拷贝要做的就是让这两个对象各自拥有自己独立的空间就行了。
这样对两个对象进行操作就不会互相影响了。
那我们来实现一下代码吧:
Stack(const Stack& st)
{
_array = (DataType*)malloc(sizeof(DataType) * st._capacity);
if (NULL == _array)
{
perror("malloc申请空间失败!!!");
exit(-1);
}
memcpy(_array, st._array, sizeof(DataType) * st._size);
_capacity = st._capacity;
_size = st._size;
}
🆗,我们来运行一下:
这次就正常运行了。
再来调试观察一下:
是不是没问题啊。
当然:
如果类的成员变量有自定义类型,默认生成的拷贝构造还是会去调用该类对应的拷贝构造。
我们再来看这个类:
class MyQueue
{
public:
// 默认生成构造
// 默认生成析构
// 默认生成拷贝构造
private:
Stack _pushST;
Stack _popST;
int _size = 0;
};
大家看,对于这个类来说,我们还需要自己写构造函数、析构函数包括拷贝构造函数嘛!
是不是不需要啊,默认的是不是都能搞定啊。
对于构造函数来说,内置类型虽然不做处理,但是我们给了缺省值,对于自定义类型,默认生成的会自动调用它对应的构造函数啊,而Stack
的构造函数我们也实现的有了;
对于析构函数,内置类型不用处理,自定义类型这里也会自动调用Stack
对应的析构;
那如果用到拷贝构造的话,这里的_size
直接默认的浅拷贝就能搞定,自定义类型还是会自动调Stack
对应的拷贝构造。
那总结一下这一部分,就是:
在编译器生成的默认拷贝构造函数中,内置类型是按照浅拷贝(值拷贝)进行拷贝的,而自定义类型是调用其对应的拷贝构造函数完成拷贝的。
- 拷贝构造函数典型调用场景:
使用已存在对象创建新对象
函数参数类型为类对象
函数返回值类型为类对象
当然:
为了提高程序效率,一般对象传参时,尽量使用引用类型(减少拷贝),返回时根据实际场景,能用引用尽量使用引用。
5.赋值运算符重载
接下来我们要来学习赋值运算符重载,那赋值运算符重载呢是属于运算符重载的,所以在学习之前,我们要先来了解一下C++的运算符重载。
5.1 运算符重载
我们还来看上面实现过的那个日期Date类:
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;
};
那我们现在用Date类实例化出两个对象:
int main()
{
Date d1(2023, 4, 13);
Date d2(2023, 4, 12);
return 0;
}
现在有两个对象d1,d2,大家思考一个问题,现在我们想比较这两个对象是否相等,要怎么搞?
🆗,那我们是不是可以考虑实现一个函数来判断两个对象是否相等:
bool Equal(Date x1, Date x2)
{
//...
}
大家看该函数的参数这样写好不好,是不是不太好啊。
这里是传值传参,形参是实参的拷贝,那对象的拷贝还要调用拷贝构造。
所以这里我们是不是可以考虑传引用啊,这样就不用拷贝了,另外呢,这里只是去比较两个对象,我们并不想改变它们,所以是不是再加一个const比较好:
bool Equal(const Date& x1, const Date& x2)
{
//...
}
写一个函数,这是一种方法。
那C++引入了运算符重载之后呢,就使得我们能够这样去玩:
比较两个日期类对象d1,d2是否相等,直接这样:
d1==d2
但是我们首先要知道自定义类型是不能直接作为这些操作符的操作数的。
不像我们的内置类型可以直接进行加减乘除比较相等这些运算,为什么自定义类型不可以啊?
因为自定义自定义,是不是我们自己写的啊,就比如我们实现的这个日期类,是我们按照自己的想法实现出来的,编译器肯定不知道比较这样两个对象应该怎么做。
而且,有些自定义类型不是进行所有的运算都有意义的,就比如日期类,两个日期对象如果相加,有意义吗,是不是没啥意义啊,如果两个日期相减还有点意义,可以理解为两个日期之间差了多少天。
所以这个是由我们自己决定的,我们觉得它可以进行什么样的运算有意义,然后去实现。
那我们要怎么做才能让我们的自定义类型像这样d1==d2
直接进行一些运算和比较呢?
这就需要我们对这些运算符进行重载。
概念
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:关键字operator后面接需要重载的运算符符号
函数原型:返回值类型 operator操作符(参数列表)
那我们接下来就来练习一下:
上面我们不是相比较两个日期类对象是否相等嘛,那我们就来重载一下
==
运算符。
根据上面的概念,我们可以写出:
bool operator==(const Date& x1, const Date& x2)
{
}
那函数体的实现,即比较的逻辑,其实也很简单:
只要两个对象的三个属性(成员变量)_year,_month,_day
全部相同,就说明两个对象相等。
bool operator==(const Date& d1, const Date& d2)
{
return d1._year == d2._year
&& d1._month == d2._month
&& d1.day == d2.day;
}
这样是不是就行了,但是现在有一个问题:
什么原因呢?
因为我们Date类的这3个成员变量是私有的(private),所以在类外面是不能访问的。
那怎么解决?
我们可以在类里写一个Get方法(函数),通过Get方法来访问,或者呢,直接把private访问限定符去掉。
我们这里先把private注释一下:
然后就不报错了。
那重载好,我们就可以直接用了:
当然,我们也可以像普通函数那样去调用:
当然正常情况下我们不会像普通函数那样去调用,因为我们重载就是为了可以直接d1 == d2
这样用。
所以我们直接写成这样就行:
d1 == d2
剩下的工作就由编译器去做,编译器看到这样的代码,就会去看你有没有重载,如果进行了重载,就会转化成去调用这个函数operator==(d1, d2)
。
那我们可以打印一下这个结果:
cout << d1 == d2 << endl;
cout << operator==(d1, d2) << endl;
但是我们会发现又报错球了:
cout << d1 == d2 << endl;
这一句报错了。
什么原因呢?
🆗,是因为这里<<
的优先级比==
高,所以加个括号就行了:
int main()
{
Date d1(2023, 4, 13);
Date d2(2023, 4, 12);
cout << (d1 == d2) << endl;
cout << operator==(d1, d2) << endl;
return 0;
}
0为假,而这两个对象也确实是不相等的。
那我们就把==
重载好了,但是:
刚才我们是直接重载到了全局,我们把成员变量变成了共有的才能这样的。
那么问题又来了:我们把成员变量全部公有了,封装性又如何体现呢?
那当然是有办法解决的,我们刚才上面已经提了一种,就是提供一些共有的get方法,那除此之外呢,我们还可以用友元函数解决,但是我们还没学,而且不推荐用这个。
所以这里比较好的一种方法是:
我们直接重载到类里面,即重载成成员函数。
但是呢?我们直接把它放到类里面的话:
嗯???又报错了,说此运算符函数的参数太多。
怎么回事啊?
🆗,这里我们重载的是
==
运算符,正常情况下只有两个操作数,所以只需要两个参数就够了。
那大家可能会疑惑了,这里不就是两个参数嘛?
那大家不要忘了,这里是不是还有一个隐藏参数啊。
什么隐藏参数,是不是就是this指针啊。
这是不是我们上一篇文章学习的知识啊。
C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象)
所以我们这里只需给一个参数就够了。
bool operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
那调用的时候,this指针接收d1的地址,形参d就是d2(引用传参)。
注意
下面我们一起来看一下,在运算符重载这一块,需要注意的一些内容:
- 不能连接其他符号来创建新的操作符:比如operator@
- 重载操作符至少有一个类类型的参数
- 用于内置类型的运算符,其含义不能重载改变,例如:内置的整型+,不能改变其含义
- 作为类成员函数重载时,其形参看起来比操作数数目少1个,因为成员函数的第一个参数为隐藏的this
.* :: sizeof ?: .
注意这5个运算符不能重载,这个经常在笔试选择题中出现。
.*
其中这个运算符大家可能都没见过也没用过,没关系,大家可以记一下就行了。
练习
那上面我们对==
运算符进行了重载,接下来我们再来练习几个。
那就还是上面那个日期类,现在我们来尝试重载一下<
好吧:
那其实逻辑也不难,就是判断两个日期的大小嘛。
我们可以只判断小于的情况返回true,其它情况一律false:
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;
}
就是这样嘛。
测试一下:
没毛病。
那我们再重载一下日期类的>
吧:
那是不是很简单啊,那
<
里面的<
换成>
就行了嘛:
这样当然是可以的。
但是呢,我们可能还会实现大于等于,小于等于…
所以呢,接下来给大家说一个简单的方法,对所有的类都适用:
怎么做呢?
🆗,我们现在是不是已经重载了==
和<
了,那现在我在想去重载什么大于,大于小于之类的,其实根本没必要再自己写了,可以直接复用我们写好的那两个。
那我们现在想重载>
的话,其实可以考虑先重载<=
:
那怎么复用:
bool operator<=(const Date& d)
{
return *this == d || *this < d;
}
是不是就搞定了,我们的小于等于。
那>
呢:
bool operator>(const Date& d)
{
return !(*this <= d);
}
再来,>=
呢:
bool operator>=(const Date& d)
{
return !(*this < d);
}
不等于!=
呢:
bool operator!=(const Date& d)
{
return !(*this == d);
}
这样搞是不是很爽啊。
5.2 赋值重载
赋值运算符重载呢 是属于运算符重载的一种,但是,它还是我们类的6个默认成员函数的其中一个。
实现
那我们就先来重载一下赋值=
运算符吧:
那经过了刚才的学习,重载一个
=
,是不是简简单单啊。
//d1=d2(this就是d1,d就是d2)
void operator=(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
这是不是就好了啊,测试一下:
可以完成赋值。
但是呢,我们当前的这个实现还有一些缺陷:
什么缺陷呢?
大家回忆一下,我们之前用内置类型进行赋值操作时是不是支持像这样的连续赋值啊:
i = j = k;
这句代码怎么执行的,是不是从右向左啊,先把k赋给j,然后再把表达式j = k
的结果,就是k赋给i。
当然还可以连续的更多。
而对于我们刚才对日期类重载的=
,可以支持连续赋值吗:
额,是不行的,这里直接报错了。
那这里为啥报错了啊:
因为正常情况下d2赋给d1是不是应该有一个结果啊,然后把这个结果再赋给d3。
但是我们这里d1 = d2
是不是调了我们重载的函数,而我们上面实现的函数并没有返回值。
所以我们要加一个返回值来支持连续赋值:
那我们返回的话是不是还是返回对象的引用比较好啊:
//d1=d2(this就是d1,d就是d2)
Date& operator=(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
return *this;
}
那这下我们的连续赋值就可以了。
但是有时候呢不排除有人可能会写出这样的代码:
把自己赋给自己。
这样可以吗?
可以当然是可以的,但是它调用函数是不是白白进行了一次拷贝啊,所以呢,我们一般还会加一点东西:
Date& operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
加一个判断,如果它们是同一个对象,就不用进行拷贝了。
🆗,那我们来简单总结一下赋值运算符重载:
参数类型:const 类对象的引用,传递引用可以提高传参效率
返回值类型:类类型&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
最好检测一下是否是自己给自己赋值,并进行一下处理
返回*this:返回的结果用于支持连续赋值
那我们说了赋值运算符重载是属于6个类默认成员函数的其中一个,所以它还有一些属于自己的特性。
赋值重载的特性
- 用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝(浅拷贝)。
注意:默认生成的赋值重载对于内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用其对应类的赋值运算符重载完成赋值。
那有了这个特性的话,对于我们上面的日期类,我们还需要自己写赋值重载吗?
是不是不用啊,用编译器自动生成的是不是就可以完成啊。
因为日期类的成员变量是不是都是内置类型啊,而且赋值不涉及深拷贝的问题,浅拷贝就可以完成。
那我们试一下,把我们自己写的赋值重载注释掉:
然后运行:
是不是可以啊。
那这里的问题是不是就和拷贝构造一样了:
编译器生成的默认赋值运算符重载函数已经可以完成浅拷贝赋值了,所以像日期类这样的我们就没必要自己实现赋值重载了,因为默认生成的就可以帮我们搞定了。
那同样,如果涉及深拷贝的问题,像栈Stack这样的类,是不是就得我们自己实现去完成深拷贝了。
和拷贝构造一样,如果类中未涉及到资源管理,赋值运算符是否实现都可以;一旦涉及到资源管理则必须要自己实现。
然后我们再来看一个代码:
大家看这里会调用拷贝构造还是赋值重载?
这里是不是拷贝构造啊,这个我们上见过的嘛:
那为啥这里用了赋值=
,但是是拷贝构造呢?
🆗,我们来简单总结一下:
什么时候是调赋值重载呢?
是我们用已经实例化出来的对象进行相互赋值的时候,调用赋值重载。
而当我们用一个已经实例化出来的对象去初始化一个新对象的时候,调的是拷贝构造。
- 赋值运算符只能重载成类的成员函数不能重载成全局函数
我们上面重载的一些什么等于、大于、小于、大于等于之类的运算符是不是可以重载到类外也可以重载到类里面啊。
那赋值重载也是运算符重载,我们刚才是定义在类里面的,那它可以重载到外面吗?
我们试一下:
先把成员变量的private注释掉,确保在类外能访问。
然后我们在类外实现一下赋值重载:
Date& operator=(Date& left, const Date& right)
{
if (&left != &right)
{
left._year = right._year;
left._month = right._month;
left._day = right._day;
}
return left;
}
重载成全局函数,注意重载成全局函数时没有this指针了,需要给两个参数
那这就实现好了。
行不行呢:
还没运行直接就看到报错了,说必须是成员函数。
为什么这样不行呢?解释一下:
赋值重载如果在类里不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。
6. const成员函数
我们来看这样一个类:
class A
{
public:
void Print()
{
cout << _a << endl;
}
private:
int _a = 10;
};
然后:
int main()
{
A a;
a.Print();
return 0;
}
定义一个对象a,并调用成员函数Print。
没有什么问题。
那这样呢?
加一个const修饰对象a。
然后我们发现调用Print就出错了。
那为什么呢?
其实呢是因为这里存在了一个权限放大的问题。
这也是我们之前学习过的:对于引用,还有指针来说,对它们进行赋值和初始化时,权限可以缩小,但不能放大。
我们来分析一下:
对于我们的成员函数Print,虽然看起来没有参数,但是是不是有一个隐藏参数,就是我们熟悉的this指针嘛。
那this指针的类型是啥?
this指针的类型:类类型* const
那对于当前这个类来说就是A* const this
,const 修饰的是指针this,即指针this不能被修改,但this指向的内容可以被修改。
那我们传过来的参数是啥,是调用函数的对象的地址,即a的地址,但我们的对象a是const修饰的,所以传过来的地址的是const A* &a
,const修饰的是该地址指向的内容,即对象a不能被修改。
那这样的话,传给this,this可以修改其指向的内容即对象a,所以就是权限放大了。
所以这里报错了。
那怎么解决呢?
🆗,如果我们可以把this指针的类型也变成
const A*
是不是就可以了啊。
但是this指针的类型是我们想改变就能改变的吗?
this指针是类成员函数中的一个隐藏参数,我们是没法直接改变它的。
那就没有办法了吗?
办法肯定是有的:
我们只需在对应成员函数的括号后面加一个const 就行了。
这就是我们要学的const成员函数:
const修饰的“成员函数”称之为const成员函数。
const修饰类成员函数,实际修饰的是*this
,这样this指向的对象将不能被修改。
那这样this指针的类型就也变成了const A*
了,这样就可以传了。
但是我们平时定义一个对象好像一般也不会在前面加一个const,那这个用处是不是不大啊?
🆗,虽然定义对象时我们一般不加const,但是我们是不是可能经常会这样搞:
void Func(const A& x)
{
x.Print();
}
首先这里传引用与传值相比减少拷贝,然后如果我们不想对象被改变的话,不是一般会加一个const嘛。
那当前这种情况:
class A
{
public:
void Print()
{
cout << _a << endl;
}
private:
int _a = 10;
};
void Func(const A& x)
{
x.Print();
}
int main()
{
A a;
Func(a);
return 0;
}
x是a的引用(别名),a没有被const修饰,然后在
Func
里,x是被cosnt修饰的,x去调用
那这是不是跟我们开始讲的那个例子一样啊,怎么解决?
把Print变成const成员函数就行了:
像这种情况其实还是比较常见的。
所以说:
对于类的成员函数,如果在成员函数内部不需要改变调用它的对象,最好呢都可以把它写成const成员函数。
另外,如果const成员函数的声明和定义是分开的,声明和定义都要加const。
7. 取地址及const取地址操作符重载
类的6个成员函数呢,比较重要的前4个我们已经学完了,最后还剩两个。
我们一起来看一下:
那剩下的两个默认成员函数呢都是取地址重载,包括对普通对象的取地址和对const对象取地址。
这两个默认成员函数呢一般不需要我们自己去实现,编译器会自动生成,绝大多数情况下我们用编译器自动生成的就行了。
我们可以试一下:
对普通对象取地址
对const对象取地址
所以这两个默认成员函数一般不需要我们自己写,用编译器默认生成的取地址的重载即可
但是,如果你想自己去重载一下的话当然也是可以的:
你可以自己指定一个地址返回。
这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容!
🆗,那我们这篇文章的内容就先到这里,欢迎大家指正!!!