文章目录
前言
本篇主要介绍类的6个默认成员函数,这篇文章很重点!
1. 类的默认成员函数
如果一个类中什么成员都没有,简称为空类(如下)
class Date { };
空类中什么都没有吗?
并不是的,任何一个类在我们不写的情况下,都会自动生成下面6个默认成员函数。
需要注意的是这6个中最重要的是前4个,最后两个取地址重载不重要,我们稍微了解⼀下即可。其次就是C++11以后还会增加两个默认成员函数,移动构造和移动赋值,这个我们后⾯再讲解。
默认成员函数很重要,也⽐较复杂,我们要从两个⽅⾯去学习:
• 第⼀:我们不写时,编译器默认⽣成的函数⾏为是什么,是否满⾜我们的需求。
• 第⼆:编译器默认⽣成的函数不满⾜我们的需求,我们需要⾃⼰实现,那么如何⾃⼰实现?
2. 构造函数
🍎概念
对于下面 Date 类,可以通过 Init 公有的方法给对象设置内容,但是如果每次创建对象都调用该方法设置信息,未免有点麻烦,那能否在对象创建时,就将信息设置进去呢?
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, 5, 1); d1.Print(); Date d2; d2.Init(2022, 7, 1); d2.Print(); return 0; }
这时候就引出了我们的 构造函数!
🍎特点
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象(我们常使⽤的局部对象是栈帧创建时,空间就开好了),⽽是对象实例化时初始化对象。构造函数 的本质是要替代我们以前Stack和Date类中写的Init函数的功能,构造函数⾃动调⽤的特点就完美的替代的了Init。
🍌特点一
(1)函数名与类名相同。
🍌特点二
(2)⽆返回值。 (返回值啥都不需要给,也不需要写void,不要纠结,C++规定如此)
🍌特点三
(3)对象实例化时系统会⾃动调⽤对应的构造函数。
当你用类创建一个对象时,编译器会自动调用该类的构造函数对新创建的变量进行初始化。
🍌特点四
(4)构造函数可以重载。
class Date { public: //无参的构造函数 Date () { _year = 1; _month = 1; _day = 1; } //带参的构造函数 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; //调用无参的构造函数 d1.Print(); Date d2(2000, 10, 1); //调用带参的构造函数 d2.Print(); return 0; }
调用结果:
注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明
比如:我定义了 Date d3(),这句代码的意思是,声明了 d3 函数,该函数无参,返回一个日期类型的对象,编译的时候会有警告,这种是不对的!
🍌特点五
(5)无参的构造函数和全缺省的构造函数都称为 默认构造函数并且默认构造函数只能有一个。
注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数,也就是说,不用传参就可以调用的。
所以,一般情况下,最好把构造函数写成全缺省的:
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; int _month; int _day; }; int main() { Date d1; d1.Print(); Date d2(2000, 10, 1); d2.Print(); return 0; }
调用过程如下:
🍌特点六
(6)如果类中没有显式定义构造函数,则 C++ 编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
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; }
可以看到,编译器将调用 自动生成 的默认构造函数对 d1 进行初始化。
我们不写,编译器默认⽣成的构造,对内置类型成员变量的初始化没有要求,也就是说是是否初始化是不确定的,看编译器。
对于⾃定义类型成员变量,要求调⽤这个成员变量的默认构造函数初始化。如果这个成员变量,没有默认构造函数,那么就会报错,我们要初始化这个成员变量,需要⽤初始化列表才能解决,初始化列表,我们下个章节再细细讲解。
🍌特性七
(7)如果一个类中的成员全是自定义类型,我们就可以用默认生成的构造函数;如果有内置类型的成员,或者需要显示传参初始化,那么都要自己实现构造函数。
C++ 把类型分成 内置类型(基本类型) 和 自定义类型
内置类型(基本类型):int、char、double、指针 等等,
自定义类型:class 或者 struct 定义类型的对象。
🍎总结
默认构造函数 有3 种:
1.我们不用手动去写,编译器自动生成 的。
2.我们自己写的 无参 的构造函数。
3.我们自己写的 全缺省 的构造函数。
虽然我们在不写的情况下,编译器会自动生成构造函数,但是编译器自动生成的构造函数可能达不到我们想要的效果,所以大多数情况下都需要我们自己写构造函数,并且最好是写全缺省的构造函数。
3.析构函数
🍎概念
前面通过构造函数的学习,我们知道了一个对象是怎么来的,那么一个对象又是怎么没呢的?
析构函数: 与构造函数功能相反,析构函数不是完成对象的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成类的一些资源清理工作。
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; } // 析构函数 ~Date() { cout << "~Date()" << endl; // 证明我来过 } private: int _year; int _month; int _day; }; int main() { Date d1(2022, 9, 1); d1.Print(); return 0; }
我这里给了 全缺省 的构造函数,然后给了一个析构函数,并且在里面加了一个打印,因为编译器是会默认调用这个析构函数。
注意:像 Date 这样的类是不需要析构函数的,因为它内部没有什么资源需要清理。
🍎特性
析构函数是特殊的成员函数。
我们知道,当一个类对象销毁时,其中的局部变量也会随着该对象的销毁而销毁。
例如,我们用日期类创建了一个对象 d1,当 d1 被销毁时,对象 d1 当中的局部变量 _year、_month、_day 也会被编译器销毁。
但是这并不意味着析构函数没有什么意义。
像栈(Stack)这样的类对象,当该对象被销毁时,其中动态开辟的栈并不会随之被销毁,需要我们对其进行空间释放,这时析构函数的意义就体现了。
class Stack { public: // 构造函数 Stack(int capacity = 2) { _a = (int*)malloc(sizeof(int) * capacity); if (_a == nullptr) { cout << "malloc fail" << endl; exit(-1); } _top = 0; _capacity = capacity; } // 析构函数 ~Stack() { free(_a); _a = nullptr; } private: int* _a; int _top; int _capacity; }; int main() { Stack st; return 0; }
在数据结构中,我们实现栈时都需要写一个 Destroy 在程序结束前销毁动态开辟的内存,如果使用完动态开辟的内存没有及时销毁,那么就会导致内存泄漏的操作。
而析构函数的出现就是为了解决这种场景的,对象实例化后,同构造函数一样,它不需要我们主动调用,它是在对象生命周期结束后自动调用,需要注意的是,析构函数没有参数所以不能重载。
构造函数是为了替代 Init 进行初始化,析构函数是为了替代 Destroy 进行销毁.。
🍌特性一
(1)析构函数名是在类名前加上字符 ~。
class Date { public: // 构造函数 Date() {} // 析构函数 ~Date() {} private: int _year; int _month; int _day; }
🍌特性二
(2)析构函数无参数,无返回值
这一点和构造函数一样。
🍌特性三
(3)对象生命周期结束时,C++编译器会自动调用析构函数
这就大大降低了 C 语言中栈空间忘记释放问题的发生,因为当栈对象生命周期结束时,C++ 编译器会自动调用析构函数对其栈空间进行释放。
🍌特性四
(4)一个类有且只有一个析构函数。若未显式定义,系统会自动生成默认的析构函数
编译器自动生成的析构函数对内置类型不做处理,
对于自定义类型,编译器会再去调用它们自己的默认析构函数。
为什么内置类型不处理呢?
因为它不好处理,如果这个指针是一个文件指针,那你也要去 free 吗 ?
那对于什么样的类可以不写析构呢或者它的价值是什么呢?
我们还是拿栈实现队列来举例:
//栈 class Stack { public: //构造函数 Stack(int capacity = 10) { _a = (int*)malloc(sizeof(int) * capacity); if (_a == NULL) { exit(-1); } _top = 0; _capacity = capacity; } //析构函数 ~Stack() { free(_a); _a = nullptr; _top = _capacity = 0; } private: int* _a; int _top; int _capacity; }; //队列 class MyQueue { public: void push(int x) { //操作 ... } int pop() { //操作 ... } private: Stack _st1; Stack _st2; }; int main() { MyQueue q; return 0; }
现在对于 MyQueue,我们可以不写构造函数和析构函数,让编译器自动生成构造函数和析构函数也可以 初始化 和销毁,具体如下:
首先,调用自定义类型的构造函数:
然后,再调用自定义类型的析构函数:
🍌特性五
(5)先构造的后析构,后构造的先析构
因为对象是定义在函数中的,函数调用会建立栈帧,栈帧中的对象构造和析构也要符合先进后出的原则。
4. 拷⻉构造函数
🍎概念
如果⼀个构造函数的第⼀个参数是⾃⾝类类型的引⽤,且任何额外的参数都有默认值,则此构造函数也叫做拷⻉构造函数,也就是说拷⻉构造是⼀个特殊的构造函数。
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() { Date d1(2021, 5, 31); Date d2(d1); // 用已存在的对象d1创建对象d2 return 0; }
我们可以调试可以看到 d2 就是 d1 的拷贝构造:
🍎特性
拷贝构造函数也是特殊的成员函数,其特征如下:
🍌特性一
(1)拷⻉构造函数是构造函数的⼀个重载。
因为拷贝构造函数的函数名也与类名相同。
🍌特性二
拷⻉构造函数的参数只有⼀个且必须是类类型对象的引⽤,使⽤传值⽅式编译器直接报错,因为语法逻辑上会引发⽆穷递归调⽤。
这个是重点,需要好好讲一下。
class Date { public: Date(int year = 0, 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(2022, 9, 1); Date d2(d1); // 用已存在的对象d1创建对象d2 return 0; }
思考一下,上面这段代码能不能正常运行呢?
答案是:肯定不行的!
这里会引发一个无穷递归的现象,只是语法进行了强制检查,所以它由运行时错误转向了编译时错误。
为了便于理解,可以看下面:
C++规定⾃定义类型对象进⾏拷⻉⾏为必须调⽤拷⻉构造,所以这⾥传值传参要调⽤拷⻉构造
所以这⾥的d1传值传参给d要调⽤拷⻉构造完成拷⻉,传引⽤传参可以较少这⾥的拷⻉。
要调用 拷贝构造函数 就需要先 传参,若传参使用传值传参,那么在传参过程中又需要进行对象的拷贝构造
如此循环往复,最终引发无穷递归调用。
但是对于这种问题,有什么解决办法呢?
这里用一个 引用 来解决了问题,当使用了 引用 以后,形参就是实参的别名,即 d是 d1 的别名,
但是,如果不希望 d被改变的话,最好在实参部分加上 const
🍌特性三
若未显式定义拷⻉构造,编译器会⽣成⾃动⽣成拷⻉构造函数。⾃动⽣成的拷⻉构造对内置类型成员变量会完成值拷⻉/浅拷⻉(⼀个字节⼀个字节的拷⻉),对⾃定义类型成员变量会调⽤他的拷⻉构造。
class Date { public: Date(int year = 0, int month = 1, int day = 1)// 构造函数 { _year = year; _month = month; _day = day; } void Print() { cout << _year << "年" << _month << "月" << _day << "日" << endl; } private: int _year; int _month; int _day; }; int main() { Date d1(2021, 5, 30); Date d2(d1); // 用已存在的对象d1创建对象d2 d1.Print(); d2.Print(); return 0; }
运行结果:
代码中,我们自己并没有定义拷贝构造函数,但编译器自动生成的拷贝构造函数最终还是完成了对象的拷贝构造。
编译器自动生成的拷贝构造函数机制:
编译器自动生成的拷贝构造函数对内置类型会完成浅拷贝(值拷贝)。
对于自定义类型,编译器会再去调用它们自己的默认拷贝构造函数。
🍌特性四
(4)编译器自动生成的拷贝构造函数不能实现深拷贝
编译器生成的默认拷贝构造函数已经可以完成字节序的值拷贝了,我们还需要自己实现吗?
当然像日期类这样的类是没必要的。那么下面的类呢?验证一下试试?
class Stack { public: Stack(int capacity = 4) { _a = (int*)malloc(sizeof(int) * capacity); if (_a == nullptr) { cout << "malloc fail" << endl; exit(-1); } _top = 0; _capacity = capacity; } ~Stack() { free(_a); _a = nullptr; } private: int* _a; int _top; int _capacity; }; int main() { Stack st1; Stack st2(st1); return 0; }
从调试结果可以看到,已经完成了拷贝:
但是程序崩溃了!
那么为什么会崩溃呢?
st1 栈和 st2 栈空间的地址相同,这就意味着,就算在创建完 st2 栈后,我们对 st1 栈做的任何操作都会直接影响到 st2 栈。
为什么呢?
因为 st2 栈 是 st1 栈的浅拷贝(值拷贝),所以 st2._a和 st1._a。两个指针存储的是同一个地址,也就是说这两个指针指向同一块儿空间。
首先我们自己定义的析构函数是正确的情况下,当程序运行结束,st2 栈将被先析构,也就是说 st2 和 st1 指
向同一块儿空间已经被释放了,那么当 st1 栈再去调用析构函数的时候,会再次对那一块空间进行释放。
这种情况下,出现了对同一块空间释放多次的问题,程序肯定会崩溃的!
显然这不是我们希望看到的结果,我们希望在创建时,st2 栈和 st1 栈中的数据是相同的,并且在创建完 st2栈后,我们对 st1 栈和 st2 栈之间的任何操作能够互不影响。
可以看到,这种情况下编译器自动生成的拷贝构造函数就不能满足我们的要求了。
🍎总结
我们不写,编译器会默认生成一个拷贝构造:
(1)内置类型的成员会完成值拷贝,也就是浅拷贝。
像 Date 这样的类,需要的就是浅拷贝,那么编译器自动生成的拷贝构造函数就够用了,我们不需要自己写。
(2)自定义类型的成员,去调用这个成员的拷贝构造
像 stack 这样的类,它是自己直接管理资源,那么需要自己实现深拷贝,浅拷贝的话会导致析构两次、程序崩溃等问题。
5. 赋值运算符重载
🍎 运算符重载
假设我们创建了一个 Date 类,定义了一个对象 d1 和 d2,现在需要判断 d1 和 d2 是否相等。
class Date { public: //构造函数 Date(int year = 1, int month = 1, int day = 1) { _year = year; _month = month; _day = day; } private: int _year; int _month; int _day; }; int main() { Date d1(2022, 9, 1); Date d2(2022, 9, 1); d1 == d2; return 0; }
当我们编译时,会报错:
为什么会报错呢?那是因为 运算符默认都是给内置类型变量用的。自定义类型的变量想用这些运算符,得自实现运算符的重载。
运算符重载指的是需要自己写一个函数实现这里运算符的行为。
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名:关键字 operator 后面接需要重载的运算符符号
参数:运算符操作数
·返回值:运算符运算后的结果
函数原型:
返回值类型 operator操作符(参数列表)
注意:
1.不能通过连接其他符号来创建新的操作符:比如 operator@
2.重载操作符必须有一个类类型或者枚举类型的操作数。
3.用于内置类型的操作符,其含义不能改变,例如:内置的整型 +,不能改变其含义
4.如果⼀个重载运算符函数是成员函数,则它的第⼀个运算对象默认传给隐式的this指针,因此运算符重载作为成员函数时,参数⽐运算对象少⼀个。
.、 ::、 sizeof、 ?: 、.* 注意以上5个运算符不能重载。
⼀个类需要重载哪些运算符,是看哪些运算符重载后有意义,⽐如Date类重载operator-就有意义,但是重载operator+就没有意义。
重载++运算符时,有前置++和后置++,运算符重载函数名都是operator++,⽆法很好的区分。
C++规定,后置++重载时,增加⼀个int形参,跟前置++构成函数重载,⽅便区分。
8.重载<<和>>时,需要重载为全局函数,因为重载为成员函数,this指针默认抢占了第⼀个形参位置,第⼀个形参位置是左侧运算对象,调⽤时就变成了 对象<<cout,不符合使⽤习惯和可读性。重载为全局函数把ostream/istream放到第⼀个形参位置就可以了,第⼆个形参位置当类类型对象。
这里以重载 == 运算符作为例子:我们可以将该运算符重载函数作为类的一个成员函数,此时该函数的第一个形参默认传的是 this 指针。
class Date { public: Date(int year = 0, int month = 1, int day = 1) // 构造函数 { _year = year; _month = month; _day = day; } // 等价于 bool operator==(Date* this, const Date& d2) // 这里需要注意的是,左操作数是this指向的调用函数的对象 bool operator==(const Date& d) // ==运算符重载 { return _year == d._year && _month == d._month && _day == d._day; } private: int _year; int _month; int _day; }; int main() { Date d1(2022, 9, 1); Date d2(2022, 9, 1); d1.operator==(d2); d1 == d2;//同上,编译器会自动识别转换为 d1.operator==(d2) ---> d1.operator(&d1, d2); return 0; }
我们可以打印看下结果(bool 返回的 1 就表示 true)
我们也可以将该运算符重载函数放在类外面,但此时外部无法访问类中的成员变量,这时我们可以将类中的成员变量设置为共有(public),这样外部就可以访问该类的成员变量了(也可以用 友元 函数解决该问题)
并且在类外没有 this 指针,所以此时函数的形参我们必须显示的设置两个。
class Date { public: Date(int year = 1, int month = 1, int day = 1) // 构造函数 { _year = year; _month = month; _day = day; } //private: public://这里破坏封装使得可以在类外访问成员 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; } int main() { Date d1(2022, 9, 1); Date d2(2022, 9, 1); operator==(d1, d2); // 可以这样调用,但是这样可读性很差,还不如写一个函数 d1 == d2; //同上,如果没有重载会报错,如果重载了它会转换为 operator==(d1, d2); cout << (d1 == d2) << endl; return 0; }
可以看到,这样也是可以滴!
不推荐这种写法,因为破坏了封装!
以上两种写法编译器会自动识别转换,如果是全局函数那么它会转换成operator==(d1,d2);
如果是成员函数那么它会转换成 d1.operator(d2);
不管是全局还是成员一般我们都是直接写 d1 == d2
🍎赋值运算符重载
上面我们重载了 == 这个符号,这里我们要重载的是 = 符号
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; } // d2 = d1; --> d2.operator=(&d2, d1) // d1 = d1 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; }; int main() { Date d1(2022, 9, 1); Date d2(2022, 9, 2); Date d3(d1); // 拷贝构造 -- 一个存在的对象去初始化另一个要创建的对象 d3 = d2 = d1; // 赋值重载/复制拷贝 --> 两个已经存在对象之间赋值 (d3 = d2) = d1; // 赋值重载/复制拷贝 --> 两个已经存在对象之间赋值 return 0; }
我们可以调用 Print 函数,看一下结果:
有一个特殊情况需要思考一下,比如下图中,你是不是以为我们把 d1 赋值给了 d3 呢?
当然不是的,这里不是 赋值,而是 拷贝构造,因为这是在 实例化对象,它等同于 Date d3(d1);
所以我们这里不能使用 拷贝构造 替代 赋值运算符重载 ,因为它们的使用场景是不一样滴:
拷贝构造是用于一个对象准备定义时,用另一个对象来初始化它;
赋值运算符重载是用于两个已经定义出来的对象间的拷贝复制。
🍎特性
赋值运算符主要有以下 5 点。
🍌特性一
(1)赋值运算符重载是⼀个运算符重载,规定必须重载为成员函数。赋值运算重载的参数建议写成const 当前类类型引⽤,否则会传值传参会有拷⻉ 。
赋值运算符重载函数的第一个形参默认是 this 指针,第二个形参是我们赋值运算符的右操作数。
由于是自定义类型传参,我们如果使用 传值 传参,会额外调用一次拷贝构造函数,所以函数的第二个参数最好使用 引用传参(第一个参数是默认的 this 指针,我们不用管)。
其次,第二个参数,即赋值运算符的右操作数,我们在函数体内不会对其进行修改,所以最好加上 const 进行修饰。
🍌特性二
(2)有返回值,且建议写成当前类类型引⽤,引⽤返回可以提⾼效率,有返回值⽬的是为了⽀持连续赋值场景
实际上,我们若是只以 d2 = d1 这种方式使用赋值运算符,赋值运算符重载函数就没必要有返回值,因为在函数体内已经通过 this 指针对 d2 进行了修改。
但是为了支持连续赋值,即 d3= d2 =d1,我们就需要为函数设置一个返回值了,而且很明显,返回值应该是赋值运算符的左操作数,即 this 指针指向的对象。
和使用 引用 传参的道理一样,为了避免不必要的拷贝,我们最好还是使用 引用 返回,因为此时出了函数作用域 this 指针指向的对象并没有被销毁,所以可以使用 引用 返回。
🍌特性三
(3)赋值前检查是否是给自己赋值
若是出现 d1 = d1 ,我们不必进行赋值操作,因为自己赋值给自己是没有必要进行的。
所以在进行赋值操作前可以先判断是否是给自己赋值,避免不必要的赋值操作。
🍌特性四
(4)引用返回的 *this
赋值操作进行完毕时,我们应该返回赋值运算符的左操作数,而在函数体内我们只能通过 this 指针访问到左
操作数,所以要返回左操作数就只能返回 *this。
🍌特性五
一个类如果没有显式定义赋值运算符重载,编译器也会生成一个,完成对象按字节序的值拷贝。
没错,赋值运算符重载编译器也可以自动生成,并且也是支持连续赋值的。
class Date { public: Date(int year = 1900, int month = 1, int day = 1) { _year = year; _month = month; _day = day; } void Print() // 打印函数 { cout << _year << "-" << _month << "-" << _day << endl; } private: int _year; int _month; int _day; }; int main() { Date d1; Date d2(2022, 9, 1); // 这里d1调用的编译器生成 operator= 完成拷贝,d2和d1的值也是一样的。 d1 = d2; d1.Print(); d2.Print(); return 0; }
编译器自动生成的赋值运算符重载完成的是对象按字节序的值拷贝。
例如 d1 = d2 ,编译器会将 d2 所占内存空间的值完完全全地拷贝到 d1 的内存空间中去,类似于 memcpy。
对于日期类,编译器自动生成的赋值运算符重载函数就可以满足我们的需求,我们可以不用自己写。
但是这也不意味着所有的类都不用我们自己写赋值运算符重载函数,当遇到一些特殊的类,我们还是得自己动
手写赋值运算符函数的。
像Stack这样的类,虽然也都是内置类型,但是_a指向了资源,编译器⾃动⽣成的赋值运算符重载完成的值拷⻉/浅拷⻉不符合我们的需求,所以需要我们⾃⼰实现深拷⻉(对指向的资源也进⾏拷⻉)。
像MyQueue这样的类型内部主要是⾃定义类型Stack成员,编译器⾃动⽣成的赋值运算符重载会调⽤Stack的赋值运算符重载,也不需要我们显⽰实现MyQueue的赋值运算符重载。
这⾥还有⼀个⼩技巧,如果⼀个类显⽰实现了析构并释放资源,那么他就需要显⽰写赋值运算符重载,否则就不需要。
6.const成员函数
🍎const 修饰类的成员函数
我们把 const 修饰的类成员函数称之为 const 成员函数。
const 修饰类成员函数,实际修饰该成员函数隐含的 this 指针,表明在该成员函数中不能对类的任何成员进行修改。
先看下面一段代码:
class Date { public: // 构造函数 Date(int year = 1, int month = 1, int day = 1) { _year = year; _month = month; _day = day; } // 打印函数 void Print1() { cout << _year << "-" << _month << "-" << _day << endl; } private: int _year; int _month; int _day; }; int main() { const Date d1(2022, 9, 1); d1.Print1(); return 0; }
我们运行程序可以看到在 d1 对象调用 Print1()函数的时候报错了。
那么为什么 const 修饰的对象 d1 不能调用 Print1 呢?
其实很简单,因为在 d1 对象去调用 Print1 函数的时候,实参会把 d1 的地址传过去,但是 d1 是被const修饰的,也就是传过去的是 const Date*
那么在 Print1 函数这边,形参部分会有一个隐含的 this 指针,也是 Date*const this(当const在* 号的右边,表示该指针本身不能被修改,但是可以进行第一次初始化赋值),也就是说把const Date* 传给了 Date *const this ,在这里属于权限的放大,所以编译会不通过。
所以就引出了 const 修饰类的成员函数:把 const 放在成员函数之后,实际就修饰类 this 指针:
class Date { public: // 构造函数 Date(int year = 1, int month = 1, int day = 1) { _year = year; _month = month; _day = day; } // const成员函数 void Print2() const { cout << _year << "-" << _month << "-" << _day << endl; } private: int _year; int _month; int _day; }; int main() { const Date d1(2022, 9, 1); d1.Print2(); return 0; }
那么在参数传递部分,实参还是和上面一样,形参部分因为 const 修饰的成员函数,所以就变成了 const Date const this* ,那么此时就是权限相等了。
额外补充: 建议成员函数中不修改成员变量的成员函数都可以加上 const,这样普通函数和 const 对象都可以调用。
🍎思考
🍌问题一
const 对象可以调用非 const 成员函数吗?
不可以,非 const 成员函数,即成员函数的 this 指针没有被 const 所修饰,我们传入一个被 const 修饰的对象,使用没有被 const 修饰的 this 指针进行接收,属于权限的放大,函数调用失败。
🍌问题二
非 const 对象可以调用 const 成员函数吗?
可以,const 成员函数,即成员函数的 this 指针被 const 所修饰,我们传入一个没有被 const 修饰的对象,使用被 const 修饰的 this 指针进行接收,属于权限的缩小,函数调用成功。
🍌问题三
const 成员函数内可以调用其它的非 const 成员函数吗?
不可以,在一个被 const 所修饰的成员函数中调用其他没有被 const 所修饰的成员函数,也就是将一个被const 修饰的 this 指针的值赋值给一个没有被 const 修饰的 this 指针,属于权限的放大,函数调用失败。
🍌问题四
非 const 成员函数内可以调用其它的 const 成员函数吗?
可以,在一个没有被 const 所修饰的成员函数中调用其他被 const 所修饰的成员函数,也就是将一个没有被const 修饰的 this 指针的值赋值给一个被 const 修饰的 this 指针,属于权限的缩小,函数调用成功。
7.取地址运算符重载
取地址操作符重载和 const 取地址操作符重载,这两个默认成员函数一般不用自己重新定义,使用编译器自动生成的就行了。
class Date { public: Date(int year = 1, int month = 1, int day = 1) { _year = year; _month = month; _day = day; } //普通对象 取地址操作符重载 Date* operator&() { return this; } //const对象 取地址操作符重载 const Date* operator&() const { return this; } private: int _year; int _month; int _day; }; int main() { Date d1(2021, 10, 13); const Date d2(2021, 10, 14); cout << &d1 << endl; cout << &d2 << endl; return 0; }
我们可以打印看一下结果:
这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容,就可以自己实现。
如果不想让别人获取对象的地址,也可以自己实现,直接返回 nullptr(把 return this 改为 return nullptr)。
总结
这篇文章的干货细节还是蛮多的,需要细细阅读!