【C++】类与对象之类的默认成员函数(二)
前言:C++的类中,不仅仅有着普通的函数,还有着六大默认成员函数,当然C++11之后又加入了两个所以严格来说是八个默认成员函数,但目前主流还是认为是六个。本文先给大家介绍六个中比较重要的前四个中的三个剩下那个放在下一篇文章中,后面两个不怎么重要的我也会简单的在后续的文章做下简单的介绍。至于C++11后新加入的两个我想留到大后期再去做介绍
1. 类的默认成员函数的概念
在类中,我们可以在里面写函数。但其实类在创建的时候如果我们不写的话,就会默认的生成一些函数。这些函数就是我们的默认成员函数。默认成员函数不如果我们不显式的写的话,编译器也会自动的生成。这个时候你可能会产生疑问,既然编译器会自动的生成的话那我们又何必要去了解它呢?原因有以下几点:
- 在有些情况下,编译器自己生成的默认成员函数是不足以满足我们的要求的所以我们有时候还要自己显式的写一些默认成员函数
- 如果完全的依靠编译器默认生成的话,有时候会造成资源泄漏或者崩溃的情况下面介绍默认成员函数时也会有具体的例子
- 了解并学习如何正确的写默认成员函数我们可以更加方便的完成一些功能比如初始化、封装的工作等等
我们可以根据功能给我们的六大默认成员函数进行一个分类:
下面我会依次讲解比较重要的四个,剩下两个会在后面的文章中做补充
2.构造函数
2.1构造函数的定义与特点
构造函数虽然名字叫做构造函数,但构造函数的任务其实并不是开辟空间创建对象(当前函数的栈帧创建时空间就开好了),而是在类实例化时初始化对象,就像是我们用C语言手动实现栈或者队列时写的初始化函数Init时所做的那样。当类实例化为对象时,构造函数会自动的调用并用来初始化这个对象。
构造函数有下面几条特点与规则:
- 函数名要和类名一样
- 构造函数没有返回值,连void也没有!!!
- 实例化时会默认的调用构造函数
- 构造函数也是可以重载的
- 如果你自己没有显式的定义构造函数的话,编译器会默认的生成一个无参数的隐式默认构造函数。但如果你自己写了的话就不会生成直接用你自己写的那个
- 无参构造函数、全缺省默认构造函数(半缺省函数不能是构造函数)、以及编译器自己生成的构造函数都被称之为默认构造函数,并且这三个有却仅有一个不能同时存在。无参构造函数虽然会和全缺省构造函数构成重载但它们在调用时会存在歧义,这点我在前面的文章中也有过介绍
- 编译器自动生成的构造函数对内置类型(如int、double)成员变量的初始化没有做处理,也就是说成员变量到底怎么初始化的这个是不确定的,不同的编译器都有不同的做法。如果是你自定义的类型(比如其他类的对象)那么就会调用那个自定义类型的默认构造函数,如果那个自定义类型没有有效的构造函数的话就会报错。需要使用初始化列表来解决,初始化列表我会在后面的文章给补充。
光看这些规则与特点会觉得很枯燥或难以理解,所以下面我就以代码最为例子给大家依次说明
2.2 构造函数的例子
当我们想在不调用Init函数的情况下让年月日默认为1的情况下,处了给成员变量赋值为还可以通过构造函数完成,因为构造函数会在实例化对象时自动调用完成初始化工作,但因为编译器默认生成的构造函数不会对成员变量做处理(我的编译器是vs2022,有些会初始化为0但我的是随机值),所以这里我们要自己写一个构造函数。
构造函数的定义:
classDate{public:// 无参构造函数Date()//名字和类名相同并且没有返回值{_year=1;_month=1;_day=1;}//构造函数可重载Date(intyear,intmonth,intday){_year=year;_month=month;_day=day;}//也可以写成下面这种方式,不能和上面的构造函数同时存在//否则存在调用歧义Date(intyear=1,intmonth=1,intday=1){_year=year;_month=month;_day=day;}voidInit(intyear,intmonth,intday){_year=year;_month=month;_day=day;}voidPintf(){std::cout<<_year<<'/'<<_month<<'/'<<_day<<std::endl;}private:int_year;int_month;int_day;};以上面的代码作为例子,在类实例化为对象时会被自动调用构造函数,但有几点需要注意:
int main() { //我们不穿参数时(使用默认值) //Date d1();// 错误写法编译器无法区别是函数调用还是实例化对象 Date d1; // 正确写法 //穿参数 Date d2(2026);//其他两个参数默认为1 d1.Pintf(); d2.Pintf(); return 0; } //打印结果 // 1 / 1 / 1 // 2026 / 1 / 1其实在大多数的情况下,编译器默认生成的是不足以满足我们的要求的,尤其是类里有只定义类型时就很容易出现问题。但是编译器会自动调用这个自定义类型的默认构造函数:
#include <iostream> class Stack { public: Stack(int n = 16) { _a = (int*)malloc(n * sizeof(int)); if (_a == nullptr) { perror("malloc fail!"); return; } top = 0; capacity = n; } private: int* _a; size_t capacity; size_t top; }; class myQueue { public: //编译器会自动的调用Stack的默认构造函数 //完成两个自定义类型的初始化 //就是类里只有其他的自定义类型,也会调用自己的构造函数 myQueue() { std::cout << "myQueue" << std::endl; } private: Stack pushStack; Stack popStack; }; int main() { myQueue q1; return 0; } // 运行结果 // myQueue3.析构函数
3.1析构函数的定义与特点
析构函数的作用和构造函数相反,构造函数是完成初始化的工作,析构函数是完成清理的工作。析构函数不是完成局部变量的销毁工作,当这个函数栈帧结束时里面的变量就自动销毁了。析构函数真正的作用是完成对象中申请的资源的清理释放工作。就类似于我之前实现Stack时的Destroy函数的工作。像是上面的写的Date类就没有资源需要释放,所以编译器自动生成的就够用。下面是析构函数的特点:
- 析构函数名是在类名前加上
~ - 无参数也无返回值(和构造函数相似, void也没有)
- 和构造函数相似,一个类只能有一个析构函数。没有显式的写的话就用编译器自动生成的
- 析构函数的会在对象生命周期结束时自动的调用
- 和构造函数相似,析构函数对内置类型的成员变量不做处理,但对于自定义的成员函数会调用这个自定义类型的析构函数
- 对于自定义类型,不管什么情况下系统都会调用它自己的析构函数
- 如果类里面没有资源申请的话,析构函数也可以不写自动生成的就够用比如Date类。但是如果申请了资源的话就一定要写,否则就会有资源泄漏的问题
- 当一个局部域有多个对象时,C++规定后定义的先析构
下面还是以代码作为例子来讲解
3.2析构函数的例子
我这里就以Stack类作为例子,因为在Stack类中我们在堆区里申请了资源:
#include<iostream>classStack{public:Stack(intn=16){_a=(int*)malloc(n*sizeof(int));if(_a==nullptr){perror("malloc fail!");return;}_top=0;_capacity=n;}//析构函数~Stack(){std::cout<<"~Stack()"<<std::endl;free(_a);_a=nullptr;_top=_capacity=0;}private:int*_a;size_t _capacity;size_t _top;};classmyQueue{public://自己调用Stack的析构函数//也会调用自己的析构函数~myQueue(){std::cout<<"~myQueue()"<<std::endl;}private:Stack pushStack;Stack popStack;};intmain(){myQueue q1;return0;}// 运行结果:// ~myQueue()// ~Stack() 因为我们申请了两个栈所以需要析构两次// ~Stack()有了构造函数和析构函数,我们就可以不用像C语言那里一样,要自己调用Init函数于Destroy函数关键是还很容易给忘记然后出问题
4. 拷贝构造函数
4.1拷贝构造函数的特点与定义
拷贝构造是特殊的构造函数函数,该函数的第一个参数为自身类型的引用且另外的参数都有默认值。因此拷贝构造函数其实是构造函数的重载。
这里先给大家介绍一下什么是浅拷贝/值拷贝(一个字节一个字节的拷贝),什么是深拷贝。在C语言阶段包含指针成员的结构体/联合体进行拷贝时,我们认为就是浅拷贝。因为这样暴力的一个字节一个字节的拷过去会导致指针指向同一块空间,而深拷贝就是我们要直接编写代码解决这个问题。下面我也会有代码来说明这个问题
拷贝构造的特点:
- 拷贝构造的第一个参数必须是该类类型的引用,不能是传值否则编译器会报错,因为语法逻辑上会导致引发无穷递归调用(编译无法通过),可以有除第一个参数外的其他参数但必须给缺省值
- C++规定自定义类型的拷贝必须要调用拷贝构造,所以自定义传值调用和传值返回都会调用拷贝构造,因为先拷贝到临时变量中
- 如果没有显式的写拷贝构造,编译器会自动生成拷贝构造函数。自动生成的拷贝构造函数会对内置类型进行浅拷贝,对于内置类型则会调用它自己的拷贝构造函数
- 像是Date那样全是内置类型的类其实自动生成的拷贝构造函数就够用。但是像是Stack有资源申请的类那么自动生成的拷贝构造就不够我们用需要我们自己写。在大多数情况下如果我们需要写析构函数那么我们大概率也要写拷贝构造
- 传值返回会产生一个临时变量对象调用拷贝函数,但是如果是传引用返回的话返回的只是这个对象的别名就不会拷贝,但是如果这个对象在出局部域时被销毁的话就会出现野引用的问题类似于野指针一样,所以为了放在这个问题一定要保证这个函数域被销毁后这个对象还在(比如用static修饰)这样才能用传引用
我们先来看看为什么写拷贝构造函数必须要传引用,以下面的代码为例子:
classDate{public:Date(intyear=1,intmonth=1,intday=1){_year=year;_month=month;_day=day;}//编译无法通过,因为会引发无穷拷贝递归Date(constDate d){std::cout<<"我是拷贝构造"<<std::endl;_year=d._year;_month=d._month;_day=d._day;}private:int_year;int_month;int_day;};但我们这样构造时:
Date d1;Dated2(d1);当我们想要把d1拷贝到d2时,编译器会自动调用拷贝函数,但又因为我们的拷贝函数是传值调用。所以又要调用拷贝函数,而又因为我们的拷贝构造是传值调用又需要调用拷贝构造。。。。。。。这样就会无线的递归下去,这就是为什么我们写拷贝构造时需要使用传引用的方式
4.2拷贝构造的代码例子
为了帮助大家把拷贝构造搞明白,我这里以上面的Date来举一个例子:
DateFunc1(Date tmp){returntmp;}DateFunc2(Date&tmp){returntmp;}Date&Func3(Date&tmp){returntmp;}intmain(){Dated1(2026,6,6);Date ret1=Func1(d1);printf("====================================\n");Date ret2=Func2(d1);printf("====================================\n");Date ret3=Func3(d1);return0;}我们来运行程序:
实际上Func1理论上会调用三次拷贝构造函数,但是因为现代编译器的优化,在返回时会直接把临时变量直接构建到ret1(ret2同理)的位置,从而消除了第二次拷贝,所以程序运行我们只观察到两次拷贝,Func2也是同理。但是因为Func2使用了传引用,所以并没有像Func1的传值调用一样需要把d1拷贝到tmp中。
至于Func2和Func3,一个是在func2中调用拷贝构造一个是在main函数中调用拷贝构造
把一些细节的问题解决了,我们来看看另外一个问题。就是为什么浅拷贝无法满足我们?我们来看下面的程序:
classStack{public:Stack(intn=16){_a=(int*)malloc(n*sizeof(int));if(_a==nullptr){perror("malloc fail!");return;}_top=0;_capacity=n;}~Stack(){std::cout<<"~Stack()"<<std::endl;free(_a);_a=nullptr;_top=_capacity=0;}voidStPush(intx){if(_top==_capacity){intn=_capacity*2;int*tmp=(int*)malloc(sizeof(int)*n);if(tmp==nullptr){perror("malloc fail");return;}_a=tmp;_capacity=n;}_a[_top++]=x;}private:int*_a;size_t _capacity;size_t _top;};StackFunc(Stack&st){returnst;}intmain(){Stack st1;Stack st2=Func(st1);return0;}当我运行程序时,程序直接崩溃了。但我们可用通过调试来发现问题:
浅拷贝不仅会导致这个问题,因为st1和st2的地址一样。会导致我们操作st1时st2也会跟着变化
为了解决这个问题,我们需要自己写一个拷贝构造函数(深拷贝):
classStack{public:Stack(intn=16){_a=(int*)malloc(n*sizeof(int));if(_a==nullptr){perror("malloc fail!");return;}_top=0;_capacity=n;}Stack(constStack&st){//为拷贝出的对象分配新的内存_a=(int*)malloc(sizeof(int)*st._capacity);if(_a==nullptr){perror("malloc fail");return;}memcpy(_a,st._a,sizeof(int)*st._top);_capacity=st._capacity;_top=st._top;}~Stack(){std::cout<<"~Stack()"<<std::endl;free(_a);_a=nullptr;_top=_capacity=0;}voidStPush(intx){if(_top==_capacity){intn=_capacity*2;int*tmp=(int*)malloc(sizeof(int)*n);if(tmp==nullptr){perror("malloc fail");return;}_a=tmp;_capacity=n;}_a[_top++]=x;}private:int*_a;size_t _capacity;size_t _top;};这个时候再去调试:
这样这两个栈就会指向不同的空间
完, 我本来是打算一下在本文中介绍完四个默认成员函数的,但是剩下那个赋值运算符重载的内容比较多所以我打算单独再用一个文章介绍完后面的几个。
