当前位置: 首页 > web >正文

C++ - 类和对象 #类的默认成员函数 #构造函数 #析构函数 #拷贝构造函数 #运算符重载函数 #赋值运算符重载函数

目录

前言

一、类的默认成员函数

二、构造函数

三、析构函数

四、拷贝构造函数

五、赋值运算符重载函数

1)、运算符重载

2)、赋值运算符重载

总结


前言

路漫漫其修远兮,吾将上下而求索;


一、类的默认成员函数

默认成员函数就是用户没有显式实现、编译器会自动生成的成员函数称为默认成员函数;一个类,我们不写的情况下编译器会默认生成以下6个默认成员函数,需要注意的是这个6个中最重要的是前4个,最后两个取地址重载不重要,我们稍微了解以下即可;其次就是C++11以后还会增加两个默认成员函数,移动构造和移动赋值,这个会在后面的博文中进行讲解。默认成员函数非常重要,也比较复杂,本篇博文会从以下两个方面来讲解:

  • 1、我们不写(显式实现)的时候,编译器默认生成的行为是什么,是否满足我们的要求?
  • 2、当编译器默认生成的函数不满足我们的要求,我们需要自己实现,那么如何实现?

简单来说,当我们不显式实现这个函数的时候编译器会默认帮我们生成,这个函数就是默认成员函数,默认成员函数有6个,在C++11 中还会增加2个;但是编译器帮我们实现的默认成员函数可能并不会符合我们的预期,所以此时就需要我们自己来实现这个默认成员函数;在这个过程中,我们需要理解编译器的默认生成的行为、以及我们如何实现;

可以将构造函数类比以前学习的Init (初始化函数):对该类中的成员变量进行初始化;还可以将析构函数类比成Destroy;

在C语言中可以直接对结构体进行初始化,但是对于C++来说就不行了,在C++中必须专门用一个函数去进行初始化

  • 因为C++中的成员变量一般情况下都是私有的,无法在外部直接进行访问,只有通过函数内部访问;

二、构造函数

构造函数特殊的成员函数,需要注意的是,构造函数虽然名称叫做构造,但是构造函数的主要任务并不是开辟空间创建对象(我们常使用的局部对象是栈帧创建时,空间就开好了),而是在对象实例化时去初始化对象,构造函数的本质是要替代我们以前的Stack和Date 类中写的Init函数的功能,构造函数自动调用的特点就完美地替代了Init;

构造函数的特点:

  • 1、函数名与类名相同
  • 2、无返回值。(C++规定,构造函数不用写返回值)
  • 3、对象实例化时系统会自动调用其对应的构造函数
  • 4、构造函数可以重载
  • 5、如果在这个类中没有显示定义构造函数时,则C++编译器会自动生成以一个无参的默认构造函数,一旦用户显式定义编译器就不再自动生成
  • 6、无参构造函数、全缺省构造函数、我们没有显式实现编译器默认生成构造函数,都叫做默认构造函数;但是这三个函数有且只有一个存在,不能同时存在(核心就是函数重载的函数参数类型、个数要不同,因为都是无参的,就只能存在一个,不然就重定义了);无参构造函数和全缺省构造函数虽然构成函数重载,但是调用的时候会产生歧义(当都没传参的时候,无参构造函数和全缺省构造函数就是一样的,编译器无法识别你究竟想要调用哪一个函数,就产生了歧义);而当我们没有显式实现构造的时候,编译器就会自动生成一个,所以说这三个函数只能有且只有一个存在,不能同时存在;需要注意的时,默认构造函数并不只是我们没有显式实现构造函数编译器默认生成的默认构造函数,实际上无参构造函数、全缺省构造函数也是默认构造函数;本质来说,不用传实参的构造函数就可以称为默认构造函数
  •  7、我们没有显式实现,编译器默认生成的构造函数,对内置类型成员变量的初始化没有要求,也就是说是否初始化我们是不确定的,这种行为却决于编译器;而对于自定义类型成员变量,要求调用这个成员变量的默认构造函数初始化。如果这个成员变量没有默认构造函数,那么就会报错,我们要初始化这个成员变量,需要用初始化列表才能解决(下篇博文讲解初始化列表)

注:C++将类型划分为内置类型(基本类型)和自定义类型。内置类型就是语言提供的原生数据类型,eg. int/char/double/指针等,自定义类型就是我们用class/struct/union等关键字自己定义的类型;

 构造函数与类名相同,如下:

我们可以写多个构造函数,让其构成重载:

运行结果如下:

需要注意的时,无参的构造函数在实例化的时候直接写做:Date d1; 就可以了,不能在其后加上() ,写做:Date d1(); --> 这是错误的写法;

Q1:为什么不能这么写?

  • 因为会产生歧义,与函数声明相冲突

即便是带参的构造函数,在实例化对象时写的应该是数据而非类型,如下:

函数声明的时候,是一定要写参数类型的,参数变量名可写可不写;实例化对象的时候传参传数据就可,并不会与函数带参的声明相冲突;

在实例化对象的时候,会根据对象传参的个数、类型,系统自动调用对应的构造函数;构造函数只能放在公有,不能放在私有,因为我们要去外部调用构造函数;也可以写很多个构造函数让其构成重载;

接下来,我们进一步改造-->缺省参数的使用,如下:

有了全缺省构造,就不用写无参构造;这两个函数构成重载,语法上可以同时存在,但是在不传参调用的时候会出现歧义;这是因为当都没传参的时候,无参构造函数和全缺省构造函数就是一样的,编译器无法识别你究竟想要调用哪一个函数,就产生了歧义;

VS中如果未给成员变量,初始化为随机值,不同编译器的行为不同,在有的编译器中会初始化为0

