温馨提示:这篇文章已超过424天没有更新,请注意相关的内容是否还可用!
摘要:本篇文章继续探讨C++中的类与对象。详细介绍了类的属性和方法的定义与使用,以及如何通过对象来操作类的成员。文章强调了面向对象编程的核心理念,即通过类和对象来实现代码的模块化、复用和封装。通过实例演示了如何创建和使用类的对象,以及如何通过对象来访问类的属性和方法。本文有助于读者更好地理解和掌握C++面向对象编程的基本概念。
创作不易,感谢三连!
一、六大默认成员函数
C++为了弥补C语言的不足,设置了6个默认成员函数
二、构造函数
2.1 概念
在我们学习数据结构的时候,我们总是要在使用一个对象前进行初始化,这似乎已经成为了一件无法改变的事情,如以下的Data类
对于Date类,可以通过 Init 公有方法给对象设置日期,但如果每次创建对象时都调用该方法设置信息,未免有点麻烦,我们的祖师爷就在想,像初始化这种傻瓜式的行为,能不能交给编译器去完成呢?能否在对象创建时,就将信息设置进去呢?于是就有了构造函数!!
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。
2.2 特性
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
其特征如下:
特性1. 函数名与类名相同。
特性2. 无返回值。
特性3. 对象实例化时编译器自动调用对应的构造函数。(由编译器完成)
特性4. 构造函数可以重载。(即一个类可以有多种构造函数,也就是多种初始化方式)
思考:
1、为什么调用无参构造不加个括号呢??总感觉很奇怪??
答:其实按道理来说加个括号比较合理,但是如果我们加上了一个括号,如上图的Date d3(),就会发现这个和函数的声明会难以区分,从函数的声明来看,会被翻译成声明了一个d3函数,该函数无参,返回一个Date对象,所以为了区分这两种情况,要求无参构造不能加括号。那你可能会问,为什么传参构造就不会当成有参函数的声明了呢??因为有参函数声明的写法应该是Date d2(int x,int y,int z)这个样子的写法,那么你会发现有参构造和他是有区别的。所以这里不需要区分。
2、每次都写一个有参构造和无参构造是不是有点麻烦??有没有改进方法?
答:这个时候我们之前学的缺省参数就派上用场了!!我们可以将有参和无参构造合并成一个全缺省构造函数。一样可以完成这个初始化过程,如下图:
3、既然构造函数支持重载,那么全缺省的构造函数和无参构造函数可以同时存在吗??
答:不行!这两个只能存在一个,虽然你在定义的时候好像没有报错,但是你在调用的时候就存在歧义,因为编译器区分不出来应该去调用哪个!
特性5:如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
如下图,当我们注释掉我们之前写的构造函数,编译器调用了他自动生成的默认构造函数,将实例化对象的成员初始化成了随机值。
如下图,如果我们自己写了一个构造函数,无论有参还是无参,编译器的默认构造函数都不会生成了!!
思考:
1、不实现构造函数的情况下,编译器会生成默认的构造函数。但是看起来默认构造函数又没什么用?d对象调用了编译器生成的默认构造函数,但是d对象_year/_month/_day,依旧是随机值。也就说在这里编译器生成的默认构造函数并没有什么用??
答:C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类
型,如:int/char...,自定义类型就是我们使用class/struct/union等自己定义的类型,看看
下面的程序,就会发现编译器生成默认的构造函数会对自定类型成员_t调用的它的默认成员
函数。
所以我们可以得到两个结论:
默认生成的构造函数
(1)对内置类型不做处理
(2)自定义类型的成员,会去调用他们的默认构造(无参构造、自动生成的构造、全缺省的构造)
2、 既然都可以默认处理自定义类型了,那为什么不顺便把内置类型也处理一下呢?
这其实是设计过程中遗留下来的一个问题,后来在C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给默认值。
特性6:无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。(特性4的思考3中已经分析过了)
注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为
是默认构造函数。
思考:
1、我们怎么去把握什么时候用编译器的构造函数,什么时候用自己写的构造函数呢?
答:无论是自己写的还是编译器提供的,一般的建议是,确保每个类都提供一个默认构造函数,因为有时候如果该类中有自定义类型的成员,我们就可以利用特性(自定义类型的成员,会去调用他们的默认构造),让编译器来帮助我们完成对自定义类型成员的初始化。
2、内置类型的初始化一般怎么处理?
答:由于编译器不会处理,所以有两种思路:1、自己写构造函数,根据该类的特定去初始化其内置类型成员。尽量用缺省,这样可以同时应对无参和有参的情况 2、利用c++11新增加的特性,让内置类型在声明的时候给一个默认值。一般来说这两个可以综合起来运用。
三、析构函数
3.1 概念
通过前面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么没呢的?
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由
编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
3.2 特性
析构函数是特殊的成员函数
其特征如下:
特性1. 析构函数名是在类名前加上字符 ~。
特性2. 无参数无返回值类型。
特性3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构
函数不能重载
特性4. 对象生命周期结束时,C++编译系统系统自动调用析构函数
typedef int DataType; class Stack { public: Stack(size_t capacity = 3) { _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() { if (_array) { free(_array); _array = NULL; _capacity = 0; _size = 0; } } private: DataType* _array; int _capacity; int _size; }; int main() { Stack s; s.Push(1); s.Push(2); }
特性5: 如果类中没有显式定义析构函数,则C++编译器会自动生成一个析构函数,一旦用户显式定义编译器将不再生成。
通过上图我们可以得到结论:
默认构造函数对
(1)内置类型成员不处理
(2)自定义类型成员,调用他的析构函数
思考:
1、对于构造函数,我们会想办法对他的内置类型初始化,那析构函数需要对内置类型需要处理吗??
答:并不需要!对于内置类型来说,销毁时是不需要进行资源清理的,最后系统的内存会将其回收的(因为我们把内置类型成员恢复成0是没有意义的,因为他不管是多少,当内存被系统回收后,最后都是会被覆盖的,不用多此一举)
2、我们之前学过,局部对象在函数调用完成后会随着函数栈帧的销毁而销毁,该过程是由编译器完成的,那究竟什么时候我们需要用到析构函数??
答:析构函数并不是对对象本身进行销毁,因为对象本身作为一个局部变量,在函数结束后会自动被回收的,所以析构函数本质上是对对象的资源进行清理,什么叫做资源呢?可以理解成我们实例化某些对象时需要向内存申请在堆区开辟的空间,所以需要在对象销毁前将该空间还给操作系统,否则就容易造成内存泄露!!
结论:如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如
Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类(Stack类的实例化需要在堆区申请空间)
3、了解了构造函数和析构函数,我们来对比一下和C语言使用起来的区别(如下图)
使用起来不仅简洁,而且不需要担心自己忘记初始化栈或者销毁栈。只要一开始我们把每个类的定义的构造函数和析构函数都考虑清楚,那么其他的就放手交给编译器去做!!
四、拷贝构造函数
在现实生活中,可能存在一个与你一样的自己,我们称其为双胞胎。
然后我们的祖师爷思考:那在创建对象时,可否创建一个与已存在对象一某一样的新对象呢?所以有了 拷贝构造函数
4.1 概念
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存
在的类类型对象创建新对象时由编译器自动调用。
4.2 特性
拷贝构造函数也是特殊的成员函数
其特征如下:
特性1:拷贝构造函数是构造函数的一个重载形式。(可以理解成比较特殊的构造函数)
特性2:拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。
思考:
1、拷贝明明是一个对象拷贝给另一个对象,为什么这边只有一个参数呢?
答:因为成员函数会隐藏一个this指针,在运行的时候编译器会自动帮我们处理,所以我们只需要传那个我们需要拷贝的类类型对象就行
2、为什么传值方式编译器会无限递归?
我们观察上图,通过函数栈帧的了解我们可以知道,每次传值调用的时候本质上形参和实参并不是同一块空间,而是我们在调用的时候开辟了一块形参的空间,然后将实参的数据拷贝过来,再到函数中进行使用。而每次拷贝本质上都是创建一个同类型的对象。然后他为了和实参同步数据也会调用自己的拷贝构造, 因此就跟套娃一样引发无线递归。但如果是传引用,就不存在这个问题了,因为存引用本身就是给实参起一个别名,函数调用的时候操作的是同一块空间,不需要创建新的对象也不需要拷贝。所以不会引发无穷递归!
3、为什么概念里提到,对本类类型对象的引用一般用const修饰?有什么好处吗?
好处1:确保被拷贝的对象不会被修改,比如我们一不小心写反了,这个时候const可以及时帮助我们报错来提示我们
好处2:如果拷贝构造传的是const修饰的变量,如果你的拷贝构造函数没用const修饰,就会造成权限放大
把拷贝构造函数的参数用const修饰就可以避免这种问题
特性3:若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
通过上图我们可以得到结论:
1、内置类型,编译器可以直接拷贝
2、自定义类型的拷贝,需要调用其拷贝构造函数
思考
1: 我们发现,我们将我们原来写的拷贝构造删除掉,编译器生成的拷贝构造也可以完成字节序的值拷贝工作啊,那我们还有必要自己写拷贝构造函数吗?
答:要分具体情况而定,如果是之前的Date日期类,就不需要,但是有些情况下浅拷贝就会造成很严重的后果
// 这里会发现下面的程序会崩溃掉?这里需要深拷贝去解决。 typedef int DataType; class Stack { public: Stack(size_t capacity = 10) { _array = (DataType*)malloc(capacity * sizeof(DataType)); if (nullptr == _array) { perror("malloc申请空间失败"); return; } _size = 0; _capacity = capacity; } void Push(const DataType& data) { // CheckCapacity(); _array[_size] = data; _size++; } ~Stack() { if (_array) { free(_array); _array = nullptr; _capacity = 0; _size = 0; } } private: DataType* _array; size_t _size; size_t _capacity; }; int main() { Stack s1; s1.Push(1); s1.Push(2); s1.Push(3); s1.Push(4); Stack s2(s1); return 0; }
报错原因如下:
2、根据思考1,总结浅拷贝可能造成的问题,然后思考怎么用深拷贝去解决问题。
答:问题1:两个对象操控同一块空间,严重情况下会造成数据丢失!!如上图,s1 push了4个元素后,他的size变成了4,但是s2并不知道size变成了4,他的size还是0,如果s2继续push 5 6 7 8,那么因为操控的是同一块空间,那么就会造成s2 push的数据将s1 push的数据给覆盖了,造成了数据丢失,这个在实际工作中是很严重的问题(假如你在银行先存了100万,我后存了200元,如果由于这个原因导致我的200把你的100万覆盖了。你在系统上看到的就是200元而不是你原来的100万!)问题2:造成空间的多次释放,这个在上图已经解释过了,由于共用一块空间,s2先调用析构函数把空间释放了,但是s1并不知道,他再调用自己的析构函数释放的时候就会造成程序的崩溃!!!
3、怎么用深拷贝解决上述问题??
上述问题的根源是指向了同一块空间,所以我们解决思路就是给拷贝出来的对象也开辟一块相应的空间,让他们能够各自操作各自独立的空间。
typedef int DataType; class Stack { public: Stack(size_t capacity = 10) { _array = (DataType*)malloc(capacity * sizeof(DataType)); if (nullptr == _array) { perror("malloc申请空间失败"); return; } _size = 0; _capacity = capacity; } Stack(const Stack& st) { _array = (DataType*)malloc(st._capacity * sizeof(DataType)); if (nullptr == _array) { perror("malloc申请空间失败"); return; } memcpy(_array, st._array, st._size * sizeof(DataType));//要记得把原来的数据拷贝过去 _size = st._size; _capacity = st._capacity; } void Push(const DataType& data) { // CheckCapacity(); _array[_size] = data; _size++; } ~Stack() { if (_array) { free(_array); _array = nullptr; _capacity = 0; _size = 0; } } private: DataType* _array; size_t _size; size_t _capacity; }; int main() { Stack s1; s1.Push(1); s1.Push(2); s1.Push(3); s1.Push(4); Stack s2(s1); //看看操作s2会不会影响s1 s2.Push(5); s2.Push(6); s2.Push(7); s2.Push(8); return 0; }
如上图,这个深拷贝的关键就是要开辟一个新的空间,然后将原空间的数组拷贝过来!
我们发现s1和s2的array已经不是指向一块空间了
而且对s2操作不会影响到s1,他们去调用自己的析构函数都会去释放自己独立的空间,这就是深拷贝!!
4.3 使用场景
1、使用已存在对象创建新对象
2、函数参数类型为类类型对象
3、函数返回值类型为类类型对象
为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用
尽量使用引用(看看栈帧销毁后他的空间是否还存在,如果存在就引用,不存在就不要引用)
五、运算符重载
以前我们学习操作符的时候都知道操作符只能对内置类型其效果,对有多个成员的结构体成员是起不到效果的,因为编译器无法判断对哪一个类型成员进行操作或者哪一个类型成员进行比较,但是有些时候我们还是避免不了要对结构体进行比较和操作,如果总是通过调用函数来操作自定义类型的话,可读性太差!所以我们的祖师爷发明了运算符重载。
5.1 运算符重载
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其
返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:关键字operator后面接需要重载的运算符符号。
函数原型:返回值类型 operator操作符(参数列表)
注意事项:
1、不能通过连接其他符号来创建新的操作符:比如operator@
2、重载操作符必须有一个类类型参数
3、用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不能改变其含义
4、作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐
藏的this(所以至少有一个类型参数就够了)
5、.*(少用,注意和*区分) ::(访问限定符) sizeof(计算类型大小) ?:(三目运算符) .(类成员访问操作符) 注意以上5个运算符不能重载。
如上图,我们如果用全局的operator,那么我们就不能给他的成员变量用private保护起来,否则会访问不到对应的操作数
因此我们的运算符重载一般在类的里面去定义,这样有两个好处:
1、在类内部定义就可以用private去保护成员变量了,保证了封装性
2、在类里面定义,这样operator就有一个隐藏的this指针,所以只要传一个参数就可以了。
5.2 赋值运算符重载
特性1:参数类型:const T&,传递引用可以提高传参效率
特性2:返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
特性3:检测是否自己给自己赋值(避免额外的开销)
特性4:返回*this :要复合连续赋值的含义
Date& operator=(const Date& d) { if(this != &d) { _year = d._year; _month = d._month; _day = d._day; } return *this; }
思考:
1、为什么要避免自赋值的情况 ?不避免可以吗?
如果是自赋值,如果类里面含有指针指向动态开辟的内存的话,那么自身赋值就可能出错,因为在赋值前需要把原来的空间给释放掉。就不能赋值了。
2、为什么要用引用返回?
为了支持连续赋值!!
特性5:赋值运算符只能重载成类的成员函数不能重载成全局函数
思考:
1、之前我们实现其他运算符,也是可以定义全局函数啊,大不了传两个参数不就行了。为什么这里赋值运算符重载必须是成员函数?
答:赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。
上图的情况就是编译器自动生成的默认赋值重载函数
为了不和该情况冲突,C++强制让=重载必须是成员函数。
特性6:用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。
思考:
1、既然编译器生成的默认赋值运算符重载函数已经可以完成字节序的值拷贝了,还需要自己实现吗?
答:当然像日期类这样的类是没必要,但是一旦涉及到资源管理就必须要自己去实现赋值运算符的重载。
typedef int DataType; class Stack { public: Stack(size_t capacity = 10) { _array = (DataType*)malloc(capacity * sizeof(DataType)); if (nullptr == _array) { perror("malloc申请空间失败"); return; } _size = 0; _capacity = capacity; } void Push(const DataType& data) { // CheckCapacity(); _array[_size] = data; _size++; } ~Stack() { if (_array) { free(_array); _array = nullptr; _capacity = 0; _size = 0; } } private: DataType *_array; size_t _size; size_t _capacity; }; int main() { Stack s1; s1.Push(1); s1.Push(2); s1.Push(3); s1.Push(4); Stack s2; s2 = s1; return 0; }
比如上述代码会崩掉
5.3 前置++和后置++重载
前置++和后置++都是一元运算符,为了让前置++与后置++形成能正确重载。C++规定:后置++重载时多增加一个int类型的参数,但调用函数时该参数不用传递,编译器自动传递
int只是用来占位,没有实际意义
前置++:
Date& operator++() { _day += 1; return *this; }
++d1,相当于调用d1.operator( )
后置++:
Date operator++(int) { Date temp(*this); _day += 1; return temp; }
d1++,相当于调用d1.operator(0)
从这里可以看出,后置++还需要再实例化一个对象用来拷贝原先的数据,并且由于temp是局部变量,出作用域销毁,所以这里不能用传引用返回,效率相比前置++有一定拷贝的损失,所以我们平时要尽量用前置++。
关于== != = += -= = - ……我们后面通过一个日期类来实现
六、const成员函数(修饰*this)
有些时候我们可能会遇到以下情况
由于我们定义的d1是const类型,当取d1的地址传给隐藏的*this时,出现了权限放大!!那我们要样才能让const类型对象调用自己的成员函数???就必须给*this指针也加上const,这样传参就不会出现权限放大的问题。但是C++中的*this指针是隐含的参数,我们没办法直接加,C++为了解决此类问题,规定当我们将const修饰放在成员函数后面的时候,默认就是将该成员函数隐藏的*this进行const修饰
将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数
隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
注意:
1、非const的对象可以调用const或者是非const的成员函数,而const的对象只能调用const的成员函数,其实总的来说就是权限可以变小、可以平移,就是不能放大
2、使用建议:内部不改变成员变量的成员函数最好加上const,这样const对象和普通对象都可以调用
七、取地址及const取地址操作符重载
这两个默认成员函数一般不用重新定义 ,编译器默认会生成。
这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容!
八、用类实现一个数组
我们可以设置一个类,在里面定义一个静态数组,然后重载一个[ ]来让这个类模拟数组
你可能会觉得,这样子是不是多此一举,其实不是的。
1、一般来说,编译器对数组的越界检查并不是非常的明显,如果我们用这个类去模拟数组,功能就可以很丰富,比如说使用assert去检查越界
2、 我可以重载两个[ ],一个用const修饰*this确保类不被改变,另一个不用const修饰,确保类可以改变,这样可以根据不同的场景去调用
九、日期类的实现(时间计算器)
先展示全部代码,然后再细扣
9.1 Date.h
#pragma once #include #include using namespace std; class Date { //友元,告诉该类这两个全局函数是我们的朋友,允许使用私有成员 friend ostream& operator(istream& in, Date& d); public: //全缺省的构造函数 Date(int year = 1900, int month = 1, int day = 1); //用来打印 void Print() const; //比较 bool operator==(const Date& d) const; bool operator!=(const Date& d) const; bool operator=(const Date& d) const; //日期加天数 Date& operator+=(int day); Date operator+(int day) const; //日期减天数 Date& operator-=(int day); Date operator-(int day) const; // ++d1 Date& operator++(); // int参数 仅仅是为了占位,跟前置重载区分 Date operator++(int); // --d1 -> d1.operator--() Date& operator--(); // d1-- -> d1.operator--(1) Date operator--(int); //返回两个日期的相差天数 int operator-(const Date& d) const; //void operator
还没有评论,来说两句吧...