前言
本节继续进一步学习类的概念~~
类的默认成员函数
空类:类中没有任何成员变量和成员函数。
//空类
class Date{
};
一般在设计一个类时我们通常会定义对类的数据成员进行初始化的函数,对类中数据成员进行销毁(比如动态申请空间的释放)的函数…这些函数实现了特定的功能,并且不是这一个类独有的功能,而是很多类都会需要实现的功能。在C++的类中,便将一些类经常会用到的功能由编译器默认以函数的方式隐士的实现了,这样就简化了类的实现,一些功能我们可以不需要显式的写出来了,编译器帮我们完成了。
当然,编译器实现的这些函数遵循同用的规则,并不一定适合我们所写的类,所以有时还是需要我们显式的写出来的,当我们将某些函数显式的写出来了,编译器就不会再隐式的实现了。
一个类中没有写任何成员函数时编译器会自动生成默认成员函数。
默认成员函数是我们设计类时没有显式实现,而编译器自动生成的成员函数。
默认成员函数对于我们来说是隐式的、不可见的,但确实是存在的。
简单来说,默认成员函数是在我们设计类成员函数时没有显式实现时,由编译器自动为类隐式生成的实现特定功能的一系列函数。它的存在就是为了我们在设计类时提供可能的便利。
但是这些成员函数的引入,我们学习类时变得复杂艰难了,每个成员函数又有着各自的特性,往往还杂糅在一起,在学习时非常容易混淆和懵逼。(笑哭)
默认成员函数可以分为6种:
- 构造函数
- 析构函数
- 拷贝构造函数
- 赋值重载函数
- 普通对象取地址重载函数
- const对象取地址重载函数
构造函数
引子
我们还是要从C语言开始说起。
在用C语言实现数据结构时我们经常使用到了结构体,让我们可以自定义类型。
比如说数据结构栈:
typedef int STDataType;
struct Stack {
STDataType* val;
int top;
int capacity;
};
//初始化栈
void StackInit(struct Stack* pst) {
assert(pst);
pst->val = NULL;
pst->top = 0;
pst->capacity = 0;
}
在通过栈的结构体类型定义了一个栈时,我们需要对其进行初始化操作,以便于对结构体中成员进行初始化(分别为每一个成员赋予一个合理的初始值),这是非常有必要的操作,因为未初始化的变量往往是随机值,这可能会导致出乎意料的错误。
这样的初始化操作时普遍需要的,所以前人在对C++类的设计时便引入了默认成员函数的概念,构造函数便是其中之一。
构造函数概念
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,在对象整个生命周期内只调用一次,使每个数据成员都有一个合适的初始值。
构造函数本质是对类成员变量的初始化,即构造的功能是对类成员变量的初始化。
构造函数特性
构造函数的函数名与类名相同、无返回值、类对象实例化时编译器自动调用对应的构造函数;
写第一个构造函数:
class Date {
public:
//构造函数
//对象创建时,自动调用构造函数对对象进行初始化
//函数名和类名相同
//没有返回值且返回类型位置啥也不写
Date(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;
};
构造函数重载
函数重载:函数名相同,存在函数的参数个数、参数种类、参数顺序不同。
构造函数也是函数,我们也可以写出构造函数的重载,以此形成满足多样的初始化方式。
class Date {
public:
Date(int year, int month, int day) {
_year = year;
_month = month;
_day = day;
}
Date(int year, int month) {
_year = year;
_month = month;
_day = 1;
}
Date(int year) {
_year = year;
_month = 0;
_day = 0;
}
Date() {
_year = 0;
_month = 0;
_day = 0;
}
void Print() {
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
构造函数重载的初始化:
Date a1(2021, 1, 1);
Date a2(2022, 2);
Date a3(2023);
Date a4;
//Date a4();//error
这里需要注意的一点是:
使用构造函数的无参重载形式对类成员变量初始化时,不需要向改构造函数传入参数,注意不要写上只包含空格的小括号,对于无参构造函数来说这个小括号是多余的。同时如果加上了小括号会产生二义性:这到底是对类对象的初始化呢,还是定义了一个无参且返回类型是类类型的函数呢?
可以看到,我们虽然想把它认为是类对象的无参初始化,但是编译器好像并不这么认为:
vs2019编译器把它当成了函数声明。
含缺省参数的构造函数
构造函数形参也可以与使用缺省值,这使得同一个构造函数可以实现不同的类对象初始化方式。
class Date {
public:
Date(int year = 1970, int month = 1, int day = 1) {
_year = year;
_month = month;
_day = day;
}
//全缺省和无参构造函数可以同时存在,但函数调用时会产生二义性,
//全缺省构造函数可以完美代替无参构造函数
/*Date() {
_year = 1970;
_month = 1;
_day = 1;
}*/
void Print() {
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main() {
Date a1(2020, 1, 2);
Date a2;
a1.Print();
a2.Print();
return 0;
}
需要注意的是:
全缺省构造函数和无参构造函数是可以同时存在的,这在语法上没有错误;但是在具体的函数调用时,如果这两个函数都起作用的话,在对类对象使用无参初始化时就会产生二义性,编译器不知道应该调用全缺省构造函数还是调用无参构造函数了,产生编译错误。
全缺省参数构造函数能够很好的代替无参构造函数。
对默认构造函数的分析
首先第一点:一个类的默认构造函数有哪几种?
默认构造函数共三种,调用它们时都不需要传入参数
包括:无参的构造函数、全缺省的构造函数、编译器默认生成的构造函数;
注意,全缺省构造函数不传参数也可以成立,默认构造函数只能有一个
如果类中没有显式定义构造函数,那么C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义构造函数之后编译器就不在生成无参的默认构造函数;
编译器默认生成的构造函数到底在初始化时做了什么呢?
先来看类中只有内置类型的情况:
class Date {
public:
void Print() {
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main() {
Date a1;
a1.Print();
return 0;
}
哦,看来编译器啥也没做,类对象的成员变量是随机值。
再来看一个队列类,注意队列的底层实现是两个栈类对象
//栈
class Stack {
public:
//构造函数
Stack(int capacity = 4) {
//cout用于辅助说明
cout << "构造函数: Stack(int capacity = 4)" << endl;
_array = (int*)malloc(sizeof(int) * capacity);
_top = 0;
_capacity = capacity;
}
void Push(int val) {
if (_top == _capacity) {
int newcapacity = _capacity * 2;
int* tmp = (int*)realloc(_array, sizeof(int) * newcapacity);
assert(tmp);
_array = tmp;
_capacity = newcapacity;
}
_array[_top++] = val;
}
private:
int* _array;
int _top;
int _capacity;
};
//队列
class Queue {
public:
void Push(int val) {
_stPush.Push(val);
}
private:
Stack _stPush;
Stack _stPop;
int size;
};
int main() {
//队列对象
Queue q;
return 0;
}
那么Queue类
的默认构造函数究竟做了什么呢?
首先可以确定的一点是Queue类
对象q
创建时,其默认构造函数调用了自定义类型(栈类
)成员变量_stPush、_stPop
的构造函数;
而对于内置类型int型
size来说,可以看到其被默认构造函数初始化为了0
;
首先C++把类型分为了内置类型和自定义类型。
内置类型:
C++本身提供的类型;比如:char、int、float、int*、int&...
等。
自定义类型:
使用者使用class/struct/union/enum
等定义的类型;
前人在设计默认构造函数功能时,对于不同类型成员变量采取了不同的措施:
对于自定义类型成员变量,默认构造函数会直接调用自定义类型自己的构造函数对自定义类型变量进行间接的初始化,即初始化是有自定义类型本身的构造函数完成的,默认构造函数只是简单的调用;
对于内置类型成员变量,C++并没有规定默认构造函数是否需要对内置类型变量进行初始化,于是乎大多数编译器都选择了不对内置类型进行初始化,总的来说,内置类型变量是否被初始化了是未知的,这与具体的编译器有关。
如何解决类中即含(有自己的构造函数的)自定义类型又含内置类型,且需要对内置类型进行初始化的情况呢?
- 显式定义构造函数,并使用初始化形参列表完成初始化;
class Stack {
public:
//缺省值能声明或定义一处给出
Stack(int capacity = 4) {
cout << "Stack(int capacity = 4)" << endl;
_array = (int*)malloc(sizeof(int) * capacity);
if (_array == nullptr) {
perror("Init file");
exit(-1);
}
_top = 0;
_capacity = capacity;
private:
int* _array;
size_t _top;
size_t _capacity;
};
class Queue {
public:
//构造函数
//初始化形参列表
Queue(int capacity = 8) : _stPush(8),_stPop(8),_size(0){
//_size = 10;
}
private:
Stack _stPush;
Stack _stPop;
int _size;
};
int main() {
Queue q;
return 0;
}
- 内置类型成员变量在类中声明时给一个默认值 ;
这个默认值是在C++11中新增的对默认构造函数的补丁,目的是为了应对以前设计默认构造函数留下的这个不足之处。
class Stack {
public:
//构造函数
Stack(int capacity = 4) {
_array = (int*)malloc(sizeof(int) * capacity);
_top = 0;
_capacity = capacity;
}
private:
int* _array;
int _top;
int _capacity;
};
class Queue {
public: //...
private:
Stack _stPush;
Stack _stPop;
int size = 0;//内置类型变量size声明时给一缺省值
};
int main() {
Queue q;
return 0;
}
日期类成员变量都是内置类型,可以使用这种方法:
构造函数什么时候写,什么时候不写(使用默认构造函数)?
先说结论:
要看我们对类的具体需求,编译器默认生成的构造函数能够满足我们的需求就不需要写,不满足时才需要手动写;
首先,并不是说有内置类型的变量就一定要写,自定义类型就一定不需要写;
内置类型
需要写:
栈类就需要我们写构造函数,因为栈需要初始时大小为0,容量为我们期望的大小,指向动态开辟的指针的指向可能是NULL
,也可能有具体的指向。
自定义类型
不需要写:
队列类用两个栈模拟实现时就不需要我们写构造函数,队列会调用栈自己的构造函数。
需要写:
队列类用两个栈模拟实现,但是我们对两个栈的大小给出了明确的大小,这样栈的构造函数初始化的大小就不符合我们期望的初始大小了,这种情况我们就需要手动写队列类的构造函数了。
析构函数
引子
在用C语言实现数据结构时我们经常使用到了结构体,让我们可以自定义类型。
比如说数据结构栈:
typedef int STDataType;
struct Stack {
STDataType* val;
int top;
int capacity;
};
//初始化栈
void StackInit(struct Stack* pst) {
assert(pst);
pst->val = NULL;
pst->top = 0;
pst->capacity = 0;
}
void StackDestroy() {
free(val);
val = NULL;//指针指向NULL
top = capacity = 0;//手动置0
}
在使用完栈后,需要执行栈的销毁函数Destroy()
,目的并不是完成对栈本身的销毁,而是为了释放动态开辟的栈内指针成员所指向的空间,一般还有对栈内其他成员变量的合理赋值。
C++中引入了析构函数来完成与之相似的功能。
析构函数概念
我们知道局部对象的销毁是由编译器完成的,所以析构函数并不是完成对对象本身的销毁,而是完成对象中的资源清理工作,这些工作系统一般不会帮我们完成。比如对动态内存的释放等。
析构函数在类对象销毁时会自动被调用。
构造函数特性
析构函数名是由~+类名
组合而成;
析构函数没有显式的参数(除了this指针);
析构函数没有返回值类型,并省略返回值类型的书写;
写第一个析构函数:
class Stack {
public:
//构造函数
Stack(int capacity = 4) {
cout << "构造函数: Stack(int capacity = 4)" << endl;
_array = (int*)malloc(sizeof(int) * capacity);
_top = 0;
_capacity = capacity;
}
//析构函数 - 类似于Destroy()功能,完成资源的清理,不清理类对象本身
//局部域、全局域、动态申请的,影响生命周期
~Stack() {
cout << "析构函数: ~Stack()" << endl;
free(_array);
_array = nullptr;
_top = _capacity = 0;
}
private:
int* _array;
int _top;
int _capacity;
};
int main() {
Stack st;
return 0;
}
对象生命周期结束时,C++编译器自动调用析构函数
class Stack {
public:
//构造函数
Stack(int capacity = 4) {
cout << "构造函数: Stack(int capacity = 4)" << endl;
_array = (int*)malloc(sizeof(int) * capacity);
_top = 0;
_capacity = capacity;
}
~Stack() {
cout << "析构函数: ~Stack()" << endl;
free(_array);
_array = nullptr;
_top = _capacity = 0;
}
private:
int* _array;
int _top;
int _capacity;
};
void Function() {
Stack st;
}
int main() {
Function();
Stack st;
return 0;
}
析构函数不能重载
一个类只能有一个析构函数,如果没有显式定义析构函数,系统会自动生成默认的析构函数。
那么编译器自动生成的默认析构函数到底做了什么呢?
其实这里与构造函数相比,做了逻辑相同的事:
对于内置类型:默认析构函数不处理;
对于自定义类型:默认析构函数会直接调用自定义类型自己的析构函数,从而间接完成对自定义类型的析构。
class Stack {
public:
Stack(int capacity = 4) {
cout << "Stack(int capacity = 4)" << endl;
_array = (int*)malloc(sizeof(int) * capacity);
if (_array == nullptr) {
perror("Init file");
exit(-1);
}
_top = 0;
_capacity = capacity;
}
~Stack() {
cout << "~Stack()" << endl;
free(_array);
_array = nullptr;
_top = _capacity = 0;
}
private:
int* _array;
size_t _top;
size_t _capacity;
};
class myQueue {
public:
private:
Stack _stPush;
Stack _stPop;
int size = 10;
};
int main() {
myQueue q;
return 0;
}
析构函数什么时候写,什么时候不写?
结论:
看实际需求,编译器生成的能够满足我们的需求就不用再写析构函数,不能满足我们的需求就需要手动写。
对于没有资源申请的类,析构函数可以不写,当然写了也没有问题;
对于有资源申请的类,析构函数必须由我们自己来写,因为编译器并不知道我们怎样申请的资源,我们需要手动写析构函数来特定清理申请的资源,防止造成资源的泄露,比如内存泄漏。
对构造函数与析构函数调用时的先后关系分析
多个相同生命周期的类对象同时存在时,后构造的先析构,符合栈后进先出的特性;
来看一个例子:
class Date {
public:
Date(int year = 1, int month = 1, int day = 1) {
cout << "构造函数: Date(int year = 1, int month = 1, int day = 1)" << endl;
_year = year; _month = month; _day = day;
}
//显式写这个析构 只为了说明调用先后的关系
~Date() {
cout << "析构函数: ~Date()" << endl;
_year = 0; _month = 0; _day = 0;
}
private:
int _year;
int _month;
int _day;
};
int main() {
Date d1;
Date d2;
Date d3;
cout << endl;
return 0;
}
拷贝构造函数
引子
再C语言中创建一个某一个类型的变量时往往会对其初始化,其中有一种方式是使用已创建的变量对新创建的变量进行赋值初始化。
int a = 10;
int b = a;//故b == 10
C语言中结构体变量也支持赋值初始化方式:
#include <stdio.h>
struct Date {
int year;
int month;
int day;
};
int main() {
struct Date a = { 2022,10,10 };
struct Date b = a;//b{2022,10,10}
return 0;
}
C++的类中把赋值初始化的功能交给了拷贝构造函数,可以由编译器默认生成。
拷贝构造概念
拷贝构造是构造函数的一种;
拷贝构造只有一个显式的形参(不包含隐式的this指针),该形参是对本类类型对象的常引用;
在通过已存在的类类型对象创建新对象时由编译器自动调用。
拷贝即赋值,构造即初始化,所以功能是赋值初始化。
拷贝构造特性
拷贝构造函数是构造函数的一个重载形式;
拷贝构造函数的参数只有一个且必须是类类型对象的引用;
class Date {
public:
//构造即是初始化
Date(int year = 1, int month = 1, int day = 1) {
cout << "构造函数 Date(int year = 1,"
"int month = 1, int day = 1)" << endl;
_year = year; _month = month; _day = day;
}
//显式写出的拷贝构造函数
Date(Date& d) {
cout << "拷贝构造函数 Date(Date &d)" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
private:
int _year;
int _month;
int _day;
};
int main() {
Date d1(2022, 11, 11);//构造初始化
Date d2(d1);//拷贝构造 - 赋值初始化
return 0;
}
一般拷贝构造的引用形参会用const修饰,使引用d权限序缩小,防止引用形参的对象被意外改变:
Date(const Date& d) {
cout << "拷贝构造函数 Date(Date &d)" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
使用传值调用传参时拷贝构造会发生什么?
Date(const Date d) {
_year = d._year;
_month = d._month;
_day = d._day;
}
使用传值传参方式,会引发无穷递归,将一直调用拷贝构造函数。
为什么呢?
因为传值传参方式,形参是实参的临时拷贝;
也就是说,传参时会创建形参,然后形参再被实参赋值初始化,这又会调用拷贝构造函数本身,…,这样每次调用拷贝构造时都会对形参进行初始化,而对形参初始化又会调用拷贝初始化函数,这是一个死递归。显然这将导致栈溢出,所以有的编译器会对拷贝构造函数的形参进行检查,不是引用类型时就直接报错了,以防止死递归的发生。
对默认拷贝构造函数的分析
如果我们没有显式定义拷贝构造函数,编译器会生成隐式的默认的拷贝构造函数。
那么默认的拷贝构造函数到底做了什么事呢?
对于自定义类型:
默认拷贝构造会直接调用自定义类型自己的拷贝构造函数完成对自定义类型的拷贝初始化;
对于内置类型:
默认拷贝构造完成的是浅拷贝(值拷贝),是按内存存储的字节序完成的拷贝,是逐字节的拷贝,类似于memcpy()函数
。
class Date {
public:
//构造即是初始化
Date(int year = 1, int month = 1, int day = 1) {
cout << "构造函数 Date(int year = 1,"
"int month = 1, int day = 1)" << endl;
_year = year; _month = month; _day = day;
}
private:
int _year;
int _month;
int _day;
};
int main() {
Date d1(2022, 11, 11);//构造初始化
Date d2(d1);//拷贝构造 - 赋值初始化
return 0;
}
对于类中不涉及资源申请的成员变量来说,浅拷贝(值拷贝)是满足要求的,需要的就是值的拷贝;
对于类中涉及资源申请的成员变量来说,浅拷贝(值拷贝)就不满足我们的需求了,这时我们需要的是深拷贝,这需要我们自己手动实现,因为编译器并不知道资源具体的拷贝情况(如含有动态开辟的空间,我们需要自己再开辟一个空间)。
这会引发一个错误,导致程序崩溃:
原因是浅拷贝导致的,栈类对象st1
内成员指针变量_array
指向了一块动态开辟的空间,而栈类对象st2
内成员指针变量_array
也指向了栈st1
指针成员_array
指向的空间;再st1、st2
对象声明周期结束时,会分别调用析构函数,释放动态开辟的空间,导致了同一块空间被释放free()
了两次,程序崩了。
拷贝构造函数什么时候写,什么时候不写
浅拷贝(值拷贝)时,编译器默认生成的拷贝构造就是够用的;
深拷贝时,内置类型也许要自己写拷贝构造函数,即编译器默认生成的不完全合适;
一般来说,必须手写析构函数的类,都需要写深拷贝的拷贝构造,因为有资源需要额外创建空间并拷贝到该空间,
比如说动态申请的空间资源…
拷贝构造函数的调用场景
使用已存在对象创建新对象;
传参时,函数参数类型为类类型对象;
函数返回时,函数返回值类型为类类型对象;
class A {
public:
A(int a, int b) {
cout << "构造函数: A(int a, int b) " << this << endl;
_a = a;
_b = b;
}
A(const A& A1) {
cout << "拷贝构造函数: A(const A& A1) " << this << endl;
_a = A1._a;
_b = A1._b;
}
~A() {
cout << "析构函数: ~A() " << this << endl;
_a = _b = 0;
}
private:
int _a;
int _b;
};
A Copy(const A A2) {
A A3(A2);
return A3;
}
int main() {
A A1(1, 1);
Copy(A1);
return 0;
}
为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用尽量使用引用
赋值运算符重载函数
概念
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其
返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似
赋值运算符重载函数也是类的默认成员函数之一。
对于内置类型的赋值操作我们是非常熟悉的:
int a,b;
a = 10;//赋值
b = a;//赋值
int*p = nullptr;//初始化
p = &a;//赋值
赋值运算符重载特性
格式
假设有一个类的类名是
T
;
T& operator=(const T& t1){
//具体赋值操作
return *this;
}
函数名
operator=
形参类型const T&
返回值类型T&
一个栗子
class Date {
public:
//赋值运算符重载
Date& operator=(const Date& d) {
_year = d._year;
_month = d._month;
_day = d._day;
return *this;
}
//默认 构造
Date(int year = 1, int month = 1, int day = 1) {
_year = year;
_month = month;
_day = day;
if (!(_year >= 1
&& (_month >= 1 && _month <= 12)
&& (_day >= 1 && _day <= GetMonthDay(_year, _month)))) {
cout << "非法日期" << endl;
}
}
//拷贝构造
Date(const Date& d) {
_year = d._year;
_month = d._month;
_day = d._day;
}
//析构
~Date() {
_year = 0;
_month = 0;
_day = 0;
}
void Print() {
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
对初始化和赋值的区分
初始化是在创建变量时对变量赋一个初值;
int a = 10;//初始化
赋值是覆盖一个已经存在的变量原本空间储存的值,同时为该变量赋一个新的值;
int a = 10;//初始化
a = 20;//赋值
这同样也适用于类对象:
class A{
A(int a){
_a = a;
}
void Print(){
cout << _a << endl;
}
private:
int _a;
}
int main(){
A a1(10); //初始化(构造)
A a2 = 20; //初始化(拷贝构造),而不是赋值
A a3;
a3 = a1; //赋值重载,复制拷贝
return 0;
}
到底是初始化
拷贝构造
还是赋值复制运算符重载
呢?
只需要注意=
所代表的的含义:
在创建对象时赋一个初值就是初始化;
不涉及对象的创建且有=
就是赋值;
赋值运算符重载函数只能显式在类内实现
赋值运算符只能重载成类的成员函数不能重载成全局函数
class Date {
public:
//类内的赋值运算符重载 -- true
Date& operator=(const Date& d) {
if(this != &d){
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
private:
int _year;
int _month;
int _day;
};
赋值运算符重载函数是类的默认成员函数之一,如果我们在类内没有显式实现它,那么编译器就会自动隐式的实现默认的赋值运算符重载函数。
假如我们再类外某处实现了全局的赋值运算符重载函数,就会与类内的赋值运算符重载函数冲突,导致编译错误。
class Date {
public:
//...
private:
int _year;
int _month;
int _day;
};
//类外的赋值运算符重载 -- error
Date& operator=(const Date& d1, const Date& d2) {
d1._year = d2._year;
d1._month = d2._month;
d1._day = d2._day;
return *this;
}
其实这里还有这另外的问题:
定义在类外的普通函数,不能够直接访问到类内的私有成员,所以这还会有编译错误;
为了防止这个影响,上述编译器的报错是我们暂时先把类内的私有限定符private注释掉
产生的;
现在我们来看看如何解决类外的函数访问正常访问类内的私有成员:
首先我们一般不会选择取消掉私有成员的限定符private
,这对类内的私有成员失去了保护;
我们可以选择友元函数关键字friend
来实现类外普通函数对类内私有成员的访问。
class Date {
public:
//友元函数
friend Date& operator=(const Date& d1, const Date& d2);
//...
private:
int _year;
int _month;
int _day;
};
//类外的赋值运算符重载 -- error
Date& operator=(const Date& d1, const Date& d2) {
d1._year = d2._year;
d1._month = d2._month;
d1._day = d2._day;
return *this;
}
这样就编译器报错的原因就只有一个了:
我们在类外自己写的赋值运算符重载和编译器在类内默认生成的赋值运算符重载函数重定义了。
默认赋值运算符重载函数做了什么
前面我们知道我们在类内没有显式实现赋值运算符重载函数时,编译器会生成一个默认的赋值运算符重载。
那么,这个默认的赋值运算符重载到底完成了甚么功能呢?
先上结论:
对于内置类型,默认赋值运算符重载以字节的方式完成值拷贝
浅拷贝
;
class Date {
public:
//类内的默认赋值运算符重载
void Print(){
cout << _a << endl;
}
//默认 构造
Date(int year = 1, int month = 1, int day = 1) {
_year = year;
_month = month;
_day = day;
if (!(_year >= 1
&& (_month >= 1 && _month <= 12)
&& (_day >= 1 && _day <= GetMonthDay(_year, _month)))) {
cout << "非法日期" << endl;
}
}
private:
int _year;
int _month;
int _day;
};
int main(){
Date d1(2022,10,12), d2;
d2.Print();
d2 = d1;
d2.Print();
return 0;
}
对于不涉及资源申请和释放的成员变量来说,(值拷贝)
浅拷贝
是完全满足需求的,我们需要的就是值拷贝。
但是遇到涉及资源申请和释放的成员变量来说,(值拷贝)浅拷贝
就不够看了,我们此时不仅需要值拷贝,还需要深拷贝。
class Stack {
public:
//默认赋值运算符重载
//普通构造
Stack(int capacity = 4) {
cout << "普通构造: Stack(int capacity = 4)" << endl;
_array = (int*)malloc(sizeof(int) * capacity);
_top = 0;
_capacity = capacity;
}
//手写拷贝构造
Stack(const Stack& st) {
cout << "拷贝构造: Stack(const Stack& st)" << endl;
_array = (int*)malloc(sizeof(int) * st._capacity);
assert(_array);
//memcpy(des, src, sizeByte)
memcpy(_array, st._array, sizeof(int) * st._top);
_top = st._top;
_capacity = st._capacity;
}
void Push(int val) {
if (_top == _capacity) {
int newcapacity = _capacity * 2;
int* tmp = (int*)realloc(_array, sizeof(int) * newcapacity);
assert(tmp);
_array = tmp;
_capacity = newcapacity;
}
_array[_top++] = val;
}
~Stack() {
cout << "析构: ~Stack()" << endl;
free(_array);
_array = nullptr;
_top = _capacity = 0;
}
private:
int* _array;
size_t _top;
size_t _capacity;
};
int main(){
Stack st1;
st1.Push(1);
st1.Push(2);
Stack st2;
st2.Push(10);
st2.Push(20);
st2.Push(30);
return 0;
}
默认的浅拷贝造成程序崩溃:
程序为什么崩了呢?
默认赋值运算符重载函数只完成了值拷贝,拷贝完成后栈st1
中成员指针变量_array
存放的就是栈st2
中成员指针变量_array
的值,也就是说,栈st1
的指针_array
指向了栈str2
的指针_array
所指向的空间;即二者指向了同一块空间;
并且,栈st1
的指针_array
原来指向的空间也找不到了,也没有释放导致内存泄漏。
在main函数返回时
,两个栈对象st2,st1
先后销毁,分别调用各自的析构函数,st2
调用析构函数时,两个指针指向的同一块空间正常销毁,而st1
调用析构函数时同一块空间再次被释放,即开辟的同一块空间被释放了两次,导致程序崩溃。
栈涉及到了堆上空间的申请和释放,需要我们手动显式实现栈的赋值运算符重载函数:
//赋值运算符重载
Stack& operator=(const Stack& st) {
cout << "赋值运算符重载: Stack& operator=(const Stack& st)" << endl;
//这里的if判断是为了防止自己给自己赋值可能导致的随机值问题
if (this != &st) {
free(this->_array);
_array = (int*)malloc(sizeof(int) * st._capacity);
assert(_array);
//memcpy(des, src, sizeByte)
memcpy(_array, st._array, sizeof(int) * st._top);
_top = st._top;
_capacity = st._capacity;
}
return *this;
}
对于自定义类型,默认赋值运算符重载会调用自定义类型自己的赋值运算符重载函数;
class Stack {
public:
//普通构造
Stack(int capacity = 4) {
cout << "普通构造: Stack(int capacity = 4)" << endl;
_array = (int*)malloc(sizeof(int) * capacity);
_top = 0;
_capacity = capacity;
}
//手写拷贝构造
Stack(const Stack& st) {
cout << "拷贝构造: Stack(const Stack& st)" << endl;
_array = (int*)malloc(sizeof(int) * st._capacity);
assert(_array);
//memcpy(des, src, sizeByte)
memcpy(_array, st._array, sizeof(int) * st._top);
_top = st._top;
_capacity = st._capacity;
}
void Push(int val) {
if (_top == _capacity) {
int newcapacity = _capacity * 2;
int* tmp = (int*)realloc(_array, sizeof(int) * newcapacity);
assert(tmp);
_array = tmp;
_capacity = newcapacity;
}
_array[_top++] = val;
}
//赋值运算符重载
Stack& operator=(const Stack& st) {
cout << "赋值运算符重载: Stack& operator=(const Stack& st)" << endl;
if (this != &st) {
free(this->_array);
_array = (int*)malloc(sizeof(int) * st._capacity);
assert(_array);
//memcpy(des, src, sizeByte)
memcpy(_array, st._array, sizeof(int) * st._top);
_top = st._top;
_capacity = st._capacity;
}
return *this;
}
~Stack() {
cout << "析构: ~Stack()" << endl;
free(_array);
_array = nullptr;
_top = _capacity = 0;
}
private:
int* _array;
size_t _top;
size_t _capacity;
};
class myQueue {
public:
void Push(int val) {
stPush.Push(val);
size++;
}
private:
Stack stPush;
Stack stPop;
int size = 0;//是声明,给的是缺省值
};
int main() {
myQueue q1;
q1.Push(1);
q1.Push(2);
myQueue q2;
q2.Push(2);
q2.Push(2);
q1 = q2;
return 0;
}
可以看到,队列类内部并没有显式的实现赋值运算符重载函数在内的成员函数,但是,默认成员函数比如默认赋值运算符重载函数就是满足队列类的需求的:
两个队列对象q1,q2
,q1
向q2
赋值时会自动调用q1的默认赋值运算符重载函数而不是拷贝构造
,q1
的默认赋值运算符重载函数在调用自定义类型成员自己的赋值运算符重载函数完成对应的赋值操作。
赋值运算符重载函数什么时候写或不写
这里的判断方式和是否是内置类型无关,主要是根据需求来判断,默认赋值重载能够完成功能满足我们的需求,那么就不需要显式的写;默认赋值重载不能够完全满足我们的需求,那么就需要显式的写。
可以这样认为:
拷贝构造与赋值运算符重载相似,
不涉及资源申请和释放的内置类型都不需要显式的写构造与赋值运算符重载。
当我们必须要显式的写析构函数时,那么就一定需要显式的写拷贝构造和赋值运算符重载;
当我们不比显式的写析构函数时,就不比显式的写拷贝构造和赋值运算符重载;
这里的资源管理一般有动态申请的空间、文件打开和关闭等。
运算符重载函数
概念
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其
返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似
格式
运算符重载函数格式:
返回值类型 operator操作符 ( 参数列表 )
函数名是关键字operator加上要重载的操作符("+"、"-"、"*"、"/"、"++"、"--"、"+="、"-="......
)
需要注意的是:
只能重载已有运算符;
重载操作符必须有一个类类型参数;
内置类型的运算符,其含义不能改变;
作为类成员函数重载时,其形参比操作数数目少1,因为成员函数的第一个参数为隐藏的this;
".*"
点星,不常用
"::"
域作用运算符
"sizeof"
"?:"
三目运算符
"."
成员访问运算符
这5个运算符不能被重载
对等于运算符的重载
==
class Date {
public:
//构造
Date(int year = 1, int month = 1, int day = 1) {
_year = year;
_month = month;
_day = day;
}
Date(const Date& d) {
_year = d._year;
_month = d._month;
_day = d._day;
}
//private:
int _year;
int _month;
int _day;
};
int main() {
return 0;
}
假设类成员变量都是公有的:
对于Date类来说,如果想要比较两个Date
对象是否相等我们可以直接用一个函数实现,在该函数内部依次比较年月日是否对应相等即可。
//函数完成功能
bool isEqual(const Date& d1, const Date& d2) {
return d1._year == d2._year &&
d1._month == d2._month &&
d1._day == d2._day;
}
调用这个函数isEqual()
即可比较:
isEqual(d1, d2);//d1、d2是类对象
但这样不太直观,能不能像整型变量那样使用==
运算符进行比较呢?
在C语言中不能实现,C++中引入了运算符重载函数来实现这样的想法:
//c++引入,但是在类外不能访问到私有成员变量了,除了友元函数
bool operator==(const Date& d1, const Date& d2) {
return d1._year == d2._year &&
d1._month == d2._month &&
d1._day == d2._day;
}
于是我们可以这样调用运算符==
:
d1==d2;//d1、d2是类对象
//operator==(d1, d2);
但是成员变量一般是私有的private
,在类外时一般不能访问到类内的成员变量,那有什么方法解决呢?
方法1:使用友元函数;
class Date {
public:
//友元函数
friend bool operator==(const Date& d1, const Date& d2);
Date(int year = 1, int month = 1, int day = 1) {
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
bool operator==(const Date& d1, const Date& d2) {
return d1._year == d2._year &&
d1._month == d2._month &&
d1._day == d2._day;
}
这里使用了友元函数,关键字为
friend
。
友元函数首先是定义在类外的普通函数,再类中任意位置存在该普通函数的声明,且该声明使用关键字friend
修饰。
作用是使类外的普通函数(非成员函数)也可以访问类内的私有成员。
方法2:借助成员函数分别得到年月日,再分别进行比较;
class Date{
public:
int GetYear() {
int year = _year;
return year;
}
int GetMonth() {
int month = _month;
return month;
}
int GetDay() {
int day = _day;
return day;
}
}
bool operator==(Date& d1, Date& d2) {
return d1.GetYear() == d2.GetYear() &&
d1.GetMonth() == d2.GetMonth() &&
d1.GetDay() == d2.GetDay();
}
这里
operator==()引用类型形参
没有使用const修饰,因为使用const修饰之后传参时会导致权限放大。
比如:d1
如果用const
修饰:const Date& d1
d1的类型是const Date&
,调用类内的函数GetYear()
时会把d1的地址传给GetYear()函数
,而GetYear()函数
的隐式指针形参this
的类型是Date* const
,常引用d1的地址类型是const Date*
。
可以看到从const Date* ----> Date* const
权限是放大的,会导致编译错误,所以不加const
修饰。
方法3:把运算符重载函数写到类的里面,成为类的成员函数;
class Date {
public:
Date(int year = 1, int month = 1, int day = 1) {
_year = year;
_month = month;
_day = day;
}
//错误,参数过多,==应该只有两个操作数,
//而在类内的函数都会有一个隐含的形参this指针
/*bool operator==(const Date& d1, const Date& d2) {
return d1._year == d2._year &&
d1._month == d2._month &&
d1._day == d2._day;
}*/
bool operator==(const Date& d) {
return _year == d._year &&
_month == d._month &&
_day == d._day;
}
private:
int _year;
int _month;
int _day;
};
不过需要注意的是:
类的成员函数第一个参数是隐含的this
指针,运算符==
的操作数为2,运算符重载函数的形参就需要减少一个,被this指针
代替。
对其他运算符的重载
大于
>
bool Date::operator>(const Date& d) {
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;
}
不等于
!=
bool Date::operator!=(const Date& d) {
return !(*this == d);
}
大于等于
>=
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);
}
日期+=天数
+=
Date& Date::operator+=(int day) {
//加负的天数,特殊处理
if (day < 0) {
return *this -= -day;
}
_day += day;
while (_day > GetMonthDay(_year, _month)) {
_day -= GetMonthDay(_year, _month);
++_month;
if (_month == 13) {
_month = 1;
++_year;
}
}
return *this;
}
日期+天数
+
Date Date::operator+(int day) {
Date cur(*this);
cur += day;
return cur;
}
日期-=天数
-=
Date& Date::operator-=(int day) {
//减负的天数
if (day < 0) {
return *this += -day;
}
_day -= day;
while (_day <= 0) {
--_month;
if (_month == 0) {
_month = 12;
--_year;
}
_day += GetMonthDay(_year, _month);
}
return *this;
}
日期-天数
Date Date::operator-(int day) {
Date cur(*this);
cur -= day;
return cur;
}
日期-日期
-
//日期之差
//d1.operator-(d2) --> d1 - d2
int Date::operator-(const Date& d) {
//小日期逐渐趋近大日期,同时计数
//1.找大小日期
Date max(*this);
Date min(d);
int flag = 1;
if (*this < d) {
max = d;
min = *this;
flag = -1;
}
//2.循环计数
int count = 0;
while (min != max) {
++min;
++count;
//cout << count << endl;
}
return count * flag;
}
自增
++
前置++与后置++对象都+1,但是前置++返回的是+之后的结果(先+在使用),后置++返回的是+之前的结果(先使用在+)。
为了区分前置++与后置++,对于运算符重载来说,前辈规定,前置++没有显式形参,后置++有一个int型显式形参。
前置++
//前置++
Date& Date::operator++() {
*this += 1;
return *this;
}
后置++
//后置++
Date Date::operator++(int) {
Date cur(*this);
*this += 1;
return cur;
}
对于前置++或者
后置++来说,首先分别是运算符重载函数;
对于前置++和
后置++来说,二者又构成函数重载,二者函数名相同,而参数个数不同。
自减
--
前置–与后置–对象都-1,但是前置–返回的是-之后的结果(先-在使用),后置-返回的是-之前的结果(先使用在-)。
为了区分前置–与后置–,对于运算符重载来说,前辈规定,前置–没有显式形参,后置–有一个int型显式形参。
前置–
//前置--
Date& Date::operator--() {
*this -= 1;
return *this;
}
后置–
//后置--
Date Date::operator--(int) {
Date cur(*this);
*this -= 1;
return cur;
}
流插入运算符
<<
<<
本来是C语言中的左移操作符,在C++中<<
又被重载(运算符重载
)为流插入运算符(输出运算符)。
为了输出不同类型的参数,根据不同的参数<<
又重载运算符重载
为不同的运算符重载函数;
不同的运算符重载函数<<
之间又构成函数重载。
上图来源:cplusplus
ostream& operator<<(ostream& output, const Date& d) {
output << d._year << "/" << d._month << "/" << d._day << endl;
return output;
}
Date d(2022,10,12);//定义日期类对象d
cout << d;//operator(cout, d);
重载的流插入运算符
<<
一般不在类内实现,而在类外实现,并以友元函数的身份访问类内的私有成员。
至于为什么不要在类内实现<<
:
类内实现就是类的成员函数,而类的成员函数第一个形参是默认的
this指针
;
成员函数的形参的顺序决定了重载后运算符的操作数的顺序:
运算符的操作数与成员函数参数从左到右依次对应,包括隐式的this指针
对于有些运算符来说
+,-等
,与运算符的左操作数或右操作数顺序无关;
而对于另一些运算符来说<<、>>、>
,与运算符的右操作数或右操作数顺序有很大关系,如a>b
与b>a
;
对于<<
,一般是a<<b
,b流向a,而不是b<<a
。
但是在类内实现就是b<<a
,这也不能说错,但是很别扭。
//日期类内
ostream& operator<<(ostream& output) {
output << _year << "/" << _month << "/" << _day << endl;
return output;
}
Date d(2022,10,12);//定义日期类对象d
//d.operator<<(cout);
d << cout//流插入运算符>> 左右操作数反了
在运算符重载在类外就可以了,我们可以通过运算符重载的参数决定运算符的左右操作数了;
流提取运算符
>>
上图来源:cplusplus
//d1.operator>>(cin); --> d1 << cin ??
istream& operator>>(istream& input, Date& d) {
input >> d._year >> d._month >> d._day;
return input;
}
与
<<
运算符重载一样,>>
运算符重载也需要定义在类外。
运算符重载与函数重载的说明
运算符重载与函数重载是不同的概念:
运算符重载是对某一个运算符进行的重载;
函数重载是指函数名相同,函数参数个数、参数类型不同的不同函数;
二者基本没有关系
const成员
const修饰的成员函数称为const成员函数。
对于非const成员函数:
void Print()
{
cout << "Print()" << endl;
cout << "year:" << _year << endl;
cout << "month:" << _month << endl;
cout << "day:" << _day << endl;
}
Print()隐式的
this
指针类型是Date* const
;
对const成员函数:
void Print() const
{
cout << "Print()const" << endl;
cout << "year:" << _year << endl;
cout << "month:" << _month << endl;
cout << "day:" << _day << endl;
}
Print()隐式的
this
指针类型是const Date* const
;
const修饰成员函数修饰的到底是那个参数?
修饰的是隐式的this指针,而非其它形参;
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << "Print()" << endl;
cout << "year:" << _year << endl;
cout << "month:" << _month << endl;
cout << "day:" << _day << endl;
}
void Print() const
{
cout << "Print()const" << endl;
cout << "year:" << _year << endl;
cout << "month:" << _month << endl;
cout << "day:" << _day << endl;
}
private:
int _year; // 年
int _month; // 月
int _day; // 日
};
int main(){
Date d1(2022, 10, 12);
d1.Print();
const Date d2(1970, 1, 1);
d2.Print();
return 0;
}
关于const成员的一些问题
非const对象可以调用非const成员函数和const成员函数;
const对象只能调用const成员函数;
这里涉及到的是指针或引用相关的权限大小问题:
权限可以平移和缩小,但是权限不能放大。
非const成员函数可以调用const成员函数;
const成员函数只能调用const成员函数;
取地址重载
对取地址运算符
&
进行重载
是类内的默认成员函数之一,一般由编译器默认生成即可,不需要我们显式实现。
与构造函数、析构函数、拷贝构造、赋值运算符重载不同,取地址重载我们基本不需要实现,除非有特殊需求,我们也不会在此花费多少时间。
class Date {
public:
//默认 构造
Date(int year = 1, int month = 1, int day = 1) {
_year = year;
_month = month;
_day = day;
if (!(_year >= 1
&& (_month >= 1 && _month <= 12)
&& (_day >= 1 && _day <= GetMonthDay(_year, _month)))) {
cout << "非法日期" << endl;
}
}
Date* operator&() {
cout << "Date* operator&()" << endl;
return this;
}
private:
int _year;
int _month;
int _day;
};
int main(){
Date d1;
cout << &d1 << endl;
return 0;
}
如果我们不写,编译器默认生成的取地址重载函数也可以完成功能:
const取地址重载
const取地址重载也是类内默认成员函数之一,并且是我要介绍的默认成员函数的最后一个。
const取地址重载与取地址重载函数基本相同,只是其加了const修饰
。
const Date* operator&() const {
cout << "Date* operator&() const " << endl;
return this;
}
如果我们不写,编译器默认生成的const
取地址重载函数也可以完成功能:
结语
本节主要介绍了类的六个默认成员函数:
构造函数、析构函数、拷贝构造函数、赋值运算符重载函数、取地址重载函数、const取地址重载函数
它们最大的特点就是我们不显式的写时,编译器就会默认生成。
其中前四个成员函数是需要我们重点记忆的!
下次再见!
E N D END END