在此处看,当我们未显式实现默认构造的时候,编译器默认生成的默认构造有什么意义?

  • 1、编译器对内置类型的数据的初始化是未定义的,具体情况取决于编译器;
  • 2、对于自定义类型成员变量,要求调用这个成员变量的默认构造来进行初始化;

注:自定义类型:class、struct、union 定义的类型

当编译器的行为不符合我们的需求的时候,此时需要我们自己实现:

同理,对于栈Stack 的实现,也需要我们自己来实现构造函数,因为栈的三个成员函数也是内置类型的数据,在VS中对于内置类型数据的初始化未定义,不符合我们的预期,所以需要我们自己实现;而当成员变量是自定义类型的数据的时候,若我们未显式实现其构造,编译器会自动调用该自定义类型的数据的默认构造函数,而如果这个自定义类型的数据没有自己的默认构造函数,就会报错;倘若在这个自定义类型数据没有默认构造函数的情况下而我们又想要初始化这个自定义类型成员变量,还可以用初始化列表进行初始化;

需要注意的是,默认构造函数不仅仅是我们未显式实现该类的构造函数而编译器默认生成的构造函数,还包括无参构造函数、全缺省构造函数

这三个构造函数的特点:不用传参便可以调用;

这三个函数不可以同时存在,当我们要自己显式实现默认构造函数的时候,就只能写成无参构造或者全缺省构造,无参构造或者全缺省构造不可以同时存在否则就会产生歧义(此处不再赘述原因),而当我们没有显式实现构造函数的时候,编译器才会默认生成构造函数;所以这三个默认构造函数一定不会同时存在在一个类中;

Q2:什么情况下没有默认构造?

  • 如果显式地实现了带参的构造函数,此时就没有无参、全缺省的构造函数,当然编译器也不会自动生成默认构造;

也就是说如果在类Stack 之中中显式实现带参的构造函数。那么在写MyQueue时(我们没有实现MyQueue的构造函数),MyQueue 中的成员函数有Stack,那么编译器初始化Stack类型的数据的时候回去找这个自定义类型数据的默认构造函数,但是Stack 只有我们实现的带参的构造函数,而没有默认构造函数,此时编译器会报错,代码如下:

typedef int STDataType;
class Stack
{
public://显式实现的带参的构造函数Stack(int n){_a = (STDataType*)malloc(sizeof(STDataType) * n);if (_a == nullptr){perror("malloc fail");return;}_capacity = n;_top = 0;}private:STDataType* _a;size_t _capacity;size_t _top;
};class MyQueue
{
private:Stack _pushst;Stack _popst;
};

运行结果如下:

要是Stack 没有默认构造,那么编译器无法生成类MyQueue的默认构造函数,而就是想初始化MyQueue,此时该怎么办?

需要显式写,借助初始化列表来实现;如下:

此时便可以回答我们写在开头的问题:

Q:我们不写(显式实现)的时候,编译器默认生成的行为是什么,是否满足我们的要求?

  • 1、对于内置类型数据,编译器不作处理(不同编译器的行为不同,没有定义)
  • 2、对于自定义类型数据,编译器会自动调这个类的默认构造函数;

Q:什么情况下默认构造,我们不用自己显式实现,编译器自己默认生成的就够用?

  • 该类中的成员变量为自定义类型数据的时候(注:就现学阶段而言,往后学还有其他的情况,在学习了初始化列表和缺省值的概念之后)

Q:编译器默认生成的函数不满足我们的要求,我们需要自己实现,那么如何自己实现;

  • 掌握构造函数的特点;大部分的构造函数均是需要我们自己实现的;

三、析构函数

析构函数与构造函数的功能相反,析构函数并不是完成对对象本身的销毁,比如在局部创建对象时,该对象是存储在栈帧中的,函数栈帧结束栈帧销毁,这个对象所占用的空间自己就释放了,完全不用我们去管,C++规定对象在销毁时会自动调用析构函数完成对象中资源的清理释放工作。可以将析构函数的功能类比为Stack 中实现的Destroy的功能,而Date 没有Destory ,其实就是没有资源需要释放,所以严格来说Date 是不需要析构函数的;

析构函数的特点

  • 1、析构函数名是在类名前加上字符 ~
  • 2、无参数无返回值(此处跟构造函数的要求一样,这是C++的规定)
  • 3、一个类只能有一个析构函数。倘若没有显式定义析构函数,系统会自动生成默认的析构函数
  • 4、对象生命周期结束的时候,系统会自动调用析构函数
  • 5、跟构造函数类似,我们显式实现析构函数,编译器就会自动生成的析构函数,对于内置类型成员变量不作处理,而对于自定义类型的成员变量会去调用这个自定义类的析构函数
  • 6、我们显式实现了析构函数,对于自定义类型的成员变量也会去调用他自己的析构函数,也就是说自定义类型成员变量无论在什么情况下均会自动调用析构函数
  • 7、如果类中没有申请使用额外的资源的时候,可以不用写析构函数,直接使用编译器默认生成的析构函数就行了,如:Date(Date 中的成员变量均为内置类型的数据,并不涉及资源);如果默认生成的析构函数就可以用,我们可以不用显式实现析构函数,编译器自动默认生成的析构函数就足以完成我们所设想的工作,如:MyQueue(因为MyQueue 中的成员变量为自定义类型的数据,编译器会去自动调用自定义类型成员的析构函数);但是如果涉及资源,就一定要自己显式实现析构函数,否则就会造成资源泄露,例如:Stack (Stack 中存在在堆上开辟的空间);
  • 8、一个局部域的多个对象,C++ 规定后定义的先析构(函数栈帧);

栈帧中的变量又称为自动变量,栈帧中的变量开空间、释放空间均是编译器或指令的角度,系统直接就处理了,无需我们管;

C++ 规定了,对象在销毁的时候会自动调用析构函数;

