“三步走”带你拿下C++类与对象(中)(本系列精华篇)

距离"(上)"发文到现在间隔这么长时间是因为(中)篇的内容多且难度大,不易于理解,所以发布的晚一些,建议小伙伴们仔细斟酌。

本文主要介绍了类的默认成员函数、构造函数、析构函数、拷贝构造函数等内容,是上篇的后续,建议没有看过上篇的朋友先去看“三步走”带你拿下C++类与对象(上)以更好地理解本文。

一、类的6个默认成员函数

上次我们谈到,如果一个类中什么成员都没有,简称为空类。但在空类中并不是什么都没有,任何类在什么都不写时,编译器会自动生成以下6个默认成员函数。所谓默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数。

我们来逐一讲解各个函数。

1.构造函数

我们在写代码的时候,尤其是栈和队列的功能实现,往往会因为最后没有初始化变量而报错,这也是很容易忽略的点,因此构造函数就帮助其实现初始化功能。其特征如下: 1. 函数名与类名相同。 2. 无返回值(不写void)。 3. 对象实例化时编译器自动调用对应的构造函数。 4. 构造函数可以重载,即可以写多个初始化方式的构造函数。

图中的date函数就是date类的构造函数,在其运行后会自动调用并进行初始化为1。

构造函数的函数重载,需要提到的是,我们以前调用函数是函数名加参数列表,而现在是对象名加参数列表

第一个会调用无参数的date(无参数的函数调用不要写括号,虽然这可创建对应类的对象很相似,但无法与函数声明区分开),第二个调用有参数的date。

我们前面提到,如果在一个类里面用户没有自己写构造函数那么编译器会自动生成,但是当我们运行代码的时候,发现结果是这样的。

原理是:在c++中类型分为内置类型和自定义类型,前者包括int char double...及其指针。而后者包括class struct union等。编译器自动生成构造函数,对于内置类型成员变量不做处理。对于自定义类型变量才会调用无参构造。

到了c++11后,为了改善构造函数不对内置类型做处理而使其变成随机值的问题,我们在内置类型对象赋给了缺省值

注意,这里不是初始化,是声明但未定义,没有开空间。这样在创建变量时,编译器就会用这个缺省值进行初始化了。

一个类可以写多个构造函数构成函数重载,这样我们可以根据不同的需要来进行不同的初始化。但建议可以写一个全缺省的函数,一般情况下构造函数都需要我们自己显示地去实现。只有少数情况下可以让编译器自动生成构造函数,比如:

在queue的类中我们就不需要写构造函数,因为一旦运行代码,queue的构造函数就会对stack的类的两个对象进行初始化,而这个过程实质是stack中的构造函数实现的,即queue的构造函数调用了stack的构造函数,因此我们只需要在stack中写构造函数即可。总结就是类中成员全是自定义类型的可以不写或着是类中的内置类型已经给了缺省值。当我们用了显示构造函数并传参时,此时缺省值就不起作用了,由传的参数进行初始化。

补充一点:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数,且默认构造函数只能存在一个(如果有多个那么在调用无参的函数时会产生歧义)。如果用户在类中自己写了构造函数那么编译器就不会生成了。一个类必须有构造函数否则会报错,这个构造函数可以是上面指的默认构造函数中的任意一种,也可以是我们通过传参进行初始化的构造函数,例如下面这种

我们进行初始化的时候只需要把参数传过去就可以完成初始化了(必须传参否则他就没有了默认构造函数,可以理解为不传参他就会调用自动生成的构造函数,但此时我们已经自己写了一个构造函数所以他找不到那个要调用的函数)

2.析构函数

与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由 编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。其特征如下: 1. 析构函数名是在类名前加上字符 ~。 2. 无参数无返回值类型。 3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载(因为析构函数没有参数) 4. 对象生命周期结束时,C++编译系统系统自动调用析构函数。

但是,不是所有的类都需要写析构函数,析构函数其实与destroy函数功能类似,主要是空间的释放,而date类并没有开辟空间不需要释放,而类似栈就需要手动写析构函数了。析构函数也可以显示调用。编译器自动生成的析构函数的原理与构造函数是类似的,即对内置类型不做处理,对自定义类型去调用他的析构。

总结一下:有资源需要显示清理需要写析构,比如栈,链表等。有两种情况不用写,第一是没有资源需要清理,例如date类。第二是内置类型成员没有资源需要清理,剩下都是自定义类型,比如上面的queue类。

3.拷贝构造函数

拷贝构造也是构造,只不过比较特殊,那什么情况下会用到拷贝构造函数呢?比如,我创建了一个date类的对象d1,此时我还想创建一个d2使其内部的数值与d1相同(即把d1的东西拷贝给d2),此时就需要用拷贝构造函数,他的用法如下:

