前言:
让我们先想一想下面的一个问题,对于C++的类来说,一个类里若什么成员都没有,简称为空类,但空类真的就是在类里面什么都没有么?回想一下类的成员包括哪两种?类的成员变量和类的成员函数,我们一般将类的成员变量当作类特有的属性,而类的成员函数更多像是公共区域,是用来调用的,并不存在于类内部,也不计入类的大小,所以由此我引发思考,空类里面有没有可能包含着一些没有显示的函数呢?拿着空类我们又是怎样可以创建实例化一个对象呢?故这便引入了我今天要说的——类的6个默认成员函数。
1.默认成员函数的定义:
即当用户没有显式实现时,编译器会自动生成的成员函数被称为默认成员函数,当用户显式时,编译器就不会自动生成,而会使用用户自己手写的默认成员函数。
这个定义很关键,我们后序还会反复提及,在这里要理解透先。
2.默认成员函数的分类:
让我们想想我们在C语言书写的时候,感到C语言讨厌和麻烦的一点。
我们必须要对数据进行具体的重定义,在整个程序结束后还要对动态开辟的内存进行释放,我们对单独的数据进行拷贝除了开辟空间,还要将具体的数值一个一个单独给过去,这些地方稍有不注意都会忘记,其中最重要的为内存释放,我们很多人经常会忘记内存释放,从而导致内存发生泄漏,堆区的空间没法收回而使堆区爆满。
C++的创造者也同样受困于这些不起眼的小问题,故在C++中,他选择构建了用来自动处理这些功能的函数,故由此我们把默认成员函数的分类如下:
接下来,我们将一个一个来详细介绍这些默认成员函数的具体用法。
注意!!!这6种默认成员函数的最大特点是:当用户没有显式书写这几种函数时,才会由编译器自动生成,而不是一开始就是由编译器自动生成的!!!!
3.具体讲解6个默认成员函数:
1.构造函数:
构造函数的主要作用就是负责初始化。它对于类里的变量在创建的时候就为其赋上一个初始值,这时很有用的,它有效避免的野指针的问题。
1.构造函数的特点:
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。!!!注意,这里要强调,在每个对象的生命周期内只调用一次,而不是多次调用,这个要与之后的拷贝构造函数区分开。一般的构造函数的书写方式为:
class date { private: int _a; int _b; char _c; public: date() { _a=12; _b=13; _c=a; } };
在public内部书写的不带返回值,且名字和类的名字相同的即为构造函数,构造函数可以传参,也可以不传参,倘若传参,则要在类对象初始化的时候在对应的对象名字后面写上(),并在括号内部写上初始化的数值,倘若不传参,则直接写对象名字,括号也不要写,这样会和函数的形式类似,导致编译器识别错误。
例如:
class date { private: int _year int _month; int _day; public: date(int year,int month,int day) { _year=year; _month=month; _day=day; } }; int main() { date d1(2023,10,24); }
上述即为传参的构造函数,对于这类构造函数,我们在创建对象的时候是必须带上括号赋值的,否则定义为既不存在默认构造函数,又没有初始化变量。
class date { private: int _year int _month; int _day; public: date() { _year=32; _month=65; _day=88; } }; int main() { date d1; }
倘若这样写,即我们的初始构造不传参的话,则我们创建对象时正常创建,也不需要写括号。
我们之前学过缺省函数,在这里我们结合全省函数去书写(如果为半缺省,则我们创建对象的时候要给没赋给缺省值的变量赋值),完全可以实现想传参或不传参都不会报错的效果,如下:
class date { private: int _year int _month; int _day; public: date(int year=32,int month=88,int day=54) { _year=year; _month=month; _day=day; } }; int main() { date d1(2023,10,24); date d2; }
这样,不管是d1,还是d2,倘若传参就用我们传参的值,倘若不传参我们就用缺省值。
2.构造函数的特性:
第一个要注意的点:构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任
务并不是开空间创建对象,而是初始化对象。
1. 函数名与类名相同。
2. 无返回值。(甚至不需要写返回void,什么都不写即可)
3. 对象实例化时编译器自动调用对应的构造函数。
4. 构造函数可以重载。****(后面的拷贝构造函数,其实就是构造函数的重载函数)
5. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。这与我最开始强调的那一点一样
我们将第五点展开来讲:
看下面的代码:
class date { private: int _year; int _month; int _day; int* _arr1; int _size; int hbw; public: int _a; date(int year = 1, int month = 10, int day=22)//默认构造 { _year = year; _month = month; _day = day; _size = 4; _arr1 = (int*)malloc(sizeof(int) * 100000); hbw = 32; _a = 1000000; } } class Myqueue { private: date a1; date a2; int _size; public: Myqueue() { cout << 200 << endl; } Myqueue(Myqueue& a1) { cout << 10 << endl; } ~Myqueue() { cout << 3 << endl; } }; int main() { Myqueue m; return 0; }
我们在调试的时候会发现这样一个现象:
我们首先没给Myqueue写显式的构造函数,而是默认的构造函数,在这里我们发现它竟然没有对_size进行初始化,
但它却对date a1的数据初始化了,这又是为什么呢?
这便要引出我们的一个结论:
对于编译器默认生成的构造函数,它对内置类型的成员不做处理,但对于自定义类型的成员会调用它的默认构造函数(不过在VS022中似乎仍然赋了初值,但这是编译器优化的结果,本质上它仍为不处理,给内置类型一个随机的乱码)
C++将数据类型分为内置类型和自定义类型:
内置类型包括int float double int*指针类型(C++中任何类型的指针类型都是内置类型)等…
自定义类型包括结构体(在C++中我们完全可以把类和结构体看成为一种统一叫类),类,数组等…你可以这样理解,凡是需要自己人为去构造的数据类型,都是自定义类型
所以针对上面的代码,我们首先写了date类,然后写了一个Myqueue类,在Myqueue类里面我们有两个date类成员和一个int成员,故我们的默认Myqueue构造的时候会先调用date自定义的构造,从而先完成对date成员的初始化,而int成员则不处理,故我们发现int为乱码,而date成员里面的数据却完成了初始化构造。
不过,如果这样来写,我们的内置成员初始化就很麻烦,故C++11打了一个补丁,即我们在private内部写类成员的时候针对内置类型的成员为其写上一个缺省值,这样我们的内置类型的成员就可以为其初始化一个数值而不是乱码了。
故我们可以这样总结:
1.一般情况下,我们都要自己写构造函数,倘若我们不写,内置类型就会赋给乱码,由编译器自动生成构造函数。
2.倘若成员都是自定义类型,或者声明时给了缺省值,可以考虑让编译器自己生成默认构造函数(其实本质上就是去调用每一个自定义成员里面自己的默认构造函数)
我在上文中反复强调默认构造函数,那对于我们来说,默认构造函数到底指的是什么呢?
何为默认,即人为不加干预的,由编译器自己去创建的构造函数,所以,它的关键点在于我们不传参的即为构造,我们不传参的有三种情况:
1.我们不写由编译器默认生成的那个构造函数,叫做默认构造函数
2.无参数的构造函数也叫默认构造
3.全缺省的构造也叫默认构造
故对于自定义类型的调用,无论函数重载了多少个构造函数,对于编译器默认生成的构造函数而言,它只会调用这个自定义类型的默认构造函数(倘若你初始化的默认构造函数找不到,编译器甚至不会让你通过),而不会去调用其他构造函数,但有时候我们不想让他调用默认的构造函数,不过想要解决这个问题,就需要用到我们之后会学到的初始化链表来解决。
!注意,这三种被称为默认构造的函数不能同时存在,由前面我们关于构造函数的定义可知:构造函数在对象的生命周期内部只会调用一次,所以我们倘若同时写三种默认构造函数,编译器不知道应该进入哪一个进行初始化,故会报错。!!
注意看下面的代码:
class date { private: int _year=323 int _month=166; int _day=100; public: date(int year=32,int month=88,int day=54) { _year=year; _month=month; _day=day; } }; int main() { date q1; }
这里我们对成员变量赋缺省值,同时又在构造函数内部赋缺省值,我们打印的结果为:
首先这样写符合我们默认构造唯一性的规律,给变量赋初始缺省值不算是默认构造,故我们这里的默认构造函数还是我们写的构造函数,它的逻辑是首先_year=323 _month=166 _day=100,然后调用默认构造,再一次把_year _month _day改成32 88 54,故我们的输出为32 88 54.
2.析构函数:
就像我们创建一个顺序表,有初始化的过程就有销毁堆区内存空间的过程吗,析构函数正是为此应运而生。
析构函数的是用来完成对象中的资源清理工作的,!!但注意,它并不是用来完成对对象本身进行销毁的,对对象本身进行销毁是由编译器来完成的,而对象在被销毁之前往往会自动调用析构函数,完成资源清理工作。!!
我们这里强调的资源:其实就是这个对象对应的除去对象本身的栈区空间之外的空间的,即为这个对象的资源,故从目前我的认知来看,西析构函数就是用来释放回收堆区资源的。
!!!必须强调:析构函数要在前面加~号,千万别忘了,我都已经因为这个错好几次了!!!
1.析构函数的特性:
**析构函数是特殊的成员函数,其特征如下:
- 析构函数名是在类名前加上字符 ~。
- 无参数无返回值类型。
- 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载!!
- 对象生命周期结束时,C++编译系统系统自动调用析构函数**
- 默认生成的析构函数,行为和构造函数类似,对于内置类型不做处理,但对于自定义类型的成员回去调用它的析构(注意这里就不是默认析构了,而是我们人为写的析构或者我们不写时编译器默认生成的析构)
注意,不同于构造函数,析构函数是没有默认这一说的,只有显式析构和不显式析构这两种,这意味着析构函数的一些收尾功能是需要我们自己去书写的,比如堆区内存的释放,编译器默认的析构时不会自己释放的,需要我们人为写程序告诉析构函数去释放
例如下面的代码:
class date { private: int _a; int_b; int*_arr1; int _size; public: date(int size=3) { _size=size; _arr1=(int*)malloc(sizeof(int)*_size); if(_arr1==NULL) { perror("malloc failed"); eixt(-1); } _b=32; _a=300; } ~date() { free(_arr1); _arr1=NULL; } }:
~date即为我们的析构函数,由于我们构造函数里面开辟了一段堆区空间,故我们要在析构函数里面释放掉这部分的空间。
故我们得知:析构的价值在于可以自动释放堆区空间,它对其他资源的清理无实际意义,但对于我们自己开辟的空间的回收则很有效,因为有时候我们确实会经常忘记在代码的最后释放内存从而导致内存泄漏。
补充:构造函数和析构函数的调用顺序:
1、类的析构函数调用一般按照构造函数调用的相反顺序进行调用,但是要注意static对象的存在,因为static改变了对象的生存作用域,需要等待程序结束时才会析构释放对象
2、全局对象先于局部对象进行构造
3、局部对象按照出现的顺序进行构造,无论是否为static
4、析构的顺序按照构造的相反顺序析构,只需注意static改变对象的生存作用域之后,会放在局部对象之后进行析构
3.拷贝构造函数:
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。在C++中,我们规定自定义类型传参的时候会自动进行拷贝构造,哪怕是传值返回,由于返回的数据出函数之后就不存在了,故我们常规的数值就需要拷贝一份,对于自定义类型也是同理,这个时候自动调用拷贝构造函数来进行拷贝带回。
1.拷贝构造的特征:
1. 拷贝构造函数是构造函数的一个重载形式。(在构造函数那里提到过)
2. 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。
3. 若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
4. 注意:在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定义类型是调用其拷贝构造函数完成拷贝的。
这一点类似前面构造函数和析构函数的处理方式,都是内置类型和自定义类型分开处理
拷贝构造函数的基本格式为:
类名(类类型& 名字) { //在里面写拷贝的内容 }
我们对第二点进行分析:倘若我们不传引用而是传值会导致什么呢?
情况一:
date(date dd) { _year=d.year; } date d2(d1)
在这里,我们想用d1拷贝一份得到新的对象d2.
由前面的结论可知,自定义类型传值就会调用拷贝构造函数,这意味着首先我们要先对d1进行一次拷贝,然后将其还给d2,还给d2又要进行下一次拷贝,然后下一次拷贝的返回又要再进行一次拷贝…以此类推,这个过程是递归进行无穷无尽的,大致的图片如下:
这样,传值就会导致无限递归,程序会直接爆炸,但倘如我们传引用,date&dd本身就代表着d1,故我们的程序返回时不会自动调用拷贝构造,因为哪怕除了函数d1本身也没被销毁。所以没必要在出函数的时候自动调用拷贝构造返回值。
再看下面的情况:
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; }
注意,在这行程序中,我们运行起来发现程序报错了,其原因就是因为我们的成员变量本身是带指针的,我们动态开辟了一块空间,让其用我们的成员指针去接收,这些都没问题,但我们的s2由于拷贝了s1的原因,其它自己的指针也由于拷贝指向了同一块空间,由我们上面讲到的构造析构的调用顺序,由于拷贝构造本身也是一种构造,故s2先被析构,导致s2指针指向的空间被释放了,但后续的s1也会调用析构函数,从而导致对同一块内存多次释放,这便导致了报错的原因,在这里我们拷贝构造没有显示,是默认的传值拷贝构造,但显然这种情况下传值拷贝是一定会错的,故我们必须要自己写拷贝构造函数。
如图:
**故我们总结:类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请
时,则拷贝构造函数是一定要写的,否则就是浅拷贝。**比如二叉树,顺序表,链表这些实际需要堆区资源的,就要写拷贝构造函数。
4.赋值重载:
1.C++运算符重载:
内置对象可以直接使用各种运算符,这是由于内置类型都是简单类型,是由语言自己定义的编译器会将其自动转换为指令,而自定义类型由于有人为的干涉性,编译器是不支持自动识别的,千变万化的自定义类型对于计算机来说没法做到全部识别。故我们为了处理好自定义类型的运算符问题,我们要为自定义类型特地写函数处理,例如下面的情况:
int charge(date& d1) { if(_year>d1._year) { return 1; } else if(_year==d1._year&&_month>d1._month) { return 1; } else if(_year==d1._year&&_month==d1._month&&_day>d1._day) { return 1; } else { return -1; } } int s=charge(d2,d1);
这便是一个很基础的判断当前的日期是否更大的一个代码,但假如我们创建了很多类,都需要对其比较,我们的函数命名就会极其混乱,故这里我们引入一个关键字operator+对应符号,即可代表我们要使用的自定义符号,之后再调用函数时,直接给两个参数中间加比较符号即可.就像两个内置类型的符号运算那样去写即可。(但这样写的前提是我们一定要写对应的operator关键字的函数)。
比如:上述代码可以这样写
int operator>(date& d1) { if(_year>d1._year) { return 1; } else if(_year==d1._year&&_month>d1._month) { return 1; } else if(_year==d1._year&&_month==d1._month&&_day>d1._day) { return 1; } else { return -1; } } int s=d2>d1;
注意:运算符重载和函数重载没有关系,仔细想一想你会发现运算符重载的某些规律是不符合函数重载的,它只是为了让自定义类型可以直接使用运算符而生的。
我们是否需要重载取决于这个运算符在当前环境下有什么意义,有意义就可以实现,没有意义就没必要实现。
就像我说的,C++的运算符重载本质上就是让我们的自定义类型也可以像内置类型那样直接使用符号进行操作和运算,方便别人理解,这就是运算符重载的很重要的作用
!!补充一点:在类的内部,倘若我们要返回这个类本身,则我们要想到我们在函数内部隐藏的this指针,它是用来指代当前类本身的,同样我们的返回值只需要引用即可,因为本身存在所以不需要传值待会,只需要引用返回即可,这样还可以省下一次自动调用拷贝构造函数的过程,但倘若为函数内部开辟的类,出了函数是会被销毁的,这个时候必须传值返回,否则数据就丢失了,需要进行一次拷贝构造把数据保存下来带回。
总结:
以上便是前4种最为重要的类默认的成员函数,这些成员函数的存在方便了我们的C++在程序书写时要远远优秀于C语言,故希望各位好好学习这几种函数,为C++的学习来一个好的开始。