Q1:日期类的析构函数该如何写?

  • 日期类对象存储在栈帧之中,其所在的函数结束时,该函数栈帧便销毁,此日期类对象也跟着销毁,所以日期类对象无需析构函数,因为它没有资源可以释放;

Q2:哪些类是需要析构函数的?

  • 当该类中的成员变量涉及资源的时候,例如:用malloc 在堆上开辟空间;栈、队列、链表等;即有资源不在栈中的成员变量的对象,我们均需要显式实现析构函数

Stack 的析构函数如下,需要释放在堆上开辟的空间:

构造函数和析构函数的其中一大优势:可以更好地替代之前我们写的Init、Destroy 函数,编译器会自动调用;自动调用的优势很大,可以避免忘记调用Init 、Destroy 而带来的错误;

日期类我们没有实现析构函数,但编译器会自动生成析构函数,也就意味着,对于日期类是有析构函数的,不过由于日期类中的成员变量全为内置类型,编译器自动生成的析构函数不对内置类型的数据做处理;而对于自定义类型的成员变量,当其对象的生命周期结束的时,会调用该自定义成员自己的析构函数,如下MyQueue:

调试:

其中需要注意的是,对于有自定义成员的类无论我们有没有显式实现其析构函数,编译器均会去自动调用该自定义成员的析构函数;从上例就可以显然地看出来,我们并未给给MyQueue 显式实现析构函数,但是它会去调用Stack 的析构函数来释放资源;

小结:日期类,我们要写构造函数,不用写析构函数;栈Stack 我们要写构造函数,也要写析构函数;队列MyQueue 不需要写构造函数,也不需要写析构函数;

Q3:比对C语言中没有类的情况下来解决括号匹配的问题:

C语言:

 

C++:

通过对比,有构造与析构函数与没有构造与析构函数的区别还是很大的;

四、拷贝构造函数

如果一个构造函数第一个参数是自身类型的引用且任何额外的参数都有默认值(一个参数未自身类型的引用,其他参数均是缺省参数),则此构造函数也叫做拷贝构造函数,也就是说拷贝构造函数是一个特殊的构造函数

拷贝构造函数的特点:

  • 1、拷贝构造函数是构造函数的一个重载
  • 2、拷贝构造函数的第一个参数必须是类类型对象的引用,使用传值方式编译器就会直接报错,因为语法逻辑上会引发无穷递归调用拷贝构造函数可以有多个参数,但是第一个参数必须是类类型对象的引用,后面的参数必须有缺省值;
  • 3、C++规定自定义类型对象进行拷行为必须调用拷贝构造函数,所以自定义类型传值传参传值返回均会调用拷贝构造函数完成
  • 4、若未显式定义拷贝构造函数,编译器会自动生成拷贝构造函数。自动生成的拷贝构造函数对于内置类型成员变量会完成值拷贝(也叫做浅拷贝,一个字节一个字节地拷贝),对自定义类型成员变量会去调用他的拷贝构造函数;

若构造是初始化,那么拷贝构造就是拷贝初始化

Date 中的拷贝构造如下:

#include<iostream>using namespace std;class Date
{
public://构造函数Date(int year = 1, int month = 1, int day = 1){_year = year;_month = month;_day = day;}//拷贝构造Date(const Date& d)//const 不是必须的,但是使用引用是必须的{//this 指针指向d2 对象,d 是对象d1 的引用this->_year = d._year;this->_month = d._month;this->_day = d._day;}
private:int _year;int _month;int _day;
};int main()
{Date d1(2025, 5, 1);Date d2(d1);//用d1 初始化d2return 0;
}

拷贝构造函数与构造函数一样,编译器会自动调用,如下调试:

Q1:为什么形参为引用?

传参有三种方式:传值传参、传址传参、传引用传参(传引用传参本质上是传址传参,只不过在使用层面上,引用比地址更为方便使用)

C++规定传值传参会调用拷贝构造函数,如下例:

调用Func1 这个函数前,首先是要进行传值传参,因为Date 是自定义类型,所以会调用拷贝构造函数来实现传参,而调用拷贝构造的过程相当于将实参d1 中的值拷贝放入形参d 之中;

测试一下,我们在拷贝构造函数中写一条打印的语句,如下:

Date 类的实现如下:

class Date
{
public://构造函数Date(int year = 1, int month = 1, int day = 1){_year = year;_month = month;_day = day;}//拷贝构造Date(const Date& d)//const 不是必须的,但是使用引用是必须的{cout << "调用了拷贝构造函数" << endl;//this 指针指向d2 对象,d 是对象d1 的引用this->_year = d._year;this->_month = d._month;this->_day = d._day;}
private:int _year;int _month;int _day;
};

运行结果如下:

图解如下:

调用Func1(d1) ,由于实参d1 为自定义类型对象,所以在调用Func1(d1) 前首先会调用拷贝构造函数将实参d1拷贝初始化给实参d .传值传参完成之后就会继续调用Func1,函数Func1 执行完成之后便会回到调用Func1 的语句上;

传值传参会调用拷贝构造,传值返回也会调用拷贝构造,如下:

Q2:为什么传值返回也会调用拷贝构造函数?

  • 因为C++规定传值返回并不是直接返回此处的变量 d ;如果要返回的对象为局部对象,那么出了作用域之后该对象便会销毁,如果直接返回这个变量本身,作为该函数调用的返回值是有问题的;故而在返回的时候,首先会将要返回的值放在临时对象之中,然后再返回临时对象,即用临时对象作为该函数的返回值;同理,也不能使用引用传参返回局部对象,因为出了该局部对象的作用域,该局部对象的空间就被释放了;

自定义类型的传值传参、传值返回均比内置类型要付出更大的代价(拷贝带来的消耗),因为内置类型均是一些系统自己定义的类型,它再指令级就可以完成拷贝;其对象均比较小,一般都是 1~8 byte;而自定义类型可能是一些很大的对象,其拷贝的问题牵涉很复杂(深、浅拷贝)。

