前言:本章继【逐步剖C++】-第二章-C++类和对象(上)介绍有关类和对象更深层次的知识点,这里是文章导图:
本文较长,内容较多,大家可以根据需求跳转到自己感兴趣的部分,希望能对读者有一些帮助
那么本文也主要以导图为思路进行分享,话不多说,让我们开始吧。
一、默认成员函数
1、定义
默认成员函数指的是,用户没有显示定义时,编译器会自动生成的成员函数称为默认成员函数。
2、种类
默认成员函数一共有六个:
(1)构造函数
(2)析构函数
(3)拷贝构造函数
(4)赋值运算符重载
(5)const成员函数
(6)取地址操作符重载
上面六个默认成员函数重点在于前四个,那么本文主要侧重于对前四个进行介绍,后面两个仅进行简单的说明。
二、构造函数
1、定义及作用
构造函数用于初始化类的成员变量,在实例化对象时由编译器自动调用,以保证每个成员变量都有 一个合适的初始值。(PS:需要注意的是:其仅给类的成员变量赋予初始值,并不涉及相关的开空间问题)
2、默认构造函数
准确来说,编译在对象实例化时自动调用的其实是类的默认构造函数,默认构造函数一共可分为三种:
- 编译器自动生成的构造函数
- 无参的默认构造函数
class A
{
public:
A()
{
_a = 0;
}
private:
int _a;
}
- 全缺省的构造函数
class A
{
public:
A(int a = 0)
{
_a = a;
}
private:
int _a;
}
需要注意的是,默认构造函数有且仅能有一个,否则在调用时会出现歧义。
3、特性
构造函数主要有以下几点特性:
(1)构造函数的
(2)构造函数无返回值
(3)对象实例化时编译器将自动调用默认构造函数,且在对象整个生命周期内只调用一次
(4)构造函数可以重载
(5)在类中若没有显示定义一个无参的默认构造函数编译器会自动生成,一旦显示定义编译不再生成
(6)构造函数对不同类型的成员变量处理不同:对于内置类型(如int等),编译器一般不做处理;对于自定义类型(即我们自己定义的类),编译器会再去调它的构造函数,若该自定义类型的成员无默认构造,则编译器会报错。
下面是对于第六点特性的简单验证,请看:
代码:
class A
{
public:
A()
{
cout << "A()" << endl;
_a = 0;
}
private:
int _a;
};
class Test
{
public:
Test()
{
cout << "Test()" << endl;
}
private:
A _a;
int _t;
};
int main()
{
Test t;
return 0;
}
运行结果:
可以看到,对于类Test中的自定义类型A的成员_a
,编译器去调用了它的默认构造函数A()
,对于内置类型不做处理。
4、易错点
(1)注意区分默认构造函数和默认成员函数
(2)构造函数一般为public
(3)多个默认构造函数语法上虽然可以构成重载而同时存在,但调用时会出现歧义,所以规定默认构造函数有且仅能有一个
(4)在显示定义构造函数时,若不是默认构造函数中的其中一个,最好再多定义一个构成重载,因为显示定义构造后编译器不再自动生成。即最好保证一个类中有默认构造函数,以防如上特性(6)所述的报错情况。如:
class A
{
public:
A(int a) //显示定义的构造,但不是默认构造
{
_a = a;
}
A() //再显示定义一个默认构造
{
cout << "A()" << endl;
_a = 0;
}
private:
int _a;
};
class AA
{
private:
int _aa;
A a;
};
类AA的成员a
为自定义类型,那么在类AA自动生成的构造函数中,对成员a
会去调用它对应类的默认构造即A()
,若在A类中没有默认构造函数,则编译器会报错。
(5)调用无参构造函数的方法,应该为:A a;
而不能是A a()
;后者会和函数声明的形式冲突,编译器不容易识别。
三、析构函数
1、定义及作用
与构造函数功能相反,析构函数用于对象销毁时的资源清理工作。在对象生命周期结束时由编译器自动调用。(PS:需要注意的是,对象本身的销毁还是由编译器完成,析构函数主要用于对类中的成员变量做一些资源清理工作,主要体现在空间的释放等)
2、特性
(1)析构函数名是在类名前加上字符 ~
(2)无返回值类型且无参数
(3)对象生命周期结束时,编译器自动调用析构函数
(4)一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。
(5)析构函数不能重载
(6)构造函数对不同类型的成员变量处理不同:对于内置类型(如int等),编译器一般不做处理;对于自定义类型(即我们自己定义的类),编译器会再去调它的析构函数。
3、注意点
(1)析构函数没有构造函数所谓 “默认” 的概念,也就是说一个类一定会有析构函数,自动调用时不会报错。
(2)若设计的类中涉及到动态空间的申请,那么一定要显示定义析构函数来进行空间的释放,避免内存泄漏问题;除此之外,一般可以使用系统自动生成的。
三、拷贝构造函数
1、定义与作用
拷贝构造是构造函数的一种重载形式,用于对象之间的数据拷贝工作,在用已存在的类的对象来创建(初始化)该类的新对象时,编译器会自动调用。如:
A a1;
A a2 = a1; //编译器自动调用A类的拷贝构造
2、特性
(1)拷贝构造函数是构造函数的一种重载形式
(2)拷贝构造函数的参数有且只有一个,且必须是该类的类型的对象的引用形式(即传引用调用);否则编译器直接报错,如:
//正确的拷贝构造(传引用调用):
A(const A& a) //加const可以保证被拷贝对象数据的安全性(不被修改)
{
//..资源拷贝
}
//错误的拷贝构造(传值调用):
A(const A a)
{
//..资源拷贝
}
对于第二种错误的说明:
第二种写法会造成拷贝构造的无穷递归。我们知道,对于传值调用,函数调用时形参的本质是对实参的拷贝,而自定义类型的拷贝工作规定必须调用拷贝构造完成,所以每传一次参数就会调用一次拷贝构造,而每次调用还没等进入函数体,又会因为形参拷贝实参进行下一次拷贝构造的调用。
(3)若用户未显示定义,编译器会自动生成默认的拷贝构造函数,以**浅拷贝(值拷贝)**的方式对对象进行拷贝。
对于这一点特性,涉及到一个可以说贯穿我们编程学习的问题:深浅拷贝问题
下面通过一个例子来说明,请看:
class B
{
private:
int _b;
};
class C
{
private:
int size;
int* _c;
};
- 对于B类
其成员变量只有一个整型_b
,在拷贝时直接进行值拷贝没问题,可以直接利用编译器自动生成的拷贝构造,自动生成拷贝构造函数具体内容即为:
B(const B& b)
{
_b = b._b; //将对象b的成员变量内容拷贝给当前对象
}
- 对于A类
其成员除了一个整型size
外,还有一个指针_c
,表示动态开辟的一块空间。若此时进行浅拷贝:
C(const C& c)
{
size = c.size;
_c = c._c;
}
那么会直接将被拷贝对象中已开辟好的空间的地址直接赋值给了拷贝对象的_c
成员,这样会使两个不同对象的_c
成员指向同一块空间。示意图如下:
此时会有两个问题:修改其中一个空间中的内容会影响另外一个;后创建的那个对象会先调用析构,到被拷贝对象时会再调用一次析构,即对同一块空间进行了两次清理,会报错。
所以此时要进行的拷贝就为深拷贝,即编译器自动生成的拷贝构造已经无法满足我们的需求。那么所谓深拷贝,其实就是再开一块和被拷贝对象中相应成员变量一样的空间,接着再把原空间中的数据逐一拷贝到新空间中。代码及示意图如下:
C(const C& c)
{
size = c.size;
_c = new int[size]; //size表示空间大小
for(int i = 0; i < size; i++)
{
_c[i] = c._c[i];
}
}
总结:
类中若涉及到资源的申请,则需自己实现拷贝构造(主要是深拷贝);若不涉及,则可直接利用编译器生成的拷贝构造(浅拷贝)。
3、典型的调用场景
(1)使用已存在的对象创建新的对象
A a1;
A a2 = a1;
注意:若是使用已存在对象给另一已存在对象赋值就需要通过另一个默认成员函数——赋值运算符重载了(接下来介绍)。
(2)函数参数为类类型的对象
Test(A a){}
(3)函数返回值类型为类类型
A Test()
关于(2)(3)场景的补充:
这两个场景本质其实和内置类型一样:内置类型对象在传值调用时传参的本质是:形参对实参的临时拷贝;值返回时同理,先拷贝给一个临时对象,再经由临时对象将值拷贝给创建的新对象。可以看到,需要拷贝的地方还是比较多的。如果此时是深拷贝,拷贝的开销将会大大提高,所以对于场景(2),一般会采用传引用调用的方式;对于场景(3),符合传引用返回的要求,就用传引用返回。
五、赋值运算符重载
1、运算符重载
在了解赋值运算符重载前,先简单了解一下运算符重载
(1)概念:
C++为了增强代码的可读性引入了运算符重载,运算符重载本质上是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数原型:返回值类型 operator操作符(参数列表)
如:重载+号实现两个A类对象相加可写为:
class A
{
public:
A operator+(const &A a)
{
return _a + a._a;
}
//实现类的对象之间的运算一般定义为成员函数
private:
int _a;
};
(2)注意事项
- 不能创建新的操作符而实现运算符重载,如@
- 重载操作符的操作数中必须有一个是自定义类型
- 作为类成员函数重载时,函数形参会比实际操作数少一个,因为第一个参数是隐藏的this指针
- 有如下五个特殊的运算符不能重载:
.* :: sizeof ?: .
2、赋值运算符重载
一般固定格式:
//这里用T表示类类型
T& operator=(const T& x)
{
//..赋值操作
return *this;
}
主要是如下三点:
- 参数类型:const T&,传递引用可以提高传参效率
- 返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是支持连续赋值
- 返回
*this
:即返回对象本身,符合引用返回的场景(this指针是形参出了作用域会销毁,但对象本身不会),也符合连续赋值的含义
3、赋值运算符重载的特性
(1)赋值运算符只能重载为类的成员函数而不能重载为全局函数。因为赋值运算符重载为类的默认成员函数,定义为全局的可能会和编译器在类中自动生成的发生冲突。(PS:只有赋值运算符重载需要遵守这个规定,其他运算符不用)
(2)若用户未显示定义,编译器会自动生成,赋值运算符重载对不同类型的成员变量处理不同:对于内置类型(如int等),可直接进行值拷贝;对于自定义类型(即我们自己定义的类),编译器会再去调它的赋值运算符重载完成赋值。
关于这一点特性同样也涉及了深浅拷贝的问题,若成员变量涉及到资源的管理,就一定要显示定义,如上面拷贝构造中的例子:
class C
{
//构造函数
C()
{
size = 4;
_c = new int[size]; //创建对象时为对象开辟一定大小的空间
}
//赋值运算符重载
C& operator=(const C& c)
{
size = c.size;
//_c = c._c;
//浅拷贝,错误,会导致二者指向同一块空间,且被赋值对象原空间丢失,造成内存泄漏
delete[] _c; //先释放原空间
_c = new int[size]; //再根据赋值对象的空间大小开新空间
for(int i = 0; i < size; i++) //对赋值对象空间内容进行逐一拷贝
{
_c[i] = c._c[i];
}
return *this; //返回对象本身
}
private:
int size;
int* _c;
};
补充:这里有个易混淆的问题:C c2 = c1;
调的是拷贝构造还是赋值运算符重载?
答案是拷贝构造,其实只要清楚二者的作用即可正确判断。拷贝构造用于已存在的对象去初始化一个新创建的对象;而赋值运算符重载用于两个已存在对象间的赋值。
六、const成员函数
1、定义及作用:
将const修饰的成员函数称之为const成员函数。const修饰类成员函数,本质上修饰该成员函数
隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。我们知道this指针的类型为类类型*const
,那么经由const修饰的成员函数的this指针类型就变为了const 类类型* const
2、特性:
(1)const和非const的对象都可以调用const成员函数(分别对应权限的平移和缩小);
(2)const成员函数不能在函数中调用非const的成员函数(权限放大),而非const的成员函数可以在函数中调用const的成员函数(权限缩小)。
七、取地址运算符重载
1、定义及作用
可分为对普通对象的取地址和对const对象的取地址,用于返回类对象的地址,即this指针。一般情况下不需重载,直接使用编译器自动生成的即可。特殊情况下,可以重载而让取地址的人获取我们想让他们获取的内容。
八、实际运用——日期类的实现
经过上面的介绍,我们可以利用上面的知识编写一个简单的日期类。那么下面是编写的过程思路及部分的代码,请看:
1、成员变量
成员变量其实就是简单的年、月、日,则日期类我们可以定义为:
class Date
{
private:
int _year;
int _month;
int _day;
};
2、构造函数、析构函数、拷贝构造
(1)对于构造函数,我们可以实现一个全缺省的构造函数,这样既满足了类中最好有默认构造函数的需求,又可以手动对对象进行初始化。下面是实现过程:
其中主要是对参数的合法性进行判断,如1月32号就是一个非法的日期,那么为了便利日期的判断,我们需要单独写一个函数int GetMonthDay(int year, int month) ;
来判断某年某月有多少天。该函数的实现如下:
- 先判断是否为闰年(这个过程也可以封装成函数);
- 用一个数组把每月对应天数存起来,除二月需要特殊判断一下,其他直接根据下标索引返回即可
- 代码如下:
bool IsLeapYear(int year)
{
return ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0));
}
int Date::GetMonthDay(int year, int month) const
{
int ArrDays[13] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30 ,31, 30 ,31 };
if (IsLeapYear(year))
{
ArrDays[2] = 29;
}
return ArrDays[month];
}
参数合法就进行相应的赋值,不合法直接退出即可。完整的全缺省构造函数代码如下:
Date(int year = 1900, int month = 1, int day = 1)
{
if (year > 0
&& (month > 0 && month <= 12)
&& (day > 0 && day <= GetMonthDay(year, month)))
{
_year = year;
_month = month;
_day = day;
}
else
{
cout << "非法日期" << endl;
exit(-1);
}
}
(2)结合上面所述和日期类的成员可得,拷贝构造函数和析构函数以及赋值运算符都可以直接使用系统默认生成的,不需要自己显示定义。
3、运算符重载
前言:关于运算符重载的实现,最常用的技巧就是复用,即先实现某些运算符,再用已实现完成的运算符去实现其他运算符。如:实现了大于>
和大于等于>=
之后,小于就是不大于等于! >=
(对判断大于等于的结果进行逻辑取反),小于等于就是不大于! >
(对判断大于的结果进行逻辑取反),具体实现请继续阅读。
(1)+、+=等运算
关于日期的运算,我们需要明确哪些是有意义的。比如两个日期相减可以得出相差多少天,是有意义的;但相对的两个日期相加就没什么实际意义。那么关于日期有意义的操作运算总结如下:
- 日期
-
和-=
天数- 日期
+
和+=
天数- 日期
++
- 日期
- -
- 日期
-
日期
关于++ 和 - - 运算符的重载的补充:我们知道,在内置类型的操作中,++分为前置和后置;那么在运算符重载中,前置++的形式为:T& operator++()
;而后置++为T& operator++(int)
,定义时需要在参数中添加一个int型参数作为标志进行区分,在实际调用后置++时按其特性使用即可,不需要另外传int型参数,编译器会自动传一个参数0。
-
日期
-
和-=
天数
日期-=
天数:
这里先实现日期-=
天数,因为日期-
天数可以对其进行复用(PS:当然先实现-
再复用-
实现-=
也可以,但这样在进行-=
操作时会比用-=
实现-
多调用一次拷贝构造)实现逻辑为:可以先让当前的成员
_day
减去参数day
,结果若为负,则说明结果需要往上一个月去找,若_day
加上了上一个月的天数认为负,则需再往上一个月找,直至_day
为正,由此构成循环。日期 - 天数:
先创建一个日期类对象,再用该对象-=
(复用-=
)对应的天数,然后返回该对象即可。如上两个操作的代码为:
// 日期 -= 天数
Date& Date :: operator-=(int day)
{
if (day < 0) //减等一个负的天数其实就是加等上对应正的天数,复用+=
{
*this += -day;
return *this;
}
_day -= day;
while (_day <= 0)
{
_month--; //month先减减,保证不会出现0号
if (_month == 0)
{
_year--;
_month = 12;
}
_day += GetMonthDay(_year, _month);
}
return *this;
}
// 日期-天数
Date Date :: operator-(int day) const
{
Date tmp(*this);
tmp -= day;
return tmp;
}
-
日期
+
和+=
天数
日期+=
天数:实现思路和-=
类似,先让_day
加上对应的天数,如果_day
大于了这个月的天数,那么就先减掉当前月的天数然后到下一个月去找(可以理解为**“先过完这个月”**),由此构成循环。
日期+
天数:复用+=
的方法和-
复用-=
相同,这里不再赘述。如上两个操作的具体代码为:
Date& Date::operator+=(int day)
{
if (day < 0) //+=一个负的天数其实就是减等一个正的天数,可以复用减等
{
*this -= -day;
return *this;
}
_day += day;
while (_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
_month++;
if (_month == 13)
{
_year++;
_month = 1;
}
}
return *this;
}
Date Date::operator+(int day) const
{
Date tmp(*this);
tmp += day;
return tmp;
}
-
日期
++
:
前置++
:
实现逻辑:让成员变量_day
+1后,判断是否到下一个月,若到了下一个月,同样在月+1后需判断是否到了下一年,但其实这个判断我们在+=
的实现中已经做过了,所以可以直接复用。后置
++
:
可以直接复用前置++,先创建一个变量,再让this解引用++,然后再返回创建的变量即可。
如上两个操作的具体代码为:
// 前置++
Date& Date :: operator++()
{
(*this) += 1;
return *this;
}
// 后置++
Date Date :: operator++(int)
{
Date tmp = *this;
(*this) += 1;
return tmp;
}
- 日期
- -
:实现同++
理。下面直接给出具体代码
// 后置--
Date Date :: operator--(int)
{
Date tmp = *this;
(*this) -= 1;
return tmp;
}
// 前置--
Date& Date :: operator--()
{
(*this) -= 1;
return *this;
}
- 日期
-
日期:
实现逻辑:复用++
,让小的日期一直++
至到大的日期,统计++
的次数即可。具体代码如下:
// 日期-日期 返回天数
int Date :: operator - (const Date& d) const
{
//假设法找出大日期和小日期
Date max = d;
Date min = *this;
if (*this > d)
{
max = *this;
min = d;
}
int count = 0;
while (max != min)
{
++min;
count++;
}
return count;
}
(2)比较运算
比较运算符主要分为以下几种:>、>=、<、<=、==、!=
其实我们只需实现 >
和 ==
,然后可通过复用实现所有的比较运算
- 大于
>
:
实现逻辑:进行年月日的逐一对比即可,其中一个大于就返回true,具体代码如下:
bool Date :: operator>(const Date& d) const
{
if (_year > d._year )
{
return true;
}
else if (_year ==d._year && _month > d._month)
{
return true;
}
else if (_year == d._year && _month == d._month && _day > d._day)
{
return true;
}
return false;
}
- 判等
==
:
实现逻辑:进行年月日的逐一对比,全相等就返回true,具体代码如下:
bool Date :: operator==(const Date& d) const
{
if (_year == d._year && _month == d._month && _day == d._day)
{
return true;
}
return false;
}
- 大于等于
>=
:
实现逻辑:即大于或等等于,具体代码如下:
bool Date :: operator >= (const Date& d) const
{
if (*this > d || *this == d)
{
return true;
}
return false;
}
- 不等于
!=
:
实现逻辑:对判等结果进行逻辑取反即可,具体代码如下:
bool Date :: operator != (const Date& d) const
{
return !(*this == d);
}
- 小于
<
:
实现逻辑:对大于等于的结果进行逻辑取反即可,具体代码如下:
bool Date :: operator < (const Date& d) const
{
return (!(*this >= d));
}
- 小于等于
<=
:
实现逻辑:对小于的结果进行逻辑取反即可,具体代码如下:
bool Date :: operator <= (const Date& d) const
{
return (!(*this > d));
}
(3)输入输出运算符重载
这里主要是对流插入>>
和流提取<<
运算符进行重载,因为对于这两个操作符而言,本质也是有两个操作数的,如:
int a;
cin >> a; //第一个操作数为istream类的对象cin,第二个操作数为int类对象a
cout << a; //第一个操作数为ostream类的对象cout,第二个操作数为int类对象a
(PS:关于流插入和流提取的相关细节知识,大家可以查阅相关文档进行学习,本文就进行重点说明)
重载之后就能像打印一个整型一样,按照指定的方式打印日期类对象。
实现有两个要点:
- 为了实现连续流插入和流提取要返回一个对应流插入类和流提取类的引用(PS:流插入类和流提取类的定义包含在文件iostream中);
- 需将这两个函数定义为全局的函数,如果定义为成员函数,由于第一个参数规定了是this指针,所以在实际使用起来就会是这样的:
Date d;
d << cout;
和我们平常用于输出整型等变量的方式相比有点别扭。
但定义为全局函数后,函数的声明为:ostream& operator<<(ostream& out, const Date& d)
,这样传参的顺序就和我们平常使用的一样了。还有一点需要注意,由于函数内部需访问类的私有成员,但函数又是全局的,所以需在类内部对这两个函数进行友元声明:
friend ostream& operator<<(ostream& out, const Date& d);
friend istream& operator>>(istream& in, Date& d);
(PS:正常使用时,参数out传的其实就是cout;参数in传的其实就是cin)
下面是具体代码:
- 流提取运算符
<<
:
//日期输出
inline ostream& operator<<(ostream& out, const Date& d) //o流中内容会改变,不加const
{
out << d._year << "年" << d._month << "月" << d._day<< "日" << endl;
return out;
}
- 流插入运算符
>>
:
//日期输入
inline istream& operator>>(istream& in, Date& d)
{
in >> d._year>> d._month>> d._day;
//非法日期判断
if (d._year > 0
&& (d._month > 0 && d._month <= 12)
&& (d._day > 0 && d._day <= d.GetMonthDay(d._year, d._month)))
{
return in;
}
else
{
cout << "非法输入" << endl;;
exit(-1);
}
}
本章完。
看完觉得有觉得帮助的话不妨点赞收藏鼓励一下,有疑问或有误地方的地方还请过路的朋友们留个评论,多多指点,谢谢朋友们!🌹🌹🌹