运行代码后我们发现d2的初始化与d1就相同了。

拷贝构造函数的特性如下:1.他是构造函数的重载,它的写法就与构造函数相同。

2.拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式会报错,因为会引发无穷递归调用。(我们在之后解释)

结合上面我们知道,d是d1的别名,然后把d1拷贝给了d2。接下来我们来讲一下为什么会引发无穷递归,我们先看下面这个场景

运行结果发现此过程也运用了拷贝构造(对应了前面的传值传参要调用拷贝构造)当然,这里面如果传指针或传引用就不用拷贝了。

上图就是引发无穷递归的原理,我们知道,传值会进行拷贝构造,我们的本意是把d2拷贝给d3,一开始我们需要调用d3的拷贝构造,但我们需要先传参形成了一个新的拷贝构造,而新的拷贝构造又需要传参,此时又要调用新的拷贝构造,逻辑上此时形成一个无穷递归。而为什么我们前面调用fun函数不会发生此现象呢?

我们调用fun函数需要进行拷贝构造,此时我们用引用拷贝的情况下d2进行传参形成了拷贝就会返回给图中的d。

此处作者用大白话解释一下,调用fun函数的版本不会出错,是因为我调用fun函数不想直接把d2传参进行函数调用,想自己拷贝一份给fun函数(即参数的d)然后进行调用。此过程会开辟一块栈帧专门去实现这个拷贝过程,如果这部分写的没错(引用版本而不是传值版本就可以顺利拷贝)。而引发无穷递归的情况时,我们假设还是想调用这个fun函数,等到d2想拷贝一份过去作为参数的时候,发现拷贝函数的参数也需要自己拷贝一份作为拷贝函数的参数,此时形成第一层递归,所以需要进行拷贝函数的参数的拷贝(第二层递归...)所以就有了无穷递归(此理解若有错误请指正!)

当然,这个拷贝函数用指针版本也是可以但一般不这么用。同时,我们一般在参数的部分前加const,这是为了防止原来的类的数据被修改。其实,下面这种写法也是拷贝构造:

特性3:若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。这种的特性就是内置类型与自定义类型均调用拷贝构造函数,与前面的不同。

拷贝为啥分浅拷贝,我们假设创建一个stack类的对象,假设我们对其进行压栈操作,然后让其自动生成拷贝构造函数,结果发现程序崩掉了。这是因为,该对象在进行拷贝的时候,不仅把size,capacity拷贝了,同时会把array也拷贝,也就是前后拷贝的对象指向了同一块空间,这样当其进行析构的时候就会析构两次,由前面动态内存管理的知识,我们知道一个空间不能free两次。此时,浅拷贝就失效了。与其对应的就是我们的深拷贝。深拷贝就是需要开辟和其一样大的空间,且需要把数据拷贝过来,最关键的是让新拷贝的指针指向新空间。

总结一下:1.如果没有管理资源,一般不需要写拷贝构造,默认生成的拷贝构造就可以,如date2.如果都是自定义类型,内置类型没有指向资源,也用默认生成的就可以。3.一般情况下,不需要写析构函数就不需要写拷贝构造。4.如果内部有指针或者有一些值指向资源,需要显示写析构释放,通常就需要写深拷贝构造。比如各种数据结构。

4.运算符重载

C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其 返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。

函数名字为:关键字operator后面接需要重载的运算符符号。 函数原型:返回值类型 operator操作符(参数列表)假如我要比较两个日期的大小返回值就是bool类型,若计算两个日期之间差了多少天就是int类型。

注意:1. 不能通过连接其他符号来创建新的操作符:比如operator@ 。2.重载操作符必须有一个类3.类型参数用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不能改变其含义 (但实际操作中我们发现是可以改变的,只是为了让代码看起来更清晰一般不修改其本意)。4.作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this 。5.(.*) :: sizeof (?:) .注意以上5个运算符不能重载。这个经常在笔试选择题中出现。

假设我们创建了两个date类的对象,我们如何比较两个日期的大小呢?很简单,写一个bool类型的函数即可,我们实现一下大于的函数

但是,如果我们想再实现一个小于的函数,需要创建一个compare2函数比较,但如果不加注释,我们不知道哪个函数是大于还是小于,有什么办法能让我们不从函数名字的角度去辨别呢?有的。此时运算符重载就起到了作用了,这里我们接触到一个新的关键字——operator(重载),operatoe+运算符构成了函数名,因此我们就可以直接调用该函数判断大小,并把对象传参过去即可(其实这个做法是配合下面第二个的写法,图中两种写法等价)这样显得更直观。(如果我们不再上面写operator>函数,第二行就会报错。)