Q3:如果拷贝构造采用传值传参而不使用引用传参会发生什么?

  • 如果编译器不进行强制性的检查,那么便会导致无穷递归

当d1作为实参传值传参传递给形参d的时候,C++规定自定义类型对象传值传参、传值返回会调用拷贝构造函数,那么在实现拷贝构造的时候又调用拷贝构造去完成传值传参,显然一定是会导致无穷递归;当然,有的编译器会进行强制的检查来避免无穷递归的这个问题

无穷递归示意图:

每次要调用拷贝构造函数之前要传值传参,传值传参是一种拷贝又形成了一个新得拷贝,就形成了无穷递归;

在清楚了这个问题的基础上我们自然也就理解了为什么拷贝构造函数必须要使用传引用传参,而非传值传参;

  • 这是因为传值传参(用一个对象去初始化另外一个同类型的对象)本身就是一种拷贝的行为,而由于自定义类型对象的拷贝是需要调用拷贝构造去完成的,所以自定义类型对象传值传参就会导致调用的拷贝构造又需要去调用拷贝构造……于是就形成了无穷递归;而引用传参并不需要才调用拷贝构造来完成,因为引用的特点在于语法层上:引用不开辟空间,引用是取别名;

注:

1、引用的底层是指针,上层是取别名,不要把底层和上层搞混了;

2、即使是在底层上进行分析也完全没有问题的,因为指针属于内置类型,传址传参无需调用拷贝构造来实现

3、拷贝构造函数的第一个参数也可以用指针来实现,但是若用指针实现,那么这个函数就不叫拷贝构造函数了,而是构造函数,如下:

若写成这样,那么这个函数就不叫拷贝构造函数,而叫做构造函数

这样写的缺点:

  • 1、利用指针,传参时需要取地址,麻烦,显然没有传引用传参使用起来更加方便;
  • 2、传值传参与传值返回并不会调用此版本的函数,因为编译器只会制动调用拷贝构造函数,而这个函数只是构造函数;

Q4:为什么拷贝构造函数的第一个参数要加上const ?

  • 加上const ,可以增强程序的健壮性

因为拷贝构造函数仅仅只是为了完成拷贝,并没有其他的目的,为了避免修改原数据、使用错误(例如,将this 指向的对象与赋值对象写反了)就可以加上const 来修饰引用;

还有就是,如果想写一些比较逻辑,但是将判断相等的 == 写作了赋值 = ,如下图:

Date 类的实现代码如下:

class Date
{
public://构造函数Date(int year = 1, int month = 1, int day = 1){_year = year;_month = month;_day = day;}Date(Date& d){cout << "调用了拷贝构造函数" << endl;//此处想判断一下,但是将判断相等的 == 写作了赋值 = if (d._year = _year){//代码}this->_year = d._year;this->_month = d._month;this->_day = d._day;}void Print(){cout << _year << '/' << _month << '/' << _day << endl;}
private:int _year;int _month;int _day;
};

运行结果如下:

在上例中实现的Date 类构造函数的实现,将比较逻辑是否相等 的== 写作了赋值= , 就导致了错误;

d._year = _year ,那么此时就会改变形参d ,而形参又是实参d1 的别名,改变形参也会影响实参;初衷本来是为了让对象d1 初始化d2 ,结果还将d1 中的数据给修改了,显然这是非常离谱的;所以,为了增强程序的健壮性(为避免出错),一般情况下在不改变实参的函数中使用传引用传参,形参会用const 来修饰;

并且形参用const 修饰还有一个好处:实参为const , 非const 均可进行传参(权限只能平移、缩小,而不能放大); 

健壮性强的拷贝构造的实现如下:

当然,上面这个版本显式地用了this 指针也可以省略,如下:

Q5:拷贝构造函数可以有多少个参数?除了引用是否还可以有其他参数?

  • 在拷贝构造函数中是可以有除了引用地其他的参数,但是规定:必须是缺省参数

拷贝构造函数除了第一个参数是对应类的引用之外,其他参数必须是缺省参数,如果存在其他参数并且不是缺省参数,那么这个函数就不再是拷贝构造函数,而是构造函数,如下图:

Q6:当我们没有显式实现构造函数,编译器自动生成的构造函数的行为是什么?

  • 编译器自动生成的拷贝函数只进行浅拷贝

对于不涉及资源的类:

编译器自动生成拷贝构造函数完成了其所要完成的工作;可见,编译器自动生成拷贝构造函数与其自动生成的构造函数是不一样的;可将编译器自动生成的构造、析构的特点看为一组;构造函数与析构函数统一不对内置类型做处理(或者说没有规定构造、析构函数要对内置类型数据进行处理,具体行为取决于编译器的实现,不同的编译器的行为可能不同),而在有自定义类型成员变量的类中的构造、析构会去调用该自定义类型的构造、析构;

编译器自动生成的拷贝构造函数的特点对内置类型的成员完成值拷贝(浅拷贝),对自定义类型的成员变量而言会去调用它自己的拷贝构造函数;

Q:什么是值拷贝(浅拷贝)?

  • 类似于memcpy 的拷贝,一个字节一个字节地拷贝;

而正因为日期类的成员变量均为内置类型,所以自动生成的拷贝构造函数可以完成对应的值拷贝(浅拷贝);这样看来,对于内置类型、自定义类型,编译器自动生成的拷贝构造函数均可以处理,是不是就意味着所有类的拷贝构造函数我们均不用实现?

  • 只能说大部分的拷贝构造函数不用我们自己实现,依然有小部分的情况需要我们来实现;其核心问题是深浅拷贝的问题

eg. C++实现栈,其构造、析构均需要我们自己显式实现,拷贝构造也是如此;

未显式实现拷贝构造函数:

类Stack 的(部分)实现代码如下:

