类和对象(中)
类的默认成员函数
- 构造函数
- 析构函数
- 拷贝构造函数
- 赋值运算符重载
默认成员函数就是程序中没有显示实现的函数,但是编译器会自动生成的成员函数就是默认成员函数。一个类,在我们不显示实现的情况下,编译器会默认生成6个成员函数,分别是构造函数、析构函数、拷贝构造函数、赋值重载、普通对象取地址和对const对象取地址,最后2个很少会自己实现,我们重点放在前面4个函数。
构造函数
构造函数是特殊的成员函数,构造函数并不是开空间创建对象,其主要任务是对象实例化时初始化对象,与栈和队列的初始化Init函数的功能类似,但Init函数不能自动调用,构造函数完美地取代Init函数
特点:
- 函数名与类名相同
- 无返回值(不需要写void)
#include <iostream>
class Date
{
public:Date() // 构造函数{_year = 1990;_month = 1;_day = 1;}
private:int _year;int _month;int _day;
};
- 构造函数可以重载
class Date
{
public:Date(){_year = 1990;_month = 1;_day = 1;}Date(int year, int month, int day) // 参数不同,函数名相同,构成函数重载{_year = year;_month = month;_day = day;}
private:int _year;int _month;int _day;
};
- 对象实例化时系统会自动调用对应的构造函数(保证了创建出来的对象一定初始化)
可以看到,d1和d2实例化时,调用了构造函数,与普通的调用不一样,这里的调用是对象直接调用(没有参数时,对象后面不能加括号)或者对象后面直接加参数,来调用构造函数,调用完之后,对象就完成了初始化
一般情况下,对于构造函数,一般提供的是全缺省参数,这样既可以传参,也可以不传参,避免了对构造函数的多次重载
#include<iostream>
class Date
{
public:Date(int year = 1990, int month = 1, int day = 1){_year = year;_month = month;_day = day;}
private:int _year;int _month;int _day;
};
int main()
{Date d1;Date d2(2024, 9, 3);Date d3(2024);return 0;
}
- 当不显示实现构造函数时,C++编译器会自动生成一个无参的默认构造函数,一旦显示实现就不再生成
从上面代码中可看到,在没有显示实现构造函数的情况下,编译器自动调用了默认生成的构造函数,打印出来的结果是随机值,似乎并没有起到作用,没有得到预期,这是因为,编译器默认生成的构造函数,对内置类型(int、char、double、指针等)成员变量是否初始化是确定的,这得看编译器如何处理;而对于自定义类型,也就是类类型的成员变量,会调用这个成员变量的默认构造函数进行初始化,没有默认构造就会进行报错,很显然上面的代码的编译器对内置类型的成员变量并没有进行初始化的处理
刚刚提到的,对于类类型的成员变量,会调用这个成员变量的默认构造来进行初始化,默认构造不单单只是编译器自动生成的构造,其实,默认构造一共有3中,分别是无参构造函数、全缺省构造函数、我们不显示构造时编译器自动生成的构造函数,这些都叫默认构造函数。但是这3个默认构造函数有且只有一个存在,不能同时存在。(总结:不传实参就可调用的构造函数,就是默认构造函数,传实参调用的是构造函数)
一个类有没有可能没有默认构造呢?
在显示实现构造的情况下,编译器就不会自动生成默认构造函数,当不传实参时,调用的是默认构造,所以会出现报错
什么情况下,对于类类型的成员变量,会调用该成员变量的默认构造呢?
- 用两个栈,实现队列(栈的成员变量是内置类型,编译器是否对其进行初始化是不确定的,所以需要我们手动实现,但队列的成员变量是类类型,编译器会自动调用该成员变量的默认构造),代码如下:
typedef int STDataType;
class stack
{
public://显示实现默认构造函数stack(int capacity = 4){_a = (STDataType*)malloc(sizeof(STDataType) * capacity);if (_a == nullptr){perror("malloc fail!");exit(-1);}_capacity = capacity;_top = 0;}
private:STDataType* _a;size_t _capacity;size_t _top;
};//两个栈,实现一个队列
class MyQueue
{
public:
private:stack _pushst;stack _popst;
};
int main()
{stack st1; //成员变量是内置类型,编译器是否对其进行初始化是不确定的,所以得自己实现MyQueue mq;//编译器会自动调用该成员变量的默认构造,无需手动实现return 0;
}
可看到代码中并没有显示实现队列的默认构造,但因为编译器自动调用了队列的成员变量的默认构造,所以队列的成员变量被进行了初始化
总结:一般情况下,都要自己手动实现默认构造
析构函数
析构函数不是完成对象本身的销毁,因为局部对象是存在栈帧中的,函数结束时栈帧销毁,对象就会被释放了,所以析构函数是完成对象中的资源的清理和释放工作,如:手动开辟的空间,像这样的资源就需要在对象销毁前进行清理和释放,C++规定对象在销毁时自动调用析构函数。析构函数的功能类比实现栈时,对栈进行销毁destroy的操作。
特点:
- 析构函数要在类名前加上符号 ~
- 无参无返回(不需要写void)
- 无函数重载,若未显示定义,系统会自动生成默认的析构函数
- 对象生命周期结束时,系统会自动调用析构函数
严格来说日期类是不需要写析构函数的,因为没有资源需要清理和释放,但为了方便理解以上的特点,就举了日期类这个例子
class Date
{
public:Date(int year = 1990, int month = 1, int day = 1){_year = year;_month = month;_day = day;}// 析构函数~Date(){cout << "~Date()" << endl;}
private:int _year;int _month;int _day;
};
int main()
{Date d1;return 0;
}
可以看到d1的生命周期快结束时,系统自动调用了析构函数
什么样的类才需要到析构函数呢?像栈这样的类就需要析构函数,因为栈需要我们手动开辟空间来创建数组,下面就来实现栈的析构函数
typedef int STDataType;
class stack
{
public:stack(int capacity = 4){_a = (STDataType*)malloc(sizeof(STDataType) * capacity);if (_a == nullptr){perror("malloc fail!");exit(-1);}_capacity = capacity;_top = 0;}~stack(){free(_a);_a = nullptr;_capacity = _top = 0;}
private:STDataType* _a;size_t _capacity;size_t _top;
};
int main()
{stack st1;return 0;
}
析构函数不止是清理和释放空间,在有些时候,如果有特殊需求时,也可以用到析构函数,如:日期类不需要写析构函数,但如果想在对象销毁时将年月日存储到文件中时,就可以强制写一个析构函数,在对象销毁前,就可先将对象中的数据存储到文件中,来满足我们的需求
为什么会有析构函数呢?
看上面的图,在主函数中创建了一个栈的对象st1,main函数的栈帧在开辟的时候就已经把st1的空间开辟好了,当栈帧被销毁时,st1也会自动被销毁,但是我们在对st1进行初始化时,在堆上开辟了空间,如果不调用析构函数对对象的资源进行清理和释放的话,当函数栈帧被销毁时,就会导致堆上的内存被泄露
- 跟构造函数类似,当我们不显示定义时,编译器对内置类型的成员不做处理,如:日期类;对类类型的成员,也会调用它的析构函数,如:两个栈,实现队列,代码如下:
typedef int STDataType;
class stack
{
public:stack(int capacity = 4){_a = (STDataType*)malloc(sizeof(STDataType) * capacity);if (_a == nullptr){perror("malloc fail!");exit(-1);}_capacity = capacity;_top = 0;}~stack(){free(_a);_a = nullptr;_capacity = _top = 0;}
private:STDataType* _a;size_t _capacity;size_t _top;
};
class MyQueue
{
public:// ...
private:stack _pushst;stack _popst;
};
int main()
{MyQueue mq1;return 0;
}
上面的代码中,没有显示的定义队列的析构函数,但在mq1销毁前,编译器自动调用了mq1中的自定义成员的析构函数(注意:若我们显式定义队列的析构函数时,就是在队列中又手动开辟了资源,对于自定义类型的成员变量,编译器也会自动调用该自定义类型的成员的的析构函数,也就是说,无论什么情况下,对于自定义类型的成员变量,都会自动调用它的析构函数)
- 若类中没有申请资源时,析构函数可以不写,直接使用编译器默认生成的析构即可;若类中申请了资源,且默认生成的析构可以用,也不需要显示写析构函数,如刚刚所举的MyQueue;若类中申请了资源,但默认生成的析构不可以用,一定要显示写析构函数,否则存在内存泄漏,如,stack。
总结:一般情况下,手动申请了资源的都要显示写析构函数,其他情况都不需要写
- 一个局部域中有多个对象,后定义的先析构
学习了构造函数和析构函数,可以通过用C语言实现栈的括号匹配问题,和用C++实现栈的括号匹配的问题,来感受一下这两个函数带来的便利,看下面代码的对比:
C和C++实现栈的括号匹配的问题,它们的底层实现的逻辑是不变的,只是在形态上发生了变化,从上面的对比中,我们可以看到,C++不需要调用Init初始化函数,也不用调用destroy销毁函数,而是在程序的运行中,编译器会自动调用它们的默认构造函数和析构函数,而且我们还可以看到,C++在传参的过程中,不需要每次都传栈的地址,因为C++中在调用成员函数时会有一个隐含的this指针
拷贝构造函数
拷贝构造是一种特殊的构造函数,它的第一个参数是自身类类型的引用。若有其他额外的参数且都有默认值,这种函数也叫作拷贝构造函数
特点:
- 拷贝构造是构造函数的一个重载
- 可以有多个参数,但是第一个参数必须是自身类类型的引用,其它参数必须是缺省值
class Date
{
public:Date(int year = 1990, int month = 1, int day = 1){_year = year;_month = month;_day = day;}Date(Date& d) // 拷贝构造函数{_year = d._year;_month = d._month;_day = d._day;}
private:int _year;int _month;int _day;
};int main()
{Date d1(2024, 9, 4);Date d2(d1); //调用拷贝构造函数return 0;
}
上面的代码中,d2使用d1来进行初始化的操作,这就是一个拷贝构造,通过拷贝来进行初始化,用d1去初始化d2,使得d2中的值与d1中的值相等
为什么拷贝构造的第一个参数一定要是自身类类型的引用呢?
可以看到第一个参数不是自身类类型的引用时,编译器就会报错,出现了无限递归。报错的原因:C++规定自定义类型传值传参必须调用拷贝构造
看下面这个代码,来理解上面报错的原因:
class Date
{
public:Date(int year = 1990, int month = 1, int day = 1){_year = year;_month = month;_day = day;}Date(Date& d){_year = d._year;_month = d._month;_day = d._day;}
private:int _year;int _month;int _day;
};
void func(Date d)
{//...
}
int main()
{Date d1;func(d1);return 0;
}
在上面的代码中,要调用func函数,在调用func函数的时候,会先传参,传参就会去调用d1的拷贝构造,将d1的值拷贝给d,做完这些操作之后,才会进入到func这个函数中,整体逻辑图如下:
上面的func在传值传参时,调用的拷贝构造是正确的,如果func调用的拷贝构造是不正确的,也就是说该拷贝构造的第一个参数不是自身类类型的引用,会出现无限递归的现象,代码如下:
class Date
{
public:Date(int year = 1990, int month = 1, int day = 1){_year = year;_month = month;_day = day;}Date(Date d){_year = d._year;_month = d._month;_day = d._day;}
private:int _year;int _month;int _day;
};
void func(Date d)
{//...
}
int main()
{Date d1;func(d1);return 0;
}
此时可以看到,构造函数的第一个参数的类型是类类型,不是类类型的引用,当调用func函数的时候 ,会先传值传参,传值传参会调用对应的拷贝构造,调用完之后如果回得来才会进入到func函数中,而调用的拷贝构造也是先要传值传参,又继续去调用拷贝构造,每一次调用拷贝构造之前都要进行传值传参,传值传参是一种拷贝,又形成新的拷贝构造,就导致了无限递归,逻辑图如下:
若拷贝构造中的第一个参数不是类类型的引用,而是类类型的传值时,会继续调用拷贝构造,而拷贝构造又是传值,又会继续进行调用拷贝构造,无限调用下去就形成了递归,所以拷贝构造的第一个参数是自身类类型的引用就不会报错了,因为形参是实参的别名,传参时不需要进行拷贝
- 拷贝构造的第一个参数必须是自身类类型的引用,且最好被const修饰,为什么要被const修饰呢?
我们知道调用拷贝构造可以这样调用:
Date d1(2024,1,1);Date d2(d1);
但其实也可以这样调用:
Date d1(2024,1,1);Date d2 = d1;
显然第二种调用方式,更具有可读性,来看下面代码,其会将第二种调用方式展现得淋漓尽致
class Date
{
public:Date(int year = 1990, int month = 1, int day = 1){_year = year;_month = month;_day = day;}Date(Date& d){_year = d._year;_month = d._month;_day = d._day;}
private:int _year;int _month;int _day;
};
Date func()
{Date ret;// ...return ret;
}
int main()
{Date d1(2024,1,1);//第一种调用方法Date d2(func());//第二种调用方法Date d3 = func(); // 显然第二种方法更具有可读性和习惯性return 0;
}
将上述代码放到vs中运行,来看结果:
编译器为什么说没有找到拷贝构造呢,我们明明已经实现了拷贝构造函数,这是因为这里出现了权限放大,我们知道,传值传参作为返回值时,返回的是临时拷贝,而不是返回值本身,在临时拷贝的过程中会生成一个临时对象,用来存储拷贝到的数据,而临时对象具有常性不能被修改,所以就出现了权限放大,当临时对象作为拷贝构造的实参传给要调用的拷贝构造的形参时,是不允许被修改的,所以拷贝构造的第一个参数需要被const修饰,通过下图加深理解:
所以拷贝构造的第一个参数,最好加上const进行修饰,加上const对普通对象不会造成影响,因为权限不可放大,但是权限可以缩小
- 若未显式定义拷贝构造,编译器会自动生成拷贝构造,与构造函数、析构函数不同,编译器会对内置类型进行处理,进行浅拷贝(一个字节一个字节的拷贝),对自定义类型对调用它的拷贝构造
对内置类型的成员,编译器会对其进行处理,是不是就说明了,对于所有内置类型的成员,我们就不用显示定义拷贝构造了呢?答案肯定是否定的,就用栈来说明,栈的成员也是内置类型
typedef int STDataType;
class stack
{
public:stack(int capacity = 4){_a = (STDataType*)malloc(sizeof(STDataType) * capacity);if (_a == nullptr){perror("malloc fail!");exit(-1);}_capacity = capacity;_top = 0;}~stack(){free(_a);_a = nullptr;_capacity = _top = 0;}
private:STDataType* _a;int _capacity;int _top;
};
int main()
{stack st1(10);stack st2 = st1;return 0;
}
可看到,未显示定义拷贝构造时,编译器对栈进行了浅拷贝的处理,但是代码运行到最后时为什么会出问题呢?
原因如下:
编译器对栈进行浅拷贝处理之后,使得st1和st2所开辟的空间都指向了同一块,当出了作用域之后,编译器就会自动调用析构函数,st2先进行析构,将st2所指向的空间释放掉,然后再释放st1所指向的空间,此时,因为st2先析构,所以st1所指向的空间已经被释放了,当析构st1时,该空间已不能再被释放,同一块空间只能被释放一次;且浅拷贝出来的空间,使得创建出来的栈没有各自的空间,当对st1中的数据进行更改时,st2也会受到干扰
解决方法:
对栈的拷贝,进行深拷贝,深拷贝是指,开辟同样大的空间,里面的值都要一样,栈的深拷贝代码如下:
public:stack(const stack& st){_a = (STDataTtpe*)malloc(sizeof(STDataType) * st._capacity);if(_a == nullptr){perror("malloc fail!");exit(-1); }memcpy(_a,st._a,sizeof(STDataType)*st._top); // 拷贝值_capacity = st._capacity;_top = st._top;}
C++规定传值传参,要调用对应的拷贝构造,为了减少拷贝,我们可以用它的别名,来减少拷贝,如下:
传值传参,会调用它的拷贝构造,若改成传引用传参,就可以减少拷贝
总结:如果一个类显示定义了析构且释放资源,那么这个类就要显示定义拷贝构造,否则就不需要
- 传值返回会产生临时对象的拷贝就会调用拷贝构造。传引用返回,可避免临时对象的拷贝。若想避免临时对象的拷贝,就一定要确保返回的对象,在当前函数结束后还在,才能传引用返回,如果返回的对象只存在于当前的函数中,一旦函数销毁,使用引用返回就会出现问题,此时的引用相当于一个“野引用”,与野指针的说法类似。
可看到,当返回的是局部变量的引用时,编译器出现了警告,func函数结束后,st已经被释放了,再将st拷贝给ret时,就会导致越界访问
将上图中func函数的代码改一改:
stack& func()
{static stack st;// ...return st;
}
此时,将st就不在func函数的栈上了,而是在静态区,func函数被销毁后,st不销毁,就可使用引用返回,所以使用引用返回时,一定要注意返回的对象的生命周期
注:拷贝构造用于已初始化的对象,拷贝给另外一个正在创建的对象
赋值运算符重载
- 运算符重载
概念:当运算符被用于自定义类型的对象时,C++语言允许我们通过运算符重载的形式给运算符指定新的含义,来适用于自定义类型的对象,且C++规定类类型对象在使用运算符时,必须转换调用对应的运算符重载,若没有对应的运算符重载,则编译报错
为什么自定义类型在使用运算符时,必须要调用对应的运算符重载呢?
对于内置类型,系统是可以直接支持使用运算符的,因为内置类型是系统自己定义的简单类型,所以内置类型在使用运算符时,有直接对应的指令,然后直接使用运算符;而对于自定义类型,系统不支持直接使用运算符,因为系统根本不认识我们自己定义的类型,不能预设自定义类型的使用运算符的规则,不能直接转换成运算符的指令来使用,所以自定义类型对于运算符的使用规则,应该有怎样的行为,是由写代码的人来规定,这也是为什么自定义类型在使用运算符时,必须调用对应的运算符重载的原因
- 运算符重载具有特殊的函数名,函数名为operator + 要定义的运算符,具有参数和返回值
- 运算符重载函数的参数个数和该运算符作用的运算对象数量一样多,如:“+”,是个二元运算符,所以加号的运算符重载函数的参数的数量为2;“++”,是一元运算符,其重载的参数的数量为1
例子: 两个日期类进行大小比较
class Date
{
public:Date(int year = 2024, int month = 1, int day = 1){_year = year;_month = month;_day = day;}
private:int _year;int _month;int _day;
};
bool operator<(const Date& x, const Date& y)
{if (x._year < y._year){return true;}else if (x._year == y._year && x._month < y._month){return true;}else if (x._year == y._year && x._month == y._month && x._day < y._day){return true;}else{return false;}
}
int main()
{Date d1;Date d2(2024, 9, 6);//显示地调用operator<函数bool ret1 = operator<(d1,d2);//不显示地调用,显然更具有可读性和习惯性,两种调用方式都可bool ret2 = d1 < d2; 本质上,两种调用方式,转换成的汇编指令都是一样的return 0;
}
但代码运行时,出现了报错:
解决上面的报错方式,可以将类的成员变量放到公有的区域(public)中即可,但是在正常情况下,成员变量默认都是私有的。那还有其他哪些解决方式呢?将运算符重载函数定义成成员函数,就可以访问私有的成员变量
代码如下:
class Date
{
public:Date(int year = 2024, int month = 1, int day = 1){_year = year;_month = month;_day = day;}bool operator<(const Date& x, const Date& y){if (x._year < y._year){return true;}else if (x._year == y._year && x._month < y._month){return true;}else if (x._year == y._year && x._month == y._month && x._day < y._day){return true;}else{return false;}}
private:int _year;int _month;int _day;
};
int main()
{Date d1;Date d2(2024, 9, 6);bool ret = d1 < d2;return 0;
}
可是在运行时,又出现了新的错误:
为什么参数会过多呢?这是因为当运算符重载函数变成成员函数时,则该运算符重载函数的第一个参数是隐含的this指针,因此运算符重载函数变成成员函数时,参数要比运算对象少一个。正确代码如下:
class Date
{
public:Date(int year = 2024, int month = 1, int day = 1){_year = year;_month = month;_day = day;}bool operator<(const Date& y){if (_year < y._year){return true;}else if (_year == y._year && _month < y._month){return true;}else if (_year == y._year && _month == y._month && _day < y._day){return true;}else{return false;}}
private:int _year;int _month;int _day;
};
int main()
{Date d1;Date d2(2024, 9, 6);//重载成成员函数时,显示调用如下:bool ret1 = d1.operator<(d2);//不显示调用如下:bool ret2 = d1 < d2;return 0;
}
运算符重载的特点:
- 运算符重载以后,其优先级和结合性与对应的内置类型运算符保持一致
- 不能通过链接语法中没有的符号来创建新的操作符,如:operator@
- 重载运算符至少有一个类类型参数
- .* 、sizeof、: : 、? : 、.(点) ,以上这5种运算符不能进行重载
第一个运算符用于调用成员函数的指针,用例如下:
- 重载运算符时,有前置++和后置++,重载的函数名都为operator++,为了方便区分,C++规定,后置++的函数的参数加一个int类型
对于后置++传不传值都可以,参数增加一个int类型,主要是为了与前置++构成运算符重载
- 赋值运算符重载
赋值运算符重载是一个默认成员函数,用于完成两个已经存在的对象直接的拷贝赋值,注意与拷贝构造进行区分,拷贝构造用于一个已经存在的拷贝给另外一个正在创建的
拷贝构造:
Date d1(2023,12,19);
Date d2 = d1;
赋值拷贝:
Date d1(2024,9,6);
Date d2(2023,1,1);
d2 = d1;
特点:
- 赋值运算符重载必须重载为成员函数,避免与编译器生成的默认赋值运算符产生冲突,参数写成const当前类类型的引用,避免传值传参的拷贝
class Date
{
public:Date(int year = 1990, int month = 1, int day = 1){_year = year;_month = month;_day = day;}void Print(){cout << _year << "/" << _month << "/" << _day << endl;}// d1 = d2// d1.operator=(d2)void operator=(const Date& d){_year = d._year;_month = d._month;_day = d._day;}
private:int _year;int _month;int _day;
};
int main()
{Date d1;Date d2(2024, 12, 19);d1 = d2;d1.Print();return 0;
}
- 有返回值时,建议写成类类型的引用,提高效率
上面实现的赋值运算符重载是没有返回值的,所以不能满足连续赋值,而想要使得该赋值运算符能连续赋值就要有返回值,为什么呢?看下面的板图:
所以赋值运算符重载要有返回值,且返回的是类类型的引用(注意返回值的周期判断是否加const),避免了临时对象的拷贝。还从上图中看到,在赋值运算符重载中,返回的是运算符左边的对象,而左边的对象在函数中是隐含的this指针控制的,所以返回值为*this
所以日期类的赋值运算符重载函数的正确写法为:
public:Date& operator=(const Date& d){// 为了防止自己给自己赋值,所以还需再判断一下if(this != &d){_year = d._year;_month = d._month;_day = d._day;}return *this;}
- 当显示定义时,编译器会自动生成一个默认赋值运算符重载,默认赋值运算符重载与拷贝构造类似,对于内置类型进行浅拷贝,所以严格来说日期类是不用显示定义运算符重载的;对于自定义类型会调用它的默认赋值运算符重载函数
总结:如果一个类需要显示定义析构函数释放空间,就需要显示定义赋值运算符重载函数,否则就不需要