到此又会出现一个问题,我写这个函数的时候,需要访问类中的成员,甚至有时候需要修改,但大部分情况下那些成员都是private的,此时就会产生矛盾,为了解决这种问题,我们有getset函数、友元等操作,其中我们重点讲解的办法是重载为成员函数。也就是把operator函数放在类中

但是他报错了,因为此运算符函数的参数太多,为什么呢?这就是我们在上面的“注意”中的第4点,4.作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this 。也就是说,当我们重载为成员函数的时候,有一个隐藏的参数this,对于这个判断相等的函数中,我们是不能改变其为3个参数的,我们说,这个运算符是几个操作数参数就是几个,判断相等是双目,那么参数就是两个,为了解决报错的问题,我们对代码进行如下修改。

同时,我们进行函数调用的时候也只能传一个参数了,具体调用方法如下:

其中,d2传给了隐藏的this,d就是d1的别名。这样就解决访问私有的问题了。这么一看,这不就是个类中函数的调用吗,只不过函数名字奇怪了点,但我们写完了比较函数后,一般还是喜欢转换调用也就是d2==d1;

5.赋值运算符重载

我们来解读以下代码

我们首先创建了一个类d1并进行初始化,然后用了两种方式将d1拷贝给了d2,然后又创建一个类d3并初始化,接下来我们想让d3的类和d1一样,此时就不是拷贝操作了,而是d1赋值给了d3,怎么区分呢?拷贝构造就是把一个已经存在的对象拷贝给另一个刚要创建初始化的对象(图中的d2,d2的初始化是靠d1的拷贝实现的),而赋值拷贝(又称赋值重载)是一个已经存在的对象拷贝给另一个已经存在的对象。咱们也可以把这个过程用成员函数重载的方式写出来

有的时候见到别人还写成date类型的返回,其目的就是为了满足连续赋值的要求。因为连续赋值的本质,假如 i=j=k=1;(假设都是整型),是先把返回值1赋给k,然后k作为返回值给了j,所以上面的修改为date类型也是这个原理(此处不要写成引用返回,即date&,因为一旦出了当前的作用域,这个类就会调用析构函数把内容都销毁了,导致随机值,野指针等问题。但是如果我们把*this作为返回值是可以写成date&类型的,这就是真正意义上的函数运算符重载,代码如下)当然如果没有连续赋值的习惯写成void是最保险的。

但是别忘了,赋值重载函数也是默认成员函数,我们不显示写时,编译器也会自动生成,以值的方式逐字节拷贝。内置类型直接复制,自定义类型需要调用对应类的赋值运算符重载完成赋值。具体是否显示写与前面的拷贝函数一样(深拷贝就需要显示写)

有了上面的理论,我们实现一下日期类的完整版:

6.日期类的完整实现

我们先把日期比较大小的函数都玩一遍。先来个小于和等于

bool Date::operator<(const Date& d)
{
	if (_year == d._year)
	{
		if (_month == d._month)
		{
			return _day < d._day;
		}
		else if (_month < d._month)
		{
			return true;
		}
	}
	else if (_year < d._year)
	{
		return true;
	}
	return false;
}
bool Date::operator==(const Date& d)
{
	return (_year == d._year)
		&& (_month == d._month)
		&& (_day == d._day);
}

此时有人就会用cv大法去写大于和不等于,但是这还是很麻烦,更好的思路是,我要写大于,只要证明他不小于等于即可,我要写大于等于,只要证明他不小于即可,于是剩下的比较代码如下。

bool Date::operator<=(const Date& d)
{
	return *this < d || *this == d;
}
bool Date::operator>(const Date& d)
{
	return  !(*this <= d);
}
bool Date::operator>=(const Date& d)
{
	return !(*this < d);
}

bool Date::operator!=(const Date& d)
{
	return !(*this == d);
}

显得很简便。

接下来,玩点有意思的,日期加减多少天的新日期,这个函数返回类型还是date,参数只需要传整型天数即可(由前面的隐藏this参数可得)。

起初的思路是,先把天数都加到天上,如果不符合当月的天数就进位同时day减去当月的天数,如果仍不符合继续相同操作。但问题是,每个月的天数不一样啊...咋办呢?我们再弄个成员函数来搞定。

//直接定义在类里面,默认是inline
int Getmonthday(int year, int month)
{
	assert(month > 0 && month < 13);
	//这个函数会频繁地调用,防止数组被修改
	static int monthdayarray[13] = { -1,31,28,31,30,31,30,31,31,30,31,30,31 };
	//是2月才需要判断是否是闰年
	if ( (month == 2)&&(year % 4 == 0) && (year % 100 != 0) || (year % 400 == 0))
	{
		return 29;
	}
	else
		return monthdayarray[month];
}