typedef int STDataType;
class Stack
{
public://构造Stack(int n = 4){_a = (STDataType*)malloc(sizeof(STDataType)*n);if (_a == nullptr){perror("malloc fail");return;}_capacity = n;_top = 0;}void Push(STDataType x){if (_top == _capacity){int newcapacity =  2 * _capacity;STDataType* tmp = (STDataType*)realloc(_a, sizeof(STDataType) * newcapacity);if (tmp == nullptr){perror("realloc fail");return;}_a = tmp;_capacity = newcapacity;}//插入数据_a[_top++] = x;}//析构~Stack(){free(_a);_a = nullptr;_top = _capacity = 0;}
private:STDataType* _a;int _capacity;int _top;
};

测试代码:

int main()
{Stack st1;st1.Push(1);st1.Push(2);st1.Push(3);st1.Push(4);Stack st2(st1);return 0;
}

调试结果如下:

运行结果如下:运行异常

我们未实现Stack 的拷贝构造函数,Stack st2(st1); 则调用编译器自动生成的拷贝构造,st2 一个字节一个字节地拷贝st1 中的内容,从调试结果可看出,st1 与 st2 中_a 指向的空间相同

当main 函数结束后,main函数的函数栈帧销毁了,即对象st1 st2 的生命周期结束了,此时编译器会自动调用st2 st1 的析构函数。然而此处st2 拷贝st1 是浅拷贝,那么st2 与 st1 指向了同一块空间后实例化的先析构,所以当栈帧销毁的时候会先调用st2 的析构函数,st2 指向的空间释放了归还给了操作系统,而接下来还会调用st1 的析构函数 ,显然就会再次释放那块已经归还给操作系统的空间 --> 空间重复释放;

存在两个问题:

  • 1、此块内存会被析构两次(free 了两次)
  • 2、一个对象修改数据会影响另外一个

一个在堆上动态开辟的空间不可以free 两次;因为若将堆上动态开辟的空间释放,该空间就会归还给操作系统,而此时这块空间也有可能会被别人申请走,倘若此时又将其释放,就会导致别人那里出现了野指针;而即使未被别人申请走,也无法释放一块不属于当前程序的空间;

修改:凡是有一个成员变量指向“资源”类,要么不让拷贝(以后会学习的防拷贝),要么拷贝就做深拷贝(不仅仅是拷贝成员变量中的数据,如果该成员变量指向了资源,还会对指向的资源进行拷贝),如下图:

Stack 拷贝构造显式实现的代码:

	//拷贝构造 st2(st1)Stack(const Stack& st)//st 是st1 的别名,this 指向st2{//首先是开辟和st1 一样大的空间_a = (STDataType*)malloc(sizeof(STDataType) * st._capacity);if (_a == nullptr){perror("malloc fail");return;}//将st1._a 中的数据拷贝放入st2._a 中memcpy(_a, st._a, sizeof(STDataType) * st._top);//处理剩余两个成员变量_top = st._top;_capacity = st._capacity;}

带上显式实现的拷贝构造,在构造、拷贝构造、析构中添加打印语句,然后再次运行上例中的代码:

对于MyQueue 来说,因为他的两个成员变量均为Stack类型,Stack 中实现了构造、拷贝构造、析构,所以MyQueue 什么都不用实现,编译器会自动调用Stack 中的,运行结果如下:

调试结果:

注:成员变量全为自定义类型的成员,我们无需实现拷贝构造,编译器自动生成的拷贝构造会自动去调用该自定义类中的拷贝构造函数;

在C++ 中设计拷贝构造其中最重要的一点是因为为了解决存在资源类的拷贝问题

C语言中的结构体变量可以在函数中传参(传值传参),也可作为函数的返回,均是进行值拷贝(语言机制完成),C语言中并没有拷贝构造的概念;值拷贝也是C语言中的坑,如果该结构体所占用的内存很大,那么此处拷贝的消耗便会很大,倘若该结构还涉及资源(例如栈),坑就更大了,如下:

C语言中的结构体传参实际上就是值拷贝,同样也存在多次释放一块空间的可能性危险;

解决:专门写一个函数去完成深拷贝,以及各种传参处理……处理起来比较麻烦……

Q7:传引用返回与传值返回

  • 传值返回也会调用拷贝构造;

注:拷贝构造函数有两种写法:

Q:为什么会有这两种写法?

被拷贝的数据来自一个函数的返回值,倘若使用方法一便会特别别扭,反而使用方法二会好一些,如下图:

函数Func 采用的是传值返回,变量st1 的类型为自定义类型,并且此类还涉及了资源,故而此处调用拷贝构造函数为自己实现深拷贝;

需要注意的是,不能使用传引用返回,因为对象st1 是一个局部对象,出了作用域是会被销毁的;如下例,使用传引用返回,得到的就是随机值:

Func的返回值为st1 的别名,而Func的返回值会用来初始化st2(深拷贝) ,而当Func 返回数据的时候就意味着Func函数栈帧销毁了,故而其中的数据为随机值;实则在深拷贝的时候就已经越界了,但是由于编译器是对越界进行抽查,此处未抽查到,故而没有报错

五、赋值运算符重载函数

1)、运算符重载

