欢迎各位大佬光临本文章!!!
还请各位大佬提出宝贵的意见,如发现文章错误请联系冰冰,冰冰一定会虚心接受,及时改正。
本系列文章为冰冰学习编程的学习笔记,如果对您也有帮助,还请各位大佬、帅哥、美女点点支持,您的每一分关心都是我坚持的动力。
我的博客地址:bingbing~bang的博客_优快云博客
https://blog.youkuaiyun.com/bingbing_bang?type=blog
我的gitee:冰冰棒 (BingbingSuperEffort) - Gitee.com
https://gitee.com/BingbingSuperEffort
系列文章推荐
目录
前言
简单了解类的基本定义与特性后,下面我们开始深入的解析一下C++中的类与对象。我们知道C++中类的成员函数是不存在类中的,一个类中,如果没有成员变量,那么类就是一个空类,空类真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成6个默认成员函数。
1.类的6个默认成员函数
任何一个类,在什么都不写的时候,都会默认生成6个成员函数。
默认成员函数:用户没有显示实现,编译器会自动生成的成员函数。
2.构造函数
2.1构造函数的概念
在之前我们实现栈、队列等数据结构时,使用结构体创建对象时都需要调用初始化函数来将自己创建的对象进行初始化,然后再对其进行各种数据插入删除操作。再C++中同样具备这样的问题,例如下面的Date类,每次创建一个对象都要调用Init进行设置,不然对象内部就是随机值,非常的麻烦,并且有时还会忘记调用初始化函数直接对其进行操作。为了避免这种状况,C++大佬们在类生成的时候,如果用户没有显示提供构造函数,会自动创建一个默认构造函数。
class Date
{
public:
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.Init(2022, 7, 23);//每次都需要调用初始化函数进行处理
d1.Print();
Date d2;
d2.Init(2022, 7, 25);//
d2.Print();
return 0;
}
构造函数的功能与初始化函数一样,在创建对象的时候编译器直接自己调用相应的构造函数对对象进行处理。这样对象就一定会被初始化。
2.2构造函数的特性
构造函数是一种特殊的成员函数,构造函数并不是开辟空间创建对象,而是对已经创建的对象进行初始化。
其有以下特点:
(1)函数名与类名相同
class classname
{
classname()//构造函数
{
}
};
(2)无返回值
(3)对象实例化时编译器自动调用,不用显示调用
(4)构造函数可以重载
还是上面的日期类,我们可以为其创建构造函数,再创建对象时为其进行初始化。
但是对于日期类这种并不复杂的类,我们可以直接使用缺省参数写一个构造函数即可,不需要写两个来实现重载。这样无参调用就会采用缺省值进行初始化,有参调用就用参数初始化。
更改后的构造函数:
Date(int year = 1, int month = 1, int day = 1)//构造函数
{
_year = year;
_month = month;
_day = day;
if ( !CheckDate() )//增加检查日期合法性函数,后文介绍
{
Print();
cout << "日期非法" << endl;
}
}
注意:Date d3( ); 并不是通过无参构造函数创建对象d3,而是一个函数声明,该函数名为d3,没有参数调用,返回类型为Date类。
(5)如果类中没有显示定义构造函数,C++会默认生成一个无参的构造函数,一旦用户显示定义,编译器不在生成。
那我们不定义构造函数,编译器默认生成的构造函数是怎么处理的呢?例如我们实现以下日期和时间类,日期类中包含自定义类型时间类Time。日期类没有显示提供构造函数,时间类中提供了构造函数。
class Time
{
public:
Time(int hour=0, int minute=0, int second=0)
{
cout << "Time()" << endl;
_hour = hour;
_minute = minute;
_second = second;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
Time _t;
};
因此编译器自动生成的构造函数初始化的特点构成了构造函数的第六条特性。
(6) 编译器默认自动生成的构造函数,对于自定义结构的初始化,编译器会去调用该类型的默认构造函数,对于内置成员不做处理。
补充:
C++将类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,int、char、double、指针(任何类型的指针都是内置类型)。自定义类型就是我们自己实现定义的类型。
如果自定义类型的成员内部也没有显示定义构造函数,内置成员也不会处理。
既然如此,这个默认生成的构造函数好像并没有什么用,因为内置类型的成员变量初始化后还是和没初始化一样,自定义的调用我自己写的构造函数,归根结底还得自己写!
基于这种情况,C++11中打了一个补丁,内置类型的成员变量在类中声明时可以给默认值,如果没有显示定义构造函数,默认生成的构造函数会将内置成员变量初始化为声明时给的默认值。
(7)默认构造函数并不是只有编译器生成的函数才能称为默认构造函数,无参的构造函数,全缺省的构造函数,我们没写编译器自动生成的构造函数都是默认构造函数,但是默认构造函数只能有一个!也就是说,不传参数的构造函数就是默认构造函数。
如果我们实现了全缺省的构造函数,就不用再写一个无参的构造函数了,因为在进行类型创建时,如果直接创建不传参数的对象,两个函数都符合调用规则,编译报错。
2.3构造函数的总结
综上所述,一般类中都不会让编译器默认生成构造函数,都会写一个全缺省的显示构造函数,一些特殊类中,例如使用两个栈构成的队列类,可以不写,因为编译器会自动调用栈的构造函数进行初始化。虽然构造函数可以重载,但是默认构造函数只有一个。
3.析构函数
3.1析构函数的概念
析构函数也是特殊的成员函数。我们在C语言实现的栈,队列等数据结构时,难免会使用内存开辟函数来开辟空间存储数据,我们当时是自己写了销毁函数,在使用完该数据结构后,主动调用该函数释放内存空间,避免内存泄漏。但是我们有时会忘记主动调用销毁函数,照成内存空间没有释放,从而存在内存泄漏的风险。
析构函数就是C++用来解决这一问题提供的方案,在一个类中,析构函数与构造函数执行的功能恰恰相反,该函数不是完成对象本身的销毁,局部对象的销毁在栈空间释放时会自动销毁,而是对象在销毁时会自动调用析构函数进行空间的清理工作,将动态开辟的内存还给操作系统。
3.2析构函数的特性
作为一个特殊的成员函数,析构函数具备以下特征:
(1)析构函数名是在类名前加上字符~
class classname
{
~classname()//析构函数
{
}
};
(2)函数无参数,无返回值
(3)一个类只能有一个析构函数。若未显示定义,系统会自动生成默认的析构函数。析构函数不支持重载
(4)对象生命周期结束时,系统自动调用析构函数
还是日期类的例子,我们搭建一个析构函数,由于日期类并没有开辟空间,我们采用打印方式进行验证。
class Date
{
public:
Date(int year=1, int month=1, int day=1)//构造函数
{
_year = year;
_month = month;
_day = day;
}
~Date()//析构函数
{
cout<<"~Date"<<endl;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
我们发现,系统确实自动调用了析构函数,哪怕该类型没有空间进行释放。
那我们不写析构函数,系统自动生成的析构函数是什么样的呢?
(5)与构造函数类似,默认生成的析构函数对内置类型不做处理,自定义类型成员会去调用他的析构函数。
我们对日期类进行改造并验证,增加数组空间开辟,如果调用析构函数将会释放数组空间。
class Time
{
public:
Time(int hour=0, int minute=0, int second=0,int capacity=4)
{
_hour = hour;
_minute = minute;
_second = second;
_arr = (int*)malloc(sizeof(int) * capacity);
}
~Time()//析构函数
{
cout<<"~Time()"<<endl;
_hour = -1;
_minute = -1;
_second = -1;
free(_arr);
_arr = nullptr;
}
private:
int _hour;
int _minute;
int _second;
int* _arr;//增加数组开辟空间
};
class Date
{
public:
Date(int year=1, int month=1, int day=1)
{
_year = year;
_month = month;
_day = day;
}
//不显示提供析构函数
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
Time _t;//自定义类型
};
(6)析构函数并不是一定要写的函数,如果一个类中并没有申请空间,那么类中的对象在栈销毁时会自动收回成员空间,使用系统默认生成的析构函数即可,但是如果类中申请了空间,就必须要写析构函数来释放空间,如Stack类。
(7)析构函数对对象进行清理时与对象的存储区域有关。相同存储区域下先构造后析构,全局变量优先构造,最后析构,局部静态变量运行时初始化,等栈中的对象析构完成后才会去静态区进行析构。
3.3析构函数的总结
析构函数也是特殊的成员函数,但是析构函数不支持重载,无参数无返回值,函数名与类名相同,前面需要加上~。对于没有空间开辟的类,不需要显示写析构函数,使用系统默认生成的即可。当类中具有空间开辟时,析构函数必须要写,析构函数不用主动调用,系统会在对象销毁时自动调用析构函数。析构函数再对对象清理时的顺序与对象的存储区域有关,相同区域下,执行先构造后析构的操作。对象的生命周期与存储区域有关。
4.拷贝构造
4.1拷贝构造的概念
在C语言中我们可以使用自定义变量给另一个自定义变量赋值,其实就是用到了拷贝构造。
#include<stdio.h>
struct Date
{
int _year;
int _month;
int _day;
int* arr;
};
int main()
{
struct Date d1;
d1._day = 25;
d1._month = 7;
d1._year = 2022;
d1.arr = (int*)malloc(sizeof(int));
struct Date d2 = d1;//C语言中的拷贝构造
return0;
}
C++中的类也具备一个默认成员函数,拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已经存在的类类型对象创建新对象时由编译器自动调用。
4.2拷贝构造的特性
拷贝构造函数也是特殊的成员函数,其特征如下:
(1)拷贝构造函数是构造函数的一个重载形式
(2)拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器会报错,因为会引发无穷递归。
拷贝构造函数既然是构造函数的重载形式,那么就应该和构造函数一样,函数名与类名相同,且没有返回值。
为什么拷贝构造函数的参数需要使用引用而不是形参呢?
原因很简单,使用形参传递时,形参本身就是实参的一份临时拷贝,既然要拷贝就需要调用拷贝函数进行拷贝,调用后参数又是形参,那我们还得继续调用拷贝函数来拷贝形参,这样下去就形成了无穷的递归调用,最终崩溃。
而我们使用引用传参就不会引发这种状况,因为d是d1的别名,指向的还是d1,传参过程不会触发拷贝构造,因此不会出现无穷递归现象。
注意:通常我们在使用拷贝构造时还会加上const来确保拷贝的对象不会被更改,因为如果我们写成下面这样,编译器并不会报错。
所以正确的拷贝构造函数应该如下实现:
Date( const Date& d)//拷贝构造
{
_year = d._year;
_month = d._month;
_day = d._day;
}
(3)与构造、析构函数类似,在我们没有显示写出构造函数的时候,编译器也会自动生成默认的拷贝构造函数。默认的拷贝构造函数对象按内存存储字节序进行拷贝,此种拷贝称为浅拷贝,或者值拷贝。
默认生成的拷贝构造函数内置类型按照字节直接拷贝,自定义类型调用其拷贝构造函数完成拷贝。
还是用日期类进行验证:
class Time
{
public:
Time(int hour=0, int minute=0, int second=0)//构造函数
{
_hour = hour;
_minute = minute;
_second = second;
}
Time(const Time& t)//拷贝函数
{
cout << "Time(Time& t)" << endl;
_hour = t._hour;
_minute = t._minute;
_second = t._second;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
Date(int year=1, int month=1, int day=1)//构造函数
{
_year = year;
_month = month;
_day = day;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
Time _t;
};
Date类中没有显示提供拷贝函数,在调用时采用默认生成的拷贝函数,Time中显示定义了拷贝函数。如果调用了Time类中自定义的拷贝构造函数将会打印字符到屏幕上。
(4)虽然编译器能够默认生成按字节拷贝的拷贝构造函数,但是并不是所有的类都不需要主动实现拷贝构造。
像日期类这种是没有必要实现拷贝函数的,因为默认生成的拷贝函数足够我们使用,但是如果我们在实现类的时候需要开辟数组空间时就会出现问题。例如Stack类中,默认生成的拷贝构造就会出现问题。
typedef int DateType;
class Stack
{
public:
Stack(int newcapacity = 4)//构造函数
{
a = (DateType*)malloc(sizeof(DateType) * newcapacity);
size = 0;
capacity = newcapacity;
}
~Stack()//析构函数
{
if ( a )
{
free(a);
size = capacity = 0;
a = nullptr;
}
}
private:
DateType* a = 0;
int size = 0;
int capacity = 0;
};
我们发现,使用默认拷贝构造生成的s2和s1里面的数组a都指向同一块空间,为什么呢?这是因为默认拷贝函数按照内存中的字节一一拷贝(如同memcpy函数),生成s2的时候会将s1中a开辟的空间地址拷贝给s2中的a,因此两个指针指向同一块空间。
在函数调用完成后,对象销毁时会自动去调用析构函数释放空间,因此s1,s2中的a指向的同一块空间将会释放两次,此时编译器将会报错。
注意:函数在使用传值返回时,会将返回结果调用拷贝构造进行复制一份,返回的是复制的结果,对象销毁时会将拷贝的对象进行析构,相比于引用返回,传值返回多调用一次拷贝构造和析构函数,因此我们尽量使用引用返回(前提是对象不销毁)。
4.3拷贝构造的总结
拷贝函数是构造函数的重载,拷贝构造函数通常用在创建相同内容的对象,函数传参是对象为类类型,函数返回类类型的情况下,返回类类型变量时,能用引用尽量用引用返回。拷贝函数不写系统会默认生成,默认生成的拷贝函数使用的是浅拷贝,如果类对象需要开辟空间,则需要自己实现深拷贝来构建拷贝构造函数。拷贝构造函数的参数必须为类类型的引用。
5.赋值运算符重载
5.1运算符重载
我们在学习C语言时,对各种运算符进行了认识和运用,但是运算符只能进行内置类型的操作,例如+,-,*,/,等操作符,只能进行整型,浮点型的运算,如果我们想对自定义类型进行运算符操作,那我们就得用函数实现,然后传递参数进行比较或者运算,阅读,理解和使用起来都不方便。
//C语言中判断两个日期类是否相等的函数
int IsEqual(struct Date d1, struct Date d2)
{
return d1._day == d2._day
&& d1._month == d2._month
&& d1._year == d2._year;
}
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值的类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字:关键字 operator 后面接需要重载的运算符符号
函数原型:返回值类型operator操作符
以日期类相等运算符"=="为例:
class Date
{
public:
Date(int year=1, int month=1, int day=1)
{
_year = year;
_month = month;
_day = day;
}
bool operator ==(const Date& d)//相等运算符的重载
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
//全局相等运算符的重载,内置私有成员无法访问
//bool operator ==(const Date& d1,const Date& d2)
//{
//return d1._year == d2._year
// && d1._month == d2._month
// && d1._day == d2._day;
//}
int main()
{
Date d1(2022, 7, 25);
Date d2(2022,7,26);
cout << (d1 == d2) << endl;//直接使用相等判断即可
return 0;
}
一般情况下,运算符的重载都不会写成全局函数,全局函数会造成类中私有成员无法访问的问题,虽然我们可以使用友元进行解决,但是友元破坏了封装,因此我们都会在类中进行运算符重载。
注意:
(1)不能通过连接其他符号来创建新的操作符:比如 operator@,运算符重载只能重载已存在的运算符。
(2)重载操作符必须有一个类类型参数,因为内置类型可以直接使用运算符,不需要重载。
(3)用于内置类型的运算符,其含义不能改变,重载的运算符应该执行该运算符本身具有的运算操作,+就是+,- 就是 - 。
(4)作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this
(5).* :: sizeof ? : . 以上5个运算符不能重载
运算符的重载将在日期类的具体实现时进行练习讲解,这里不在赘述!
5.2赋值运算符重载的特性
(1)赋值运算符的重载也是类中默认会生成的一个函数,因此赋值运算符只能重载成类的成员函数,不能重载成全局函数。
为什么不能写成全局呢?难道仅仅是因为私有成员无法访问到吗?并不是,原因在于如果类中没有显示提供赋值运算符重载,类会自动生成一个,当我们在全局实现时,与类中自动生成的赋值运算符重载冲突,无法调用,所以赋值运算符只能是类的成员函数。
(2)赋值运算符重载的格式:
参数类型:引用传参,const Date& d ,使用引用传参可以提高效率,使用const修饰可以避免书写错误对原有数据进行修改。
返回值类型:引用返回,Date& ,提高效率,有返回值可以支持连续赋值。
进行检测:检测是否自己给自己赋值
返回 *this:符合连续赋值
以日期类为例,赋值运算符重载如下:
Date& operator=(const Date& y)//=
{
if ( this != &y )
{
_year = y._year;
_month = y._month;
_day = y._day;
}
return *this;
}
(3)用户没有显示实现时,编译器会自动生成一个默认的赋值运算符,逐字节进行拷贝。注意:内置类型成员变量直接赋值,自定义类型需要调用该类型的赋值运算符进行赋值。
以日期类为例,Time类中自己进行了赋值运算符重载的实现,Date类中没有实现,在调用时会自动生成。
class Time
{
public:
Time(int hour=0, int minute=0, int second=0,int capacity=4)
{
_hour = hour;
_minute = minute;
_second = second;
}
Time& operator =(const Time& d)
{
cout << "Time==" << endl;
_hour == d._hour;
_minute == d._minute;
_second == d._second;
return *this;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
Date(int year=1, int month=1, int day=1)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
Time _t;
};
虽然编译器可以自己生成默认的赋值运算符重载函数,但是并不意味着我们就可以不用再写,赋值运算符重载在面对日期类这种只需要值拷贝时确实不需要自己实现,但是在Stack这种类中,我们则必须自己写,原因在于,栈,队列中的类自己开辟的空间,默认生成的赋值运算符按照逐字节方式进行拷贝将导致两个不同栈对象中的动态开辟的内存指向了同一块空间,被赋值的对象原有空间丢失,存在内存泄漏,析构函数调用时还是会对同一个空间进行两次释放,导致程序崩溃。
5.3赋值运算符重载的总结
赋值运算符重载是类中默认生成的一个函数,默认生成的只能进行逐字节拷贝赋值,对于没有开辟空间的类来说可以不显示书写,对于开辟空间的类来说,需要自己构建,以免造成内存泄漏和程序崩溃。赋值运算符必须在类中实现,不能写成全局类型,赋值运算符重载时返回的是*this,以便于进行连续赋值。
6.const成员
将const修饰的成员函数称为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明该成员函数中不能对类的任何成员进行修改。
例如打印函数,我们不需要改变this指针指向的内容,我们只需要将其内容打印即可,但是打印函数并不会显示传递对象,函数也不用显示接收,那么const怎么添加呢?当我们对const修饰的对象进行打印时,编译还会报错,这种状况如何改变呢?
这里C++是将const写在了函数名和参数后面,编译器会自动处理。
class Date
{
void Print()const
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
7.取地址及const取地址操作符的重载
取地址操作符无非是对自己实现的类进行取地址操作,const修饰的对象取地址取出的应该是const 类型的地址,所以编译器会默认生成这两个类型的重载,只有当我们不想让别人获取自定义类型的地址时,才会显示提供函数进行重载。
总结
对于析构函数来说,大部分类都需要自己写构造函数,只有类中对象仅包含自定义类型时才不需要写,每个类最后都提供默认构造函数。当类中涉及到空间开辟时,必须要写析构函数,没有资源需要清理的类就不用写。析构函数与构造函数系统默认生成的函数对内置对象不做处理,自定义对象会去调用自定义对象的相应函数。拷贝和赋值分情况来写,Date这种类就不需要,系统默认生成的浅拷贝和赋值可以使用,当空间开辟时,我们需要自己进行深拷贝的构造。赋值运算符重载与拷贝类似,默认生成的都是逐字节进行拷贝,当有空间开辟时,应该显示提供赋值运算符重载函数,避免内存泄漏和程序崩溃。取地址与const取地址通常不用我们显示提供,使用系统自动生成的就可以。