前言
在上篇我们讲解了类与对象的基础框架,中篇我们将讲解类与对象的基本内容,即类的六个默认成员函数。
一、类的6个默认成员函数
如果一个类中什么成员都没有,简称为空类。
问题: 空类中真的什么都没有吗?
答案是: 空类不是什么都没有,空类,编译器会自动生成6个默认成员函数。
tip:
①默认成员函数:用户没有显式实现,编译器会自动生成的成员函数称为默认成员函数。
②显式实现就是自己定义。
③这6个默认成员函数,前四个我们需要重点学习,最后两个了解即可。
二、构造函数与析构函数
1、引入构造与析构
括号匹配问题
用C语言实现括号匹配问题,代码示例:
//栈的元素类型
typedef char STDataType;
//栈的类型
typedef struct Stack
{
STDataType* a;//指向堆区开辟的数组
int top;//栈顶指针
int capacity;//栈的容量
}ST;
//栈的初始化
void STInit(ST* pst)
{
//pst一定不为空,断言
assert(pst);
pst->a = (STDataType*)malloc(sizeof(STDataType) * 4);
//判断是否申请空间成功
if(NULL == pst->a)
{
//打印错误信息,并退出
perror("STInit::malloc");
return ;
}
//初始化栈的容量和栈顶指针
pst->top = 0;//指向栈顶位置的下一个
pst->capacity = 4;
}
//栈的销毁
void STDestroy(ST* pst)
{
assert(pst);
//清理栈变量
//1、释放在堆区申请的空间
free(pst->a);
pst->a = NULL;//释放之后并不会改变pst->a,防止野指针生成置为空
pst->top = 0;
pst->capacity = 0;
}
//判断是否为空栈——空栈返回真
bool STEmpty(ST* pst)
{
assert(pst);
return pst->top == 0;
}
//获取栈的元素个数
int STSize(ST* pst)
{
assert(pst);
//断言是否为空栈
assert(!STEmpty(pst));
return pst->top;
}
//入栈——即数组尾插
void STPush(ST* pst ,STDataType x)
{
assert(pst);
//先判断是否需要扩容
if(pst->top == pst->capacity)
{
//防止扩容失败,先用一个临时变量保存扩容后的空间地址
//扩容一般按倍数扩
STDataType* tmp = (STDataType*)realloc(pst->a,sizeof(STDataType) * pst->capacity * 2);
//判断扩容是否成功
if(NULL == tmp)
{
//扩容失败打印错误信息,并退出
perror("STPush::realloc");
return;
}
//扩容成功
pst->a = tmp;
pst->capacity *= 2;
}
//入栈——即数组的尾插
pst->a[pst->top] = x;
pst->top++;
}
//出栈——即数组尾删
void STPop(ST* pst)
{
assert(pst);
//断言栈不为空
assert(!STEmpty(pst));
//出栈——即数组尾删
pst->top--;
}
//获取栈顶元素
STDataType STTop(ST* pst)
{
assert(pst);
//断言站不为空
assert(!STEmpty(pst));
return pst->a[pst->top - 1];
}
//利用栈先进后出的特点——遇到'(','{','[',进栈,遇到')','}',']'出栈
bool isValid(char* s)
{
//定义栈
ST st;
//初始化栈
STInit(&st);
int i = 0;
//字符串进栈,判断是否匹配
for(i = 0;s[i] != '\0';i++)
{
//判断是否入栈
if(s[i] == '(' || s[i] == '{' || s[i] == '[')
{
//入栈
STPush(&st,s[i]);
}
//不是入栈就是出栈
else
{
//判断栈是否为空
if(STEmpty(&st))
{
//栈为空,不匹配
//1、记得销毁栈
STDestroy(&st);
//2、返回假
return false;
}
//栈不为空
char tmp = STTop(&st);//保存栈顶元素
STPop(&st);//出栈
//判断栈顶元素是否匹配
if(tmp == '(' && s[i] != ')' ||
tmp == '[' && s[i] != ']' ||
tmp == '{' && s[i] != '}'
)
{
//不匹配
//1、记得销毁栈
STDestroy(&st);
//2、返回假
return false;
}
}
}
//字符串匹配结束,
//在释放之前要保存匹配的真假
bool ret = STEmpty(&st);//如果栈为空则全部匹配成功,不为空则匹配失败
//记得销毁栈
STDestroy(&st);
return ret;
}
tip:
①用C语言实现的话,你会发现一个栈类型ST,我们定义了一个栈变量后,并不是就可以直接使用了,要通过STInit函数给栈变量初始化,才能使用。
② 在栈变量出作用域(销毁)时,就要通过STDestroy函数清理栈变量资源。
③但是实际中我们a.初始化和销毁经常忘记(①初始化忘记虽然会报错,但是每创建一个栈对象都要调用STInit函数初始化太麻烦了;②栈对象出了作用域忘记调用STDestroy函数清理资源,就会造成内存泄漏,但是不报错,这就严重了);b.销毁有些地方写起来很繁琐。 如下图所示:
④我们C++祖师爷也遇到了这些问题,对象都要初始化和清理,这时祖师爷就想能不能自动初始化和清理,所以祖师爷引入了两个默认成员函数,构造函数——对象实例化时自动初始化;析构函数——对象出了作用域之前就自动清理对象资源。 有了构造和析构,就不怕忘记写初始化和清理了,也简化了。
2、构造函数
(1)概念
构造函数 是一个特殊的成员函数:
①名字与类名相同。
②创建类类型对象时由编译器自动调用, 以保证每个数据成员都有一个合适的初始值。
③在对象整个生命周期内只调用一次。
代码示例:
#include<iostream>
using namespace std;
class Stack
{
public:
//构造函数
Stack(int capacity = 4)
{
cout << "Stack(int capacity = 4)" << endl;
_a = (int*)malloc(sizeof(int) * capacity);
if (NULL == _a)
{
perror("malloc申请空间失败!!!");
return;
}
_capacity = capacity;
_top = 0;
}
//栈对象的初始化
/*void Init(int capacity = 4)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (NULL == _a)
{
perror("malloc申请空间失败!!!");
return;
}
_capacity = capacity;
_top = 0;
}*/
// 其他方法...
void Destroy()
{
if (_a)
{
free(_a);
_a = NULL;
_capacity = 0;
_top = 0;
}
}
private:
int* _a;
int _top;
int _capacity;
};
int main()
{
//实例化对象s1
Stack s1;
s1.Destroy();
//实例化对象s2
Stack s2;
s2.Destroy();
return 0;
}
运行结果:
tip:构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。 因为不管是局部变量,还是全局变量的创建都是系统自动创建的,是系统的事情。例如局部变量在栈帧里面,栈帧创建时变量自动创建,栈帧结束时自动销毁。
(2)特性
tip:不能把构造函数当成普通函数,它是特殊函数。
其特征如下:
①函数名与类名相同。
②无返回值。(注意,也不需要写void)
③对象实例化时编译器自动调用对应的构造函数。
④构造函数可以重载。(重载:构造函数虽然是特殊函数,但是也是函数,所以只要构造函数的形参列表不同即可重载。)
代码示例:
class Stack
{
public:
//全缺省构造函数——默认构造函数
Stack(int capacity = 4)
{
cout << "Stack(int capacity = 4)" << endl;
_a = (int*)malloc(sizeof(int) * capacity);
if (NULL == _a)
{
perror("malloc申请空间失败!!!");
return;
}
_capacity = capacity;
_top = 0;
}
//构造函数:用数组初始化
Stack(int* a, int n)
{
cout << "Stack(int* a, int n)" << endl;
_a = (int*)malloc(sizeof(int) * n);
if (NULL == _a)
{
perror("malloc申请空间失败!!!");
return;
}
//将数组的值拷贝到栈
memcpy(_a, a, sizeof(int) * n);
_capacity = n;
_top = n;
}
// 其他方法...
~Stack()
{
if (_a)
{
free(_a);
_a = NULL;
_capacity = 0;
_top = 0;
}
}
private:
int* _a;
int _top;
int _capacity;
};
int main()
{
//实例化对象s1
Stack s1;//不传参,调用默认构造函数
//实例化对象s2
int a[] = { 1, 2, 3 };
Stack s2(a, sizeof(a) / sizeof(int));//用数组初始化
return 0;
}
tip: 如上代码,我们发现构造函数的调用与普通函数不一样!
构造函数是实例化对象时自动调用的,a.如果实例化时不给参数,不能在后面添加括号;b.实例化时给参数,才可以添加括号。
⑤如果类中没有显示定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显示定义编译器将不再生成。
代码示例1:
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;
};
int main()
{
//不传参,调用无参的默认构造函数
Date d1;//报错,没有合适的默认构造函数可用,因为我们显示定义了一个构造函数
return 0;
}
代码示例2:
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;
};
int main()
{
//不传参,调用无参的默认构造函数
Date d1;//正确,当我们将类中的构造函数屏蔽后,没有显示的构造函数,编译器会生成一个无参的默认构造函数
return 0;
}
⑥既然我们不显式定义构造函数,编译会生成一个无参的默认构造函数,那我们还显式定义干嘛?都让编译器做了不更好?
我们先来看一段代码,我们不显式定义构造函数,让编译器生成,观察调用编译器默认生成的构造函数初始化后对象的值。
class Date
{
public:
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
//调用编译器生成无参的默认构造函数
Date d1;
//打印观察,初始化后的对象
d1.Print();
return 0;
}
运行结果:
我们发现调用编译器生成的默认构造函数初始化后对象依旧是随机值,这是为什么呢?
答案是:编译器生成的默认构造函数——a.内置类型不做处理(有些编译器可能会去处理内置类型,但是那是个性化行为,不是所有编译器都会处理,所以建议就算你的编译器会处理内置类型,你也要当成内置类型都不处理。例如VS这个系列的集成开发器,VS2019添加一个自定义类型,其他内置类型也被处理了,VS2013就不会处理。);b.自定义类型会去调用它的默认构造函数(注意:自定义类型如果没有默认构造函数,会报错!)。 这里应该是祖师爷的失误,应该都初始化的。
tip:C++把类型分为内置类型(基本类型)和自定义类型。a.内置类型:语言本身提供的数据类型,例如:int、char、double、指针(包括任意类型的指针)等等;b.自定义类型:用class、struct、union等自己定义的类型。
注意:C++11中针对内置类型成员不初始化的缺陷,打了个补丁,即:内置类型成员变量在类中声明时可以给缺省值。
代码示例:
class Date
{
public:
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
//注意是C++11支持,这里不是初始化,因为还没有开空间
//这里的默认的缺省值,是给编译器生成的默认构造函数使用的
int _year = 2001;
int _month = 1;
int _day = 1;
//自定义类型,编译器生成的默认构造函数会去调用它的默认构造函数
Stack _s;
};
int main()
{
//调用编译器生成无参的默认构造函数
Date d1;
//打印观察,初始化后的对象
d1.Print();
return 0;
}
运行结果:
结论:
- 一般情况下,有内置类型就需要自己显示定义构造函数,不能用编译器自己生成的默认构造函数。(特殊:在C++11中,如果内置类型都有缺省值,且初始化符合我们的要求,可以不显式定义构造。)
- 如果成员变量全是自定义类型,且这些自定义类型都显式定义了默认构造函数,可以考虑不显式定义构造,使用编译器默认生成的构造。
⑦默认构造函数: 无参构造函数、全缺省构造函数、编译器默认生成的构造函数都称为默认构造函数。注意:默认构造函数只能有一个!
代码示例:
class Date
{
public:
//无参的构造函数——默认构造函数
Date()
{
_year = 2001;
_month = 1;
_day = 1;
}
//全缺省的构造函数——默认构造函数
Date(int year = 2001, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year = 2001;
int _month = 1;
int _day = 1;
};
int main()
{
//不传参调用的就是默认构造函数
Date d1;
d1.Print();
return 0;
}
运行结果:编译报错!
解读:
- 语法上,Date();Date(int year = 2001, int month = 1, int day = 1);两个默认构造函数构成重载,可以同时存在。但是无参调用时存在歧义,所以默认构造函数只能有一个。
- 关于3种默认构造函数,推荐使用全缺省默认构造函数,因为全缺省默认构造函数即可以不传参使用缺省值初始化对象,也可以传参自己初始化对象。
- 不传参就可以调用的就是默认构造函数。
混淆知识:区分默认成员函数与默认构造函数
- 默认成员函数——我们不显式定义,编译器会自动生成的成员函数称为默认成员函数,一共有6个。
- 默认构造函数——不传参就可以调用的就是默认构造函数,默认构造函数一共有3个,编译器自动生成的只是其中一个,所以默认构造函数 != 默认成员函数,大家不要混淆了。
3、析构函数
(1)概念
析构函数执行与构造函数相反的操作:构造函数初始化对象的非static数据成员;析构函数释放对象使用的资源,销毁对象的非static数据成员。
析构函数:对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。 注意:析构不是完成对对象本身的销毁,对象的销毁工作是由编译器完成的。
(2)特性
①析构函数名是在类名前加上字符~。
②无参数无返回值类型。(因为没有参数,自然析构函数不存在重载。)
③一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。
④对象生命周期结束时,C++编译系统会自动调用析构函数。
代码示例:
class Date
{
public:
//默认构造函数
Date(int year = 2001, int month = 1, int day = 1)
{
cout << "Date(int year = 2001, int month = 1, int day = 1)" << endl;
_year = year;
_month = month;
_day = day;
}
//析构函数
~Date()
{
//一般只有在堆区的内存资源需要我们自己清理
//这里我们只是观察对象销毁时,是否会自动调用析构函数
cout << "~Date()" << endl;
}
private:
int _year = 2001;
int _month = 1;
int _day = 1;
};
int main()
{
Date d1;
return 0;
}
运行结果:实例化对象时自动调用构造函数初始化对象,对象销毁时自动调用析构函数清理对象。
⑤编译器自动生成的析构函数,与自动生成的构造函数一样——a.内置类型成员不做处理;b.自定义类型会去调用它的析构函数。
代码示例:
class Stack
{
public:
//全缺省构造函数——默认构造函数
Stack(int capacity = 4)
{
cout << "Stack(int capacity = 4)" << endl;
_a = (int*)malloc(sizeof(int) * capacity);
if (NULL == _a)
{
perror("malloc申请空间失败!!!");
return;
}
_capacity = capacity;
_top = 0;
}
private:
int* _a;
int _top;
int _capacity;
};
void TestStack()
{
Stack s1;
}
int main()
{
TestStack();
return 0;
}
通过F10调试观察内置类型成员s1.a:
对象销毁前:
对象销毁后:
tip:内置类型成员,销毁时不需要资源清理,最后系统直接将其内存回收即可。
总结:
- 一般情况下,有动态申请资源,就需要显示写析构函数释放资源。比如Stack类。
- 没有动态申请的资源,不需要写析构。比如Date类。(只有在堆区的空间,不用需要程序员来手动释放,其他区域的空间,系统自动销毁释放)
- 需要释放资源的成员都是自定义类型,不需要写析构。比如两个栈实现一个队列。(编译器生成的析构会去调用自定义类型的析构)
三、拷贝构造函数
1、引入
例如当前日期是2023.12.5,我想知道100天之后日期是多少,又不改变当前日期,该怎么办呢?
答案是:创建一个与已存在对象一模一样的新对象——拷贝构造函数。
2、概念
拷贝构造函数: 只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
3、特征
拷贝构造函数也是特殊的成员函数,其特征如下:
①拷贝构造函数是构造函数的一个重载形式。
②拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。
解析:
- C++规定,值传递——a.内置类型按字节直接拷贝;b.自定义类型必须调用拷贝构造完成拷贝。
- 当形参是引用类型时,没有拷贝,因为从语法层面上引用形参是它对应的实参的别名,没开空间。
③若未显示定义,编译器会生成默认的拷贝构造函数。
- 内置类型成员完成值拷贝/浅拷贝。(值拷贝/浅拷贝——默认的拷贝构造函数对象按内存存储按字节序完成拷贝)
- 自定义类型成员会调用它的拷贝构造函数。
代码示例1:Date类只需完成浅拷贝即可,所以不用写拷贝构造函数
class Date
{
public:
//默认构造函数
Date(int year = 2001, int month = 1, int day = 1)
{
cout << "Date(int year = 2001, int month = 1, int day = 1)" << endl;
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023,12,6);
Date d2(d1);
return 0;
}
F10调试结果,如下图:
代码示例2:MyQueue类的成员都是自定义类型,编译器生成的默认构造函数会去调用其拷贝构造函数完成拷贝,所以不用写拷贝构造函数。
class MyQueue
{
private:
Stack _popst;
Stack _pushst;
};
int main()
{
MyQueue d1;
MyQueue d2(d1);
return 0;
}
问题:编译器生成的默认拷贝构造对内置类型也会做处理,完成值拷贝,那还需要自己显示实现吗?
代码示例3:Stack类有动态申请的资源
class Stack
{
public:
//全缺省构造函数——默认构造函数
Stack(int capacity = 4)
{
cout << "Stack(int capacity = 4)" << endl;
_a = (int*)malloc(sizeof(int) * capacity);
if (NULL == _a)
{
perror("malloc申请空间失败!!!");
return;
}
_capacity = capacity;
_top = 0;
}
//析构函数——有动态申请的空间,所以需要显示定义析构,自己清理资源
~Stack()
{
cout << "~Stack()" << endl;
if (_a)
{
free(_a);
_a = NULL;
_capacity = 0;
_top = 0;
}
}
void Push(const int& data)
{
// CheckCapacity();
_a[_top] = data;
_top++;
}
private:
int* _a;
int _top;
int _capacity;
};
int main()
{
Stack s1;
s1.Push(1);
s1.Push(2);
s1.Push(3);
s1.Push(4);
//调用编译器默认生成的拷贝构造函数——只完成浅拷贝
Stack s2(s1);
return 0;
}
运行结果:崩溃!
解析:
- 没有显式定义拷贝构造函数,编译器生成的默认构造函数只完成了值拷贝,即s1和s2内容一样,s1._a和s2._a指向同一块内存空间。
- s2与s1销毁时,都要调用析构函数,一块内存空间多次释放,程序崩溃。
- s1和s2指向同一块内存空间,造成一个对象修改会影响另一个对象
总结:
- 一般类中没有涉及资源申请时,浅拷贝即可完成需要;一旦涉及到资源申请,需要深拷贝才能完成需要。
- 编译器生成的默认拷贝构造函数,就可以完成浅拷贝,所以可以不写;深拷贝必须自己写拷贝构造函数。
- 为什么自定义类型拷贝,必须要调用拷贝构造函数——考虑到了深拷贝。
④拷贝构造函数典型调用场景:
- 使用已存在对象创建新对象
- 函数参数类型为类类型对象
- 函数返回值类型为类类型对象
代码示例:
#include<iostream>
using namespace std;
class Date
{
public:
//默认构造函数
Date(int year = 2023, int month = 12, int day = 11)
{
_year = year;
_month = month;
_day = day;
}
//拷贝构造函数
Date(const Date& d)
{
//日期类没有申请资源,所以浅拷贝即可,
//这里只是观察拷贝构造函数的调用场景
cout << "Date(const Date& d)" << endl;
}
private:
int _year;
int _month;
int _day;
};
Date Test(Date d)
{
Date temp(d);
return temp;
}
int main()
{
Date d1(2001, 1, 1);
Test(d1);
return 0;
}
运行结果:
tip:为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用尽量使用引用。
四、赋值运算符重载
1、运算符重载
(1)引入
为什么运算符对内置类型可以直接使用,对自定义类型不可以直接使用呢?
答案是:内置类型是祖师爷定义,所以当然知道该怎么运算。而自定义类型是我们自己定义的,祖师爷不知道该怎么运算,所以需要我们自己定义运算。
例如,要比较两个日期的大小,我们可以定义一个函数实现。
class Date
{
public:
//默认构造函数
Date(int year = 2023, int month = 12, int day = 11)
{
_year = year;
_month = month;
_day = day;
}
//private:
int _year;
int _month;
int _day;
};
//如果d1 > d2,返回真
bool Less(const Date& d1, const Date& d2)
{
if (d1._year > d2._year)
{
return true;
}
else if (d1._year == d2._year && d1._month > d2._month)
{
return true;
}
else if (d1._year == d2._year && d1._month == d2._month && d1._day > d2._day)
{
return true;
}
return false;
}
int main()
{
Date d1(2023, 12, 11);
Date d2(2023, 12, 12);
cout << Less(d1, d2) << endl;
cout << Less(d2, d1) << endl;
int a = 3;
int b = 5;
cout << (a > b) << endl;
cout << (b > a) << endl;
return 0;
}
如上代码日期类的比较我们都要调用Less函数,而内置类型我们使用>运算符就可以比较了,我们发现可读性太差了,有没有好的办法解决呢?
虽然我们可以定义函数实现自定义类型的运算,但是该方式可读性差。所以C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数。
简单来说:运算符重载就是让自定义类型支持运算符,增强代码可读性。
(2)概念
运算符重载是具有特殊函数名的函数, 也具有其返回值类型,函数名字以及参数列表,其返回类型与参数列表与普通的函数类似。
函数名字为: 关键字operator后面接需要重载的运算符符号。
函数原型: 返回值类型 operator操作符(参数列表)。
tip:一般操作符是几个操作数,就是几个参数。
(3)注意事项
- 不能通过连接其他符号来创建新的操作符:比如operator@
- 重载操作符必须有一个类类型参数
- 用于内置类型的运算符,其含义不能改变,例如:内置的整形+,不能改变其含义
- 有些操作符参数要分顺序,一般第一个参数是左操作数,第二个参数是右操作数。例如:-操作符
- 作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this
- .*(点星操作符) :: (域操作符)?: (条件操作符). (点操作符)sizeof ,注意以上5个不能重载。
- 是否要重载运算符,具体分析这个运算符对这个类是否有意义。
现在我们可以将Less函数改造为重载运算符>。
代码示例:
class Date
{
public:
//默认构造函数
Date(int year = 2023, int month = 12, int day = 11)
{
_year = year;
_month = month;
_day = day;
}
bool operator>(const Date& d);
private:
int _year;
int _month;
int _day;
};
//如果d1 > d2,返回真
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;
}
int main()
{
Date d1(2023, 12, 11);
Date d2(2023, 12, 12);
d1 > d2;//等价于d1.operator>(d2)
d1.operator>(d2);
return 0;
}
tip:
①因为刚才的Less函数写成全局的,成员变量如果是私有的类外不能访问,改成公有的破坏了类封装性,所以我们为了保证类的封装性。可以将其改为成员函数。
②重载运算符可读性好,因为编译器会自动转换调用重载运算符函数。
2、赋值运算符重载
(1)赋值运算符重载属于默认成员函数
①注意: 赋值运算符只能重载成类的成员函数,不能重载成全局函数。
原因: 赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。
②分清赋值运算符重载与拷贝构造函数!
int main()
{
Date d1;
//用一个已经存在的对象初始化另一个对象——拷贝构造函数
Date d2(d1);
//已经存在的两个对象之间复制拷贝——复制运算符重载
d2 = d1;
return 0;
}
tip:
- 拷贝构造函数:用一个已经存在的对象初始化 另一个对象。
- 赋值运算符重载:已经存在的两个对象之间复制拷贝。
(2)赋值运算符重载格式
错误代码示例1:
class Date
{
public:
//默认构造函数
Date(int year = 2023, int month = 12, int day = 11)
{
_year = year;
_month = month;
_day = day;
}
//赋值运算符重载
void operator=(Date& d);
private:
int _year;
int _month;
int _day;
};
//d1 = d2
void Date::operator=(Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
int main()
{
Date d1(2023, 12, 12);
Date d2;
d2 = d1;
Date d3;
//连续赋值
d3 = d2 = d1;
return 0;
}
编译报错:
tip:忘记返回值, 赋值运算符应该可以连续赋值的。
错误代码示例2:
//赋值运算符重载
Date& Date::operator=(Date& d)
{
d._year = _year;
d._month = _month;
d._day = _day;
//d1 = d2,返回赋值之后的d1,d1是左操作数,即是this指针指向d1
return *this;
}
int main()
{
Date d1(2023, 12, 12);
Date d2;
Date d3;
//连续赋值
d3 = d2 = d1;
return 0;
}
F10调试观察:
赋值之前:
赋值之后:
我们代码本是想将d1拷贝给d2与d3,但是由上图我们可知程序没有完成工作,反而将d1改变了。
经过调试发现,赋值运算符重载函数内部,将赋值顺序弄反了!
tip:常引用参数——如果函数中只是使用参数,不改变参数的值,建议使用常引用。
正确代码示例:
class Date
{
public:
//默认构造函数
Date(int year = 2023, int month = 12, int day = 11)
{
_year = year;
_month = month;
_day = day;
}
//赋值运算符重载
Date& operator=(const Date& d);
private:
int _year;
int _month;
int _day;
};
//d1 = d2
Date& Date::operator=(const Date& d)
{
//防止自己赋值自己
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
//d1 = d2,返回赋值之后的d1,d1是左操作数,即是this指针指向d1
return *this;
}
int main()
{
Date d1(2023, 12, 12);
Date d2;
Date d3;
//连续赋值
d3 = d2 = d1;
return 0;
}
总结:
- 参数类型: const T&,①传递引用可以提高传参效率;②常引用提高程序健壮性。
- 返回值类型: T&,①返回引用可以提高返回的效率;②有返回值目的是为了支持连续赋值。
- 检查是否自己给自己赋值。
- 返回*this: ①赋值运算符返回的是左操作数,成员函数this指针指向左操作数;②*this出了作用域还在,所以我们可以返回引用。
(3)编译器生成的默认赋值运算符重载
用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。
- 内置类型成员变量直接赋值——完成值拷贝/浅拷贝(值拷贝/浅拷贝——内存存储按字节序完成拷贝)
- 自定义类型成员变量会去调用它的赋值运算符重载完成赋值
与拷贝构造函数一样,编译器生成的默认赋值运算符重载函数只能完成按字节序的浅拷贝,所以一旦涉及申请资源则必须自己实现赋值运算符重载。
例如:stack类,如下图分析
如图,我们可知:
- 编译器生成的赋值运算符重载只能完成浅拷贝,即将一个对象中内容原封不动拷贝到另一个对象。
- 如上图我们可知,当涉及动态资源的申请时,只实现浅拷贝会产生以下问题:
- s2原来的空间丢失,存在内存泄漏。
- s1和s2共享同一块空间,最后销毁时会导致同一块空间释放两次而引起程序崩溃。
- s1和s2共享同一块空间,造成一个对象的修改会影响另一个对象。
总结
- 如果类中未涉及到动态资源的申请,赋值运算符重载是否实现都可以(因为编译器生成的赋值运算符重载即可完成浅拷贝)。
- 如果类中涉及到动态资源的申请,则必须自己实现运算符重载(因为深拷贝需要我们自己实现)。
五、const成员
1、引入
我们先来看一段代码:
class Date
{
public:
Date(int year = 2024, int month = 1, int day = 8)
{
_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.Print();
//常量对象——const修饰
const Date d2;
d2.Print();
return 0;
}
该代码编译报错:
分析:
在传参时,d2的权限被放大,所以编译报错。(引用过程中权限可以平移或缩小,但是不能放大!)
那怎么解决这个问题呢?
加一个const修饰*this,但是this是隐含的,我们该怎么修饰呢?祖师爷想了很久是在没有办法了,只能将const写在成员函数的最后面表示修饰 *this。
2、const成员
(1)概念
将const修饰的成员函数称为const成员函数。
解析:
- const写在成员函数的最后面
- const修饰成员函数,实际修饰的是*this
- const修饰*this,表示在该成员函数中不能对类的任何成员进行修改
(2)const成员函数的使用
成员函数后面加上const后,普通对象和const对象都可以调用了。
const成员函数这么好,那能不能所有成员函数都加上const?
答案是:不是,对于函数体内部需要修改对象的成员函数不能加const。
总结:只要成员函数内部不修改成员变量,都应该加const,这样普通对象和const对象都可以调用。
tip:
只有引用与指针涉及权限的放大与缩小,赋值不涉及。如下图:
权限可以平移或缩小,但不能放大!
六、取地址重载
对象取地址有两类——普通对象与const对象。
代码示例:
class Date
{
public:
Date(int year = 2024, int month = 1, int day = 9)
{
_year = year;
_month = month;
_day = day;
}
//普通对象取地址
Date* operator&()
{
cout << "Date* operator&()" << endl;
return this;
}
//const对象取地址
const Date* operator&()const
{
cout << "const Date* operator&()const" << endl;
return this;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
//普通对象
Date d1;
cout << &d1 << endl;
//const对象
const Date d2;
cout << &d2 << endl;
return 0;
}
运行结果:
tip:
- 自定义类型使用运算符需要重载。
- 普通对象与const对象取地址这两个函数属于默认成员函数,对于这两个默认成员函数我们一般不用定义,编译器会默认生成。
注意: 当有特殊情况,例如不想让别人取到对象的地址时,要自己实现取地址重载。
代码示例:
class Date
{
public:
Date(int year = 2024, int month = 1, int day = 9)
{
_year = year;
_month = month;
_day = day;
}
//不想让别人取到对象的地址,返回nullptr
Date* operator&()
{
return nullptr;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
//普通对象不能取到地址——需要自己实现
Date d1;
cout << &d1 << endl;
//const对象能取到地址——自己不实现,使用编译器生成即可
const Date d2;
cout << &d2 << endl;
return 0;
}
运行结果:
总结:取地址重载一般情况下我们都不需要实现,使用编译器生成的即可。当然如果开发中有特殊要求,我们也是需要自己实现的。