运算符重载的特点:

  • 1、当运算符被用于类型的对象的时候,C++语言允许我们通过运算符重载的形式指定该运算符新的含义;C++规定自定义类型对象使用运算符的时候,必须转换成调用对应运算符重载函数,若没有对应运算符重载函数,则会编译报错;
  • 2、运算符重载是具有特殊名字的函数,它的名字是由operator 和后面要定义的运算符共同构成,和其他函数一样,运算符重载函数也具有其返回类型和参数列表以及函数体
  • 3、运算符重载函数的参数个数与该运算符的运算对象数量一样多一元运算符有一个参数,二元运算符有两个参数;二元运算符的左侧运算对象传给运算符重载函数的第一个参数,右侧运算对象传给运算符重载函数的第二个参数;
  • 4、如果一个运算符重载函数是成员函数,则它的第一个运算对象默认传给隐式的this 指针,因此运算符重载函数作为成员函数,实际显式的参数比运算符对象少一个
  • 5、运算符重载以后,其优先级结合性对应的内置类型运算符保持一致
  • 6、不能通过链接语法中没有的符号来创建性的操作符,例如:operator@
  • 7、.*    ::    sizeof    ?:     .  注意这五个运算符不可以重载。运算符重载函数至少会有一个参数(this 指针传递),即运算符至少要有一个运算对象,并且不能通过运算符重载改变内置类型对象的含义,例如: int operator+(int x , int y)
  • 8、一个类需要重载哪些运算符,是看哪些运算符重载后有意义,比如Date 类重载operator- 就有意义,但是重载operator+ 就没有意义;因为日期相减可以算出两个日期之间相差的天数,但是两个日期相加就没有什么意义了;
  • 9、重载 ++ 运算符的时候,分为前置 ++ 、后置++ ,运算符重载函数名都是operator++ ,就不太好区分这两个函数,于是C++规定,增加一个int 形参,让后置++ 与 前置++ 在参数个数上区分开来,与前置++ 的运算符重载函数构成函数重载
  • 10、 重载 << 和 >> 时,需要重载为全局函数。因为当运算符重载函数作为成员函数的时候,this指针默认抢占第一个形参的位置,第一个形参的位置就是左侧运算对象(左操作数),即 原本想要的效果是cout<< 对象,结果变为:对象 << cout ,不符合使用习惯和可读性;重载为全局函数把ostream/istream 放到第一个形参位置就可以了,第二个形参位置为自定义类型的对象;

编译器在程序员和CPU之间本质是充当了翻译的功能;内置类型的对象可以直接使用各种操作符这是因为内置类型是语言本身自己定义的,是一些简单的类型,其计算均会直接转换成对应的指令,如下图:

代码以及运行结果:

转汇编:

但是自定义类型的数据的计算,对于CPU来说是非常复杂的,并不是转换成两三句指令便就可以搞定的,它需要很多条指令;如果想要实现+,编译器是不知道如何进行+,并且也不知道这样做究竟是否有意义,故而只有我们自己来实现逻辑, 然后编译器再编译,eg.日期相加没有意义;

相当于,我们要用一系列的逻辑(最好写成一个函数)来实现自定义类型数据的运算符逻辑,这个函数就是运算符重载函数;

Q1:运算符重载函数是如何规定的?

  • operator + 运算符;如果运算符重载函数为成员函数,其this 指针指向第一个参数,如果该运算符为二元运算符,第一个参数为左操作数(this 指向的),第二个参数为右操作数;

注:有些操作符(运算符)是不用区分左、右操作数的,但是有些操作数是需要区分的;

我们先在全局实现:

注:C++中引用能做的事,指针也能做,但是有些地方用引用比用指针方便,并且用引用的代码可读性更高;

修改:

当然,我们也可以显式调用运算符重载函数。如下:

更推荐让编译器自己去调用,代码的可读性更高;无论是编译器自己调用还是我们显式调用,本质上都是调用的同一个函数;

在全局实现的 == 重载函数:

报错的原因:因为这些变量均是Date 中私有的成员变量,在类外部无法访问;

核心:受访问限定符的限制;

注:私有的private ,在类外不可使用该类的对象直接访问私有中的成员变量、成员函数,但是在类中对象(并非专指调用的对象(this 指向的)),只要任何该类的对象在类中均可访问其成员变量;

解决:

  • 方法一:利用友元函数(下一篇博文中会讲解)
  • 方法二:间接访问(java 中喜欢用的方式)
  • 方法三:将该函数重载为类Date 的成员函数(公有)

至于方法二,就是在public 中实现能获取的私有成员变量的成员函数,调用该成员函数相当于就间接拿到了私有的成员变量;即换种形式将数据弄出来,如下:

方法三:

其中需要注意的是,在运算符重载的时候,有多少个操作数就有多少个参数,其中this 指针默认为第一个参数,如果有两个操作数的情况,this 指针指向左操作数

Q2:关于不可进行运算符重载的五个运算符

  • .*    ::    sizeof    ?:     . 

.*  点星操作符,C++新增的运算符;

Q: 如何获取一个成员函数的指针?

注:函数指针:存放函数地址的指针;函数名为函数的地址,函数指针需要说清楚这个函数的参数类型、参数名可以省略以及该函数的返回类型

函数指针与数组指针的特点:定义的变量名以及typedef 的类型名称是嵌套在其中的

当Func 为一个全局函数的时候:

函数Func 的函数指针pf:void(*pf) ()   -->  *与 pf 结合代表了pf 为指针,void 为该函数返回类型,() 之中放这个函数的参数,此时为无参;

那个函数Func 的函数指针的类型为: void (*) () 

typedef  为PF一下: typedef void(*PF)() ;

如若Func为一个成员函数:

由于成员函数有一个隐藏的this 指针,语法规定其函数前面要增加一个域作用限定符;

成员函数Func 的函数指针pfvoid (A :: *pf) ()

成员函数Func 的函数指针类型 void (A :: *)()

typedef  为PF一下: typedef void(A :: * PF)();

Q2.1:如何获得成员函数的函数指针?

  • 若直接将成员函数名Func作为其函数指针,编译器在编译的时候首先就会在全局进行查找,并不会到类域中进行查找;如果想要让编译器到类域中去找,此时需要利用域作用限定符指定一下;C++规定,普通的函数名就代表函数指针、成员函数不能直接用函数名代表成员函数指针,要在成员函数前面加 &

Q2.2:利用函数指针调用函数,成员函数和普通函数一样吗?