Date &Date:: operator+=(int day)
{
	_day += day;
	while (_day > Getmonthday(_year, _month))
	{
		_day -= Getmonthday(_year, _month);
		++_month;
		if (_month == 13)
		{
			++_year;
			_month = 1;
		}
	}
    return *this
}

此时细心的小伙伴会发现,这个函数并不是+而是+=,因为这个函数整个过程都是在一个变量上进行修改最后也把这个变量进行返回,但是已经找不到他修改前的样子了。(比如一开始定义i=5,如果进行i+1操作,现在i是6了,已经找不到等于5的时候了,这对于日期来说是很关键的,不能为了计算多少天后的日期就把一开始的基础日期丢掉了。)

为了保留一开始的日期,我们选择把原来的日期进行拷贝然后对拷贝的日期进行修改,再利用复用+=函数的操作,日期+天数的操作就实现完毕了。(感受到复用强大的力量)

Date Date::operator+(int day)
{
	Date tmp = *this;
	tmp += day;
	return tmp;
}

接下来咱们简单跑一把(只演示大于小于,后面的原理相同)

有了这个思路,我们也可以实现一下-和-=

Date& Date:: operator-=(int day)
{
	_day -= day;
	while (_day <= 0)
	{
		
		--_month;
		if (_month == 0)
		{
			--_year;
			_month = 12;
		}
//这里注意细节,这里如果天为负数需要加上之前那一月的天数
		_day += Getmonthday(_year, _month);//因为上边已经--就不用-1了
	}
	return *this;
}
Date Date:: operator-(int day)
{
	Date tmp = *this;
	tmp -= day;
	return tmp;
}

此处说明一点,虽然函数重载和运算符重载之间的名字很相近但并没有直接的联系,可以建立桥梁的可能性就是多个运算符重载可以构成函数重载,比如我既想实现日期-天数也想实现日期-日期,那么都是用到了-运算符,只是函数类型不同,此时构成了函数重载。那么问题来了,我想实现日期++和++日期函数怎么办,两个完全相同的函数名字?调用歧义啊,c++祖师爷迫不得已,在后置++的函数中强行加入一个int形参来区分,在调用后置++的时候,随便给一个整形就行(为了区分)

那么前置++和后置++函数就易如反掌了(前置++返回的是加后的值,后置++是返回加之前的值)

Date& Date:: operator++()//前置++
{
	*this += 1;
	return *this;
}
Date Date:: operator++(int)//后置++,不需要写形参名
{
	Date tmp = *this;
	*this += 1;
	return tmp;
}

--的前置后置我们就不多说了。搞一下日期-日期==,也就是看看之间差了多少天。

思路:让小的日期一直加(复用+函数,最后与大的日期相等+的次数就是相差的天数)

int Date:: operator-(const Date& d)
{
	//假设法
	Date max = *this;
	Date min = d;
	int flag = 1;
	if (*this < d)
	{
		max = d;
		min = *this;
		flag = -1;
	}
	int n = 0;
	while (min != max)
	{
		++min;
		++n;
	}
	return n*flag;
}

这里的flag就是控制结果的正负,如果被减数的日期大于减数结果就是正,反之就是负。

我们来看下一个问题,如果我不想通过调用print()函数去打印当前的日期该怎么办呢?肯定不能直接用cout,因为cout只支持对内置类型的直接打印,你自定义类型不知道要打印什么,所以,我们需要再来一个运算符重载来实现,在这里我们直接上结论,建议重载成全局函数而不是成员函数(不是不可以,只是用起来不规范)

第十行就是运算符重载成功的结果,也就是能对日期类进行直接打印。

这里对返回类型的处理是为了连续打印,不然就不用加&。有了这个思路,那么输入也就大同小异

这里的d不要加const因为我们要对其进行数据的输入,也就是修改日期。

但这里有猫腻,我其实把类中的成员都变成了public所以这里才不会报错,所以接下来的任务就是把这个问题解决。方法是上面提到的友元函数声明

用法就是把函数的声明放在类里面然后前面加friend即可(证明我是你类的朋友,我就可以访问你家里的东西)

7.const成员函数

我们假设现在创建一个新的日期类对象d4并打印

但是报错了,这里涉及到一个权限放大的问题。在调用函数的时候,把d4的地址作为参数传给了Date*this,此时就是可读可写,而我们原来的类只是可读,权限被放大了。我们如果想办法在调用函数的那行加个const问题就解决了,可是这不符合语法啊。为了解决,在函数的声明后面加const

那么它也可以调非const的日期类对象(权限缩小是可以的)

8.取地址及const取地址操作符重载

一般不显示写,运行代码我们发现,他们的区别就是看对象前有const就调用const的函数。

再次说明,以上都是默认成员函数,不写编译器会自动生成。

本文完——麻烦留下你宝贵的三连再走,谢谢!

  • 21
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值