我们通过this指针可以看出来,C++其实隐藏了非常多的东西,很多事情它会在编译的时候包揽,那么作为最为重要的类和对象,它是不是还隐含了更多我们平常看不到的东西呢?
我们创建一个空类里面啥也不放。
class Text{};
看上去啥也没有,但其实里面是有默认的成员函数的,不光有,还整整有6个
目录
默认成员函数:
1.构造函数:
默认生成的构造函数特性
默认构造函数:
2.析构函数:
析构函数的应用方法:
3.拷贝构造函数:
4.赋值运算符重载
运算符重载:
赋值操作符的重载:
自己实现的赋值重载:
编译器默认生成的赋值重载:
流插入和流提取运算符重载
浅谈友元:
const成员
5和6.取地址及const取地址操作符重载
默认成员函数:
1.构造函数:
功能是初始化,虽然它的名字是构造,但是它只是在创建完对象之后直接初始化。它是一个很怪的函数,很特殊。构造函数的命名应与当前类名相同。
首先,构造函数在我们没有去写的时候,编译器会自己为其直接编写一个默认的构造函数。
构造函数可以被重载,这也意味着可以提供多个构造函数与多种初始化方案
简单总结一下:
构造函数特性:
1. 函数名与类名相同。
2. 无返回值。
3. 对象实例化时编译器自动调用对应的构造函数。
4. 构造函数可以重载。
以日期类为例,我们结合缺省值,可以重载构造函数。
class Date
{
public:
Date(int year=2022, int month=12, int day=29)
{
_year = year ;
_month= month ;
_day = day ;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
默认生成的构造函数特性
如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦
用户显式定义编译器将不再生成
那么编译器默认生成的构造函数又是怎么样的?
我们注释掉重载完的构造函数,直接打印看看
发现是随机值。
那问题就来了,如果编译器自己会生成一个默认的构造函数,而这个构造函数也只能给个随机值,那不是根本没啥用?但是其实构造函数非常的”双标“,至于是什么样的如下所示。
双标的构造函数:
构造函数对内置类型不处理。它只会对非内置类型进行初始化,而非内置类型的类型是除去语言自带的类型如int char这类的,所以当创建的对象为自定义类型如当前的Date或者是结构体这类的,会对自定类型成员调用的它的默认成员函数,反之,构造函数完全不会理会非内置类型,非内置类型内部存放的就都是随机值。就比如我们日期类内部的成员变量,都是随机值。
当我们以自定类型调用默认构造函数时,效果如下:
class Text
{
public:
Text()
{
cout << "这个自定义类型的构造函数已被调用" << endl;
}
private:
int text;
};
class Date
{
public:
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
Text tx;
};
我们可以看到,创建以Date这个类创建对象的时候,直接初始化了里面的Tx ,也就是直接创建了这个类型的对象,创建的依据就是Class A内部的构造函数被触发了。
C11对于这个特性所增加的补丁
显然这种双标带来的蛋疼感也不是一点点,所以在C11的版本中上了个补丁,允许我们在对内置类型进行声明的时候给予一个缺省值,这里看着其实很像初始化,但其实他的运算逻辑是缺省值的逻辑,也就是没有传递参数的时候默认给的值
虽然不算非常方便,不过也算得上补救的一种,至少不会往里头放随机值了
默认构造函数:
我们会有一个先入为主的概念误区,那就是我们觉得只有我们不写的时候编译器自己给的叫默认构造函数,但其实真正的默认构造函数的概念是无参的构造函数和全缺省的构造函数,也就是如下两种函数也算默认构造函数 。
总结为:不传参数的就是默认构造函数
默认构造函数有且只有一个,当我们尝试绕开这项规则的时候,就会报错。
2.析构函数:
构造函数是创建对象,那么对象的销毁则是析构函数
class Date
{
public
//构造函数
Date()
{}
//析构函数
~Date()
{}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由
编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
析构函数的特性:
1. 析构函数名是在类名前加上字符 ~
2. 无参数无返回值类型。
3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载
4. 对象生命周期结束时,C++编译系统系统自动调用析构函数。
析构函数面对自定类型,会调用其析构函数。
析构函数的销毁顺序:后定义的先销毁,符合栈的性质
举例:
答案:BADC
解析:析构函数的销毁顺序是后来的先销毁,那么我们先观察本题的创建顺序:C A B D
但是各类修饰符会影响变量的声明周期,以及变量的创建范围,C是全局变量,是程序最开始所创建的变量,那么它会被最后销毁。而D则是Static改变了局部变量的生存作用域,所以静态的变量会在局部变量析构之后析构故答案为BADC。
析构函数的应用方法:
析构函数会对自定义类型调用其构造函数,而构造函数本身是清理和释放空间,那么我们使用的时候只需要遵循以下的方法即可
如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类
3.拷贝构造函数:
当我们打算以d1的形式再次创建一个新对象的时候,这个时候我们会使用拷贝构造函数。
所以在C++中,当出现了这样的需求的时候,就会使用到拷贝构造函数。
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存
在的类类型对象创建新对象时由编译器自动调用。
1.拷贝构造函数也是特殊的成员函数,它是构造函数的一种重载形式
2.拷贝构造函数的参数有且只能有一个,并且它的参数类型为该类型的对象的引用,使用传值调用会直接报错,因为会发生无限递归。
// Date(const Date& d) // 正确写法
Date(const Date& d) // 错误写法:编译报错,会引发无穷递归
{
_year = d._year;
_month = d._month;
_day = d._day;
}
为什么会发生无限递归呢?传值调用究竟做错了什么?
我们先来看什么时候拷贝构造函数会被触发:
Date d1(2022,9,26)
Date d2(d1);
假若我们的拷贝构造函数是如下的错误写法,会发生什么呢?
Date(const Date d)
首先是最基本的传值调用的特质,也就是形参的产生。我们在这里再次复习一次形参是实参的拷贝。这个时候,传值调用所产生的拷贝相当于再次触发了一次拷贝构造函数的条件,也就是相同类所创建的对象中,新建一个同类对象。
总结:拷贝构造函数在传值调用时会因为形参的拷贝而不断再次触发拷贝构造函数,正确使用时以使用其同类型的引用。
显视声明拷贝构造函数的时候,参数应加上const,以防止写反了的错误发生,由于引用是变量的别名,若是发生则会更改到被拷贝的原对象,const则可以有效的解决这个问题。
那么拷贝构造函数究竟是如何拷贝的呢?
同样的,它既然是构造函数的重载,那么它的本质也是双标的。当我们使用编译器默认生成的拷贝构造函数时它的拷贝情况如下:
对于内置变量来说,它会一个一个字节的将原对象内部的内置变量拷贝至目标变量,但这个拷贝本质上是一种浅拷贝。
浅拷贝:
浅拷贝更像是一种获取然后覆盖,很像memcpy,我们回顾一下memcpy的基本实现:memcopy为了实现适用于内存中的覆盖以及拷贝,使用了char*来一个个拷贝,这样子可有有效保证拷贝的正确性,却也会在面对指针的时候产生问题。
当我们希望借助拷贝构造函数来实现两个对象之间的复制时,如果这个类型的对象内部是有指针的话,拷贝构造函数的浅拷贝原则只会把原指针复制并给到新指针里面,这样子的话跟我们希望额外开辟空间的基本需求不同,且会在析构函数调用的时候崩溃,因为编译器对同一块空间进行了两次析构。
而在这里,为了避免浅拷贝所带来的一系列问题,我们需要使用深拷贝,也就是我们自己写拷贝构造函数,让其开辟空间。
而当构造拷贝函数在应对自定义类型时,会直接调用其自身的拷贝构造函数。
拷贝构造函数典型调用场景:
1.使用已存在对象创建新对象
2.函数参数类型为类类型对象
3.函数返回值类型为类类型对象
4.赋值运算符重载
赋值运算符重载同前文的成员函数相同,会在没有显式调用的时候自己生成一个默认的,但是在了解之前我们先整理一下运算符重载的概念
运算符重载:
加减乘除运算符都只能方便的计算变量,在C++中,为了带上自定义变量类型一块玩,设定了运算符重载的功能,也就是为了能让自定义对象能用运算符。
这项功能可以实现自定义类型的一些运算效果,如比较大小,加减乘除,或者就拿我们的Date类型来说,实现两个Date之间的天数之差。
函数名字为:关键字operator后面接需要重载的运算符符号。
函数原型:返回值类型 operator操作符(参数列表)
重载过程
比如一个简单的检查两个类是否相等的操作符,我们重载来玩玩:
与编写函数不同,我们写形参的时候不应该写成传值调用,不然又会去调用拷贝构造函数直接白忙活了,所以我们还是需要使用引用,和拷贝构造函数的理由相同,我们应该尽量防止犯错的问题发生,加一个const上去。
那么,根据我们求相等的逻辑,写出来应该不算难
但是这里报错了,我们反应过来,我们在尝试访问私有变量。
歇逼了,那咋办呢?
我们暂时先忽略这个问题,先看一下假如我们的重载运算符生效了的打印格式
这样会报一堆错,因为流插入运算符的运算优先级要比==高。
加上括号可以有效规避因为流的运算顺序造成的报错问题。
回到刚才的问题,我们怎么获得私有的成员变量呢?
在这里可以使用友元,但是之后再说,我们可以开墙角用函数把他们拿出来,也就是直接在类内部创建成员函数,返回的是私有成员变量的值。
但是这样比较呆,其实更好的方法是将我们的重定向函数直接放进类里面。
但问题又出现了,这样子依然编译失败,为什么?
这怎么就参数过多了?我使用的操作符需要两个操作数不是很合理吗?这里的问题其实是this指针的存在所引起的。成员函数会默认有This做参数,所以在这里其实有了三个参数,那么很简单我们直接删掉一个就好了。
在这里,编译器还是非常智能的,当编译器检测到我们使用这个操作符与全局变量时,会直接使用这种形式:
赋值操作符的重载:
自己实现的赋值重载:
前面的铺垫完了,那么正式进入我们的正题,赋值赋值操作符的重载。
同拷贝构造函数不同,两个已经存在的对象之间的拷贝才是赋值重载,
int main ()
{
Date d1(2022,12,31);
Date d2(2023,1,1);
d1 = d2;
}
题外话:
那么来了个刁钻的问题,这个是拷贝构造还是赋值重载呢?
Date d3 = d2;
但其实也没啥刁钻的,这个依旧是拷贝构造,d4连实体都还没有呢,怎么称之为赋值呢?
那我们尝试实现一下赋值的重载
//赋值操作符重载,为了防止触发拷贝构造,使用传引用
void operator = (const Date& d1)
{
_year = d1._year;
_month = d1._month;
_day = d1._day;
}
这样子写本身没有任何问题,但是它没有实现链式访问,一个正常的赋值操作符应该实现链式赋值的功能,也就是i = j = 10这种
那我们就写个带返回值版本的
//实现链式访问版本
Date operator = (const Date& d1)
{
_year = d1._year;
_month = d1._month;
_day = d1._day;
}
但这个还是不够好,为啥?
因为发生了一些多余的拷贝,我们应该合理的应用好引用,将返回值也设置为引用。这样子可以减少拷贝的发生,注意,在这个过程中,被销毁的是this指针,而非*this,*this所指向的d1依然没有被下销毁。
//优化版本
Date& operator = (const Date& d1)
{
_year = d1._year;
_month = d1._month;
_day = d1._day;
}
编译器默认生成的赋值重载:
既然是赋值重载是默认成员函数中的一员,那么当我们不写它的时候会编译器默认生成的效果是怎么样的?
编译器默认生成的赋值重载函数的泛用度和拷贝构造函数相同,用于赋值日期类这类不需要额外写析构函数的就足以应付了,但是相同的是,它的原理与拷贝构造函数相同,发生的是浅拷贝。如果我i们用默认的赋值重载函数对栈类型进行赋值操作,也会发生析构两次同一空间的问题发生,所以面对这类类型我们需要自己写赋值重载函数。
但是我们发现,其实编译器自己生成的已经实现了字节序拷贝,我们还需要花那么大功夫自己重载一个吗?
我们以栈的赋值重载为例,我们使用编译器默认生成的赋值重载函数
为什么崩溃了?
原因:
所以解决方法是:直接free掉被赋值的空间,重新开一个和源相同大小的空间,用memcpy把数据拷贝过去,之后返回一个this指针就好了。
以Stack插入扩容为例,需要规避掉刁钻角度的情况发生,也就是自己给自己赋值这种操作,所以我们需要检查一下
Stack& operator =(const Stack& st1)
{
if (this != &st1)
{
free(_a);
int* tmp = (int*)malloc(st1._capacity * sizeof(int));
if (tmp == nullptr)
{
perror("malloc fail!");
exit(-1);
}
_a = tmp;
memcpy(_a, st1._a, st1._top);
_top = st1._top;
_capacity = st1._capacity;
}
return *this;
}
this指针出了这个函数的作用域是不会被销毁的,它依然存在,那么为了最优解,我们是可以用引用的
接下来是写自增的重载,++和--
那么这里就有一个头疼的问题,自增都是单操作数单操作符,编译器怎么知道是前置还是后置?
所以C++给了个标记:
但是在这里,这个int仅做标记,不需要传参,本身自增也用不着。
流插入和流提取运算符重载
> 本身在C++内部是支持内置变量重载的,为了实现对类的流插入和提取,我们 可以重载它们。需要调用Cout的话C++是支持这个类型的也就是ostream:我们用这个类型创建一个变量:ostream out
相应的,这个ostream是输出,输入则是istream
重载的时候相当于重写一遍流输出,写成如下这样
但是重载完了之后缺会报错
换一个写法就不会
这就很蛋疼了,我重载你不是让你来耍杂技的,你这样子可不符合使用习惯啊!
原因是this指针默认占用了第一个操作数
所以重载这两个操作符的时候一般不写成成员函数,因为Date对象默认就是左操作数不符合使用习惯,而且写在类里面第一个操作数绝对会是this指针,所以一般情况下不将流插入重载写成成员函数。
那么就把它拿出来写:
但是在这里报了个错,原因其实是全局函数的重复定义问题
在这里,我们的项目的声明与定义是分离的,所以会出现一个老问题,类似于头文件的多次包含的问题:
两个文件内部都包含了一次head.h,造成了这个重载函数的重定义。不同于我们当时的解决办法,我们当时使用了pragma once来防止重复调用,但是pragma once只能防止文件展开两次,而非不展开
当然,在这里其实不只是这个重载函数会报这样的错,其他全局函数,包括全局变量都会出现这个问题
解决办法:
1.声明与定义分离
声明和定义也可以解决,因为有了定义与声明才会进符号表。
2.加个static
static之所以可以解决这个问题还需要归功于它的基本定义:当static修饰全局变量时,会影响变量的生命周期,当然在修饰全局函数以及变量的时候也会修改它们的链接属性也就是修改成仅在当前文件可见
总而言之:不要在.h里面定义全局变量
这个问题解决后,我们面对的另一个问题则是如何访问到私有变量。在这里可以使用友元。
浅谈友元:
友元相当于访问私有变量的绿色通道。
友元可以在类里面的任意位置声明,为白名单上的函数提供通道,只需要在需要的函数前面加上一个friend就好了。
但是这样其实破环了我们封装的本意,一般只有再不得不用的情况下我们才会使用友元。
const成员
当我们以const类型创建一个类的对象的时候,调用其成员函数会报错
原因其实是权限放大
我们回顾一下隐藏的this指针的格式:Date* const this
而传参对象的格式: const Date d2
传入成员函数,也就是当前对象自己,即d2的地址而其类型是const Date*
对比两个类型,一个是对this指针本身加上const,另一个则是对this指针所指向的对象加上const
那这下怎么办呢?const对象都没法调用自己的成员函数了!
解决办法则是:C++允许在成员函数后面加上一个const,允许类似d2这种对象类型能正常的使用成员函数。在这里加上的const修饰的则不是this指针本身,而是它指向的那个变量,也就是变成了:const Date* const this说的通俗易懂一点就是把这个指针和他指向的变量都锁住了
但是这里有个疑问,我们针对d2做出的修改,怎么d1这个朴实无华的对象还是能继续调用呢?
因则是权限的缩小,尽管这个形参已经被const锁的明明白白的,但是并不妨碍传参的时候初始化。而一个const对象唯一能被改动的时刻就是初始化的时候。
为此我还专门写了个样例:
将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
总结下来:凡是内部不改变成员变量,也就是*this对象数据的,这些成员函数应该加上const
反正遵循不放过一个的原则
5和6.取地址及const取地址操作符重载
这两大爷不需要怎么关注,默认生成的就够用,不需要我们手动重载,这里偷个懒
当然要防止刁钻需求,比如不要返回地址的时候,还是需要重载的
到这,全部的成员函数就概述完毕了!不过还没结束!类和对象真是难啃对吧!不过剩下的都是部分细节问题了!
感谢阅读!希望对你有点帮助!