为什么呢?

  • 成员函数跟全局的函数最大的不同:成员函数的参数列表中有一个隐藏的this 指针;而this 指针不可以显式传参,此时便会借助于操作符:点星操作符 .* 

正确使用成员函数指针如下:

   Q3:函数重载与运算符重载没有关联,不要搞混了

  • 函数重载:函数名相同但是函数参数个数、类型不同的函数构成函数重载;
  • 运算符重载:重载该运算符以后可以让自定义类型的数据也使用运算符;相当于写了一个函数去定义一个自定义类型用该运算符的动作行为;

两个函数名相同的运算符重载函数参数个数、类型不同,又可以构成函数重载;

注意区分这两个概念;eg.重载减法的时候,在日期类中,可以是日期-日期,也可以是日期-天数,如下:

注:运算符重载重新定义了此运算符的行为,但这两个函数又构成函数重载;

自定义类型的数据通过编译器暗中调用运算符重载达到使用运算符的好处:可读性强

运算符重载的优势:它是一种标准的、统一的,任何人均认识并且提高代码可读性的一种方式

2)、赋值运算符重载

赋值运算符重载是一个默认成员函数,用于完成两个已经存在的对象的直接的拷贝赋值,这里需要注意与拷贝构造进行区分,拷贝构造函数时用用于一个对象拷贝初始化构造另外一个对象

赋值运算符重载函数的特点:

  • 1、赋值运算符重载函数是一个运算符重载函数,规定必须重载为成员函数。赋值运算符重载的参数建议写成const 当前类类型的引用,否则直接使用传值传参会有拷贝而产生消耗;
  • 2、有返回值,建议写成当前类类型引用,引用返回可以提高效率,赋值运算符重载函数有返回值的目的是为了支持连续赋值的场景;
  • 3、当我们没有显式实现赋值运算符重载函数的时候,编译器会自动生成一个默认赋值运算符重载函数,默认的赋值运算符重载函数的行为跟默认拷贝构造函数类似对内置类型的成员变量完成值拷贝(浅拷贝,一个字节一个字节地拷贝),对于自定义类型的成员变量会去调用他的赋值重载函数;
  • 4、像Date 这样的类成员变量全是内置类型的成员变量且没有指向什么资源,编译器自动生成的赋值运算符重载函数就可以完成需要的拷贝工作,所以不需要我们显式实现赋值运算符重载函数;而像Stack 这样的类,虽然也都是内置类型,但是_a 指向了资源,编译器自动生成的赋值运算符重载函数完成的值拷贝(浅拷贝,一个字节一个字节的拷贝)不符合我们的预期,所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)。像MyQueue 这样的类,其成员变量均是自定义类型Stack , 编译器自动生成的赋值运算符重载函数会去调用Stack中的赋值运算符重载函数,也不需要我们显式实现MyQueue 的赋值运算符重载。这里还有一个小技巧,如果一个类的显式实现了析构并且释放了资源,那么也就意味着这个类需要显式实现赋值运算符重载函数,否则就不需要;

        注:注意区分拷贝构造与赋值运算符重载

Q1:如何实现赋值运算符重载函数?

  • 首先是传参问题;其参数可以使用传值传参,但是更推荐使用传引用传参(不想改变实参加上const 修饰形参就可以了),减少了拷贝次数提高效率;但是拷贝构造的参数必须使用传引用传参,因为倘若使用传值传参会陷入无穷递归;

实现出来:

测试:

实际上,我们还有连续赋值,再测试一下:

这里报错了,为什么呢?

首先,我们先来回忆一下连续赋值,连续赋值的结合性从右往左,例如:

在这个例子中连续赋值体现为,10赋值给k ,而赋值表达式是有返回值的,赋值表达式 k=10 的返回值为该赋值运算符的左操作数,即返回k , 而k又会被赋值给 j……

所有的重载运算符函数均要给返回值才会支持该表达式具有赋值的属性;

同理,若想要赋值运算符重载函数支持连续赋值,那么赋值运算符重载函数就需要有返回值,实现如下:

没有使用传值返回,而是采用传引用返回的原因:

  • 传值返回会调用拷贝构造,即返回的不是*this本身,而是返回 *this 的拷贝(临时对象);如果该自定义类型的对象很大,那么传值返回拷贝的消耗就比较大,而此处 this 指针指向左操作数,即出了此赋值运算符重载函数,*this 也不会销毁,完全可以使用传引用返回

还需要注意的是,虽然说引用的底层就是指针,但是此处不可以使用传址返回,如下:

d2 = d3 这个表达式的返回值要为Date 对象才能支持连续赋值,而现在返回值为地址,所以报错;可见,引用与指针的功能虽然相同,但是引用有着自己的不可替代性

实际上,有时候也会出现自己给自己赋值的情况,我们可以再优化一下代码,当自己给自己赋值的时候,直接返回便可,参考代码如:

	//返回左操作数 , *this = d Date& operator=(const Date& d){if (this != &d){_year = d._year;_month = d._month;_day = d._day;}return *this;}

赋值运算符重载函数和拷贝构造函数一样,绝大多数的情况下(内置类型并且不涉及资源、自定义类型的成员变量)是不需要我们显式实现赋值运算符重载的,编译器自动生成的就够用; 

当我们不显式实现赋值运算符重载,测试一下:

日期类这种其成员变的类型均为内置类型并且不涉及资源的编译器自动生成的赋值运算符重载函数就已经够用了,同样的,拷贝构造、析构也不用我们显式实现;

  • 小技巧:只要不需要我们显式实现析构函数,那么拷贝构造函数、赋值运算符重载函数也无需显式实现
  • 核心:没有资源的释放那么也没有深拷贝的必要

而像Stack 这种涉及了资源的类,就需要我们自己写构造函数(基本上均需要写)、拷贝构造函数、析构函数、赋值运算符重载函数;

我们未显式实现Stack 中的赋值运算符重载函数:

程序崩溃了,这是因为浅拷贝的原因;在上例中,st1 = st2 ; 进行的是浅拷贝,将st2 中给的数据一个字节一个字节地拷贝到st1 中,那么此时st1 与 st2 就指向同一块空间,就会导致同一块空间多次释放的问题

调试:

需要我们显式实现赋值运算符重载函数;

如何实现?

  • 直接进行值拷贝(将st2 中的数据拷贝放入st1 之中),然后其他内置类型的数据进行赋值处理;这是不行的,能这样操作的前提是:st1 与 st2 的空间一样大;不然倘若st2 的空间大于st1 中的空间,那么st2 中的数据放入st1 之中就有可能放不下;而若st2 的空间比st1 的空间小,那么将st2 中的数据放入st1 之中有可能会浪费空间;

我们可以灵活处理,比较这两个空间是否一样大,能否直接拷贝、空间浪费地多不多……此处采用更加出粗暴的方式:先将st1 中的空间释放,然后根据st2 空间的大小给st1 开辟空间,再将st2 中的数据拷贝放入st1新开辟的空间之中,过程如下图:

代码如下:

调试一下:

注:对于需要深拷贝的类,其拷贝构造函数和赋值运算符重载函数的代价都很大,不到万不得已的场景,深拷贝的类不去拷贝;

当然,同样还存在自己给自己赋值的情况;

在深拷贝下,自己给自己赋值是存在bug 的,因为我们此处实现的逻辑是先释放空间、开辟新空间、拷贝数据;此时如果自己给自己赋值,就会给自己的空间释放掉并将其中的内容设置为随机值(free 会将该空间归还给操作系统,并将该空间的数据设置为随机值),执行拷贝的逻辑的时候会出现越界访问

特殊处理一下自己给自己赋值的情况,Stack 的赋值赋值运算符重载函数的参考代码如下:

	//赋值运算符重载// st1 = st2 --> *this = stStack& operator=(const Stack& st){if (this != &st){//释放旧空间free(_a);//开辟新空间_a = (STDataType*)malloc(sizeof(STDataType) * st._capacity);if (_a == nullptr){perror("malloc fail");return *this;}//拷贝数据memcpy(_a, st._a, sizeof(STDataType) * st._top);_capacity = st._capacity;_top = st._top;}return *this;}

赋值运算符重载函数的默认特性可以结合拷贝构造来学习;最后,注意区分拷贝构造函数与赋值运算符重载函数!

  • 拷贝构造函数:用于一个对象拷贝初始化构造另一个同类型的对象;
  • 赋值运算符重载函数:用于完成两个已经存在的对象进行直接的赋值

总结

1、当我们不显式实现这个函数的时候编译器会默认帮我们生成,这个函数就是默认成员函数,默认成员函数有6个,在C++11 中还会增加2个;但是编译器帮我们实现的默认成员函数可能并不会符合我们的预期,所以此时就需要我们自己来实现这个默认成员函数;在这个过程中,我们需要理解编译器的默认生成的行为、以及我们如何实现;

2、构造函数特殊的成员函数在对象实例化时去初始化对象;

3、析构函数并不是完成对对象本身的销毁对象在销毁时会自动调用析构函数完成对象中资源的清理释放工作

4、如果一个构造函数的第一个参数是自身类型的引用,且任何额外的参数都有默认值(一个参数未自身类型的引用,其他参数均是缺省参数),则此构造函数也叫做拷贝构造函数,也就是说拷贝构造函数是一个特殊的构造函数

5、自定义类型对象使用运算符的时候,必须转换成调用对应运算符重载函数,若没有对应运算符重载函数,则会编译报错;

6、赋值运算符重载是一个默认成员函数,用于完成两个已经存在的对象的直接的拷贝赋值,这里需要注意与拷贝构造进行区分,拷贝构造函数时用用于一个对象拷贝初始化构造另外一个对象

http://www.xdnf.cn/news/3889.html

相关文章:

  • AI 入门:关键概念
  • 高等数学同步测试卷 同济7版 试卷部分 上 做题记录 第四章 不定积分同步测试卷 B卷
  • n8n 快速入门1:构建一个简单的工作流
  • 强化学习机器人模拟器——GridWorld:一个用于强化学习的 Python 环境
  • unorder_map/set的底层实现---C++
  • ESP32S3 多固件烧录方法、合并多个固件为单一固件方法
  • LangChain4J-XiaozhiAI 项目分析报告
  • 线程间通信--线程间顺序控制
  • C++类_局部类
  • 安装与配置Go语言开发环境 -《Go语言实战指南》
  • C#与西门子PLC通信:S7NetPlus和HslCommunication使用指南
  • JavaWeb:SpringBootWeb快速入门
  • 五、shell脚本--函数与脚本结构:搭积木,让脚本更有条理
  • JavaScript 中的 Proxy 与 Reflect 教程
  • 比特、字节与布尔逻辑:计算机数据存储与逻辑运算的底层基石
  • PMP-第四章 项目整合管理(一)
  • 享元模式(Flyweight Pattern)
  • MOS管极间电容参数学习
  • spring中的@ComponentScan注解详解
  • stm32week14
  • 主机电路安全防护系统哪个厂家做
  • 招聘绩效效果评估方案与优化路径
  • 35、C# 中的反射(Reflection)
  • 深入理解 Spring MVC:DispatcherServlet 与视图解析机制​
  • 快速弄懂POM设计模式
  • 1991年-2023年 上市公司-重污染企业数据 -社科数据
  • GitHub 趋势日报 (2025年05月03日)
  • 多模态大语言模型arxiv论文略读(五十九)
  • STM32教程:ADC原理及程序(基于STM32F103C8T6最小系统板标准库开发)*详细教程*
  • 数电填空题整理(适用期末考试)