本文字数较多,建议电脑端访问。不多废话,正文开始
文章目录
- ———————————————【类和对象 · 筑基篇】 ———————————————
- 一、前言
- 二、面向过程与面向对象
- 三、结构体与类
- 1、C++中结构体的变化
- 2、C++中结构体的具体使用
- 3、结构体 --> 类
- 类的两种定义方式
- 成员变量命名规则
- 四、类的访问限定符及封装【⭐】
- 1、C++中的三类访问限定符
- 2、初探类的封装👈
- 五、类的实例化
- 1、变量的声明与定义 - - 铁瓷还会铁吗?
- 2、类对象的声明与定义 - - 别墅设计图🏠
- 六、类对象模型
- 1、成员函数是否存在重复定义?
- 2、计算类的大小【结构体内存对齐】
- 3、探究类对象的存储方式🔍
- 感性理解:私有场所与共有场所
- 4、空类大小计算【面试考点✒】
- 七、this指针【⭐重点掌握⭐】
- 1、提问:如何区分当前初始化对象?
- 2、深度探究this指针的各种特性【原理分析】
- 3、this指针的感性理解
- 4、两道夺命面试题👻
- this指针存放在哪里?
- this指针可以为空吗?
- 5、一道笔试题✍
- 八、C和C++实现栈的对比
- 1、代码展示
- 2、C语言特性分析
- C语言语法的松散性 - - 过红绿灯还是慢一点吧🚦
- 3、C++的特性优势分析
- 再谈类的封装思想 - - 兵马俑还是保护起来吧🛡
- 九、总结与提炼
- ———————————————【类和对象 · 磨砺篇】 ———————————————
- 一、前言
- 二、构造函数
- 1、概念
- 2、特性
- 三、析构函数
- 1、概念
- 2、特性
- C与C++OJ题对比【手动挡与自动挡】
- 四、拷贝构造函数【⭐】
- 1、概念解析
- 2、内置类型与自定义类型【调试观察】
- 3、深入探究拷贝构造🔍
- 4、【浅拷贝】与【深拷贝】
- 5、产生拷贝构造的三种形式
- 6、人生赢家类 —— MyQueue
- 7、实战演练 —— 日期计算器
- ① 思路分析
- ② 代码详解
- ③ 运行测试 + 优化
- ④ 整体代码展示
- 五、赋值运算符重载
- 1、运算符重载
- ① 概念引入
- ② 语法明细
- ③ 练习巩固
- ④ 代码展示
- 2、赋值运算符重载
- ① 语法说明及注意事项
- ② 默认的赋值运算符重载
- 注意:赋值运算符不能重载成全局函数!
- 六、const成员函数
- 七、取地址及const取地址操作符重载
- 八、综合案例实战 —— Date日期类
- 1、需求分析鸟瞰
- 2、【第一模块】:默认成员函数实现
- 3、【第二模块】:关系运算符重载
- 4、【第三模块】:日期计算相关运算符重载
- 5、【第四模块】:流插入和流提取的实现
- ① 流插入<<
- ② 流提取>>
- ③ 内联函数与成员函数的优化
- 6、整体代码展示
- 九、总结与提炼
- ———————————————【类和对象 · 提升篇】 ———————————————
- 一、再谈构造函数
- 1、初始化列表
- 引入
- 初始化的概念区分
- 语法格式及使用
- 注意事项
- 2、explict关键字
- 单参构造函数
- 多参构造函数
- 二、static成员
- 1、面试题引入
- 2、static特性细述
- 3、疑难解惑
- 4、在线OJ实训
- 5、有关static修饰变量的一些注意要点
- 三、匿名对象
- 四、友元
- 1、友元函数
- 2、友元类
- 六、内部类
- 1、概念引入
- 2、特性讲解
- 3、OJ题优化
- 七、拷贝对象时的一些编译器优化
- 1、传值传参
- 2、传引用传参
- 3、传值返回
- 拷贝构造和赋值重载的辨析
- 4、传引用返回【❌】
- 5、传匿名对象返回
- 6、小结
- 八、再次理解类和对象
- 九、总结与提炼
———————————————【类和对象 · 筑基篇】 ———————————————
一、前言
从本文开始,我们就要正式来学习C++中的类和对象了,本文我将带你一步步从C语言的结构体
struct
到C++的类class
,真正搞懂有关C++的面向对象的三大特征之一 —— 封装
- 作为读者,可能你正在学习C语言,亦或者已经开始学习C++了,也有可能你是一位C++的资深开发者或者其他领域的从业人员。不过这没关系,都不会影响你阅读本文📖
- 可能你了解过面向对象的一些语言,像Java、C#、python这些,也知道C++里面也有面向对象的一些思想,但是呢为何又可以写一些C语言的代码,C语言大家一定都学过,是一门面向过程的语言,可是为何C++也可以跑C语言的代码呢?
现在,我提出以下这几个问题,看看你是否都了解👇
- C++是一门面向对象的语言吗?它和面向过程有什么联系?
- 面向对象的三大特征为:封装、继承、多态,你真的有去好好了解过什么是类的封装吗?它的好处在哪里?
- 类和结构体之间似乎很像,它们存在什么关联吗?
- this指针了解多少?存放在哪里?是用来干嘛的?
接下去,就让我们带着疑惑,再度出发,好好地探一探这些知识,可能内容会比较多,但我会尽量用生动的语言和图文并茂的方式,结合一些生活中的实际场景,让你更好地理解每个知识点🤔
二、面向过程与面向对象
👉对于C语言而言,它完全是一门【面向过程】的语言。关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题
👉对于C++是基于【面向对象】的,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成。
- 可是呢,C++为了兼容C,并没有完全面向对象,所以你才可以在一些C++的编译器上去写一些C语言的代码
可是面向过程和面向对象它们之间的区别到底是怎样的呢?可以通过一个在我们生活中最普遍的例子来说明一下
- 若是现在公司要你写一个外卖订餐系统,你呢只会C语言和C++,此时若你使用C语言去写的话关注的就是从用户下单到商家接单,最后到骑手送单这整个过程该如何去实现;
- 但如果你使用的是C++这样具有面向对象的语言去编写的话,那此时你要关注的就是各个对象之间会发生什么关系,对象有【用户】、【商家】、【骑手】这三个,那此时你要考虑的就是用户下单到商家接单,最后到骑手送单,它们之间是谁和谁之间发生关系
三、结构体与类
1、C++中结构体的变化
- 之前在C语言中,我们去定义一个结构体都是这么去写的,例如说这里有一个链表结点的结构体,一个是数据域,一个是指针域
struct ListNode {
int val;
struct ListNode* next;
};
- 在C++中,我们也可以这么去写,上面说到过C++兼容C,可是呢有一处却可以发生变化。也就是在定义这个指针域的时候,可以不需要再写
struct
了
struct ListNode {
int val;
ListNode* next;
};
- 通过下面两幅图的对比就可以很清楚地看在C++中确实在使用结构体的时候不需要再去写一遍
struct
这个关键字了,直接使用定义出来的结构体即可;但是在C语言中没有这样的规定,所以是一定要写的
- 不过C++相较于C语言可不只是多了这一点,C语言结构体中只能定义变量,在C++中,结构体内不仅可以定义变量,也可以定义函数【但是这在C语言中,是不被允许的】
知道了上面这些,其实就可以回忆我们之前在数据结构中写过的很多代码,在结构体中只是定义了一些成员变量,具体的函数都是写在结构体外,那现在知道了C++可以这么去做的话,是否可以将这些函数都写到结构体内来呢?我们来试试看👇
2、C++中结构体的具体使用
下面我要使用C++去实现一个栈,如果有忘记的小伙伴可以再去回顾一下栈的一些知识
typedef int DataType;
struct Stack
{
void Init(size_t capacity)
{
_array = (DataType*)malloc(sizeof(DataType) * capacity);
if (nullptr == _array)
{
perror("malloc申请空间失败");
return;
}
_capacity = capacity;
_size = 0;
}
void Push(const DataType& data)
{
// 扩容...
_array[_size] = data;
++_size;
}
DataType Top()
{
return _array[_size - 1];
}
void Destroy()
{
if (_array)
{
free(_array);
_array = nullptr;
_capacity = 0;
_size = 0;
}
}
DataType* _array;
size_t _capacity;
size_t _size;
};
- 可以看到,虽然这个栈是使用C++去实现的,但其实和C语言没有什么大致的区别,只是将这些接口函数放到了结构体中而已。那此时便有同学问:这些变量为什么可以放在下面,不应该在函数的上面就定义出来吗?这点要注意了,这是在一个结构体中,而不是外界的普通程序,不会像我们之前那样需要先定义变量然后再去使用它,编译器需要一个向上查找的过程
- 在C++的结构体中,这个【成员变量】可以定义在结构体 / 类的任何地方,你在何处去进行引用都是可以的
定义出来这么一个栈的结构体之后,我们就可以去使用了👇
- 在C++中,调用一个数据结构的算法接口不是像C语言必须要传入当前定义出来变量的地址,因为这些算法接口直接定义在了结构体中,那一定可以知道这个是属于谁的。所以仔细观察其实可以看出,原本我以C语言实现【栈】的时候在每个算法接口前面都是有
Stack
,但是在C++这一块,我却一个都没有加,这就是因为它们一定是属于【栈】的接口算法,而不是其他数据结构:队列、链表、二叉树 - 那要如何去调用这个接口算法呢,很简单,回忆我们在结构体章节所学习的,如何去访问结构体中的成员,就可以知道是使用
.
这个操作符,然后传入对应的参数即可
int main()
{
Stack s;
s.Init(10);
s.Push(1);
s.Push(2);
s.Push(3);
cout << s.Top() << endl;
s.Destroy();
return 0;
}
来看一下运行结果💻
通过上面所写,使用C++去代替实现之前使用C语言写的【栈】时,发现完全没问题,这下你应该可以进一步了解为何C++兼容C了,不过呢在C++中,这样变量和函数存放在一起的结构我们不叫做结构体,而叫做【类】,可是对于类来说,在C++中也不常使用struct
这个关键字来定义,而是使用[class]
3、结构体 --> 类
语法格式:
class className
{
// 类体:由成员函数和成员变量组成
}; // 一定要注意后面的分号
【注】:class
为定义类的关键字,ClassName
为类的名字,{}
中为类的主体,注意类定义结束时后面分号不能省略
- 类体中内容称为类的成员:类中的变量称为类的属性或成员变量; 类中的函数称为类的方法或者成员函数
类的两种定义方式
知道了一个类长什么样,接下去我们来聊聊一个类该如何去进行规范的定义
- 声明和定义全部放在类体中,需注意:成员函数如果在类中定义,编译器可能会将其当成内联函数处理
- 这也就是我们上面讲到过有关【栈】的这种定义,只需要将
struct
换成class
即可,这种类的定义方式简单粗暴,也是我们平常用得最多的,自己练习代码可以直接这样使用,但其实在日常项目的开发中,不建议大家这样使用❌
- 类声明放在.h文件中,成员函数定义放在.cpp文件中,注意:成员函数名前需要加类名
::
- 重点来讲一讲这一种,这也叫做多文件形式的编写,之间在C语言的学习中我们写的【扫雷】和【三子棋】也是使用的这种分文件编写,如果不了解的读者一定要学会这种思想,在日常企业的开发中是经常使用到的
stack.h
#pragma once
#include <iostream>
#include <stdlib.h>
using namespace std;
typedef int DataType;
struct Stack
{
void Init(size_t capacity);
void Push(const DataType& data);
DataType Top();
void Destroy();
DataType* _array;
size_t _capacity;
size_t _size;
};
stack.cpp
#include "stack.h"
void Init(size_t capacity)
{
_array = (DataType*)malloc(sizeof(DataType) * capacity);
if (nullptr == _array)
{
perror("malloc申请空间失败");
return;
}
_capacity = capacity;
_size = 0;
}
void Push(const DataType& data)
{
// 扩容...
_array[_size] = data;
++_size;
}
DataType Top()
{
return _array[_size - 1];
}
void Destroy()
{
if (_array)
{
free(_array);
_array = nullptr;
_capacity = 0;
_size = 0;
}
}
test.cpp
#include "stack.h"
int main()
{
Stack s;
s.Init(10);
s.Push(1);
s.Push(2);
s.Push(3);
cout << s.Top() << endl;
s.Destroy();
return 0;
}
- 以上就是有关C++中类的分文件编写格式,其实和C语言的函数也相差不太多,不过从下图可以看出,似乎是出了点什么问题🤨
- 这就是在上面说到的:成员函数名前需要加类名::,我们在命名空间的讲解中有说到过有关【作用域】的概念,在C++中,对于一个类体而言,其实就属于一个作用域,将成员变量和成员函数包含在里面。那么此时要在另一个
cpp
的文件中访问这个类中定义的成员变量的话也就是访问Stack作用域中的内容,就要加上【域作用限定符::
】,就像下面这样
成员变量命名规则
最后再来普及一点,你可以自己观察我上面在写【栈】的时候对成员变量的命名形式,前面都加上了
_
,可能你看起来会很别扭,但这却是比较规范的一种定义形式
- 其实你可以去看一看库里面一些变量的命名方式,很多都是采用这种下划线的方式进行,原因其实就在于避免造成【成员变量】和【形参】的命名冲突从而引发歧义
- 可以看到,我在下面写了一个日期类,通过
Init()
这个函数对类中的成员变量去进行一个初始化,观察【成员变量】和【形参】可以发现我故意将它们写成了一样的,此时调用函数进行初始化操作的时候会发生什么呢?
class Date {
public:
void Init(int year, int month, int day)
{
year = year;
month = month;
day = day;
}
int year;
int month;
int day;
};
通过观察可以发现,若是【成员变量】和【形参】的名字一样的话,其实这个时候就会造成歧义,初始化的就不是当前这个对象的成员变量了,如果你自己观察就可以发现,命名一样的话,在VS中二者的字体都会变淡,这其实就是VS在提示你这样的做法其实是无效的❌
那要如何命名才是最规范的呢?
- 这个其实我说了不算,要看你实际的开发的地方是如何规定的,如果是你自己的做开发的话,那建议就是【成员变量】改成
_变量名
或者是m_变量名
,但如果你在公司里面的话,内部是如何规定的你怎么做就行了,这个没有强制,只要别造成相同即可 - 但是你一定在某些地方见过
this->year = year
这种写法,确实这也可以,这里面就用到了C++类和对象中很重要的一块叫做【this指针】,这里先不做详解,见最后一个模块哦😗
this->year = year;
this->month = month;
this->day = day;
四、类的访问限定符及封装【⭐】
学习了上面的这些,你只是初步了解了什么是类,但是C++中的类远远不止将
struct
换成class
这简单,如果你自己观察的话,可以发现我在上面的Date类中加了【public:】和【private:】这两个东西,它们就叫做类的访问限定符
1、C++中的三类访问限定符
- 正式来说一说C++中的三类访问限定符【public】【protected】和【private】
- 其中,对于
public
来说只的是共有,表示从当前public到收括号};
为止的所有成员变量或者是成员函数均为公有的,什么是公有呢?就是类内类外都可以随意调用访问,不受限制 private
指的就是私有,这很直观,对于共有来说就是所有东西都是公开的,无论是谁都可以访问;那对于私有来说便是无法访问,谁无法访问呢?这里指的是外界无法访问,但类内还是可以访问的,例如就是类内的成员函数访问这些成员变量是不受限制的protected
指的是保护,代表它会将类内的这些东西保护起来,外界无法访问到。但对于这个限定来说暂时可以把它当成和private
是类同的,到了C++中的【多态】才会出现差异
- 光就上面这么说你一定会觉得有些抽象,其实读者可以将这个访问限定符看作是一把【锁】🔒,设想你家里装了一把锁,那么此时锁住的就是外面的人,对家里的人是不会有影响的
接下去再来看看有关访问限定符的一些特性指南
- public修饰的成员在类外可以直接被访问
- protected和private修饰的成员在类外不能直接被访问(此处protected和private是类似的)
- 访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止
- 如果后面没有访问限定符,作用域就到 } 即类结束。
- class的默认访问权限为private,struct为public(因为struct要兼容C)
- 主要还是来看一下这个第5点,在C语言中,我们在结构体中直接定义出一个成员变量的时候不需要去考虑是否可以访问到,而是直接就这访问了;但是在C++中,我们在访问类中的一个成员变量的时候,是会受到限制的,我们可以来看看
- 可以看出,即使我将类中的
private
去掉的话,还是会存在【不可访问】的现象,原因就是在于类内的定义的内容默认访问权限都是private
,外界是无法访问到的
但一定会有同学有这么一个疑问,那在加上
[private]
关键字后,这个成员变量也是私有的呀,为什么可以对他们去进行一个初始化呢?那不是访问到了这些成员变量了
- 这一点要注意,当我在初始化的时候,并没有直接去访问类内的【成员变量】,而是调用了【成员函数】,在成员函数的内部又调用了类内的【成员变量】,上面有说到过,对于私有的东西虽然类外是不可访问的,但类内还是可以访问的,这个🔒只是锁住了外来入侵者🗡,自己家里的人还是不受限制的
对于上面这一点来说,其实就又一些C++中类的封装思想了,接下去我们就正式来谈谈面向对象的三大特性之一 —— 【封装】
2、初探类的封装👈
【封装思想】:用类将对象的属性(数据)与操作数据的方法结合在一块,让对象更加完善,通过访问权限选择性的将其接口提供给外部的用户使用
- 封装本质上是一种管理,让用户更方便使用类。比如:对于电脑这样一个复杂的设备,提供给用户的就只有开关机键、通过键盘输入,显示器,USB插孔等,让用户和计算机进行交互,完成日常事务。但实际上电脑真正工作的却是CPU、显卡、内存等一些硬件元件
- 设想若是没有将电脑中的一些元件给封装起来,就是将内部的一些部件展现在用户的眼前,那么用户每次在将电脑开始的时候都需要那边点一下,这边开个开关,使用起来就会很麻烦,所以可以看出,对于电脑来说,也是存在这么一个封装的思想【很好地将内部的细节给屏蔽起来了,方便管理】
这里先初步地讲一下有关【类的封装】思想,文章的后半部分会不断地加强读者对这块的理解
五、类的实例化
当我们写好一个类之后,就要去把它给定义出来,就好比在C语言中,我们要使用一个变量的话,也是要把它定义出来才行,才可以使用,例如:结构体声明好了之后就要将其定义出来,否则是不用的
1、变量的声明与定义 - - 铁瓷还会铁吗?
- 首先我要问你一个问题,下面的这三个成员变量是已经被定义出来了?还是只是声明呢?
- 读者一定要理解【声明】和【定义】的区别,对于声明来说,只是告诉编译器存在这么一个东西,但它不是实际存在于内存中的;而对于定义来说就是实实在在地把空间给开出来,那么此时在内存中就有它的一席之地了
可能就这么说太好理解,我们通过一个形象一点的案例来说明💬
- 你呢,背井离乡在二线城市当一个程序员💻,工作了几年也赚了不少钱,此时你就想把一直以来的出租屋换成一个崭新的房子,想要在你所处的城市买个房,虽然交不起所有的钱,但首付还是可以的,不过呢还差那么几万块钱,于是呢就想到了你大学时候的室友,也是个铁瓷很要好的朋友,想找他结点钱💴
- 于是就打电话过去说:“兄弟呀,我最近想买个房,交个首付,不过手头上还差个几万块钱,你看你有没有一些不急着用的先借我点,之后赚来了再还给你。”那听到昔日的好友这么一番话,便说:“可以可以,好兄弟开口,那必须帮忙!”于是呢他就这么答应你了,不过也只是口头答应,也就是一种承诺。这个口头答应其实指得就是【声明】,你只能知道会有这么一笔钱给到你,但是这笔钱还没真正到你的手里
- 不过呢,过了好几天了,还是不见兄弟把钱打过来,眼看就要交首付了,只能再给他打一个电话过去说:“兄弟,上次和你说的那个钱怎么样了,后天就要交首付了,你看能不能先打过来。”当你说完这句话之后,其实就会出现两种情况Ⅱ
- 你的兄弟回道:“哦哦,不好意思,最后手头太忙可了,都给忘了,马上给你转过来。”此时就听到【
支付宝到账5万元
】的声音,那么这笔钱就真正地到你手里的,这是实实在在的,已经存在了的事,指的就是【定义】 - 你的兄弟回道:“啊呀,这个,真是不好意思啊,家里的钱都给媳妇管着呢😪,它不同意我也办法,对不住了兄弟,要不你再找找别人。”于是他便小心翼翼地挂掉了电话,你俩就没有再联系过,铁瓷也不铁了~
- 你的兄弟回道:“哦哦,不好意思,最后手头太忙可了,都给忘了,马上给你转过来。”此时就听到【
- 对于上面的第二种情况,就很像平常在写程序的时候出现链接错误的情况,那就是【声明了但是未定义】的这种行为。之前承诺了、声明了,但是找你要的时候实际却没有
- 对于函数而言就是有声明找不到定义
- 对于变量而言就是这个变量没开空间
- 所以对于这三个成员变量来说只是一个声明,不是定义,并没有开出实际的空间
那怎样才算定义呢?又是何时开出空间,让我们来瞧瞧【类对象的声明与定义】👇
2、类对象的声明与定义 - - 别墅设计图🏠
- 要实际地开出空间来,其实值得就是要将这个类定义出来,因为你的成员变量是声明在类里面的,那你把写好的这个类定义出来后,【成员变量】也就对应的在内存中开出了一块空间,它们是作为一个整体来进行定义的
int main(void)
{
Date d; //类对象的实例化
return 0;
}
用类类型创建对象的过程,称为类的实例化
-
类是对对象进行描述的,是一个模型一样的东西,限定了类有哪些成员,定义出一个类并没有分配实际的内存空间来存储它;
- 比如:入学时填写的【学生信息表】📊,表格就可以看成是一个类,来描述具体学生信息
- 对于类来说就像是谜语一样,对谜底来进行描述,谜底就是谜语的一个实例。例如谜语:"年纪不大,胡子一把,主人来了,就喊妈妈“。这只是一个【描述】,但是实际要知道这个,谜语在描述写什么,这个类里面有什么东西,想要传达出什么,就要将它实例化出来,定义出来,那么谜底也就揭晓了 👉谜底:山羊
- 比如:入学时填写的【学生信息表】📊,表格就可以看成是一个类,来描述具体学生信息
-
一个类可以实例化出多个对象,实例化出的对象占用实际的物理空间,存储类成员变量
- 这个又怎么去理解呢?这里给读者举一个形象的例子:不知道你是否了解一个建筑物是如何从设计到建成的,重要要经过很多的步骤,但是在一开始建筑师一定是要设计出一张【设计图】来,好对这个房子的整体样式和架构先有一个大致的轮廓,在后面才可以一步一步地慢慢实施建设计划。
- 那其实对于这个类来说就和【设计图】是一样的,比方说现在我们要造一栋别墅🏠,那么一张图纸📑,即一个类中描述的就是这个别墅有几层,多少个房间,门朝哪儿开,是一个大致的框架,不过呢这也就仅仅是一个设计图罢了,还不是一个真实的别墅,不存在具体的空间,因此是不能住人的🛏
- 那要怎样才能住人呢?也就是建筑师通过这张设计图,找几个施工队真实地将别墅设计出来,那才可以真正地住人
- 但平常我们看到的那种高档小区中,可不止一栋这样的别墅,而是有几十栋,难道设计师也要画出几十张设计图才能建完这些别墅吗?当然不是,对于一张设计图来说是可以建造出无数的别墅,只要根据这个设计图来建就行。那上面说到对于设计图来说就是一个类,也就意味着一个类也是可以实例化出多个对象的🐘🐘🐘
- 实例化出这个对象后也就实实在在地将空间给开出来了,那我们上面说到过的【成员变量】,此时也开出了空间,就可以存放对应的数据了
Date d;
d.year = 2023;
d.month = 3;
d.day = 18;
- 但对于下面这种形式去初始化成员变量是不行的,若是还没有定义出一个对象的,成员变量不存在实际空间的,直接用类名去进行访问就会出错,不过后面的文章中我会讲到一种叫做静态成员变量,用
static
进行修饰,是可以的直接使用类名来进行访问的
六、类对象模型
1、成员函数是否存在重复定义?
- 上面,我们说到了对于一个成员变量来说,若是类没有被定义出来的话它是不存在具体空间的,那在一个类中除了成员变量外,还有【成员函数】,仔细观察可以发现,这似乎就已经把成员函数定义出来了呀,那空间不是已经存在了。 此时是外面再去实例化这个类的话,岂不是会造成重复定义了?
- 可是刚才我们有看过,在实例化出这个Date类的对象时,并没有报出重复定义这个错误,而且在调用这个
Init()
和Print()
函数的时候也不会有什么问题,这是为何呢?难道这个【成员函数】和类没什么关系吗?它存在于类中吗?
让我们带着这个疑问开始本模块的学习
2、计算类的大小【结构体内存对齐】
要想知道这个类中存在多少东西,其实我们去计算一个它的大小即可
- 还记得结构体内存对齐吗?忘记了就再去看看,下面是对应的规则👇
- 第一个成员在与结构体偏移量为0的地址处。
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
注意:对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。
VS中默认的对齐数为8 - 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
- 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
- 在C语言中,我们有去计算过一个结构体的大小,那上面我们在对于结构体和类做对比的时候说到对于
struct
和class
都可以去定义一个类,那么结构体内存对齐的规则也一样适用。不过我们只会计算成员变量的大小,那就来先计算一下这个【year】、【month】、【day】的大小
- 通过画图以及运行结果可以观察,可以得出类的大小和我们计算的【成员变量】大小竟然是一致的,那【成员函数】呢?没有算上去吗?还是根本不计算在内?
3、探究类对象的存储方式🔍
在看了上面惊人的一幕后,我们就来思考一下,对于这个类对象究竟是如何进行存储的。在下面,我给出了类对象存储方式的三种设计方式,你认为哪一种设计方式是正确的呢?
- 首先是第一种,也就是将类的【成员变量】和【成员函数】都存放在一起,其实对于每个类的成员变量来说都是不一样的,都有它们不同的空间,可调用的是同一份函数。,如果按照此种方式存储,当一个类创建多个对象时,每个对象中都会保存一份代码,相同代码保存多次,浪费空间。那么如何解决呢?
- 下面是第二种设计方式。代码只保存一份,在对象中保存存放代码的地址,这种方式似乎看起来不错,你认为可行吗?
- 再来看看第三射击方案。可以看到对于【成员变量】和【成员函数】完全就是分离了,存在了一个叫做公共代码区的地方,类的所有函数都放在一个类函数成员表中
- 对于每一个对象来说都是互相独立的,里面只存放了各自的成员变量,而要找成员函数的话就要通过当前这个类的对象去公共代码区进行调用
- 答案揭晓,那就是最后这一种,实现了成员与函数的分离,为什么要这么设计呢?上面其实有提到过,虽然每个对象的成员变量是不同的,各自各自的空间,但是对于成员函数来说,大家都是一样的,例如这个
Init()
函数,外界被定义出来的对象只需要调用一下这个函数去初始它自己的成员变量即可,不需要将其放在自己的类内。 - 设想若是每个类中都写一个这样相同函数的话,此时每个对象就会变得非常庞大,也就是我不去调用这个函数,只是将对象定义出来函数的空间就已经会存在了,这样的设计其实是不好的,所以我们应该采取第三种做法
感性理解:私有场所与共有场所
但是这么说一定是比较抽象了,我们再通过一个生活小案例来理解一下
- 就用刚才说到的这个别墅小区好了,那在每栋别墅里面都是房间的,像客厅、卧室、厨房、洗手间,每家每户基本都有,但是呢每一家都有它们自己家庭的设计,既然是个人别墅,那么一定不可能每栋房子的客厅、卧室、厨房、洗手间都在同一个位置吧,那就太单调了╮(╯▽╰)╭,这些房间呢值得就是【成员变量】
- 那在一个小区中,除了挨家挨户的的私人领域外,一定会存在公共区域,在这些公共区域中,会有一些公共场所,例如像篮球场、咖啡馆、游泳馆、小卖部或是健身器材等等,对于这个公共区域你可以理解为【公共代码区】,而对于这些公共场所、设施你可以理解为【成员函数】
- 那其实这就很形象了,【成员变量】是每个对象各自的,由于类的封装特性别人无法轻易访问,可是呢对于这个【成员函数】来说,是大家共有的,可以一起使用,所以不需要放在自己家里,除非你真的很有钱,在一个别墅小区再自己建一些私人的游泳池、篮球场和小卖部👈
4、空类大小计算【面试考点✒】
- 学习了如何去计算一个类之后,接下去请你来判别一下下面三个类的大小分别为多少
// 类中既有成员变量,又有成员函数
class A1 {
void f1() {}
private:
int a;
};
// 类中仅有成员函数
class A2 {
void f1(){}
};
// 类中什么都没有---空类
class A3 {};
- 首先是类
A1
,有一个成员变量,那经过上面的学习可以得知成员函数是不存在于类中,又因为整型占4个字节,所以很容易可以得知A3的大小为4 - 接下去对于类
A2
,只有一个成员函数f1()
,没有成员变量,那【sizeof(A2)】的结果会是多少呢?一会看运行结果后再来分析 - 接下去是类
A3
,对于这个类来说既没有成员函数也没有成员变量,那它的大小会是多少呢?0吗?
我们来看一下运行结果
- 通过观察可以得知,似乎只算对了第一个类A1的大小,但是前两个类的大小为什么都是1呢?这相信读者也是非常疑惑吧?立马为你来解答👇
- 一个类的大小,实际就是该类中”成员变量”之和,当然要注意内存对齐。但是对于空类的大小却不太一样,空类比较特殊,编译器给了空类一个字节来唯一标识这个类的对象【这1B不存储有效数据,为一个占位符,标识对象被实例化定义出来了】
上面的这个概念在笔试面试中都有可能会涉及,准备校招的同学要重视
七、this指针【⭐重点掌握⭐】
1、提问:如何区分当前初始化对象?
- 继续来回顾一下上面所写的Date日期类,有三个成员变量和两个成员函数
class Date {
public:
//定义
void Init(int year, int month, int day)
{
_year = year;
_year = month;
_year = day;
}
void Print()
{
cout << "year:" << _year << endl;
cout << "month:" << _year << endl;
cout << "day:" << _year << endl;
}
private:
int _year; //仅仅是声明,并没有开出实际的空间
int _month;
int _day;
};
- 那现在我定义出一个变量后开始传递数据,然后初始化
d1
里面的【year】【month】【day】,然后在内部Init()函数中使用_year = year
这样的方式来进行初始化,此时右边的[year]
是外部传递进来的2023,[_year]
是内部的成员变量,但是仔细回忆一下,刚才我们有说到这个[_year]
只是类内部声明的,并没有被定义出来呀,那要怎么赋值初始化呢? - 有同学说:外面不是定义出这个对象d1了,那么三个成员变量的空间自然也就开出来了,是的,这没错
Date d1;
d1.Init(2023, 3, 18);
- 可是现在呢,我又定义了一个对象,此时就会存在两个对象d1和d2,然后分别去调用这个Init()函数来初始化自己的成员变量,那外部传入实参的时候是可以分清的,但是传入到内部时
_year = year
中的[_year]
要怎么区分这是d1还是d2的成员变量呢?若有又定义了一个d3呢?如何做到精准赋值无误? - 在外部定义出来的对象调用的时候可以很明确是哪个对象调的,但是到了函数内部又是辨别的呢?对于成员函数来说存放在公共代码区,大家都可以调用,那即使调用了也没有传入当前对象的地址呀,韩素红内部怎么知道要给哪个对象初始化成员变量呢?
好,就让我们带着这些问题,进入下一小节的学习📖
Date d1;
Date d2;
d1.Init(2023, 3, 18);
d2.Init(2024, 3, 18);
2、深度探究this指针的各种特性【原理分析】
面对上面情况,其实就可以使用到C++中的
this指针
了,这个我在上面有提过一嘴,还有印象吗
- 上面讲了这么多不知读者是否关注到我说的一点:外界无法传入当前对象的地址给到被调用的成员函数
- 那我现在要说的是,其实这件事情是做了的,当这个成员函数被调用的时候,编译器就会自动给在这个函数的最前面加上一个形参,他就是专属于当前类的一个指针,就是
this指针
//void Init(int year, int month, int day)
void Init(Date* this, int year, int month, int day)
- 那么形参部分改变了,实参也需要修改,那要传递什么呢?没错,就是当前对象的地址
//d1.Init(2023, 3, 18);
d1.Init(&d1, 2023, 3, 18);
- 那么当this接受了当前对象的地址之后,编译器就将代码转换成了下面这种形式,【this】在英文单词中指的就是当前,那么意思就很明确了,为当前对象的
year
、month
和day
进行初始化。随着每次的传入的对象地址不同,this指针就会通过不同的地址去找到内存中对应的那块地址中的成员变量,进行精准赋值
- 不过通过观察可以发现,似乎我们自己去加上这一个参数好像是行不通,编译器报出了很多错误,
看看下面这段话就知道为什么了👇
- C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有“成员变量”的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成
- 所以这个this指针我们是不可以加的,编译器会自动帮我们加上,并且传递当前对象的地址
不过,虽然我们不能传递观察,但可以通过这个隐藏的this指针来看看是否真的传递了当前对象的地址进去
了解了this指针的基本原理后,我们来聊聊它的相关特性
-
this指针的类型:类类型* const(
Date* const
),即成员函数中,不能给this指针赋值- 对于this指针来说,是被
const
常所修饰的,为【指针常量】,对于指针本身的指向是不可修改的,但是指针所指向的内容可以通过解引用的方式来修改。如果不是很清楚这一块可以看看常量指针与指针常量的感性理解
- 对于this指针来说,是被
- 只能在“成员函数”的内部使用
- 这一点要牢记,对于this指针而言,只可以在类的成员函数内部进行使用,是不可以在外部进行使用的,因为它是作为一个成员函数的形参,若是没有传递给当前对象地址的话,那么它的指向是不确定的,但当进入成员函数内部时,编译器底层一定调用了这个this指针,为其传递了对象的地址,此时在内部再去使用的话是不会有问题的
-
this指针本质上是“成员函数”的形参,当对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象中不存储this指针
- 这一点上面也有强调过,this指针是作为形参来使用,那对于函数形参,我们在函数栈帧一文有深入研究过它是需要进行压栈的,那就要建立函数栈帧,可以很明确它就是存放在栈区的,而不是存放在对象中,这一点下面有一道面试题也是涉及到,再做细讲
- 而且刚才在求解类的大小时,通过结构体内存对齐可以很明确地看出除了【成员变量】之外的其他的东西都是不计算在内的
-
this指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传递,不需要用户传递
- 这一点我们可以通过汇编指令来看💻
- 可以观察到,传递进函数
Init()
的参数都会被压入栈中,不过可以观察到,由于栈【先进后出】的性质,是从第4个参数开始压栈的,若是按照原本的三个参数来说应该会压三次,但是看到2023被Push
进去之后还有一个[d1]需要被lea(load effective address)
进去,不过并不是直接加载,而是放到一个寄存器ecx中再加载,这个d1指的其实就是对象d1的地址 - 通过汇编指令可以把底层的逻辑看得很清楚,观察到确实是存在this指针接受当前调用对象的地址
3、this指针的感性理解
说到了这么多有关this指针的特性,有些特性对大家来说可能还是比较难以理解,接下去我会通过三个生活中的小场景带你好好理解一下😄
- 夏天到了,呆在家里一定会很热,一天到晚打空调对身体又不好,此时就会想到去游泳馆游泳,那去游泳的话肯定要换上专门的衣物,去哪里换呢?当然是更衣室了,有过经历的同学一定知道当你去更衣室换衣服的时候,前台就会给你一个手环,可以识别感应里面的柜子,一个人一个柜子可以放置自己的私人物品。然后就把这个手环套在你的手上,最后当你游完泳后要怎么知道那个是你的柜子呢?那是通过这个手环来进行感应打开柜门取出自己的衣物【这个手环就是用来识别的,别人的手环打不开你的柜子】
- 在大学生活中,每个人一定都有自己的校园卡,这张校园卡呢可以用来吃饭、洗澡、接水,甚至可以代替人脸识别,所以在这个校园中,校园卡就是你的身份象征,每个人都是唯一的
- 住过小区的一定都知道,现在的小区管理制度是越来越严了,出入呢都需要这个门禁卡,才可以证明你的身份,是属于这个小区的,防止外来人员入室盗窃,所以这个门禁卡就是你身份的象征【有没带门禁进不去单元门的吗🐕】
通过上面的三个生活小案例,相信你对this指针一定有有了自己的理解
4、两道夺命面试题👻
本小节的内容可能会让你感到非常枯燥,如果没有校招需求的读者可以选择跳过,当然也可以浏览一下喽😆
this指针存放在哪里?
先来看看第一位同学的回答:
💬 this指针是存放对象地址的,和对象存在关系。应该是存放在对象中的把😁
- 听完了他这一番话,我差点没拿起我的四十米大刀🔪抡过去(╯▔皿▔)╯,刚才我们讲到了有关this指针的特性,现在再重复一遍。它是作为成员函数的隐含形参,既然是函数形参的话,那就需要压栈、建立栈帧,所以这个this指针是存放在栈上的。
- 不过在VS中,编译器会使用寄存器
ecx
进行一个临时保存,刚才我们也有通过汇编指令进行一个查看
再来听听第二位同学的回答:
💬 刚才不是说这个成员函数是存放在公共代码区的吗,那隐藏形参this是属于这个函数的,为何没有存放在公共代码区呢?
- 这个问题其实问得不错,不过这属于一个知识混淆了,不要把【栈区】和【公共代码区】混为一谈
- 我们现在对一段程序进行编译,实例化出一个对象后这个对象就是存在【栈区】中的,但是成员函数不存放在其中,因为成员函数是属于公共区域的一部分,所以在编译完成之后,
call
指令的地址不在对象中找,而去【公共代码区】中找,为什么要去这个公共区找呢?因为成员函数被编译出来的这些指令(刚才看的指令)存放在这里面, 而成员函数内部的一些形参、临时变量
则不在这里面,它们都是存放在【栈区】中的。所以this指针不在【公共代码区】,而在【栈区】 - 听完我的这番话后,这个同学似乎就明白了一些东西
this指针可以为空吗?
好,接下去我们再来看第二个面试题
💬请问下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行
class Date {
public:
//定义
//void Init(Date* this, int year, int month, int day)
void Init(int year, int month, int day)
{
cout << "this:" << this << endl;
this->_year = year;
this->_month = month;
this->_day = day;
}
void Print()
{
cout << "Print()" << endl;
}
private:
int _year; //仅仅是声明,并没有开出实际的空间
int _month;
int _day;
};
int main()
{
Date* p = nullptr;
p->Print();
}
运行结果:
- 看了上面的运行结果,你是否感觉很吃惊😮,为何没有【运行崩溃】呢?为什么可以对一个空指针去解引用呢?
- 可以知道这个Print()是Date类的一个成员函数,那既然是成员函数的话就可以调用this指针,我们来试试
- 可以看到this指针所接收到的地址为空,这很明确,我们在因为在外界调用这个函数的对象指针就是空的。那可以看出其实从调用到函数内部的执行完全没有进行一次解引用的操作,所以才不会引发空指针异常的问题
- 不过呢,就上面的这一个还考察不出一个人对this指针的理解,一般考了上面这个还会接连着考下面这个
p->Init(2023, 3, 19);
运行结果:
- 很明显,若是去调用
Init()
初始化函数的话就会发生空指针异常的问题,这是为什么呢?
💬有同学说:一看就是这个this->
的问题,很明显的解引用嘛,去掉不就好了😎
- 然后继续给他看了这个,他便陷入了沉思🤨
- 这里要说明一点,本模块一开始我们初谈
this
指针的时候说到, 在成员函数内部调用成员变量时,可以在前面加上this->
去指向,当然也可以不加,因为是默认带有的,所以在这里无论是显式地加上还是隐式让他保留,都会产生一个【指针指向】,那我们在C语言的指针章节有谈到,对于指针指向而言其实就是解引用,所以这才造成了出错
通过上面这两个函数的调用,相信你对this指针传参机制有了一些基本认识。但还是没有清楚这其中的原理,接下来我便带你分析一下里面的原理
- 上面有说到过,对于成员函数而言是不存在于类内部的,而是存放于【公共代码区】,所以对于上面的这两个函数而言都不去类里找,即【栈区】里找,而是通过函数名修饰后的名称去【公共代码区】里找
- 对于
Print()
函数而言,并没有涉及到访问成员变量,那你可以把它理解为在别人家的小区里的公共蓝球场🏀打了个篮球,那这其实是属于公共的区域,而没有闯入别人的私人领地 - 使用空指针去调用了一下这个函数,此时传递这个空对象的地址给到成员函数内部的【this】指针,然后我去打印了一下这个this指针,那也就是将这个对象的地址给打印出来,因为它是一个空对象,所以结果是
00000000
。但是并没有再做任何的事了,所以不会有问题 - 就比方说你手里有一把刚从刀柄里拔出来的刀🔪,但也只是从刀柄里拔出来看看,并没有那它去做任何坏事,伤害到别人,那也没什么问题嘛╮(╯▽╰)╭
- 当在调用这个
Init()
函数时,也不是去【栈区】里找,而是去【公共代码区】里找,也是一样首先打印了一下传入的空对象地址,不过接下来的操作就不对了!只要是要访问当前类的成员变量时,都需要使用到this
指针来进行指向,只是因为它是一个隐式形参罢了 - 打个比方,你又进到别人家小区了,但是呢此时却没有经过别人的同意随意地躺在别人家沙发上🛏上,这个沙发就属于别人的私有物品,也可以看做是成员变量,刚才我们并没有访问成员变量,所以是没问题的,但是现在却访问了一个私有的东西,那报出错误也是很正常的
💬那有同学又说:那我在调用Init()
的时候不传空对象不就好了,直接调用
Init(2023, 3, 19);
- 你觉得这样可以吗?我让他罚站了半个小时~
- 上面我们说到,若是一个定义出来的对象去调用成员函数,会有一个隐藏的形参
this
接受传递过来的对象地址,以此去调用不同对象的成员变量。但是就上面这个调用,连执行的对象都没有,你觉得this
指针会接收到什么东西呢? - 通过运行结果可以看出,连编译都过不了,谈何运行呢?
好,看完了上面这一些,相信你对this指针的了解一定更加深刻了,我们再来看最后一个👇
💬还是调用刚才的Print()
函数 A、编译报错 B、运行崩溃 C、正常运行
(*ptr).Print();
- 在学习了上面的两道题后,相信你一定会觉得这是个【编译报错】或者是【运行奔溃】,不过结果却大相径庭。竟然完全没有问题,可以正常运行!😮
这是什么呢???
- 若是你完全不懂这一点的话,应该再回过去看看指针的解引用那块,难道一个指针去进行指向
->
或者是解引用*
就一定是在对指针进行解引用嘛 - 不,会不会解引用取决于要不要到指向的对象中去找成员变量,而不是看有没有
”->“
。因为对于编译器来说,是将代码的这些语法转换为指令,我们要去看的是这些汇编指令
- 通过上面的观察可以看出,从【汇编角度】而言,其实编译器把
->
和*
解析成了同一种方式,至于内部的逻辑是怎么样的,上面已经讲过了,此处不再赘述
💬最后,有位同学又提出了这样的写法,蛮不错的,给读者分享一下
- 你认为下面这种直接用类名然后【域作用限定符
::
】的方式去访问可行吗?
Date::Print();
运行结果如下:
- 对于这种调用形式,我们在上面其实也提到过,只有静态的成员函数或者是静态的成员变量才可以用类名直接访问,所以这样是不可以的,也是一种没有传入当前对象地址给this指针的形式
💬为什么没有让她罚站呢,怎么能让女生👩罚站呢,是吧😆
5、一道笔试题✍
💬下面程序段包含4个函数,其中具有隐含this指针的是( )
int f1();
class T
{
public:static int f2();
private:friend int f3();
protect:int f4();
};
【答案】:f4
【解析】:
- f1为全局函数,不是类的成员函数,因此没有this指针
- f2为static函数,放在公共代码段,不属于类,因此没有this指针
- f3为友元函数,不属于类,因此没有this指针
- f4为成员函数,在类T中具有保护权限,因此有this指针
好,接下去就对上面的笔试和面试题所引申出来的知识点做一个总结与回顾,我们就进入下一模块
【总结一下】:
- 对于this指针而言,是存放在【栈区】的,虽然其是属于成员函数的一个隐式形参,但是却不和成员函数一样存放在【公共代码区】,对于成员函数而言只是编译器将其解析后的汇编指令存放在公共区罢了,而对于函数内部的形参和临时变量,都还是存放在栈区的,是需要开辟栈帧、压栈的↓
- this指针可以为空,但能不能正常运行取决于你如何去调用,仅仅是传递了空对象地址但没有进行解引用的话不会出现问题。但若是在成员函数内部访问成员变量的话,无论你有无给出
this->
,都会造成解引用从而导致空指针异常⚠的问题。 - 看一个指针是否产生了介意用不是光光看
->
或者*
,而是要去观察底层的汇编指令如何执行,这才是计算机内部的真正执行逻辑
八、C和C++实现栈的对比
好,讲了这么多,相信读者对C++的类这一块一定有了自己的理解,本模块,我将通过C语言和C++分别去实现一个【栈】,通过观察来让读者看出C++到底是如何实现封装的
1、代码展示
C语言的相关代码可以看这篇文章 链接,这里就不贴代码了
主要来展示一下C++的代码,下面是比较规范的声明与定义分离的形式,可以先看看
stack.h
typedef int DataType;
class Stack
{
public:
void Init(size_t capacity = 4);
void Check_Capacity();
void Push(const DataType& data);
void Pop();
bool Empty();
DataType Top();
void Destroy();
private:
DataType* _array;
size_t _capacity;
size_t _top;
};
stack.cpp
#include "stack.h"
void Stack::Init(size_t capacity)
{
_array = (DataType*)malloc(sizeof(DataType) * capacity);
if (nullptr == _array)
{
perror("malloc申请空间失败");
return;
}
_capacity = capacity;
_top = 0;
}
void Stack::Check_Capacity()
{
if (_top == _capacity)
{
DataType* tmp = (DataType*)realloc(_array, sizeof(DataType) * _capacity * 2);
if (nullptr == tmp)
{
perror("fail realloc");
exit(-1);
}
_array = tmp;
_capacity = _capacity * 2;
}
}
void Stack::Push(const DataType& data)
{
// 扩容
Check_Capacity();
_array[_top] = data;
++_top;
}
bool Stack::Empty()
{
return _top == 0;
}
void Stack::Pop()
{
assert(_top > 0);
assert(!Empty());
_top--;
}
DataType Stack::Top()
{
return _array[_top - 1];
}
void Stack::Destroy()
{
if (_array)
{
free(_array);
_array = nullptr;
_capacity = 0;
_top = 0;
}
}
test.cpp
int main(void)
{
Stack st;
st.Init();
st.Push(1);
st.Push(2);
st.Push(3);
size_t top = st.Top();
cout << top << endl;
st.Pop();
top = st.Top();
cout << top << endl;
st.Destroy();
return 0;
}
运行结果:
- 其实除了C/C++之外,像其他语言例如Java都是可以实现一个栈的【链接】,只不过语言的立场、语法的细节不一样而已
- 如果平常自己懒得去写,直接使用库里面给我们写好的就行,C++和Java都有现成的库给我们封装好了,只需要调用一下需要的API即可,像我们后续会学习的STL中
stack
,里面就有上面所学的全部内容
2、C语言特性分析
- 数据和方法是分离的
- 这一点其实很好观察,当我们使用C语言去写一个栈的时候,是将存放数据的数组、栈顶指针、容量大小都放在结构体中,其他算法接口再另外分离写,二者的关联性并不是很大
-
数据访问控制是自由的,不受限制
- 还有第二点,就是在C语言中,我们去访问一个数据的时候,其实是比较自由的,不会受到过多的限制。
- 举个例子,当初我们在数据结构中写栈的
StackTop()
时,产生了分歧,有的同学说直接像下面这样取就可以了
int top1 = st->a[st->top - 1];
- 但是呢有的同学却觉得即使是再小的功能也应该封装成为一个函数的形式,之后再去进行调用
int top2 = StackTop(&st);
- 我赞同了上面这种写法,还记得为什么吗?因为对于外界来说是无法知晓你底层的逻辑实现是怎样的,若是写成第一种形式的话,调用者就得知道当前这个栈在初始化的时候
top
指针初始化的值是多少,是-1呢?还是0呢? - 但若是采用第二种写法,调用者完全不需要关心这个函数内部的实现细节,只需要进行调用即可,就会显得很方便
asssert(top > 0);
- 不仅如此,若是采取第一种形式的话,访问者不仅要知道底层top指针初始化为多少,而且还要知道当前栈中还有多少数据了,因为我们在去栈顶元素前都会去使用一个
assert()
进行检查,此时若是这个栈顶指针<= 0
的话也就表明栈里面没有元素了,再去通过数组访问的话就会造成有越界的风险
【总结一下】;
- 使用C语言去进行访问的时候过于自由,因此需要考虑到很多因素
- 需要知道底层的top的初始化
- 有越界的风险,不安全
C语言语法的松散性 - - 过红绿灯还是慢一点吧🚦
虽说使用C语言这样去进行访问确实存在一些缺陷,那为什么标准委员会没有改进这一点呢?还是允许这样的访问。
- 这其实就是因为C语言本身【语法的松散性】导致的,因为C语言的语法限制不太严格,对变量的类型约束不严格,影响程序的安全性,对数组下标越界不作检查等。所以C语言其实较其他高级语言来说其实更难掌握,要求使用者对代码具备一定的的控制能力
- 那面对C语言中的一些缺陷,官方文档中的也只是建议说使用的时候不要这样去做,像上面那样直接访问栈顶元素的方式【不做推荐】
但是推荐这个东西管用吗?
- 举一个很形象的例子,日常我们在开车🚗经过十字路口的时候,都会有很多红绿灯来阻拦我们,此时就无法做到一路畅通,但是所谓的
”红灯停,绿灯行,黄灯等一等”
真的起到了什么作用吗? - 还是会存在大批行人闯红灯的现象,总有人不遵纪守法,导致出事故
- 要知道,这个世界永远存在不确定的事,不可能所有的事情都愿你想得那么美好,就好比我们日常在做开发的时候,总是需要考虑到各种各样的问题,为什么?因为用户的行为是不确定的,可能哪一天就会做出你在开发是会后根本想不到的事,因为我们写代码时需要考虑到各方面的因素
所以可以看出来C语言存在一定的不严谨性,而且C语言还比较乱,尤其体现在学校的教科书和一些相关书籍中
- 在C++引用一文的,我有提到了很多学校的数据结构教材中的一些代码,其实是C和C++混编的,可以却告诉读者使用的是C语言是实现,就是因为C和C++之间有着一些联系,所以很多读者就会分不清哪个是C,那么是C++的代码
- 不仅如此,在一些经典书籍中,也会出现类似的情况,这里点名说一本书叫做《大话数据结构》。如果你有看过这本书的话会觉得它里面的一些讲解和图其实都蛮好的,也挺适合初学者,不过呢里面的一些
代码
让人看起来确实有点难受🤢 - 但凡你有去工作过的话,真的是看不上这本书的代码,可以说这个作者应该是缺乏一些工程经验,代码没有规范性
上面的一些种种案例其实都可以说明C语言在语法设计这一块确实是有些松散了,导致缺乏经验的初学者会遇到很多难题
3、C++的特性优势分析
再谈类的封装思想 - - 兵马俑还是保护起来吧🛡
- 上面谈到了由于C语言在语法设计这一块存在松散性,因而导致了在使用的时候会有一些随机性和不确定性,存在一定的风险。但是C++在这一块却做得很好,一起来看看C++在封装这一块的思想
-
数据和方法都封装到类里面
- C++做得很好是因为它并不是像C语言那样变量和函数都分离开来,而是将它们都封装到一个类里,全部保护起来了,外界是无法随意访问的
- 初步谈到类的封装思想的时,说到【封装】其实就是一种更好地控制,更好地进行管理,现在我通过一个形象一点的案例再来描述一下这个封装的思想
- 有去过西安的读者应该了解,这个六朝古都拥有世界八大奇迹之一的【秦始皇陵兵马俑】,是中华文化的瑰宝。从上图中我们可以看出,馆内将兵马俑都封在了中间的坑里,而外层则是一群群的游客,它们只能在站台上观看,而不可以下到坑洞里去触碰兵马俑
- 这其实指得就是一种【封装】的思想,将兵马俑全部保护起来,外人无法轻易接触到。若是不将这些兵马俑保护起来,谁都可以接触到,那么要不了一个月,兵马俑上到处都会刻着
“xxx到此一游”
、“xxx爱xxx”
,或者缺胳膊少腿
-
控制访问方式。【愿意给你访问的共有,不愿意给你访问的私有】
- 第二点呢就是在类的封装基础上,限定了外界的【控制访问方式】,若是想要给外界访问的就设置为共有
public
,不想给外界访问的就设置为私有private
- 就比如说你去定义一个栈然后对其进行初始化,此时不能直接访问到这个类内部的成员变量,类会向外部提供一个公共的接口对私有的成员变量去进行一个访问
Stack st; st.Init();
- 这个共有的接口是类里面写好给你的,类写得没问题,那你用起来就不会有问题
- 所以在C++中,我们要去获取栈顶元素的方式只有一种,那就是调用
Top()
成员函数,不需要去管内部的实现细节是怎样的,只需要调用就可以了 - 而内部的数组和栈顶指针都设置为了私有,你是无法访问到的
int top = StackTop(&st); //✔ int top = st->a[st->top - 1]; //❌
讲得通俗一点,还是用我们上面讲到过的红绿灯例子🚦
- 行人老是闯红灯怎么办! 那就不让他过去了,把红绿灯给撤了,两遍围墙围起来不能通过马路,那怎么办呢?就弄一个高架桥,你想要过去只能走上面的高架桥,这也就一劳永逸杜绝了闯红灯的问题的,杜绝了安全隐患
- 第二点呢就是在类的封装基础上,限定了外界的【控制访问方式】,若是想要给外界访问的就设置为共有
-
调用函数比C语言要轻松一点,不用传入当前对象的地址
- 如果你仔细观察C++和C语言实行去实现一个栈,不仅是类的封装这一块发生了大的改动,而且代码也简洁了不少,传入的参数均少了一个
//C语言实现 void PushStack(ST* st, STDataType x) PushStack(&st, 1); //C++实现 void Stack::Push(const DataType& data) st.Push(1);
- 那这么一对比确实有同学发现C++为何不用传当前对象的地址过去呢?
- 那我想这位同学一定是忘了一个很重要的东西
--> this指针
。还记得this指针的原理吗 ?它是成员函数的一个隐藏形参,在当前对象调用成员函数时,当前对象的地址会连同其他参数一起压入栈中,VS中则是使用寄存器ecx
来进行临时存放,最后再由成员函数中的this指针接受当前正在调用函数对象的地址,以此来访问不同对象的不同成员变量 - 所以可以看出:对于C语言来说,是需要我们显式地传入当前对象的地址,是浮于水面的;对于C++来说,不需要我们去做这一件事,编译器自动会帮我们完成,是藏于水面下的
九、总结与提炼
到这里,就讲完了【类和对象 · 筑基篇】,我们来总结回顾一下📖
- 首先我们聊到了【面向过程】与【面向对象】之间的区别,初步感受了C语言和C++两门语言特性的不同。为了引出C++中的类,便对比了
struct
和class
这两个关键字,知道了原来在C++中结构体可以这么玩,并且二者都可以用在来定义一个类 - 接下去呢我们就正式开始聊C++中的类,说到了三种访问限定符,
puiblc
、protected
和private
,若是加上了访问限定符后,类内的成员对外部来说就存在不同的限制。初次讲到了类的封装思想,将对象的属性(数据)与操作数据的方法结合在一块,起到了更好地管理 - 写好一个类后,就可以用它定义出一个对象了,在这一模块,通过形象地案例向读者展示了【声明】和【定义】之间的区别,如果要将一个东西定义出来,就要实实在在为其开出一块空间
- 将类实例化后,就可以通过这个对象去调用类中的向外提供的共有成员函数来说来操控私有成员变量,但是在计算类的大小时却发现【成员函数】似乎并不计算在内,通过探究发现原来其存在于一个公共代码区,为什么减少类内部的负担,将大家都要使用的东西像小区中的公共设施一般放在一个【公共代码区】,这样谁要用的时候通过对应的地址找到然后去调用一下即可。可是成员函数内部要如何知道是哪个对象来调用的我呢?
- 此时我们谈到了一个重要知识点 ——
this指针
,随着每次的传入的对象地址不同,隐式形参this指针就会通过不同的地址去找到内存中对应的那块地址中的成员变量,进行精准赋值 - 最后,在学习了C++中的类后,便去实现了我们之前在数据结构中写过的【栈】,对二者进行对比可以发现,原来C++在封装这一块的思想确实考虑得很周到,很好地解决了C语言因为语法松散而导致的各种不安全性,进一步加深了对【封装思想】的理解
———————————————【类和对象 · 磨砺篇】 ———————————————
下面是本篇的思维导图,可以放大了看,磨砺篇的内容是最多也是最难理解的,做好头脑风暴的准备🌊
一、前言
学习了类和对象的封装后,了解了一个类的基本组成以及如何去实例化一个类,今天就让我们进到类的内部,来探一探类里面都有哪些东西
- 首先看到下面这段代码,定义出一个栈后准备入栈三个元素
Stack st;
st.Push(1);
st.Push(2);
st.Push(3);
- 但是一运行起来可以发现,程序却奔溃了,这是为什么呢?
- 仔细一想就发现好像是忘记
Init()
初始化了,加上之后就没有问题了
- 但是去调试一下又发现了问题,这栈都入完了,好像忘了
Destroy()
😅,时间久了便会造成【内存泄漏】
- 最后,加上
Destroy()
之后才是一个完整的从定义一个栈、到使用、销毁一个栈全过程
- 看到这个栈,不由地想起我们之前做过的一道题力扣20 - 有效的括号。可以看到,在考虑一些特殊情况的时候判断括号不匹配就需要
return false
,但此时呢在return之前又要把定义出来的栈给销毁了才行,在力扣上是没关系,也不会给你保存内存泄漏的警告,不过为了【严谨性】,便需要考虑到这一点
bool isValid(char * s){
ST st;
InitStack(&st);
while(*s)
{
//1.若为左括号,则入栈
if((*s == '(')
|| (*s == '{')
|| (*s == '['))
{
Push(&st,*s);
++s;
}
else //2.若为有括号,则进行判断匹配
{
//若匹配到右括号后无左括号与之匹配,返回false
if(StackEmpty(&st))
{
DestoryStack(&st);
return false;
}
STDateType top = Top(&st);
Pop(&st);
if(top == '(' && *s != ')'
|| top == '{' && *s != '}'
|| top == '[' && *s != ']')
{
DestoryStack(&st);
return false;
}
else
++s;
}
}
//若在匹配完成后栈不为空,则表示还要括号没有完成匹配
if(!StackEmpty(&st))
return false;
DestoryStack(&st);
return true;
}
你是否发现若是我们要去使用一个栈的话不会忘了去往里面入数据或者是出数据,但是却时常会忘了【初始化】和【销毁】。这要如何是好呢😔
- 在上一文的学习中,我们学习到了一个类中的一个东西叫做
this指针
,只要是在成员函数内部都可以进行调用。而且还知晓了C++中原来是使用this指针接受调用对象地址的机制来减少对象地址的传入,减轻了调用者的工作。这也是C++区别于C很大的一点 - 那C++中是否还有东西能够替代【初始化】和【销毁】这两个工作呢?答案是有的,就是我们接下来要学习的【构造函数】和【析构函数】
首先我们来整体地介绍一下这六个成员函数
【空类的概念】:如果一个类中什么成员都没有,简称为空类。
- 对于一个空类来说,里面没有任何成员变量和成员函数,那它里面就真的是空的吗
class Date{}
- 并不是,任何类在什么都不写时,编译器会自动生成以下6个默认成员函数。
【默认成员函数概念】:用户没有显式实现,编译器会生成的成员函数称为默认成员函数
二、构造函数
1、概念
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有一个合适的初始值,并且在对象整个生命周期内只调用一次
- 例如下面这个Date类,有一个
Init()
函数可以用来初始化年月日,而下面的Date()
就是一个构造函数,若是没有调用Init()函数进行初始化的话,这个构造函数就会被自动调用
class Date
{
public:
void Init(int y, int m, int d)
{
_year = y;
_month = m;
_day = d;
}
Date()
{
_year = 1;
_month = 1;
_day = 1;
}
void Print()
{
cout << "year:" << _year << endl;
cout << "month:" << _month << endl;
cout << "day:" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
- 通过调试便可以观察到,对于定义出来的两个对象
d1
和d2
,都会去自动调用构造函数,进行一个初始化操作,但是d2又去调用了Init()
函数,便对年月日再度进行了一次初始化。打印结果之后可以发现二者的成员变量不同
2、特性
需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象
- 从上面的演示可以看出,构造函数并不是用来开空间创建对象的,而是用来代替
Init()
函数。接下去我们来聊聊它的各种特性,深入化对其进行一个了解
- 函数名与类名相同
- 这点是规定好的,所有构造函数的函数名均与类名相同
- 无返回值
- 可以看出,对于构造函数而言是不存在返回值,和我们日常写的函数不同。不过要注意的一点是:没有返回值不是写
void
,而是什么都不用写
- 可以看出,对于构造函数而言是不存在返回值,和我们日常写的函数不同。不过要注意的一点是:没有返回值不是写
- 对象实例化时编译器自动调用对应的构造函数
- 通过上面的调试相信你也可以看出,对于构造函数而言,只要你去定义了这个类的对象,那么就会自动调用它所对应的构造函数
- 构造函数可以重载【⭐】
- 这一点我们来细讲一下,在C++函数重载一文讲到了一个函数要重载需要具备哪些条件。那对于构造函数来说,其实也是可以重载的,不仅仅限于上面的
Date()
,这只是最普通的一种无参默认构造 - 那既然一个类可以有多个构造函数,那就是有多个初始化的方式,我们立马来试试看
Date()
{
_year = 1;
_month = 1;
_day = 1;
}
Date(int y, int m, int d)
{
_year = y;
_month = m;
_day = d;
}
Date d1;
Date d2(2023, 3, 22);
- 通过调试观察就可以发现,不同的初始化方式会调用各自的构造函数,就和函数重载后的函数名修饰一样,编译器去进行了一个自动的匹配。此时我们就不再需要这个
Init()
函数了
- 但是呢,你不可以像下面这样去定义对象,同学看构造函数很像函数,所以也用调用函数的形式去调用构造函数。不过上面说到过了,对于构造函数而言是会自动调用的,不需要我们手动去调用,只需要考虑重载的形式传入不同的参数即可
Date d1;
Date d2(2023, 3, 22);
Date d3();
- 可以看到,像
d3()
这样去写就会产生歧义,编译器会认为这是一个函数的声明,Date
会被它当做是一一个返回值来看待,()
会被它当做是一个函数调用符。所以这点是要注意的
谈到产生歧义这一块,再来给读者扩展一个知识点,既然讲到了【函数重载】,就来顺便说说【缺省参数】吧
- 仔细观察着两个构造函数,是否可以将它们做一个整合呢,就用到我们前面所学的缺省参数
//Date()
//{
// _year = 1;
// _month = 1;
// _day = 1;
//}
//Date(int y, int m, int d)
//{
// _year = y;
// _month = m;
// _day = d;
//}
Date(int y = 1, int m = 1, int d = 1)
{
_year = y;
_month = m;
_day = d;
}
- 那这样我们传参的时候就很灵活了,若是不传对应的参数就按照缺省值来进行初始化
- 但是不可以像下面这样,【默认无参构造】和【全缺省有参构造】是不可以同时存在的,会产生歧义
- 因为在没有给到初始值的时候编译器不知道使用哪个作为默认的初始值,所以
【存在多个默认构造函数】
- 所以为了不产生这样的歧义,一般这两个构造函数不会同时存在,我们会选择下面这种全缺省的构造函数,可以代替无参的构造
- 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成
- 通过如下图就可以看出,若是我将自己写的构造函数全部够注释掉之后,那【显式定义】的构造函数就不存在了,此时我们编译一下可以发现没有报错,因为在这里编译器自动去调用了类内默认生成的构造函数
- 这里通过调试观察不出是否有调用【默认生成的构造函数】,但是我们可以去打印一下看看这三个成员函数是否又被初始化
- 但是通过观察可以发现,怎么都是一些随机值🤨🤨🤨,真的有初始化了吗?这就要涉及到构造函数的第六个特性了👇
- 对于类中的内置类型成员(
int、char、double
)来说,不会做初始化处理;对自定义类型成员(class、struct、union
),对调用它的默认构造函数【⭐】- 上面通过打印可以发现,年、月、日似乎并没有初始化成功,而是三个随机值,那根据我们的经验来看,若是一个变量是随机值的话,那一定就是刚刚定义出来,还没有初始化的样子。但上面不是讲到若是自己不写的话会自动调用编译器生成的默认构造函数吗?这又怎么解释呢?
- 其实这是C++的一个缺陷,不知道是什么原因,可能是祖师爷本贾尼当时当时在设计语法特性的时候忽略了这一点,所以在我们后辈进行学习的时候就造成了很多的困惑
- 在C++中呢,那数据类型分为了两种,一种是【内置类型】,另一种则是【自定义类型】。对于内置类型来说就是语言原生就支持的
int、char、double..
甚至是指针;自定义类型就是class、struct、union..
对于默认生成的构造函数可能是没有设计好,对【自定义类型】会做初始化处理,但是对于【内置类型】不会做初始化处理
- 所以对于上面的这三个成员函数,都是属于【内置类型】的,均不会做处理。本来按照我们的正常思维来说,因为不写默认构造会去调用编译器自动生成的那个,把内置类型的成员初始化为0,把自定义类型的用构造函数进行初始化,这样就好理解很多,但是呢因为语法设计这一块的问题,就导致了问题的出现👈
所以为什么说C++那么难学,就是因为C++的语法错综复杂,很多初学者在看到这一幕之后就觉得很奇怪,去网上找问老师又没人给他说得通,于是就带着这个疑惑学下去了,后面也遇到类似的错误,还是这样模棱两可的,便从入门到放弃╮(╯▽╰)╭
- 所以对于像日期类这样的,拥有内置类型的成员,我们不要用编译器自动生成的,就会导致随机值的现象。难道这个默认生成的构造函数就一点用都没有了吗?还记得之前做过的用栈实现队列吗?
- 来看看下面的这两个
Create
和Free
函数,可以看到分别调用了我们使用C语言实现的InitStack()
和DestroyStack()
来初始化和销毁【stIn】和【stOut】。如果你使用C语言做了这道题的话一定会感觉这很麻烦现在我们使用C++的类来试试
MyQueue* myQueueCreate() {
MyQueue* qu = (MyQueue *)malloc(sizeof(MyQueue));
InitStack(&qu->stIn);
InitStack(&qu->stOut);
return qu;
}
void myQueueFree(MyQueue* obj) {
DestroyStack(&obj->stIn);
DestroyStack(&obj->stOut);
free(obj);
}
- 下面是用C++进行实现,把MyQueue封装成为一个类,【入栈】和【出栈】分别作为成员函数。对于这个
Stack
而言是我用C++实现的一个栈,它算做是一个内置类型,所以会使用默认的构造函数进行初始化【STL中可以直接用stack】
class MyQueue {
public:
void push(int x) {
//..
}
//...
Stack _pushST;
Stack _popST;
};
- 我们通过调试来看看编译器是否有进行一个初始化💻
- 可以看出此时我在MyQueue中没有写任何的构造函数,但是这个两个自定义类型依旧调用了Stack的构造函数进行了初始化。从中可以看出这个编译器自动生成的默认构造函数还是有点用的😊
- 不仅如此,对于自定义类型的数据,还会去自动调用编译器默认生成的析构函数,这个我们放到下一模块再细讲
不过你一定会想,内置类型不做初始化这一漏洞难道就这么让它放着吗?当然不会,在C++11中,对其进行了整改
- C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给默认值
- 此时可以看到,当我们将成员变量设置了默认值之后,再去创建对象的时候然后打印其年月日就不会发生随机值的现象
- 再看到下面,若是显式去初始化了月和日,但是【年】不初始化的话也会按照给定的缺省值来
💬那有同学问:若是成员变量是内置类型和自定义类型混搭呢?会发生什么?
class Time
{
public:
Time()
{
_hour = 1;
_minute = 1;
_second = 1;
}
Time(const Time& t)
{
_hour = t._hour;
_minute = t._minute;
_second = t._second;
cout << "Time::Time(const Time&)" << endl;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
// 基本类型(内置类型)
int _year;
int _month;
int _day;
// 自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
- 可以看到,Date类中有三个内置类型的成员和一个自定义类型的成员,此时我并没有在Date类中写任何构造函数,那么在实例化对象d的时候,就会去调用编译器默认自动生成的构造函数进行初始化
- 通过调试可以看出对于自定义类型
_t
去调用了它的构造函数初始化了【year】、【month】、【day】 ,但是对于内置类型的成员却还是随机值,表明他们没有被初始化
- 那此时我们去给一个默认值就可以了
- 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个
- 【编译器默认自动生成的构造函数】、【无参构造函数】、【全缺省构造函数】三者均为默认构造函数
- 对于这三个而言只能存在一个,相信在看了上面的内容之后你也一定有所理解:若是自己写了构造函数,那么编译器自动生成的就没有了。而且无参构造和全缺省构造只能存在一个,这也是上面补充过的一点
【总结一下】:
- 构造函数是类中默认就带有的,不过日常自己在写一个类的时候尽量不要用默认生成的,最好是自己写一个,无参或者是缺省的都可以,但是不可以无参和全缺省共存,会引发歧义。
- 若是使用默认生成的构造函数,会引发一些语言本身就带有的缺陷,【内置类型】的数据不会被初始化,还会是一个随机值;【自定义类型】的数据会调用默认构造函数(默认生成、无参、全缺省),若是不想看到随机值的话,可以参照C++11中的特性,在内置类型声明的时候就为其设置一个初始化值,便不会造成随机值的问题
对于构造函数这一块其实还有些内容,像初始化列表、explicit关键字,文章内容过长,后续放链接
三、析构函数
好,接下去我们就来讲讲类的第二大默认成员函数 —— 【析构函数】
1、概念
通过前面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么没呢的?
【析构函数的概念】:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作
- 这里有很重要的一点:对于析构函数来说是用来释放资源的,即当前对象内部的【成员变量】和【成员函数】这些。但是对象本身的销毁和析构函数没关系,例如下面的这个d这个局部对象,它是如何销毁的呢?学习函数栈帧一文之后就可以知道对于局部变量而言都是存放在它所在的函数栈帧中的,会随着栈帧的销毁而销毁
int main()
{
Date d;
return 0;
}
- 下面就是Date类的析构函数,和构造函数很像,只需要在前面加上一个
~
即可
~Date()
{
cout << "Date析构函数的调用" << endl;
_year = 0;
_month = 0;
_day = 0;
}
- 可以通过调试来看看析构函数是如何调用的【当调试指针走到
return
的时候按下F11】
2、特性
认识了什么是析构函数,接下去我们来看看有关析构函数的特性
- 析构函数名是在类名前加上字符
~
- 无参数无返回值类型【析构函数是不用加参数的】
- 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载
- 对象生命周期结束时,C++编译系统系统自动调用析构函数
C与C++OJ题对比【手动挡与自动挡】
- 对于析构来说,多数的特性和构造函数类似,这里便不再赘述,这里本小节主要通过上面学习的【构造函数】以及这里析构函数去实现这道OJ题目。也就是我们本文一开始提到的那题,现在学习C++中的构造和析构后是否可以做一个简化呢
- 当然我们不考虑STL中的stack,用自己实现的
bool isValid(char* s) {
Stack st;
while (*s)
{
//1.若为左括号,则入栈
if ((*s == '(')
|| (*s == '{')
|| (*s == '['))
{
st.Push(*s);
++s;
}
else //2.若为右括号,则进行判断匹配
{
//若匹配到右括号后无左括号与之匹配,返回false
if (st.Empty())
return false;
int top = st.Top();
st.Pop();
if (top == '(' && *s != ')'
|| top == '{' && *s != '}'
|| top == '[' && *s != ']')
{
return false;
}
else
++s;
}
}
//若在匹配完成后栈不为空,则表示还要括号没有完成匹配
return st.Empty();
}
- 通过对比可以发现,若是用C++去实现的话,因为有默认构造函数的存在,所以我们不需要去调用
Init()
函数进行手动初始化,接着很不同的一点就是这个DestroyStack()
,因为有默认析构函数的存在,所以不需要去显式地写一个析构函数,就方便了许多
- 举个形象一点的生活小案例,如果有学过车的老铁应该都知道什么是【手动挡】和【自动挡】,对于手动挡来说需要踩离合换挡位,很多事情都要自己来完成,有过亲身经历的读者脑海中一定浮现了那个画面😂但是对于自动挡来说就不一样了,油门一给就可以走了,不需要考虑什么换挡的问题
- 那你就可以把C语言当做是手动挡,像初始化、销毁这些事情都要自己来做;把C++当成是自动挡,很多事情编译器自动就给你弄好了,完全不需要担心
- 关于编译器自动生成的析构函数,是否会完成一些事情呢?下面的程序我们会看到,编译器生成的默认析构函数,对自定类型成员调用它的析构函数。
- 对于这一点我们在上面讲到构造函数的时候细讲过,对于析构函数来说依旧适用,【内置类型】的成员,不会去进行析构,【自定义类型】的成员会去调用析构函数
- 讲解构造函数时用到的Time和Date类,Time所定义的对象作为Date类的自定义成员变量,你认为此时会如何去销毁每个成员变量呢?
class Time
{
public:
~Time()
{
cout << "~Time()" << endl;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
// 基本类型(内置类型)
int _year = 1970;
int _month = 1;
int _day = 1;
// 自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
调试结果如下:
接下去我就来详细解释一下这个析构的流程~
- 在main方法中我创建了一个Date的对象d,这个对象内部有4个成员变量,三个内置类型的变量
_year、_month、_day
以及一个自定义类型变量_t
。对于内置类型成员,销毁时不需要资源清理,最后系统直接将其内存回收即可🚮 - 但是对于
_t
来说,属于Time的类所定义出来的对象,就要调用Time类的析构函数,但是在main函数中销毁Date类对象d的时候,是不可以直接调用Time类析构函数的,所以呢编译器会先去调用Date类的析构函数,不过Date类并没有显示地定义出一个析构函数,所以编译器会去调用【默认的析构函数】,目的是在其内部再去调用Time类的析构函数,即当Date对象销毁的时候,编译器要保证其内部的自定义对象可以正确销毁
【总结一下】:
- 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类。
四、拷贝构造函数【⭐】
1、概念解析
接下去我们来谈谈类中的第三个天选之子 —— 【拷贝构造函数】,提前预警⚠,本模块在理解上会比较困难,建议多看几遍
在现实生活中,可能存在一个与你一样的自己,我们称其为双胞胎👫
那在创建对象时,可否创建一个与已存在对象一某一样的新对象呢?
【拷贝构造函数概念】:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用
拷贝构造函数也是特殊的成员函数,其特征如下:
- 拷贝构造函数是构造函数的一个重载形式
- 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用
- 在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定义类型是调用其拷贝构造函数完成拷贝的
2、内置类型与自定义类型【调试观察】
- 接下去我就依照上面这三点,先来见见拷贝构造函数
//全缺省构造函数
Date(int y = 2000, int m = 1, int d = 1)
{
_year = y;
_month = m;
_day = d;
}
//拷贝构造函数
Date(Date d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
int main(void)
{
Date d1;
Date d2(d1); //调用形式
return 0;
}
- 上面这个
Date(Date d)
指的就是拷贝构造函数,Date d2(d1);
便是它的调用形式,用已经定义出来的对象d1来初始化d2 - 但是我们编译一下却看到报出了错误,说【Date类的复制构造函数不能带有Date类】,这是为什么呢?
- 但此时若是我将形参的部分加上一个引用
&
就可以编过了,这是为什么呢?
可能上面的这种形式过于复杂了,我先用下面这两个函数调用的形式来进行讲解
- 如果你看过我的C++引用详解这篇文章的话就可以知道对于
Func1(d)
来说叫做【传值调用】,对于Func2(d)
来说叫做【传引用调用】
void Func1(Date d)
{
cout << "Func1函数的调用" << endl;
}
void Func2(Date& d2)
{
cout << "Func2函数的调用" << endl;
}
int main(void)
{
Date d;
Func1(d);
Func2(d);
return 0;
}
- 通过下面的调试观察可以发现,Func1传值调用,会去调用Date类的拷贝构造函数,然后再调用本函数;但是Func2传引用调用,却直接调用了本函数
- 这就源于我们之前讲过的,对于【传值调用】会产生一个临时拷贝,所以此时
d
是d1
的拷贝;对于【传引用调用】不会产生拷贝,此时d
是d1
的别名;
💬有同学说:这和我们之前学的不一样呀,之前都是像int
、char
这样的类型,难道规则也同样适用吗?
-
这里就又要说到【内置类型】和【自定义类型】的区别了,这个我们在上面讲构造和析构的时候也有提到过。对于内置类型的数据我们知道只有
4/8
个字节,这个其实编译器直接去做一个拷贝就可以了;但是呢对于自定义类型的数据,编译器可没有那么大本事,需要我们自己去写一个拷贝构造函数,然后编译器会来调用 -
其实你也可以写成像下面这种形式,是一个传址形式进行调用,对于
Date*
是一个对象指针,所以我们将其看做是内置类型
void Func3(Date* d)
{
cout << "Func2函数的调用" << endl;
}
Func3(&d1);
- 通过调试观察便可以发现对于这种调用形式也不会去调用Date类的拷贝构造函数
- 通过上面的观察我们来总结一下:对于
内置类型(包括指针)/ 引用传值
均是按照字节方式直接拷贝(值拷贝);对于自定义类型
,需要调用调用其拷贝构造函数完成拷贝
但是在我这么对比分析之后,有位同学提出了下面的问题,这里来解答一下
💬为什么会存在拷贝构造?C语言中传递结构体也不需要拷贝呀?
- 同学,要知道你现在学习的是C++,而不是C语言。对于C语言里面那种直接传参,不调用拷贝构造就去进行拷贝的形式是错误的,C++就是为了弥补这种错误才创造出了【拷贝构造函数】
- 对于内置类型来说只有4/8个字节,直接进行值拷贝,是不会出现问题的👈
💬内置类型会直接值拷贝,值拷贝是啥?
- 同学,你等会放学留一下😅
- 对于值拷贝来说就是一个字节一个字节进行拷贝,你可以看成是我们在C语言中学习过的
memcpy()
。不管你是int、double还是结构体,我都是一个个字节给你拷过去的
3、深入探究拷贝构造🔍
所以,通过上面的一系列观察和总结,基本带读者了解了什么是拷贝构造、如何去调用拷贝构造,接下去我们来深入地研究一下拷贝构造
- 这个时候我们再来分析一下第二点特性,为什么说使用传值方式编译器直接报错,因为会引发无穷递归调用?
- 刚才讲到过,对于自定义类型来说都会去调用拷贝构造,那此时我们转换回Date类的拷贝构造函数这里。通过下面的这张图其实你可以看出自定义类型的传值调用引发的递归问题是多么严重!
- 通过
Date d2(d1)
需要实例化对象d2,所以要调用对应的构造函数,也就是拷贝构造函数,但是在调用拷贝构造函数之前要先传参,那刚才说了【自定义类型传参调用】就会引发拷贝构造,那调用拷贝构造就又需要传参数进来,传参数又会引发拷贝构造。。。于是就引发了这么一个无限递归的问题 - 所以编译器就规定了对于拷贝构造这一块的参数不可以是【传值传参】,而要写成下面这种【传引用传参】的形式。此时
d
就是d1
的别名,那因为是d2去调用的拷贝构造,此时this指针所接收的便是d2
的地址,初始化的即为d2的成员变量
Date(Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
Date d2(d1);
不过对于上面这种拷贝构造的形式并不是很规范,一般的拷贝构造函数都写成下面这种形式
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
- 那此时就会同学很疑惑,为什么要在前面加上一个
const
呢?这一点其实我们在模拟实现strcpy中其实也有说到过,有的时候你可能会不小心把代码写成下面这样👇
Date(Date& d)
{
d._year = _year;
d._month = _month;
d._day = _day;
}
- 通过调试观察可以发现。原本我们想通过对象d1来初始化对象d2,但此时在默认构造函数初始化完d1后,在调用拷贝构造初始化d2时,却将原本初始化好的d1也变成了和d2一般,可见发生了一个颠倒的问题
- 但若是将
const
加上后,编译器便报出了错误❌
- 因此可以看到,加上这个const之后,程序的安全性就得到了提升,这就是它的第一个作用①
它还有第二点作用,我们再来看看
- 我在实例化这个d1对象的时候在前面加上了一个
const
,此时这个对象就具有常属性,不可以被修改,然后此时再去使用d1对象初始化d2对象会发生什么呢?
int main(void)
{
const Date d1;
Date d2(d1);
return 0;
}
- 可以看到,编译器报出了错误,说【没有匹配的构造函数】,其实这里真正的问题还是在于权限放大,这点我在引用一文中也重点讲解过,如果不懂的同学去看一看。
- 本来这个d1对象被const所修饰具有常性,但是呢在将其当做参数传入给一个不具有常性的对象接收时,那么在拷贝构造函数内部便可以去修改这个对象的内容,也就造成了问题。不要以为这种问题不会发生,我们在写程序的时候一定要严谨,尽可能地考虑到多种情况
- 但是给形参加上
const
做修饰之后,便可以做到【权限保持】,此时程序的安全性又增加了↑
小结一下,对于const Date& d
这种不是做输出型参数,加上前面的const
的好处在于
① 防止误操作将原对象内容修改
② 防止传入const对象造成【权限放大】
- 若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝
对于构造、析构来说我们在上面讲到了若是自己不写的话都会去自动调用编译器默认生成的,那对于拷贝构造也同样适用吗?
- 此时我将上面所写的拷贝构造去除之后,再去进行一个拷贝的操作,通过下面的运行结果可以看出,d1和d2均完成了初始化操作,而且和构造函数不一样,对于内置类型也会去进行处理。其实在这里就是调用了编译器自动为我们生成的拷贝构造
//以下为有参构造
Date(int year = 2000, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
内置类型会处理,那自定义类型呢?也会处理吗?
- 此时我在Date类中声明了一个Time类的对象作为成员函数,并且去除了Date类中上面所写的【拷贝构造函数】,然后再用d1去初始化d2,你认为此刻会发生什么呢?
class Time
{
public:
Time()
{
_hour = 1;
_minute = 1;
_second = 1;
}
Time(const Time& t)
{
_hour = t._hour;
_minute = t._minute;
_second = t._second;
cout << "Time::Time(const Time&)" << endl;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
//构造..
//析构
private:
int _year;
int _month;
int _day;
Time _t; //内置自定义类型的成员
};
- 通过调试观察可以发现,即使是Date类中没有写拷贝构造函数,d2依旧是完成了初始化工作.这个Time类我们在说析构函数的时候有讲到过,那此时要去析构Date类中的自定义类型成员
_t
,便要调用Time类的析构函数,但是要先调用编译器为Date类自动生成的析构函数,然后再去调用Time类的析构函数,此时自动生成的析构函数就派上了用场【忘记了再翻上去看看】 - 既然构造、析构都可以自动生成,那么拷贝构造作为类的默认成员函数编译器也是会自动为我们生成。那么此时就会调用默认生成的拷贝构造去拷贝其内部自定义类型
_t
的时候就会去调用Time类的显式拷贝构造完成初始化工作
因此对于像Date这种日期类来说,我们可以不用去自己去实现拷贝构造,编译器自动生成的就够用了,那其他类呢,像
Stack
这样的,我们继续来看看
4、【浅拷贝】与【深拷贝】
- 继续延用我们上面所讲到过的Stack,而且没有写上拷贝构造函数,首先实例化出对象
st1
,往里面入栈了3个数据,接下去便通过st1去初始化st2
,通过上面的学习可以知道会去调用编译器自动生成的【拷贝构造】来完成,不过真的可以完成吗?我们来运行一下试试💻
Stack st1;
st1.Push(1);
st1.Push(2);
st1.Push(3);
Stack st2(st1);
- 不过运行一下就可以发现直接报出了异常,这是为什么呢?
- 其实,根本的原因就是在于我们要使用到数组栈,便要去内存中开辟一块空间,那么s1开辟了一块空间后
_array
就指向堆中的这块内存地址,接着s2去拷贝了s1,里面的数据是都拷贝过来了,但是s2的_array
也指向了堆中的这块空间
- 那此时我去往s1里面push数据的之后,s2再去push,就会造成【数据覆盖的情况】。假设现在s1
push
了【1】、【2】、【3】,那么它的size就是3,但是s1与s2二者的size是独立的,不会影响,所以此时s2的size还是0,再去push
【4】、【5】、【6】的话还是会从0的位置开始插入,也这就造成了覆盖的情况
不仅如此,二者指向同一块数据空间还会造成其他的问题
- 现在定义出来两个Stack对象,那此时我想问谁会先去进行析构呢?
- 揭晓一下,s2会先去析构,在进程空间章节我们有讲到过【栈】是进程地址空间里面的一块内容,原理都清楚是先进后出的,所以后实例化出的对象s2会先去进行一个析构的操作,接着再去析构对象s1。不过呢通过调试可以观察到s1和s2的
_array
都指向堆中的同一块空间,因此当s2去调用析构函数释放了这块空间后,那么s1对象的_array
就已经是一个野指针了,指向了堆中的一块随机地址,那再去对这块空间进行析构的话就会出现问题⚠
👉所以来总结一下指向同一块空间的问题
- 插入删除数据会互相影响
- 析构两次会造成程序的奔溃
那要如何去解决这个问题呢?此时就要涉及到【深拷贝】了
💬调用编译器自动为我们生成的拷贝构造函数去进行拷贝的时候会造成【浅拷贝】的问题,那什么又叫做深拷贝呢?
- 因为浅拷贝是原封不动地拷贝,会使得两个指针指向同一块空间,那若是我们再去自己申请一块空间来使用,让两个对象具有不同的空间,此时便不会造成上面的问题了
接下去我就来实现一下如何去进行【深拷贝】
Stack(const Stack& st)
{
//根据st的容量大小在堆区开辟出一块相同大小的空间
_array = (DataType *)malloc(sizeof(DataType) * st._capacity);
if (nullptr == _array)
{
perror("fail malloc");
exit(-1);
}
memcpy(_array, st._array, sizeof(DataType) * st._size); //将栈中的内容按字节一一拷贝过去
_size = st._size;
_capacity = st._capacity;
}
- 可以看到,对于深拷贝而言,就要去自己再去手动申请一块空间,然后将原先栈中的内容使用
memcpy()
一一拷贝过来,此时两个对象中的_array
就指向了堆中两块不同空间,那么各自去进行入栈出栈的话就不会造成上述的问题了
- 而且两块空间是独立的,所以在对象进行析构的时候也不会造成二次析构的问题。
- 通过调试便可以观察到,在我们手动进行深拷贝之后,就不会造成上述的种种问题了
💬 但是这样自己去写拷贝构造感觉很麻烦诶,哪些类需要这样去深拷贝呢?
- 你可以观察在当前这这个类中是否存在显式的析构函数,若是存在的话,表示当前这个类涉及资源管理了【资源管理指得就是去堆中申请空间了】,此时你一定要自己去是实现拷贝构造以达到一个深拷贝;若是不涉及资源管理的话,直接使用编译器自动生成的进行浅拷贝就可以了
- 像Date日期类这种只存在【年】、【月】、【日】这种内置类型的浅拷贝就可以了;像是复杂一些的,例如:链表、二叉树、哈希表这些都会涉及资源的管理,就要考虑到深拷贝了
5、产生拷贝构造的三种形式
深刻理解了拷贝构造之后,我们再来看看产生拷贝构造的三种形式
- 当用类的对象去初始化同类的另一个对象时
Date d1;
Date d2(d1);
Date d3 = d2; //也会调用拷贝构造
- 可以看到在实例化对象d2和d3的时候都去调用了拷贝构造,最后它们初始化后的结果都是一样的
- 当函数的形参是类的对象,调用函数进行形参和实参结合时
void func(Date d) //形参是类的对象
{
d.Print();
}
int main(void)
{
Date d1;
func(d1); //传参引发拷贝构造
return 0;
}
- 函数
func()
的形参是类的对象,此时在外界调用这个函数并传入对应的参数时,就会引发拷贝构造,通过调试观察一清二楚。而且当func函数执行结束时,内部的形参对象d
就会随着当前栈帧的销毁而去调用【析构函数】。到那时在外界为何又去调了一次析构呢?外界也就是main函数栈帧中的对象d1
,是作为实参进行传递的,出了main函数的栈帧当前也需要调用【析构函数】进行销毁
- 当函数的返回值是对象,函数执行完成返回调用者时
Date func2()
{
Date d(2023, 3, 24);
return d;
}
int main(void)
{
Date d1 = func2();
d1.Print();
return 0;
}
- 可以看到,这一种方式也会引发拷贝构造,当函数内部返回一个Date类的对象时,此时外界再使用Date类型的对象去接收时,就会引发拷贝构造。通过调试可以观察到外界的
对象d1
确实是以函数内部通过有参构造初始化完后的对象进行拷贝的
6、人生赢家类 —— MyQueue
再来看一个很经典的类MyQueue,我在前面说构造函数中自定义类型的时候也有讲到过
typedef int DataType;
class Stack
{
public:
Stack(size_t capacity = 10)
{
cout << "Stack()构造函数调用" << endl;
_array = (DataType*)malloc(capacity * sizeof(DataType));
if (nullptr == _array)
{
perror("malloc申请空间失败");
return;
}
_size = 0;
_capacity = capacity;
}
void CheckCapacity()
{
if (_size == _capacity)
{
DataType* tmp = (DataType*)realloc(_array, sizeof(DataType) * _capacity * 2);
if (nullptr == tmp)
{
perror("fail realloc");
exit(-1);
}
_array = tmp;
_capacity = _capacity * 2;
}
}
void Push(const DataType& data)
{
CheckCapacity();
_array[_size] = data;
_size++;
}
Stack(const Stack& st)
{
cout << "Stack()拷贝构造函数调用" << endl;
//根据st的容量大小在堆区开辟出一块相同大小的空间
_array = (DataType*)malloc(sizeof(DataType) * st._capacity);
if (nullptr == _array)
{
perror("fail malloc");
exit(-1);
}
memcpy(_array, st._array, sizeof(DataType) * st._size); //将栈中的内容按字节一一拷贝过去
_size = st._size;
_capacity = st._capacity;
}
~Stack()
{
cout << "~Stack()析构函数调用" << endl;
if (_array)
{
free(_array);
_array = nullptr;
_capacity = 0;
_size = 0;
}
}
private:
DataType* _array;
size_t _size;
size_t _capacity;
};
class MyQueue{
public:
//默认生成构造函数
//默认生成析构函数
//默认生成拷贝构造函数
private:
size_t _t = 1;
Stack _pushST;
Stack _popST;
};
- 通过下面的调试观察可以发现,这个MyQueue类简直就是捡漏王啊,仅仅是使用编译器默认生成的【构造】、【析构】、【拷贝构造】就可以就可以完成所有的工作
7、实战演练 —— 日期计算器
讲了这么多有关拷贝构造的内容后,那它在现实的场景中有什么用呢?接下去我就带你来做一个【日期计算器】
- 我们平常在面临一些重大的事情时都去翻看日历计算距离现今还剩多少天,但其实有这么一个东西却可以起到非常便捷的效果,那就是【日期计算器】,使用它可以立即计算出多少天后的日期、两个日期之间差多少天
- 本模块我们就通过拷贝构造函数来简单地实现一下第一个功能 —— 计算几天后的日期
① 思路分析
首先来分析一下思路要如何去进行实现
- 我们要去计算一下从今天开始计算往后100天是几月几日,其实就是做加法运算,加法是满10进位,但对于日期来说不一样,满30天或者是31天就会发生进位。不仅如此还要平/闰年的二月份是28/29天这种情况
- 所以先考虑地简单一些,算一算50天后是几月几日,首先用当日的天数加上50,我们就可以得到一个数,此时若
> 当前月份的总天数
,就要产生进位,对于月份来说可以单独作为一块逻辑去进行进行实现。首先将加好后的天数减去当前月的天数,然后进位到下一个月,若是这个天数还大于当前月的天数,那又要发生进位,所以这段路逻辑可以放到一个循环中去实现,直到天数小于当前所在月的天数时,就停止进位。【一些细节部分的说明见代码】
② 代码详解
- 首先在类中写这样一个成员函数,叫做
GetAfterXDay
,确定要传入的参数为多少天后的天数 ,返回值类型是一个Date类。然后在外界实例化出一个Date类的对象,调用有参构造进行初始化,通过这个对象去调用类中的日期计算函数,然后将其返回值给到一个日期类的对象做接收
Date GetAfterXDay(int x)
Date d(2023, 3, 25);
Date d1 = d.GetAfterXDay(150);
- 那一进入函数就先把天数做一个累加
_day
指得就是当前调用这个函数的日期实例类对象
_day += x;
- 接下去就要判断加上这个天数后的
_day
是否超出了当前月的天数,但当前月的天数我们要先去计算出来,这里我又单独封装了一个函数 - 在求解当前月的天数时,需要传入年份和当前月份,对于一年当中的每个月份天数,我使用一个数组进行保存,这里的数组大小定义为13,把第0个月空出来,后面正好就是1 ~ 12月份,然后通过当前传入的月份去定位即可,但是要考虑到2月比较特殊,有平年和闰年之分,这里单独做一下判断即可
int GetMonthDay(int year, int month)
{
assert(month > 0 && month < 13);
int monthArray[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
if (month == 2 && (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0))
{
return 29; //2月闰年29日天
}
else
{
return monthArray[month];
}
}
- 下面是月份进位的逻辑,首先是要减去计算出来的当前月天数,然后把
_month++
,若是月份加上去之后超过12月了,此时年份就要产生进位,然后月份重新置为1
_day -= GetMonthDay(_year, _month); //先把这个月的天数减去
_month++; //月份进位
if (_month == 13) //若是月份加到13月了
{
_year++; //年产生进位
_month = 1; //月份置为1
}
- 将上面这段逻辑套在下面这个循环中,直到
_day
的天数小于当前月的天数为止停止进位【this代表当前对象,可加可不加】
while (_day > GetMonthDay(this->_year, this->_month))
- 最后,将累加完后的结果return即可,那return什么呢?
- 你以为我上面的
this->
是白写的吗,就是为了告诉你修改的都是当前调用对象的成员变量,那么this
指向这个对象,*this
指的就是这个对象本身,return它就可以了
return *this; //this指向这个对象,*this就是这个对象
③ 运行测试 + 优化
接下去我们就用上面写的这段逻辑去试试是否可以运行
- 可以看到,确实可以将计算出100天后的日期,与在线日期计算器的结果是一样的
- 那此时若我想去计算一个200天后几月几号呢,由运行结果和在线日期计算器进行对比可以发现似乎计算得不太准确,这200天后怎么都跑到2024年去了🤨
- 此时我通过去修改了一下开始时间,改为一开始计算出来的100天后的日子就发现200天后就是2024年1月19日
- 然后我又通过调试的手段去观察就发现原来d在调用
GetAfterXDay
计算天数后进行返回自身发生了修改,所以就导致在第二次计算的时候初始时间的不对
- 也可以通过成员函数中的this指针所接受的当前对象进行判别
💬那此时就有同学问:这该如何是好呀!d自己都被修改了
- 其实学得扎实的同学已经看出来了,当前对象发生了修改,那我们不要让他发生修改不就好了吗?其实在分步讲解代码的时候已经有提到了,函数中修改的是当前调用的对象即d,那么此时便可以使用到本模块所学的【拷贝构造函数】了
- 其实上面的一切都是在为这个做铺垫,
Date tmp(*this)
得到了一份当前对象的临时拷贝,那么对于下面的所有操作,我们都修改【tmp】即可,最后也是将tmp进行返回,便可以获取到增加后的日期
Date GetAfterXDay(int x)
{
Date tmp(*this); //做一份当前对象的临时拷贝
tmp._day += x;
while (tmp._day > GetMonthDay(tmp._year, tmp._month)) //若是加完后的天数 > 这个月的天数
{
tmp._day -= GetMonthDay(tmp._year, tmp._month); //先把这个月的天数减去
tmp._month++; //月份进位
if (tmp._month == 13) //若是月份加到13月了
{
tmp._year++; //年产生进位
tmp._month = 1; //月份置为1
}
}
return tmp; //返回临时修改的对象
}
- 通过上面的学习,可以分析出这里会产生两次【拷贝构造】,一次是在临时对象tmp被拷贝出来的时候,一次便是
return tmp
的时候。我们通过调试来进行观察
你以为这样就完了吗,接下去我要拓展一些下个模块的知识 ——【赋值运算符重载】 ,不过只是一些雏形而已
- 在C语言操作符章节,我们有讲到过
+
和+=
这两个操作符,前者在运算之后不会改变,后者在运算之后会发生改变,也就是在自身做一个加法运算
👉看了上面的这段话,再结合这个日期计算,你是否能想到我要讲什么呢?
- 没错,对于一开始直接对当前对象进行的操作就是
+=
,调用对象自身会受到影响,但是后面;后面我们所做的优化修改就是+
,调用对象自身不会受到影响 - 因为还没有见到赋值运算符重载,所以我会使用【Add】和【AddEqual】这两个函数名进行修饰,其本质和
+
与+=
是一个道理
//+ —— 自身不会改变
Date Add(int x){}
//+= —— 改变自身
Date AddEqual(int x){}
其实对于上面的【AddEqual】还可以去进行一个优化
- 继续调试观察可以发现,无论是Add还是AddEqual在返回的时候都引发拷贝构造,那在学习了【引用返回】的知识后,你是否可以再去取进行一个优化呢
- 对于AddEqual,我做了如下改变,将
Date
改成了Date&
,相信学得扎实的同学一定知道我为何去这样做,因为AddEqual返回的是*this
,也就是当前对象,那对于当前对象来说出了作用域是不会销毁的,那我们便可以使用引用返回去减少一次拷贝;但是呢对于Add来说返回的是tmp
,也就是我们通过拷贝得到的一个临时对象,出了作用域会销毁,此时不可以使用引用返回,否则很可能在外界接收的就是一个随机值
Date& AddEqual(int x)
Date Add(int x)
- 通过观察便可以看到,到我在return的时候按下F11时便不会进入到当前类的拷贝构造中,那也就表明减少了一次拷贝的过程
更多的内容在【综合案例 —— Date日期类】的实现中还会细讲,继续看下去吧
④ 整体代码展示
Date类
class Date
{
public:
//3.全缺省构造函数
Date(int y = 2000, int m = 1, int d = 1)
{
_year = y;
_month = m;
_day = d;
}
Date(const Date& d) //权限保持
{
//cout << "Date拷贝构造的调用" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
//获取当前年这一月的天数
int GetMonthDay(int year, int month)
{
assert(month > 0 && month < 13);
int monthArray[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
if (month == 2 && (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0))
{
return 29; //2月闰年29日天
}
else
{
return monthArray[month];
}
}
//+ —— 自身不会改变
Date Add(int x)
{
Date tmp(*this); //做一份当前对象的临时拷贝
tmp._day += x;
while (tmp._day > GetMonthDay(tmp._year, tmp._month)) //若是加完后的天数 > 这个月的天数
{
tmp._day -= GetMonthDay(tmp._year, tmp._month); //先把这个月的天数减去
tmp._month++; //月份进位
if (tmp._month == 13) //若是月份加到13月了
{
tmp._year++; //年产生进位
tmp._month = 1; //月份置为1
}
}
return tmp; //返回临时修改的对象
}
//+= —— 改变自身
Date& AddEqual(int x)
{
_day += x;
while (_day > GetMonthDay(_year, _month)) //若是加完后的天数 > 这个月的天数
{
_day -= GetMonthDay(_year, _month); //先把这个月的天数减去
_month++; //月份进位
if (_month == 13) //若是月份加到13月了
{
_year++; //年产生进位
_month = 1; //月份置为1
}
}
return *this; //返回临时修改的对象
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
~Date()
{
//cout << "Date析构函数的调用" << endl;
_year = 0;
_month = 0;
_day = 0;
}
private:
int _year;
int _month;
int _day;
};
测试
void AddTest()
{
Date d(2023, 3, 25);
Date d2 = d.Add(100);
d.Print();
d2.Print();
}
void AddEqualTest()
{
Date d(2023, 3, 25);
Date d2 = d.AddEqual(100);
d.Print();
d2.Print();
}
int main(void)
{
//AddTest(); //25 + 100 = 125
AddEqualTest(); //25 += 100
return 0;
}
【总结一下】:
- 拷贝构造算是六大默认成员函数中较难理解的了,用了近万字来详解。主要就是要理清【内置类型】和【自定义类型】是否会调用拷贝构造的机制。还有在实现这个拷贝构造时要主要的两点:一个就是在形参部分要进行引用接收,否则会造成无穷递归的现象;还有一点就是在前面加上
const
进行修饰,可以防止误操作和权限放大的问题 - 【浅拷贝】和【深拷贝】是拷贝构造里最经典也是最难懂的一块,若是像Date日期类这样的用编译器默认生成的进行浅拷贝(值拷贝)就可以了;但若是涉及到资源管理例如像Stack这样的记住一定要自己去是实现拷贝构造防止浅拷贝的问题
- 最后又讲到了拷贝构造发生的三种形式,每个案例都做了调试分析,读者也可自己去试着调试看看,观察什么时候调用【构造函数】、【拷贝构造函数】和【析构函数】
拷贝构造这一块还存在编译器的优化,后续也以链接的形式附上
五、赋值运算符重载
1、运算符重载
① 概念引入
- 上面呢我们都是对一个日期进行初始化、销毁、加加减减等操作,现在若是我要去比较一下两个日期,该怎么实现呢?
Date d1(2023, 3, 25);
Date d2(2024, 3, 25);
- 可能你会想到直接这么去写,但是编译器允许吗?很明显它完全实现不了这样的比较
- 于是就想到了把他们封装成为一个函数来进行实现
//等于==
bool Equal(const Date& d1, const Date& d2)
{
//...
}
//小于<
bool Less(const Date& d1, const Date& d2)
{
//...
}
//大于>
bool Greater(const Date& d1, const Date& d2)
{
//...
}
Equal(d1, d2);
Less(d1, d2);
Greater(d1, d2);
- 但是,你认为所有人都会像这样去仔细对函数进行命名吗,尤其是打一些算法竞赛的。它们可能就会把函数命名成下面这样
- 甚至是下面这样👿
若是每个函数都是上面这样的命名风格,那么调用的人该多心烦呀╮(╯▽╰)╭
- 如果我们不用考虑函数名,可以直接用最直观的形式也就是一开始讲的那个样子去进行调用的话该多好呀
- 但是呢编译器不认识我们上面所写的这种形式,之前我们去比较两个数的大小或者相等都是
int、char、double
这些【内置类型】的数据,对于这些类型是语法定义的,语言本身就已经存在了的,都将它们写进指令里了 - 不过对于【自定义类型】而言,是我们自己定义的类型,编译器无法去进行识别,也无法去比较像两个日期这样的大小,所以对于自定义类型而言,在于以下两点
- 类型是你定义的,怎么比较,怎么用运算符应该由你来确定
- 自定义类型不一定可以加、减、乘、除,像两个日期相加是毫无意义的,相减的话还能算出他们之间所差天数【日期类中会实现日期加一个天数】
② 语法明细
基于上述的种种问题,C++为了增强代码的可读性引入了运算符重载
【概念】:运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似
【函数名字】:关键字operator
后面接需要重载的运算符符号
【函数原型】:返回值类型 operator操作符(参数列表)
- 根据上面的语法概念,就可以写出
==
的运算符重载函数
bool operator==(const Date& d1, const Date& d2)
注意事项:
接下去我便通过几点注意实现来带你进一步了解运算符重载
- 不能通过连接其他符号来创建新的操作符:比如operator@
- 重载操作符必须有一个类类型(自定义类型)参数
- 用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不能改变其含义
- 可以看到,我重载了运算符
+
,内部实现了一个乘法运算,然后用当前对象的月数 * 10,最终改变了+
运算符的含义,这种语法虽然是可以编译过的,但是写出来毫无意义,读者可以了解一下
-
运算符重载可以放在全局,但是不能访问当前类的私有成员变量
- 可以看到,虽然运算符重载我们写出来了,但是在内部调用当前对象的成员 时却报出了错误,说无法访问private的成员,那此时该怎么办呢?
👉解决办法1:去掉[private]
,把成员函数全部设置为公有[public]
👉解决办法2:提供共有函数getYear()
、getMonth()
、getDay()
👉解决办法3:设置友元【不好,会破坏类的完整性】
👉解决办法4:直接把运算符重载放到类内
- 对于二、三两种解决方案暂时先不考虑,最直观的就是第一种方式,我们可以先来试试运行一下
- 但是这样不好看运行的结果,函数的返回值是一个
bool
类型,将其输出一下看看 - 但是可以看到
<<
这个操作符似乎出现了什么问题,这就是因为<<
的优先级比==
来得高,所以会优先执行cout << d1
,那么中间的==
就不知道和谁结合了,因此出了问题。所以在运算符重载之后我们还要考虑操作符的优先级问题
- 此时在
d1 == d2
的外面加上()
即可,让他们俩先进行运算
- 此时我们再来试试第四种解决方案,将这个函数放到类内。但是一编译却报出了下面这样的错误,这是为什么呢?【看看下一个点就知道了】
- 作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this
- 还记得我们在类和对象的封装思想中学习过的类成员函数中都存在一个隐藏形参
this
,用于接收调用对象所传入的地址,上面我们在写【日期计算器】的是有也有在类内使用过这个this
指针 - 不过对于
==
来说是个【双目操作符】,其运算符只能有两个,那此时再加上隐藏形参this
的话就会出现问题
bool operator==(Date* this, const Date& d1, const Date& d2)
- 所以当运算符重载函数放到类内时,就要改变其形参个数,否则就会造成参数过多的现象,在形参部分给一个参数即可,比较的对象就是当前调用这个函数的对象即
this
指针所指对象与形参中传入的对象
bool operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
- 那既然这是一个类内的函数,就可以使用
对象
.的形式去调用,运行结果如下
- 其实在编译器看来,它们都是一样的,
d1 == d2
因为有了运算符重载,所以当编译器执行到这个语句的时候,就会通过call
这个重载函数的地址,然后类内的成员函数执行。我们可以通过【反汇编】来进行查看
- 此时就可以看到在底层的汇编指令它们其实都是一样的
.*
、::
、sizeof
、?:
、.
注意以上5个运算符不能重载。这个经常在笔试选择题中出现。- 运算符重载和函数重载不要混淆了
【运算符重载】:自定义类型对象可以使用运算符
【函数重载】:支持函数名相同,参数不同的函数,同时可以用
③ 练习巩固
上面教了要如何去写
==
的运算符重载,接下去我们就来对其他运算符写一个重载
- 首先就是小于
<
,读者可以试着自己在编译器中写写看
bool operator<(const Date& d)
💬 接下去展示一下三位同学的代码
//Student1
bool operator<(const Date& d)
{
return _year < d._year
|| _month < d._month
|| _day < d._day;
}
//Student2
bool operator<(const Date& d)
{
return _year < d._year
|| (_year == d._year && _month < d._month)
|| (_year == d._year && _month == d._month && _day < d._day);
}
//Student3
bool 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;
}
else {
return false;
}
}
首先来看一下第一位同学的运行结果
- 可以看到第一次运行确实是没问题,但是我修改了一下两个日期的年份,他的代码逻辑就出现错误了
- 难道2025年比2024年来得小吗,日期的计算是越是离我们近的日期越大,越离我们远,已经过去的日期来得小,所以
2025.2.35
>2024.3.25
,但是这位同学的代码逻辑却出现了问题,我们对照代码来分析一下 - 首先比较两个年份,2025确实比2024来得大,所以第一个年份不成立,还会去比较第二个,但是呢2月份是比3月份来得小的,所以整个表达式即为真,因为对于逻辑或
||
来说,有1个为真即为真,全假才为假,所以最后的结果才返回了true - 在计算机中我们一般用非0值代表true(1),用0代表false,所以最后的结果才是1,如果上面的这些都不是清楚的话就再去看看C语言操作符章节
//Student1
bool operator<(const Date& d)
{
return _year < d._year
|| _month < d._month
|| _day < d._day;
}
接下去再来看看第二位同学的运行结果
- 可以看到,第二位同学的代码逻辑似乎是正确的,面对我给出的两个测试用例都跑过了
- 查看他的代码可以发现,他在每个年月日之间又多加了一些判断,这个逻辑才是正确的,设想两个年份比较大小那谁小一下就比出来了,但是月份的话难道也是谁小就可以确定吗?这是要建立在两个年份相等的情况下才可以确定的,所以我们看到这位同学用了一个逻辑与
&&
的符号,表示二者都满足才可以;那对于天来说也是一样的,要满足年和月相等的条件下才可以比较大小
//Student2
bool operator<(const Date& d)
{
return _year < d._year
|| (_year == d._year && _month < d._month)
|| (_year == d._year && _month == d._month && _day < d._day);
}
但是我却采用了第三位同学的代码来作为标准答案
- 可以观察到 ,他对于我的测试用例也是可以跑过的,一起再来看看它的代码
- 为什么要用他的代码呢?就是因为我觉得这个同学的逻辑思维很代码风格都很不错,他的逻辑其实和第二位同学是一致的,只是将第二位同学的逻辑或
||
改成了if…else条件判断罢了,原理都是一样的。 - 设想如何你是一位初学者,你愿意看哪种代码呢?那一定是这位同学,但若是你追求高质量、优质简练的风格,那我建议你可以选择第二位同学,如果你在力扣上刷过题目就可以知道对于第二位同学的代码风格是非常常见的,追求的就是一个快!
//Student3
bool 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;
}
else {
return false;
}
}
说点题外话🎈
- 为什么就上面这一段很简单的逻辑,让同学们去写会有五花八门的样子呢?其实就是因为每个人的思维方式不同而导致的,其实咱们这个行业写代码写的是逻辑,写得代码对不对取决于你想得对不对。如果想法、思路都错了,那一定不可能写对🙅
- 一个人的代码能力其实可以分为下面这四种
- 想得不对,这是最差的 ——> 要锻炼逻辑思维,尝试考虑地更全面、更细节
- 想对了,写错了,表明代码能力不行 ——> 多写代码
- 想得是对的,写的也是对的,但写法很普通 ——> 需要拔高
- 不仅要想对、写对,而且要以一种比较优的形式去实现【这才是最终目标】
你觉得自己正处于那种状态呢?
好,我们回归正题,继续来讲运算符重载
- 知道了
==
、<
如何去进行重载,那小于等于呢?该如何去实现?
bool operator<=(const Date& d)
💬有同学说:这简单,把<
都改成<=
不就好了
bool 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;
}
else {
return false;
}
}
- 确实上面这样是最直观的形式,但是刚才说了,我们在已经能够写对的情况下要去追求更优的情况。我采取的是下面这种写法,你能很快反应过来吗?
return (*this < d) || (*this == d);
- 其实很简单,我就是做了一个【复用】,使用到了上面重载后的运算符
<
和==
,this
指向当前对象,那么*this
指的就是当前对象,这样来看的话其实就一目了然了
小于、小于等于都会了,那大于>
和大于等于>=
呢?不等于!=
呢?
bool operator>(const Date& d)
bool operator>=(const Date& d)
bool operator!=(const Date& d)
- 其实上面的这两个都可以用【复用】的思想去进行实现,相信此刻不用我说你应该都知道该如何去实现了把
//大于>
bool operator>(const Date& d)
{
return !(*this <= d);
}
//大于等于>=
bool operator>=(const Date& d)
{
return !(*this < d);
}
//不等于!=
bool operator!=(const Date& d)
{
return !(*this == d);
}
这里就不给出测试结果了,读者可自己修改日期去查看一下
④ 代码展示
class Date
{
public:
//3.全缺省构造函数
Date(int y = 2000, int m = 1, int d = 1)
{
_year = y;
_month = m;
_day = d;
}
Date(const Date& d) //权限保持
{
//cout << "Date拷贝构造的调用" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
//等于==
bool operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
//小于<
bool 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;
}
else {
return false;
}
}
//小于等于<=
bool operator<=(const Date& d)
{
return (*this < d) || (*this == d);
}
//大于>
bool operator>(const Date& d)
{
return !(*this <= d);
}
//大于等于>=
bool operator>=(const Date& d)
{
return !(*this < d);
}
//不等于!=
bool operator!=(const Date& d)
{
return !(*this == d);
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
~Date()
{
//cout << "Date析构函数的调用" << endl;
_year = 0;
_month = 0;
_day = 0;
}
private:
int _year;
int _month;
int _day;
};
2、赋值运算符重载
有了运算符重载的概念后,我们就来讲讲什么是赋值运算符重载
① 语法说明及注意事项
首先给出代码,然后我再一一分解叙述
Date& operator=(const Date& d)
{
if (this != &d) //判断一下是否有给自己赋值
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
👉 【参数类型】:const T&,传递引用可以提高传参效率
- 这一点我在上面讲解拷贝构造的时候已经有重点提到过,加
&
是为了减少传值调用而引发的拷贝构造,加const
则是为了防止当前对象被修改和权限访问的问题,如果忘记了可以再翻上去看看
👉 【返回*this】 :要复合连续赋值的含义
- 这块重点讲一下,本来对于赋值运算符来说是不需要有返回值的,设想我们平常在定义一个变量的时候为其进行初始化使用的时候赋值运算,也不会去考虑到什么返回值,但是对于自定义类型来说,我们要去考虑这个返回值
Date d1(2023, 3, 27);
Date d2;
Date d3;
d3 = d2 = d1;
- 可以看到,就是上面这种情况,当d1为d2进行初始化后,还要为d3去进行初始化,那此时就要使用到d2,所以我们在写赋值重载的时候要考虑到返回值的问题,那返回什么呢?
- 因为是为当前对象做赋值,所以应该返回当前对象也就是
*this
👉 【返回值类型】:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
- 返回值这一块我在引用一文也有相信介绍过,若是返回一个出了当前作用域不会销毁的变量,就可以使用引用返回来减少拷贝构造
👉 【多方位考虑】:检测是否自己给自己赋值
- 在外界调用这个赋值重载的时候,不免会有人写成下面这种形式
d1 = d1;
- 那自己给自己赋值其实并没有什么意义,所以我们在内部应该去做一个判断,若是当前
this指针
所指向的地址和传入的地址一致的话,就不用做任何事,直接返回当前对象即可。若是不同的话才去执行一个赋值的逻辑
if (this != &d)
知晓了基本写法和注意事项后,我们就来测试运行一下看看是否真的可以完成自定义类型的赋值
- 可以看到 ,确实可以使用
=
去进行日期之间的赋值
- 不仅如此,也可以完成这种【链式】的连续赋值
- 那现在我想问,下面的这两种都属于【赋值重载】吗?
d2 = d1; //赋值重载
Date d3 = d2; //赋值重载?
- 通过调试可以观察到
d2 = d1
就是我们刚才说的赋值重载,去类中调用了对应的函数;但是对于Date d3 = d2
来说,却没有去调用赋值重载,而是去调用了【拷贝构造】,此时就会有同学很疑惑?
这里一定要区分的一点是,赋值重载是两个已经初始化的对象才可以去做的工作;对于拷贝构造来说是拿一个已经实例化的对象去初始化另一个对象
- 所以对于上面的
d1
和d2
是两个已经初始化完的对象,但是d3还未初始化,还记得我在将拷贝构造的时候说过的这一种形式吗,如果忘了就再翻上去看看吧😄
② 默认的赋值运算符重载
重点地再来谈谈默认的赋值运算符重载,相信在看了构造、析构、拷贝构造后,本小节对你来说不是什么难事😎
- 那还是咱熟悉的老朋友Time类和Date类,在Time类中我写了一个赋值重载
class Time
{
public:
Time()
{
_hour = 1;
_minute = 1;
_second = 1;
}
Time& operator=(const Time& t)
{
if (this != &t)
{
_hour = t._hour;
_minute = t._minute;
_second = t._second;
}
return *this;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
Date(int y = 2000, int m = 1, int d = 1)
{
_year = y;
_month = m;
_day = d;
}
private:
// 基本类型(内置类型)
int _year;
int _month;
int _day;
// 自定义类型
Time _t;
};
- 我们首先通过调试来进行观察
- 可以发现,当使用
d1
初始化d2
的时候,去调用了Time类的拷贝构造,这是为什么呢?我们其实可以先来看看Date类中的成员变量有:_year、_month、_day以及一个Time类的对象_t
,通过上面的学习我们可以知道对于前三者来说都叫做【内置类型】,对于后者来说都叫做【自定义类型】 - 那在构造、析构中我们有说到过对于【内置类型】编译器不会做处理;对于【自定义类型】会去调用默认的构造和析构。在拷贝构造中我们有说到过【内置类型】会按照值拷贝一个字节一个字节;对于【自定义类型】来说会去调用这个成员的拷贝构造
- 那通过上面的调试可以看出赋值重载似乎和拷贝构造是差不多,对于内置类型进行值拷贝,对于自定义类型Time去调用了其赋值重载函数
那我还是一样会提出疑问,既然编译器生成的默认赋值运算符重载函数已经可以完成字节序的值拷贝了,还需要自己实现吗?当然像日期类这样的类是没必要的。那么下面的类呢?验证一下试试?
没错,也是我们的老朋友Stack类。我想你可能已经猜到了结果😆
typedef int DataType;
class Stack
{
public:
Stack(size_t capacity = 10)
{
_array = (DataType*)malloc(capacity * sizeof(DataType));
if (nullptr == _array)
{
perror("malloc申请空间失败");
return;
}
_size = 0;
_capacity = capacity;
}
void Push(const DataType& data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
~Stack()
{
if (_array)
{
free(_array);
_array = nullptr;
_capacity = 0;
_size = 0;
}
}
private:
DataType* _array;
size_t _size;
size_t _capacity;
};
int main()
{
Stack s1;
s1.Push(1);
s1.Push(2);
s1.Push(3);
s1.Push(4);
Stack s2;
s2 = s1;
return 0;
}
- 不出所料,程序出现了奔溃,如果你认真看了上面内容的话,就能回忆起上面Stack在进行浅拷贝时出现的问题,和这里的报错是一模一样的
- 我们知道,对于浅拷贝来说是就是一个字节一个字节直接拷贝,和拷贝构造不同的是,两个对象是已经实例化出来了的,
_array
都指向了一块独立的空间,但是在赋值之后,s1和s2的_array
还是指向了同一块空间。此时便会造成两个问题- 因为它们是同一指向,所以在析构的时候就会造成二次析构
- 原本s2中的
_array
所申请出来的空间没有释放会导致内存泄漏
- 还是一个画个图来分析一下
📚所以还是一样,当果类中未涉及到资源管理,赋值运算符是否实现都可以;一旦涉及到资源管理则必须要实现
注意:赋值运算符不能重载成全局函数!
中间穿插一点内容,就是有关这个赋值运算符所定义的位置
- 如下,可以看到我将赋值重载运算符放到了类外来定义,编译一下发现报出了错误,这是为什么呢?其实编译器已经给我们写得很明确了,对于
operator=
也就是赋值重载只能写在类内,不可以写在类外,但一定有同学还是会疑惑为什么要这样规定,且听我娓娓道来~
- 上面我们讲到了,若是我们自己不去显式实现赋值运算符重载的话,当出现两个自定义类型进行赋值运算时就会默认调用编译器为当前类自动生成的默认赋值运算符重载进行赋值
- 但若是我们自己在类外又定义了一个的话,就会发生和类内默认生成冲突的情况
Date& operator=(Date& left, const Date& right)
{
if (&left != &right)
{
left._year = right._year;
left._month = right._month;
left._day = right._day;
}
return left;
}
拿一些权威性的东西来看看,以下是《C++ primer》中的原话
【总结一下】:
- 本模块我们介绍了两样东西,一个是运算符重载,一个是赋值运算符重载
- 对于运算符重载来说,我们认识到了一个新的关键字
operator
,使用这个关键字再配合一些运算符封装成为一个函数,便可以实现对自定义类型的种种运算,也加深巩固了我们对前面所学知识的掌握 - 对于赋值运算符重载而言,就是对赋值运算符
=
进行重载,分析了一些它的有关语法使用特性以及注意事项,也通过调试观察到了它原来和默认拷贝构造的调用机制是一样的,毕竟大家同为天选之子。但是也要区分二者的使用场景,不要混淆了
六、const成员函数
接下去中间穿插讲一个const成员函数,这个实际的开发中可能会用到,也为下面的const取地址操作符重载做铺垫
【概念】:将const
修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改
- 在下面我定义了一个普通的类A,然后在里面写了一个成员函数来输出成员变量a的值
class A {
public:
void Print()
{
cout << _a << endl;
}
private:
int _a = 10;
};
int main(void)
{
A a;
a.Print();
return 0;
}
- 接着也是正常地输出了结果,但此时我在实例化这个对象aa时在类的前面加上一个
const
做修饰,编译却报出了错误说了一些有关this指针的内容,那有些同学就看不太懂,这是为啥呀???
- 既然和
this
指针有关的报错,那我们是否可以从这个点入手去分析一下呢?要知道一个对象去调用当前类中的成员函数时会传递它的地址给到成员函数中的隐藏形参this指针,然后在内部this指针就可以通过不同的对象地址去访问不同地址空间中对应的成员变量
void Print(A* this)
- 那此时你就应该想到,把一个具有常性的const对象地址传递给不具有常性的this指针时,是否会造成一个权限放大呢,本来我外部这个aa是不可以修改其成员变量的,但是因为调了这个Print()函数后却又可以了,那妥妥的是一个权限放大呀!
💬那有同学问:这该怎么办呀,那可以不要让它放大吗?给this指针也加个const,这样就可以权限保持了吧
- 上面说到过,this指针是一个隐藏形参,我们是看不到它的,使用this指针接收调用对象的地址是由编译器来完成的,我们不能贸然去做,所以更改this指针的权限这种行为就更不用说了
- 其实对于this指针来说是一个指针常量,何谓指针常量?也就是指针本身就是一个常量,是不可以修改器指向的,但是指针所指向的内容是可以修改的【联想this指针已经接收到了当前对象的地址,难道在调用的时候还能改吗?但是在访问成员变量时却是可以对其进行修改的】 —— 不懂看看这个吧 链接
💬小王:那这不是没办法了吗,老师?出现Bug了
- 诶,不要急嘛,这不是还有一个天选之子没用呢,可以在这个Print()函数的右边空一个空格,然后写一个
const
,那这个成员函数就是一个const成员函数了
void Print() const
{
cout << _a << endl;
}
- 来看一下运行结果就可以发现没有报错了,这才是你说的【权限保持】哦
💬小东:但是为什么加这么一个const就可以做到为this指针做修饰呢?编译器到底是怎么识别的?
- 首先我们来运行一下,看看编译器是怎么说的
- 那其实很明确了,当加上这个
const
做修饰之后,this指针就变成了一个常量指针,对于常量指针而言是不可以去修改它所指向内容的,那_a += 1
这个操作就是违法的;而且this指针本身就是一个指针常量,那此时它完全就被锁住了,就像下面这样
void Print(const A* const this)
当我放出这样的东西后,又迎来了一堆的问题。。。。
💬小明:那限制成这样了外面要传个普通的不具有常性的成员是不是都不可以了?
- 这个其实你去运行一下就知道了,外界是可以允许你修改了,但是到了内部我不去修改了,那是不是一个权限缩小,这是可以的
💬小李:const只能用在成员函数上吗?我去修饰普通函数可不可以呢?
- 这当然是不可以的啦,同学!一开始我就说了这个
const
是用来修饰this指针的,那谁有this指针呢?是不是成员变量才有这个隐藏的参数呀,是吧。类外的普通函数是没有this指针滴😁
💬小叶:const修饰成员函数只能放在右边吗?能不能放在左边?
- 不可以哦, 这是语法规定的。就像我国规定了一辆车的驾驶室只能在左边,不过国外是右边。那你如果强行把驾驶室改到右边,然后放到国内来卖,你看有没有人会来买🚗
当同学们提问完之后,就轮到我问了,我便提出了一下四个问题,读者在学习完后可以试着解答一下
问题:
-
const对象可以调用非const成员函数吗?
-
非const对象可以调用const成员函数吗?
-
const成员函数内可以调用其它的非const成员函数吗?
-
非const成员函数内可以调用其它的const成员函数吗?
答案:
- 这个当然不可以。我们前面已经说过了,若 const对象去调用非const成员函数,会造成【权限放大】的现象,原本在类外const对象的内容是不可以修改的,但是到了函数内部却有可以修改了,这是不被允许的
- 这个当然是可以的。非const对象本身就是可读可写的,那在函数内部你要去修改或者不修改都不会有影响
- 不可以,const成员函数内部只能调用const成员函数。因为const成员函数内部的this指针已经具有常属性的,万一这个非const成员函数去修改了成员变量的内容就会出问题了
- 这个可以,非const成员函数中this的类型:
A* const
,即当前对象可修改也可不修改
七、取地址及const取地址操作符重载
这两个天选之子呢类中也是会默认提供的,但是呢我们用得却不多
class Date
{
public:
Date* operator&()
{
return this;
}
const Date* operator&() const
{
return this;
}
private:
int _year; // 年
int _month; // 月
int _day; // 日
};
- 可以看到,就是去打印一下这个对象的地址,通过函数实现可以看到
return this
,那返回的也就是当前对象的地址
说实话,这个在平常的开发中真用的不多,甚至用不到,这里提一下读者了解一下就可以了。你要真的想去玩一玩这个东西的话倒也可以
- 若是外界想要访问这个这个类实例化出对象地址的话,返回一个随机的地址给他就行,那若是他去解引用一个程序就会奔溃,相当于是访问野指针了😂
八、综合案例实战 —— Date日期类
学习完了类的六大天选之子,接下去我们来练习一道综合案例 —— Date日期类
1、需求分析鸟瞰
这个案例我打算通过多文件的形式去进行编写,分为:Date.h
、Date.cpp
、test.cpp
- 然后来说说这个Date中我们要去实现哪些功能。以下是
[Date.h]
中的内容
class Date
{
friend istream& operator>>(istream& in, Date& d);
friend ostream& operator<<(ostream& out, const Date& d);
public:
//获取当月的天数
int GetMonthDay(int year, int month);
//构造函数
Date(int y = 2000, int m = 1, int d = 1);
//拷贝构造函数
Date(const Date& d);
//赋值重载
Date& operator=(const Date& d);
//等于==
bool operator==(const Date& d);
//不等于!=
bool operator!=(const Date& d);
//小于<
bool operator<(const Date& d);
//小于等于==
bool operator<=(const Date& d);
//大于>
bool operator>(const Date& d);
//大于等于>=
bool operator>=(const Date& d);
//前置++
Date& operator++();
//后置++
Date operator++(int);
//前置--
Date& operator--();
//后置--
Date operator--(int);
//日期 += 天数
Date& operator+=(int days);
//日期 + 天数
Date operator+(int days);
//日期 -= 天数
Date& operator-=(int days);
//日期 - 天数
Date operator-(int days);
//日期 - 日期
int operator-(const Date& d); //构成重载
//打印
void Print();
//析构函数
~Date();
private:
int _year;
int _month;
int _day;
};
- 可以看到,要是实现的功能有很多,无论你上面是否有听懂,我会将其分为四个模块来进行讲解
- 【第一模块】:写出Date日期类中需要自己实现的默认成员函数
- 【第二模块】:写出Date日期类中一些关系运算符重载
==
、!=
、>
、>=
、<
、<=
- 【第三模块】:写出Date日期类中涉及日期计算的相关运算符重载
- 【第四模块】:实现流插入
<<
和流提取>>
对日期进行输入输出
注意:下面四个模块的编写都是放在
Date.cpp
中,因此成员函数前后都要加上域作用限定符::
2、【第一模块】:默认成员函数实现
① 全缺省构造函数
- 那对于一个类来说,构造函数是最起码的,除了类本身就带有的默认构造函数外,我们还要去自己实现一个全缺省构造函数
- 在C++缺省参数一文中我有说到过若是一个函数进行了分文件编写的话,对于形参部分的初始值需要再
.h
的头文件中给出,而不可以写在.cpp
中,因为主文件要包含的头文件是Date.h
Date(int y = 2000, int m = 1, int d = 1);
Date::Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
但是这样就好了吗?我们首先来main函数中测试一下
- 可以看到,上面这个对象被成功地实例化出来了,但是对象d2你觉得这是合法的吗?
- 身处2023年的读者一定知晓今年并不是闰年,所以2月份只有28天,那这个29天就是不合法的
因此当外界在实例化对象调用构造函数的时候,我们应该要去进行一个日期是否合法的判断
- 首先在Date类的全缺省构造函数的内部对传入进来的月份做一个判断,因为月份之后1 ~ 12月,其他都是不合法的,我这里直接使用
assert()
assert(month > 0 && month < 13);
- 那月份合法之后,就要去看看所传入的这个月的天数是否合法了,那我们先要知道这一年的这一个月有多少天,那么便需要使用到我在上面将【日期计算器】时所用到的
GetMonthDay
,具体实现细节这里便不再多说
int Date::GetMonthDay(int year, int month)
{
int monthArray[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
int days = monthArray[month];
if (month == 2 && (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)) {
return days + 1;
}
else {
return days;
}
}
- 当我们获取到这个月真正有多少天时,和传入进来的
day
去进行一个比较,若是合法的话就对当前对象的年、月、日去做一个初始化的工作,若是不合法的话就输出一句话进行提醒
//判断一下对象实例化的年月日是否合法
int days = GetMonthDay(year, month);
if (day > 0 && day <= days) {
_year = year;
_month = month;
_day = day;
}
else {
cout << "所传入日期不合法" << endl;
}
② 拷贝构造函数
- 拷贝构造函数没有什么需要变化的地方,注意使用引用传参
&
减少拷贝、提高效率,以及const
常修饰对象防止误操作和权限方法即可
Date::Date(const Date& d) //权限保持
{
cout << "Date拷贝构造的调用" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
③ 赋值运算符重载
- 这个天选之子类中默认也会生成,而且对于内置类型的year、month、day会进行值拷贝去赋值,不会出现问题,不过这里我也去实现一下,达到联系巩固的目的
- 需关注的点有两个,一个是在一开始进来的时候判断一下是否给自己赋值,最后在赋值完成后还要返回自身的结果,但是对于
*this
来说出了作用域不会销毁,因此我们可以使用引用返回Date&
来减少临时拷贝的过程
Date Date::operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
④ 析构函数
- 析构函数很简单,将三个成员变量置0即可
Date::~Date()
{
cout << "Date析构函数的调用" << endl;
_year = 0;
_month = 0;
_day = 0;
}
这里要说明一点,怕有的同学混淆,对于日期类来说器成员变量都是【内置类型】的,不会出现深拷贝之类的问题,所以构造、拷贝构造、赋值重载、析构都是可以不用去实现的,我这里实现只是为了起到知识点巩固的效果
3、【第二模块】:关系运算符重载
接下去我们进入第二模块,来实现一下关系运算符的重载
① 等于 ==
bool Date::operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
② 不等于 !=
- 这里复用一下
==
即可
bool Date::operator!=(const Date& d)
{
return !(*this == d);
}
③ 小于 <
- 注意判断两个日期的大小不是月份小就是小、天小就是小,而是要在年份相同的情况下才能去进行比较
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;
}
else {
return false;
}
}
④ 小于等于 <=
- 也是一样复用一下
<
和==
即可
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);
}
4、【第三模块】:日期计算相关运算符重载
好,接下去我们来进行第三模块函数的编写,这一块可能会有点难,做好准备,上车了🚗
① 前置++
- 在C语言中我们有学习过自定义类型的前置++,若是用一个范沪指去接收的话,那返回值得到的就是++之后的结果,然后数值自身也会递增。那我们就可以像下面这样来实现
- 因为是自己会实现递增,所以使用
*this
即可,最后要给到++之后的返回值,那便返回*this
即可,那返回一个出了作用域不会销毁的成员,我们可以使用引用返回减少临时拷贝
Date& Date::operator++()
{
*this += 1;
return *this; //返回++之后的值
}
② 后置++
- 对于后置++来说,外界会接收到++之前的数值,但是数值本身自己也要递增,所以我们可以做一份
*this
的临时拷贝,当自身的值递增之后,返回之前临时拷贝的tmp即可。不过要注意的是,tmp出了作用域不会销毁,因此不可以使用【引用返回】
Date Date::operator++(int)
{
Date tmp(*this);
*this += 1;
return tmp; //返回++之前的值
}
来测试一下
这里我来解释一下后置++函数内的
int
是什么意思
- 这是C++语法自身所规定的,int仅仅是为了占位,跟前置重载产生一个区分,对于编译器来说,当它遇到后置++的时候,会自动将表达式转换为
d1.operator++(0)
,里面填充一个int类型的数据,无论是0或者1 都可以 - 通过调试来观察一下
- 再通过汇编来观察一下底层的转换原理,可以看到,前置++和函数默认不传值的结果是一样的,但是传入了0或1确实和后置++一样的。这其实就一清二楚了
看完了前置++和后置++,那么前置–和后置–也不会有什么问题
③ 前置- -
Date& Date::operator--()
{
*this -= 1;
return *this; //返回--之后的值
}
④ 后置- -
Date Date::operator--(int)
{
Date tmp(*this);
*this -= 1;
return tmp; //返回--之前的值
}
- 一样通过调试来看看
- 通过观察可以发现,后置
++
或者是--
都需要现有一份临时拷贝,但是前置++
或者是--
。这就是为什么我们C语言在写循环条件的递增时要用++i
而不是i++
的原因了
上面的
++
、--
都是一个单位的变化,那是否可以多变化一些呢,例如自己传入想要增加或减少的天数
⑤ 日期 += 天数
- 首先来看看函数声明,形参部分就是需要传入改变的天数
Date& Date::operator+=(int days)
- 这一块其实我在前面【日期计算器】的部分也重点说过了,如果忘记的读者可以再翻上去看看👆
- 思路再顺理一下:
+=
是对当前调用对象自身的变化,所以直接在_day
在进行操作即可,当加上days后去判断一下是否大于当前月的天数【判断的逻辑上面讲过了,GetMonthDay
可以复用】,如果是的话就要先减去这个月的天数,然后将月份进行递增。但月份在递增的过程中也会超出一个临界值,若是_month > 12
那我们就要月份改为来年的1月,然后将年份进行递增。一直循环往复前面的操作,知道_day
比当前月的天数来的小或者相等为止,因为是对调用对象自身进行操作,所以return *this
即可,出了当前作用域不会销毁,那再考虑到使用【引用返回】
Date& Date::operator+=(int days)
{
_day += days;
while (_day > GetMonthDay(_year, _month)) {
//把当月的天数先减掉
_day -= GetMonthDay(_year, _month);
++_month;
if (_month > 12) {
//将月份置为来年的1月
_month = 1;
++_year;
}
}
return *this;
}
⑥ 日期 + 天数
+=
实现了之后,+
就可以去进行一个复用了,不需要再将上面的这块逻辑再写一遍。我们都知道+
之后不会改变自身的大小,所以不能对当前对象本身去进行一个操作,而是要做一份临时拷贝去加这个天数。又因为是临时拷贝,所以不可以使用【引用返回】
Date Date::operator+(int days)
{
Date tmp(*this); //做一份当前对象的临时拷贝
tmp._day += days;
return tmp; //+不会改变自身,返回临时对象【出了作用域销毁,不能引用返回】
}
来测试一下
+=
和+
会写了之后,我们来看看一个日期要怎么减去对应的天数
⑦ 日期 -= 天数
- 首先来分析一下,若是减去一个天数的话我们还可以像加上一个天数这样使用月进制的方法吗?答案是:可以的
- 首先很明确,先把对象自身的
_day
减去传入的days。那若这个天数还是正的话就不会有问题,但减完之后这个天数为负了,就要去考虑月份的进制了
_day -= days;
- 当这个
_day <= 0
时我们进入这段逻辑,因为一个日期是没有0日的。那要怎么去处理这个天数呢,就要去上个月借,若是上个月不够了再向它的上个月借。那要这样一直借的话我们就先要去把月处理干净了才可以。但是月份在递减的过程中可能会减到0月,此时就不对了,这和+=
到达13月是一个道理,首先将年份减1,然后将月份改为上一年的12月即可 - 把月份处理干净后,就可以去处理天数,因为
_day
是一个负数,怎么让它变成一个正数呢,就是加上这个月的天数即可。一直这么循环往复,直到_day > 0
为止
while (_day <= 0)
{
//先把月处理干净
_month--;
if (_month == 0) { //如果月减到0,说明出问题了,轮回到上一年
_year--;
_month = 12;
}
_day += GetMonthDay(_year, _month);
}
- 因为是
-=
,改变的是当前对象自身,所以需要return *this
,还是一样,返回一个出了作用域不会销毁的对象,可以使用【引用返回】
return *this;
⑧ 日期 - 天数
- 那对于
-
也是一样,复用一下-=
即可
Date Date::operator-(int days)
{
Date tmp(*this);
tmp._day -= days;
return tmp;
}
来测试一下
不过对于上面的
+=
、+
、-=
、-
完全没有任何纰漏了吗?如果我去加上一个负数呢?
Date d1(2023, 3, 1);
Date d2 = (d1 += -20);
- 可以观察到,日期在加上一个负数后出现了问题
- 通过调试观察可以发现,传入
operator+=
的days是一个负数,那么当前对象的_day
在累加后还是一个负数,此时根本都不会进入到下面的while循环中,因此出现了问题👈
那该如何去解决呢?
- 只需要在刚进入函数的时候判断一下即可,若是传入的
days < 0
的话,此时去复用一下-=
即可,那么逻辑就转变为【加上一个负数等于减去一个正数】,所以要在days
前加上一个负号就可以起到负负得正的效果
if (days < 0)
{
*this -= -days;
return *this;
}
通过调试来看看Bug修正后的结果
+=
存在这样的问题,其实-=
也是一样的
- 那也是一样,在函数传入的时候去进行一个判断即可,若为负数的话,此时的逻辑就变成【减去一个负数等于加上一个正数】,同样用到负负得正的思想
if (days < 0)
{
*this += -days;
return *this;
}
这里就不做调试观察分析了,展示一下运行结果
日期除了可以去加减固定的天数之外,两个日期之间还可以去进行运算,这个之前。有提到过,对于日期 + 日期来说是没有意义,但是日期 - 日期还是存在一定意义的
⑨ 日期 - 日期
对于两个日期的差有很多不同的计算方法,这里我介绍一种比较优的思路
- 既然是计算两个日期之间差了多少天,那么一定会存在哪个日期大,哪个日期小。因为调用成员函数的对象会自动被当前this指针所指向,所以我假设当前的
*this
是为较大的日期,而形参中的d是小的那个日期。然后定义一个flag
做标记值 - 接下去就可以复用我们上面所重载的
小于<
运算符了,再去比较一下两个对象谁大谁小,然后做一个更新,若是当前对象来的小的话,flag就要置为-1,因为二者相减的话会变成一个负数,此时就可以用到这个标记值了 - 比较出了谁大谁小后,就可以去统计两个日期之间差了几天了,这里我们又可以复用前面的
!=
运算符,让小的日期不断++,直到和大的日期相同为止 - 最后
return flag * n
即可,二者之间相差的是负数天还是正数天一目了然
下面是代码
int Date::operator-(const Date& d) //构成重载
{
Date max = *this;
Date min = d;
int flag = 1;
//再进行比较算出二者中较大的那一个
if (*this < d)
{
max = d;
min = *this;
flag = -1;
}
//计算二者之间相差几天
int n = 0;
while (min != max)
{
min++;
n++;
}
return flag * n;
}
重载好后对两个日期去进行相减就发现确实是正确的【离下一个清明节放假还剩7天🎉🎉🎉】
5、【第四模块】:流插入和流提取的实现
好,最后一个模块,我们来讲讲流插入和流提取
① 流插入<<
- 可以看到,在上面对日期进行各种操作后,我都调用了
Print()
函数打印观察日期的变化情况,虽然调用一下也不用耗费多少时间,但总觉得还是有些麻烦了,如果可以像正常变量一样直接用cout << a
输出该多好 - 但是这个去输出一下就可以发现编译器报出了没有与这些操作数匹配的"<<"运算符
- 我们可以先到cplusplus中来了解一下
cout
究竟是个什么东西。从中其实看到了一个很熟悉的东西叫做<iostream>
,这是我们日常在写代码的时候都会包的头文件,而从中又包含了<istream>
和<ostream>
,对于【cin】来说它是属于输入流的,对于【cout】来说是属于输出流的一种
- 在namespace命名空间一文中我有细致地讲解了C++中的内容都是封装在std的命名空间,所以我们要真正使用【cin】和【cout】还要去展开一下命名空间
using namespace std
,我们可以进到这个命名空间中看看
💬小王:但为什么像我们平常写的cout << a
、cout << b
不会有问题呢?
int a = 10;
int b = 20;
cout << a;
cout << b;
- 这点我前文已经提到过很多次了,对于像
int
、char
、double
来说都内置类型,我们前面重载的这些运算符就是为了去和自定义类型结合,基本每个运算符如果要去使用的话都要进行重载,只有像默认的【赋值重载】和【取地址重载】不需要之外 - 而内置类型都可以直接和操作符匹配使用是因为语法原生支持,在库里已经帮我们将这些运算符和数据类型重载好了,所以可以直接使用。具体地可以到库里来看看👇
那其实这就很清楚了,在官方的库中,已经为我们重载好了相应的运算符,所以内置类型的数据才可以直接去进行输出;而它们为什么可以同时存在呢?原因就在于这些函数又发生了一个重载,如果不清楚规则的话看看C++运算符重载规则
- 真正的原因就在于:库中进行了运算符重载 + 函数重载,我们才得以直接使用
💬小李:那我们若是要去输出自定义类型的数据时,是不是也可以使用运算符重载呢?这个也可以重载吗?
- 这个当然可以重载了,
<<
在C语言中我们有学习过是【按位左移】的意思,只是在C++中它被赋予了新的含义而已,那此时我们就可以在类中重载一下这个运算符了,然后输出对应的日期
void Date::operator<<(ostream& out)
{
out << _year << "/" << _month << "/" << _day << endl;
}
但编译器还是报出了一些错误,那有的同学就感到很疑惑了??
- 但是当我把输出的表达式写成下面这样时,却出现了神奇的现象,居然不报错了!!
- 对于上面讲到过的内置类型重载,其实可以写成下面这样,以一种函数调用的方式
- 那对于自定义的日期类型来说,其实应该写成下面这样子
- 在讲解this指针的时候我有谈到过对象调用类中的成员函数时会将其对象的地址传递给隐藏的形参this指针,那么无论如何调用的一方对象都会成为第一个参数,而形参列表中的就会成为第二个参数
💬小叶:哦,那这样的话cout
就是第二个参数了,那我用cout.operator<<(d1)
去调用不就好了吗?
- 可以是可以,那这样的话你的这个运算符重载就要写到
ostream
这个类里面去,和内置类型的重载放在一起,但是你可以去修改官方的库吗?很明显不能╮(╯▽╰)╭ - 那此时的话你只能去手动修饰这个
ostream&
参数了,不要当前所调用对象成为第一个参数那只有一个办法,那就是不要把这个函数写在写在类里面了!!!
💬小叶:嗯,也是。那把它写到类外来吧,作为全局的一个函数
- 但是现在又出现问题了哦,拿到类外来就访问不到类内的私有成员变量了
- 其实我们将运算符重载写成成员函数的原因就是在于这个,想要访问类内的私有成员变量。但是现在放到类外来了确实访问不到了。那此时你就有三个办法
- 将成员变量全部改为
public
共有【不推荐】 - 使用
GetYear()
、GetMonth()
、GetDay()
【Java中常见】 - 将次函数声明为类的友元函数【C++中常用】
- 将成员变量全部改为
- 可以看到,只需在最前面加上一个
friend
做为修饰,就可在类内声明一下这个函数为当前类的友元函数
friend void operator<<(ostream& out, const Date& d);
- 此时再去尝试
cout << d1
就可以发现可以正常运行了
- 但是呢我们一般在使用【流插入】的时候不只是输出一个东西,可能会有多样东西需要输出
cout << d1 << d2;
- 这一点我上面在讲【赋值运算符重载】的时候也有提到过,如果要实现连续赋值的话就要做一个当前对象的返回值,那这里我们若是要继续实现流插入的话就要返回一个
ostream
的对象才可以,那也就是把这个【out】返回即可,便可以实现多次流插入了
ostream& operator<<(ostream& out, const Date& d)
{
out << d._year << "/" << d._month << "/" << d._day << endl;
return out;
}
② 流提取>>
既然流插入可以实现了,那还有一个双胞胎兄弟叫做【流提取】,也就是我们常用的输入
>>
- 也是和流插入一样的两块地方,在类内首先声明一下这个函数是当前Date类的友元函数,然后将
ostream
转换为istream
输入流即可
friend istream& operator>>(istream& in, Date& d);
- 这里要注意的一点是形参的对象不可以用
const
来进行又是,不然它就具有常属性,我们无法往里面去写入东西
istream& operator>>(istream& in, Date& d)
{
in >> d._year >> d._month >> d._day;
return in;
}
来测试一下
③ 内联函数与成员函数的优化
其实对于上面的【流提取】和【流插入】还可以再去进行一个优化,那就是将其定义为【内联函数】
- 首先若是像我现在这种写法就是声明和定义分离,所以编译完之后还要去进行一个链接,在符号表里面找这个函数的定义,这也是需要花费一定时间,虽然对编译器来说不算什么
- 但是呢对于内联函数来说就不需要这样的步骤,因为在对比内联函数和宏的优缺点中我们谈起过对于【内联函数】而言是不要声明和定义分离的,因为内联函数是不进符号表的,不像普通函数一样编译过后找不到定义就可以去符号表里面找
- 内联函数将声明和定义都放在
.h
的头文件中,因为只有声明没有链接的时候才要去找,直接就有定义不用链接,在编译阶段就能拿到地址,然后call它的地址就可以进到那个函数里了
所以对于内联函数来说不可以写成下面这种形式【只在.h
声明】,这样它就找不到函数的定义了
正确的应该像下面这样,将函数的定义与声明写在一起
这里再提一个小知识点,其实我在类和对象的封装思想中有讲到了,就是对于类中直接定义的成员函数会被直接当成是【内联函数】看待,这里就不能演示了,因为我们就是从类中将这个两个函数抽离出来的,为了可以
cin >> d1 >> d2
和cout << d1 << d2
- 除了内联函数,其实还有一块也可以进行一个优化,还记得我上面讲到的const成员函数吗,用于修饰当前的this指针,将其变为一个常量指针,继而无法再函数内部改变其成员变量
- 所以在这里,我们还可以将一些内部不修改成员变量的成员函数再提升成为【常成员函数】,也可以防止误操作的发生
例如像下面的这些,都是可以声明为const成员函数的【注意在定义部分也要加上】
//获取当月的天数
int GetMonthDay(int year, int month); const
//等于==
bool operator==(const Date& d) const;
//不等于!=
bool operator!=(const Date& d) const;
//小于<
bool operator<(const Date& d) const;
//小于等于==
bool operator<=(const Date& d) const;
//大于>
bool operator>(const Date& d) const;
//大于等于>=
bool operator>=(const Date& d) const;
//日期 + 天数
Date operator+(int days) const;
//日期 - 天数
Date operator-(int days) const;
//日期 - 日期
int operator-(const Date& d) const; //构成重载
6、整体代码展示
Date.h
#pragma once
#include <iostream>
#include <assert.h>
using namespace std;
class Date
{
friend ostream& operator<<(ostream& out, const Date& d);
friend istream& operator>>(istream& in, Date& d);
public:
//获取当月的天数
int GetMonthDay(int year, int month) const;
//构造函数
Date(int y = 2000, int m = 1, int d = 1);
//拷贝构造函数
Date(const Date& d);
//赋值重载
Date& operator=(const Date& d);
//等于==
bool operator==(const Date& d) const;
//不等于!=
bool operator!=(const Date& d) const;
//小于<
bool operator<(const Date& d) const;
//小于等于==
bool operator<=(const Date& d) const;
//大于>
bool operator>(const Date& d) const;
//大于等于>=
bool operator>=(const Date& d) const;
//前置++
Date& operator++();
//后置++
Date operator++(int);
//前置--
Date& operator--();
//后置--
Date operator--(int);
//日期 += 天数
Date& operator+=(int days);
//日期 + 天数
Date operator+(int days) const;
//日期 -= 天数
Date& operator-=(int days);
//日期 - 天数
Date operator-(int days) const;
//日期 - 日期
int operator-(const Date& d) const; //构成重载
//打印
void Print() const;
//析构函数
~Date();
private:
int _year;
int _month;
int _day;
};
inline ostream& operator<<(ostream& out, const Date& d)
{
out << d._year << "/" << d._month << "/" << d._day << endl;
return out;
}
inline istream& operator>>(istream& in, Date& d)
{
in >> d._year >> d._month >> d._day;
return in;
}
Date.cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include "Date.h"
//获取当月的天数
int Date::GetMonthDay(int year, int month) const
{
int monthArray[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
int days = monthArray[month];
if (month == 2 && (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)) {
return days + 1;
}
else {
return days;
}
}
//3.全缺省构造函数
Date::Date(int year, int month, int day)
{
assert(month > 0 && month < 13);
//cout << "Date构造的调用" << endl;
//判断一下对象实例化的年月日是否合法
int days = GetMonthDay(year, month);
if (day > 0 && day <= days) {
_year = year;
_month = month;
_day = day;
}
else {
cout << "所传入日期不合法" << endl;
}
}
//拷贝构造
Date::Date(const Date& d) //权限保持
{
//cout << "Date拷贝构造的调用" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
//赋值重载
Date& Date::operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
//等于==
bool Date::operator==(const Date& d) const
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
//不等于!=
bool Date::operator!=(const Date& d) const
{
return !(*this == d);
}
//小于<
bool Date::operator<(const Date& d) const
{
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;
}
else {
return false;
}
}
//小于等于==
bool Date::operator<=(const Date& d) const
{
return (*this < d) || (*this == d);
}
//大于>
bool Date::operator>(const Date& d) const
{
return !(*this <= d);
}
//大于等于>=
bool Date::operator>=(const Date& d) const
{
return !(*this < d);
}
//前置++
Date& Date::operator++()
{
*this += 1;
return *this; //返回++之后的值
}
//后置++
Date Date::operator++(int)
{
Date tmp(*this);
*this += 1;
return tmp; //返回++之前的值
}
//前置--
Date& Date::operator--()
{
*this -= 1;
return *this; //返回--之后的值
}
//后置--
Date Date::operator--(int)
{
Date tmp(*this);
*this -= 1;
return tmp; //返回--之前的值
}
//日期 += 天数
Date& Date::operator+=(int days)
{
if (days < 0)
{
*this -= -days;
return *this;
}
_day += days;
while (_day > GetMonthDay(_year, _month)) {
//把当月的天数先减掉
_day -= GetMonthDay(_year, _month);
++_month;
if (_month > 12) {
//将月份置为来年的1月
_month = 1;
++_year;
}
}
return *this;
}
//日期 + 天数
Date Date::operator+(int days) const
{
Date tmp(*this); //做一份当前对象的临时拷贝
tmp._day += days;
return tmp; //+不会改变自身,返回临时对象【出了作用域销毁,不能引用返回】
}
//日期 -= 天数
Date& Date::operator-=(int days)
{
if (days < 0)
{
*this += -days;
return *this;
}
_day -= days;
while (_day <= 0)
{
//先把月处理干净
_month--;
if (_month == 0) { //如果月减到0,说明出问题了,轮回到上一年
_year--;
_month = 12;
}
_day += GetMonthDay(_year, _month);
}
return *this;
}
//日期 - 天数
Date Date::operator-(int days) const
{
Date tmp(*this);
tmp._day -= days;
return tmp;
}
//日期 - 日期
int Date::operator-(const Date& d) const //构成重载
{
Date max = *this;
Date min = d;
int flag = 1;
//再进行比较算出二者中较大的那一个
if (*this < d)
{
max = d;
min = *this;
flag = -1;
}
//计算二者之间相差几天
int n = 0;
while (min != max)
{
min++;
n++;
}
return flag * n;
}
void Date::Print() const
{
cout << _year << "/" << _month << "/" << _day << endl;
}
Date::~Date()
{
//cout << "Date析构函数的调用" << endl;
_year = 0;
_month = 0;
_day = 0;
}
test.cpp
int main(void)
{
//Date d1(2023, 3, 29);
//Date d2(2023, 4, 5);
Date d1;
Date d2;
cin >> d1 >> d2;
cout << endl;
cout << d1 << d2;
return 0;
}
九、总结与提炼
好,最后来总结一下【类和对象 · 磨砺篇】 所学习的内容📖
- 首先对于【构造函数】和【析构函数】而言,如果我们不写,编译器就会自动生成;如果我们写了,编译器就不会自动声明。编译器自动生成的构造和析构
- 对于内置类型不做处理
- 对于自定义类型会去调用它对应的构造/析构
- 然后是【拷贝构造函数】和【赋值运算符重载】,他们也是默认成员函数,若是我们自己不写的话编译器也会自动生成。同样分为两种类型去对待:
- 对于内置类型会完成浅拷贝/值拷贝,按照byte一个字节一个字节来拷
- 对于自定义类型,回去调用这个成员的拷贝构造/赋值重载
- 若是类内涉及资源管理,切记要自己去是实现这两个成员函数,完成深拷贝,防止浅拷贝造成的危害
- 【取地址】和【const取地址重载】一般用的不多,但也属于类的默认成员函数,若是我们不写的话编译器会自动生成
- 【const成员函数】中使用
const
去修饰当前对象的this指针,使其变为常量指针,使得在该成员函数中不能对类的任何成员进行修改 - 最后的Date日期类综合实战涵盖了前面所学的很多知识,包括:命名空间、缺省参数、函数重载、内联函数、引用、运算符重载、类的默认成员函数等等,可以强化对类和对象的编程思想,是重中之重
———————————————【类和对象 · 提升篇】 ———————————————
一、再谈构造函数
在深度探索类的六大天选之子之后,我们学习了类和对象的构造函数,知道了其可以用来初始化成员变量,也学了一些它的相关语法特性,但是C++中的构造函数真的就只是这样吗?本模块我们继续来谈谈有关构造函数的一些知识点
1、初始化列表
引入
- 我们知道,对于下面这个类A的成员变量
_a1
和_a2
属于【声明】,还没有在内存中为其开辟出一块空间以供存放,真正开出空间则是在【定义】的时候,那何时定义呢?也就是使用这个类A去实例化出对象的时候 - 这个对象的空间被开出来了,难道里面的成员变量就一定开出空间了吗?这一点我们很难去通过调试观察
class A {
public:
int _a1; //声明
int _a2;
};
int main(void)
{
A aa; // 对象整体的定义,每个成员什么时候定义?
return 0;
}
- 如果现在我在类A中加上一个const成员变量的话,初始化的时候似乎就出现了问题
const int _x;
- 在搞清楚上面的问题之前你要明白
const
修饰的变量有哪些特点
const int i;
- 可以看到我在这里定义了一个整型变量i,它前面是用
const
进行修饰的,不过编译后报出了错误说【必须初始化常量对象】,因为对于const
修饰的变量在声明的时候是必须要去进行初始化的,也就是要给到一个值
现在我们就可以来聊聊有关上面的成员变量
_x
为什么没有被初始化的原因了👇
- 之前有讲过,若是我们自己不去实现构造函数的话,类中会默认提供一个构造函数来初始化成员变量,对于【内置类型】的变量不会处理,对【自定义类型】的变量会去调用它的构造函数。那么对于这里的
_a1
、_a2
、_x
都属于内置类型的数据,所以编译器不会理睬,可是呢const
修饰的变量又必须要初始化,这个时候该怎么办呢╮(╯▽╰)╭
💬有同学说:这还不简单,给个缺省值不就好了
- 这位同学说的不错,这个办法确实是可以解决我们现在的问题,因为C++11里面为内置类型不初始化打了一个补丁,在声明的位置给到一个初始化值,就可以很好地防止编译器不处理的问题
但是现在我想问一个问题:如果不使用这个办法呢?你有其他方法吗?难道C++11以前就那它没办法了吗?
- 底下的同学确实想不出什么很好的解决办法,于是这个时候就要使用到本模块要学习的【初始化列表】了
初始化的概念区分
- 在了解【初始化列表】前,你要先知道初始化的真正含义是什么
概念:在创建对象时,编译器通过调用构造函数,给对象中各个成员变量一个合适的初始值。
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
- 上面这个Date类是我们之前写过的,这里有一个它的有参构造函数,虽然在这个构造函数调用之后,对象中已经有了一个初始值,但是不能将其称为对对象中成员变量的初始化。构造函数体中的语句只能将其称为【赋初值】,而不能称作初始化。因为初始化只能初始化一次,而构造函数体内可以多次赋值。
语法格式及使用
【初始化列表】:以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个"成员变量"后面跟一个放在括号中的初始值或表达式
- 下面就是它的具体用法,这样便可以通过外界传入一些参数对年、月、日进行初始化
class Date
{
public:
Date(int year, int month, int day)
:_year(year)
, _month(month)
, _day(day)
{}
private:
int _year;
int _month;
int _day;
};
int main(void)
{
Date d(2023, 3, 30);
return 0;
}
可以通过调试来观察一下它到底是怎么走的
接下去我再来说说这一块的难点所在,准备好头脑风暴🌊
- 还是看回到我们上面的这个类A,知道了【初始化列表】这个东西,此时就不需要再声明的部分给缺省值了,直接使用初始化列表即可。不过可以看到,对于
_a1
和_a2
我给到了缺省值,写了初始化列表后,它们还会被初始化吗?
class A {
public:
A()
:_x(1)
{}
private:
int _a1 = 1; //声明
int _a2 = 1;
const int _x;
};
也通过调试来看一下
-
可以看到,即使在初始化列表没有给到
_a1
和_a2
的初始化,还是会通过给到的默认缺省值去进行一个初始化。根据上面所学,我给出以下的结论- 哪个对象调用构造函数,初始化列表是它所有成员变量定义的位置
- 不管是否显式在初始化列表写,编译器都会为每个变量在初始化列表进行初始化
好,接下去难度升级,请问初始化列表修改成这样后三个成员变量初始化后的结果会是什么呢? 会是1、2、1吗?
class A {
public:
A()
:_x(1)
,_a2(1)
{}
private:
int _a1 = 1; //声明
int _a2 = 2;
const int _x;
};
一样通过调试来看看
- 可以观察到,最后初始化完后的结果为1、1、1,最令你困惑的应该就是这个
_a2
了,因为我在声明的时候给到了缺省值,然后初始化列表去进行定义的时候又去进行了一次初始化,最后的结果以初始化列表的方式为主 - 这里要明确的一个概念是,缺省参数只是一个备份,若是我们没有去给到值初始化的话,编译器就会使用这个初始值,若是我们自己给到了明确的值的话,不会去使用这个缺省值了【如果不清楚看看C++缺省参数】
接下去难度继续升级,请问下面这样初始化后的结果是多少?
- 可以看到对于构造函数我不仅写了【初始化列表】,而且在函数体内部还对
_a1
和_a2
进行了++和- -,那此时会有什么变化呢?
class A {
public:
A()
:_x(1)
,_a2(1)
{
_a1++;
_a2--;
}
private:
int _a1 = 1; //声明
int _a2 = 2;
const int _x;
};
如果对于上面的原理搞清楚了,那看这个就相当于是再巩固了一遍。也是一样,无论是否给到缺省值都会去初始化列表走一遍,若是构造函数内部有语句的话就会执行
注意事项
清楚了初始化列表该如何使用,接下去我们来说说其相关的注意事项
- 每个成员变量在初始化列表中只能出现一次(初始化只能初始化一次)
- 可以看到,若是一个成员变量在初始化列表的地方出现了两次,编译器在编译的时候就会报出【xxx已初始化】
- 类中包含以下成员,必须放在初始化列表位置进行初始化:
- const成员变量
- 这个在前面已经说到过了,
const
修饰的成员变量和构造函数对于内置类型不做处理产生了一个冲突,因此祖师爷就提出了【初始化列表】这个概念
- 这个在前面已经说到过了,
- 引用成员变量
- 第二点就是对于引用成员变量,如果有点忘记了看看C++引用
- 通过编译可以看出,这个引用型成员变量
_z
需要被初始化,它必须要引用一个值
- 没有默认构造的自定义类型成员(写了有参构造编译器就不会提供默认构造)
- 此时,我又写了一个类B,将它定义出的对象作为类A的成员变量,在类B中,有一个无参的默认构造,也写了相关的初始化列表去初始化
_b
- 此时,我又写了一个类B,将它定义出的对象作为类A的成员变量,在类B中,有一个无参的默认构造,也写了相关的初始化列表去初始化
class B {
public:
B()
:_b(0)
{}
private:
int _b;
};
class A {
public:
A()
:_x(1)
,_a1(3)
,_a2(1)
,_z(_a1)
{
_a1++;
_a2--;
}
private:
int _a1 = 1; //声明
int _a2 = 2;
const int _x;
int& _z;
B _bb;
};
- 通过调试来观察就可以看到,完全符合我们前面所学的知识,若是当前类中有自定义类型的成员变量,那在为其进行初始化的时候会去调用它的默认构造函数
- 但是现在我对这个构造函数做了一些改动,将其变为了有参的构造函数,此时编译时就报出了【没有合适的默认构造函数可用】
- 我们知道默认构造有:无参、全缺省和编译器自动生成的,都是不需要我们手动去调的。可以看到若是我在这里将其改为全缺省的话,就不会出问题了,因为它属于默认构造函数
💬那对于有参构造该如何去初始化呢?
- 还是可以利用到我们的【初始化列表】
通过调试来看看编译器是如何走的
- 尽量使用初始化列表初始化,因为不管你是否使用初始化列表,对于自定义类型成员变量,一定会先使用初始化列表初始化
看完了上面这一种,我们再来看看稍微复杂一些的自定义类型是否也遵循这个规则
- 也就是我们之前写过的Stack和MyQueue类
typedef int DataType;
class Stack
{
public:
Stack(size_t capacity = 10) //全缺省构造
{
cout << "Stack()构造函数调用" << endl;
_array = (DataType*)malloc(capacity * sizeof(DataType));
if (nullptr == _array)
{
perror("malloc申请空间失败");
return;
}
_size = 0;
_capacity = capacity;
}
//....
private:
DataType* _array;
size_t _size;
size_t _capacity;
};
- 此处我们主要观察Stack类的构造函数,因为在MyQueue中我没有写构造函数,为的就是使用它默认生成的构造函数去进行初始化。对于【内置类型】不做处理,不过我这里给到了一个缺省值,对于【自定义类型】会去调用它的默认构造
class MyQueue{
public:
//默认生成构造函数
private:
Stack _pushST;
Stack _popST;
size_t _t = 1;
};
int main(void)
{
MyQueue mq;
return 0;
}
可能读者有所忘却,我们再通过调试来看一下
- 可以观察到在初始化MyQueue类的对象时,因为内部有两个Stack类型的对象,所以就会去调用两次Stack类默认构造来进行初始化
- 那此时我若是将这个默认构造(全缺省构造)改为有参构造吗,它还调得动吗?
Stack(size_t capacity)
- 可以看到,此时就报出了我们上面有类似遇到过的【无法引用默认构造函数】,为什么呢?原因就在于我们写了,编译器自动生成的也就不存在了,但是我又没有传入对应的参数
- 此时就可以使用到我们本模块所学习的【初始化列表】了,将需要定义的值放在初始化列表,相当于就是为Stack类传入了一个有参构造的参数,不过对于没有写在这里的
_t
,依旧会使用我给到的初始值1
MyQueue()
:_pushST(10)
,_popST(10)
{}
可以通过调试再来看看
- 当然,如果你觉得不想要这个固定的10作为栈容量的话,也可以将这个MyQueue的构造函数设定为有参,自己传递进去也是可以的
- 最后再来看一下无参构造,也是默认构造的一种,在这里编译器也会去走MyQueue的初始化列表进行初始化
//无参构造
MyQueue()
{}
所以可以看出,对于【内置类型】不做处理,【自定义类型】会调用它的默认构造可以看出其实就是当前类构造函数的初始化列表在起作用
在看了MyQueue类各种初始化列表的方式后,其实也可以总结出一点,无论如何不管有没有给到缺省值,只要是显式地写了一个构造函数,就可以通过调试去看出编译器都会通过【初始化列表】去进行一个初始化
- 成员变量在类中声明次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后次序无关
- 最后再来看第四点,你认为下面这段代码最后打印的结果会是多少呢?1 1 吗?
class A
{
public:
A(int a)
:_a1(a)
,_a2(_a1)
{}
void Print() {
cout<<_a1<<" "<<_a2<<endl;
}
private:
int _a2;
int _a1;
};
int main() {
A aa(1);
aa.Print();
}
但结果却和我们想象的不一样,_a1
是1,_a2
却是一个随机值,这是为什么呢?
- 通过调试可以发现,似乎是先初始化的
_a2
再去初始化的_a1
,对于【内置类型】我们可以知道是编译器是不会去进行初始化的,那若是一开始使用_a1
去初始化_a2
的时候,那_a2
就会是一个随机值,但是_a1
却使用传入进来的形参a进行了初始化,那它的值就是1
- 此时我们只需要让
_a1
先进行初始化即可,就不会造成随机值的现象了
现在你在翻上去把所有的调试图一幅幅看下来就可以发现出丝滑列表是存在顺序的,它的顺序不是在列表中谁先谁后的顺序,而是类的成员变量声明的顺序
2、explict关键字
👉构造函数不仅可以构造与初始化对象,对于单个参数或者除第一个参数无默认值其余均有默认值的构造函数,还具有类型转换的作用
单参构造函数
- 还是老朋友,我们通过下面这个日期类进行讲解
class Date
{
public:
Date(int year)
:_year(year)
{}
private:
int _year;
int _month = 3;
int _day = 31;
};
- 对于下面的d1很清楚一定是调用了有参构造进行初始化,不过对于d2来说,也是一种构造方式
int main()
{
Date d1(2022);
Date d2 = 2023;
return 0;
}
依旧通过调试来看就会非常清晰,这种写法也会去调用构造函数
- 在操作符章节,我有提到过【隐式类型转换】这个概念,像下面将一个int类型的数值赋值给到一个double类型的数据,此时就会产生一个隐式类型转换
int i = 1;
double d = i;
- 对于类型转换而言,我在C++引用一文中也有提到过,这里并不是将值直接赋值给到左边的对象,而是在中间呢会产生一个临时变量,例如右边的这个
i
会先去构造一个临时变量,这个临时变量的类型是[double]
。把它里面的值初始化为1,然后再通过这个临时对象进行拷贝构造给d
,这就是编译器会做的一件事 - 那对于这个d2其实也是一样,2023会先去构造一个临时对象,这个临时对象的类型是
[Date]
把它里面的year初始化为2023,然后再通过这个临时对象进行拷贝构造给到d2
,
💬小陈:不是说构造函数有初始化列表吗?拷贝构造怎么去初始化呢?
//拷贝构造
Date(const Date& d)
:_year(d._year)
,_month(d._month)
,_day(d._day)
{}
- 同学,别忘了【拷贝构造】也是属于构造函数的一种哦,也是会有初始化列表的
刚才说到了中间会产生一个临时对象,而且会调用构造 + 拷贝构造,那此时我们在Date类中写一个拷贝构造函数,调试再去看看会不会去进行调用
- 很明显没有,我在进入Date类后一直在按F11,但是却进不到拷贝构造中,这是为什么呢?
- 原因其实在于编译器在这里地方做了一个优化,将【构造 + 拷贝构造】优化成了【一个构造】,因为编译器在这里觉得构造再加拷贝构造太费事了,干脆就合二为一了。其实对于这里的优化不同编译器是有区别的,像一下VC++、DevC++可能就不会去优化,越是新的编译器越可能去进行这种优化。在本文的最后一个模块我还会详细展开分析
💬小叶:但您是怎么知道中间赋值这一块产生了临时对象呢?如果不清楚编译器的优化机制这一块肯定就会认为这里只有一个构造
- 这点确实是,若是我现在不是直接赋值了,而是去做一个引用,此时会发生什么呢?
Date& d3 = 2024;
- 可以看到,报出了一个错误,原因就在于d3是一个Date类型,2024则是一个内置类型的数据
- 但若是我在前面加一个
const
做修饰后,就不会出现问题了,这是为什么呢?
- 其实这里的真正原因就在于产生的这个【临时变量】,它就是通过Date类的构造函数构造出来的,同类型之间可以做引用。还有一点就是临时变量具有常性,所以给到一个
const
类型修饰对象不会有问题
但若是你不想让这种隐式类型转换发生怎么办呢?此时就可以使用到C++中的一个关键字叫做
explicit
- 它加在构造函数的前面进行修饰,有了它就不会发生上面的这一系列事儿了,它会【禁止类型转换】
explicit Date(int year)
:_year(year)
{}
多参构造函数
对于上面所讲的都是基于单参的构造函数,接下去我们来瞧瞧多参的构造函数
//多参构造函数
Date(int year, int month ,int day = 31)
:_year(year)
,_month(month)
,_day(day)
{}
- 根据从右往左缺省的规则,我们在初始化构造的时候要给到2个参数,
d1
没有问题传入了两个参数,但是若是像上面那样沿袭单参构造函数这么去初始化还行得通吗?很明显不行,编译器报出了错误
💬小冯:那要怎么办呀,对于一定要传入多参数进行构造的场景
- 这个时候就要使用到我们C++11中的新特性了,在对多参构造进行初始化的时候在外面加上一个
{}
就可以了,可能你觉得这种写法像是C语言里面结构体的初始化,但实际不是,而是在调用多参构造函数
Date d2 = { 2023, 3 };
- 不仅如此,对于下面这种也同样适用,调用构造去产生一个临时对象
const Date& d3 = { 2024, 4 };
那要如何去防止这样的隐式类型转换发生呢,还是可以使用到
explicit
关键字吗?
//多参构造函数
explicit Date(int year, int month ,int day = 31)
:_year(year)
,_month(month)
,_day(day)
{}
- 可以看到,加上
explicit
关键字做修饰,同样可以起到【禁止类型转换】的作用
- 还有一种例外,当缺省参数从右往左给到两个的时候,此时只需要传入一个实参即可,那也就相当于是单参构造
explicit
关键字依旧可以起到作用·
explicit Date(int year, int month = 3,int day = 31)
:_year(year)
,_month(month)
,_day(day)
{}
- 所以对于可读性不是很好的代码,可以使用
explicit
修饰构造函数,将会禁止构造函数的隐式转换
二、static成员
【概念】:声明为static的类成员称为类的静态成员,用static修饰的成员变量,称之为静态成员变量;用static修饰的成员函数,称之为静态成员函数。静态成员变量一定要在类外进行初始化
1、面试题引入
💬面试题:实现一个类,计算程序中创建出了多少个类对象
- 上面这个是曾经一家公司的面试题,要你用一个类去计算创建出多少个对象。分析一下可以知道我们去实例化出一个对象的时候,无非是调用构造或者是拷贝构造,或者是通过一些传参返回的方式去构造对象
- 那第一时间就会想到在全局定义一个
count
,然后在可能产生构造的地方进行累加
int count = 0;
class A {
A()
{
count++;
}
A(const A& a)
{
count++;
}
};
void func(A a)
{
count++;
}
int main(void)
{
A aa1;
A aa2(aa1);
func(aa1);
return 0;
}
但是编译后可以发现,count++
的地方都出现了报错,说是不明确的问题
- 但是你仔细去看输出窗口的话可以发现其实这个
count
是和【std库】中的count发生了冲突
那这个时候该怎么办呢?
💬有同学说:这还不简单,不要写成count不就可以了,改成Count
都可以
- 确实,这不乏是一种直截了当的方法。但是同学,我们现在学习的是C++而不是C语言,改成大写的这种方式是C语言思路
- 还记得我们在细谈namespace命名空间说到的部分展开命名空间吗?和std库里发生冲突就是因为
using namespace std;
展开了std的命名空间,在这里我们只需要部分展开即可
using std::cout;
using std::endl;
此时通过调试再去观察就可以看出创建了多少个对象
- 但这个时候呢,我手比较欠缺,对这个
count
又去++了几下,此时最后的结果就出现问题了
2、static特性细述
可以看到对于上面这种问题在C语言中是无法避免的,因为count是一个全局变量,那么它的生命周期就是从定义开始到main函数结束的时候销毁,这任何地方都是可以访问到的,并且它还不具有常性可以做任意修改,这其实也就缺乏了一定的安全性
- 于是呢,在C++中就引入了这样一个东西,把count作为类的成员函数
class A {
public:
A(int a = 0)
{
count++;
}
A(const A& a)
{
count++;
}
private:
int count = 0;
};
- 但是对于这个count而言还是属于某个对象的,但我们若要去统计的话它一定不是专属于某个对象的 ,而是要属于所有对象。此时我们就可以用
static
来修饰一下这个成员变量
static int count = 0;
- 此时这个count便被包裹在了这个类中,我们知道类也是一个作用域,可以起到隔绝外界的作用,那么此时我们的count就不会和库里面的count冲突了,直接展开std命名空间也不会存在问题
- static其实我们在C语言中也有学到过,若一个变量被
static
修饰的话,它就不会放到在【栈区】了,而是在【静态区中】
此时就引出了static的第一条特性如下👇
- 静态成员为所有类对象所共享,不属于某个具体的对象,存放在静态区
但此刻我为其设定缺省值的时候却报出了这样的错误,似乎不能在这里为它初始化呢?
- 上面刚学习了构造函数的【初始化列表】,在类内直接定义是因为缺省值是给到构造函数的初始化列表用的,初始化列表初始化的是非静态成员,是属于当前对象的;而静态成员是属于所有对象的,是共有的
- 所以我们考虑把它放到全局去进行一个定义,但是出了当前作用域又无法访问,此时就可以使用我们学习过的域作用限定符
::
int A::count = 0;
那么就引出了static
的第二条特性👇
- 静态成员变量必须在类外定义,定义时不添加static关键字,类中只是声明
那此时我若是要在外界去访问一下这个静态成员呢?能不能做到
- 可以看到,直接打印访问是不可以的,因为需要域作用限定符
::
- 不过呢,加上域作用限定符
::
又说它是私有的无法访问
那么就引出了static
的第三条特性👇
- 静态成员也是类的成员,受public、protected、private 访问限定符的限制
那要怎么去访问呢?这里有两种方式
- 将count改为公有的之后就可以通过下面这两种方式去访问类中的静态成员变量
cout << A::count << endl;
cout << aa1.count << endl;
cout << aa2.count << endl;
【拓展一下】
- 对于这种方式也是可以的,如果你有认真看过详解类封装中的this指针就可以知道下面这种做法就是为当前的this指针传递了一个空的地址,虽然我们看到了
->
,但其实并没有去产生一个解引用,因为count是一个静态成员变量,虽然形式上放在类内,但上面说过了它是存放在内存中的静态区,所以无法用this指针访问到这个count
A* aa3 = nullptr;
aa3->count;
那么就引出了static
的第四条特性👇
- 类静态成员即可用
[类名::静态成员]
或者[对象.静态成员]
这些指令在代码段
上面这样都可以访问是因为我将静态变量count设置成了公有,若一旦设置为私有的话,上面这些访问形式就都是非法的了
- 此时我们可以在类中写一个对外公开的接口,那么外界就可以通过这个接口访问到类内的私有成员变量了,在Java里面很喜欢这么去写,不过在C++中一般很少这样,这点我在下面的【友元】会细述
int GetCount()
{
return count;
}
可以看到成员函数都是用对象去调的,那我现在假设我没有对象呢【博主确实没有🐶】。此时还有办法获取到类内的静态成员变量吗?
- 此时就要使用到一个静态成员变量的双胞胎兄弟 —— 【静态成员函数】
- 只需要在GetCount()成员函数前加一个
static
作为修饰即可
static int GetCount()
{
return count;
}
- 在外界还是使用域作用限定符
::
便可以访问到,可以说这个【静态成员函数】是专门为静态成员变量而生的
看来这个静态成员函数还挺厉害的,若是我现在类中又新增了一个普通成员变量,可以在里面进行访问吗?
通过运行可以看出似乎是不可以
那么就引出了static
的第五条特性👇
- 静态成员函数没有隐藏的this指针,不能访问任何非静态成员
- 这其实很清楚,因为静态成员函数在静态区没有this指针,但普通的成员变量都是属于当前对象的,需要通过this指针来访问
3、疑难解惑
学习完上了上面这五条特性之后,来回答一下下面这三个问题吧
💬静态成员函数可以调用非静态成员函数吗?
- 这个当然也是不可以的,记住一点!在静态成员函数内部只能调用静态的成员变量或者函数
可以看到静态成员函数也是调用静态成员函数的
💬非静态成员函数可以调用类的静态成员函数吗?
- 这个是可以的,因为静态成员函数是公共的,公共的大家当然都可以用。包括像count也是可以使用的,这里不做演示
💬请问静态成员函数存放在哪里,静态区还是公共代码区?
- 答案揭晓,静态成员函数是存放在公共代码段的,我们在类和对象的封装思想中有谈到过,在一个类中对于成员变量而言和对象一起存放在【栈区】,但对于类内的成员函数而言,则不属于类,而是存放在【公共代码区】,那对于静态成员函数来说也是属于成员函数的一种,在编译过后都会被解析成指令,这些指令都是存放在公共代码区的
- 而对于静态成员变量来说是存放在静态区的,若是你去计算一下类的大小其实就一目了然了
4、在线OJ实训
在学习了C++中的静态成员相关知识后,我们通过一道在线OJ来练练手
链接:牛客JZ64 求1+2+3+…+n
1. 题目描述
2. 思路分析
- 来分析一下这道题该怎么去做,题目本身要求的意思很简单,就是求解1 ~ n的阶乘,但是限制条件有很多,例如:不能用分支判断、循环、条件判断、乘除法等等,这就令很多同学抓耳挠腮了,这要怎么去写呀?
💬有同学说:这还不简单,用个递归呗
- 递归是我们求解阶乘以及前n项和最常见的,不过既然是递归,那一定存在递归出口,那你肯定得去用if条件判断一下是否到底了递归出口吧
在同学们冥思苦想后,有一位同学提到了我们刚学习的static成员,那我们就用它来试试💪
- 首先要考虑清楚,此处是有两个变量在一直递增的,一个是1 ~ n这些数,一个则是累加和,不过它们都是上一轮的基础上去进行一个递增,我们知道对于静态成员变量只会在定义的时候初始化一次,后面每一次的值都是在上一次的基础上变化的
static int sum;
static int i;
- 所以此刻便可以将这个递增的变量设置为
i
,将累加和设置为sum
,它们均为静态变量,根据我们上面所学知识要将其在类的外部进行定义初始化
int Count::sum = 0;
int Count::i = 1;
- 可以把这个累加的逻辑放在类的构造函数中,然后考虑去实例化具有n个大小的对象数组,那么就会调用n次构造函数去进行一个累加
Count()
{
sum += i;
i++;
}
Count c[n];
- 最后在类的内部提供的一个静态成员函数,外界便可以通过它来获取到静态成员变量
sum
static int GetSum()
{
return sum;
}
return Count::GetSum();
3. 代码展示
最后展示一下整体代码和运行结果
class Count{
public:
Count()
{
sum += i;
i++;
}
static int GetSum()
{
return sum;
}
private:
static int sum;
static int i;
};
int Count::sum = 0;
int Count::i = 1;
class Solution {
public:
int Sum_Solution(int n) {
Count c[n];
return Count::GetSum();
}
};
5、有关static修饰变量的一些注意要点
说完static修饰成员变量和成员函数,这里再来补充一点有关static修饰变量的注意点,我们主要通过题目来进行讲解
- 有一个类A,其数据成员如下: 则构造函数中,成员变量一定要通过初始化列表来初始化的是:( )
class A {
private:
int a;
public:
const int b;
float* &c;
static const char* d;
static double* e;
};
A.a b c
B.b c
C.b c d e
D.b c d
E.b
F.c
【答案】:B
【解析】:
对初始化列表不了解的可以先看看C++ | 谈谈构造函数的初始化列表
- 对于【const成员变量】、【引用成员变量】、【无默认构造函数的自定义类型】都是必须通过初始化列表进行初始化的,因此
b
、c
很明确要选上,对于d
而言,虽然它有const修饰,但前面又有[static]
作为修饰,所以是一个静态成员函数,不属于类,存放在静态区中,当程序开始执行的时候就被初始化了,对于e
而言也是同理,所以答案选择B
- 设已经有A,B,C,D4个类的定义,程序中A,B,C,D析构函数调用顺序为?( )
C c;
int main()
{
A a;
B b;
static D d;
return 0;
}
A.D B A C
B.B A D C
C.C D B A
D.A B D C
【答案】:B
【解析】:
这题的话通过调试来看一下就很清楚了,主要是观察static
的修饰对局部变量的作用域和生命周期的更改
- 可以观察到,因为对象d有static修饰,它的生命周期发生了变化,本来应该是最早析构的,却等到了b和a析构完了之后它才去析构,所以生命周期发生了延长,但还是比最先定义出来的对象d先析构,因为d后于c被实例化出来
- 虽然它的生命周期发生了变化,但是作用域却没有发生改变,从下图可以看出在fun()函数中访问不到main函数中定义的对象d
-
在一个cpp文件里面,定义了一个static类型的全局变量,下面一个正确的描述是:( )
A. 只能在该cpp所在的编译模块中使用该变量
B. 该变量的值是不可改变的
C. 该变量不能在类的成员函数中引用
D. 这种变量只能是基本类型(如int,char)不能是C++类类型
【答案】:A
【分析】:
- 首先那来看一下本题的代码,定义了一个静态的全局变量c,还有两个类,class A 和 class B
static int c = 1;
class A {
public:
A()
{
cout << "A的构造函数" << endl;
}
void func()
{
cout << "A的成员函数" << endl;
cout << c << endl;
}
~A()
{
cout << "A析构函数" << endl;
}
private:
int _a = 1;
};
class B {
public:
B()
{
cout << "B的构造函数" << endl;
}
void func()
{
cout << "B的成员函数" << endl;
}
~B()
{
cout << "B析构函数" << endl;
}
private:
int _b = 1;
};
- 然后通过一些测试看到,静态全局变量是可以更改的,而且可以在类的成员函数中进行调用,并且看大这个
static B b
就可以知道其也可以为自定义类型即C++的类类型
- 但是对于A选项确实就是这样,涉及到一些链接属性的解析,可以看看这篇文章
【总结一下】:
- 做了上面的三题,我们来总结一下:
- 对于
static
修饰的成员变量,存放在静态区,而不在栈区,是不属于当前类的,因此需要在类外初始化 - 对于
static
修饰的局部变量,其生命周期会延长,但作用域不会发生变化 - 对于
static
修饰的全局变量,只在当前编译模块中(即当前文件内)生效,其他文件不可访问。因此其作用域发生了变化,但是生命周期没有变化(从定义到结束都不会被释放)
- 对于
三、匿名对象
顺着上面这道OJ题,顺带讲一个语法,就是这个【匿名对象】
- 把上面这段代码放到VS上来进行讲说,不过有一点VS上是会报错的,也就是这个
Count c[n]
,这是C99里面的变长数组,在VS上是不支持的,语言可以提出规范,但是编译器支不支持是编译器自己的选择 - 可能是微软在VS里面编译器这一块的开发,即使是比较新一点的VS都是不支持C99标准的,之前在C语言中也有带大家看过,不过Linux下gcc是支持C99标准的,读者有兴趣可以自己去试一试,这里不做演示
int Sum_Solution(int n) {
//Count c[n];
Count* c = new Count[n];
return Count::GetSum();
}
- 把OJ上的题目放到VS里调试,对类来说就是实例化出它的一个对象,然后用对象去调用我们刚才实现的主接口函数即可,接收一下返回值【LeetCode转VS调试技巧】
int main(void)
{
Solution s;
int ret = s.Sum_Solution(100);
cout << ret << endl;
return 0;
}
但是你觉得为了调用一个函数而去定义一个对象,不觉得浪费资源吗?如果可以不实例化对象就调用函数该多好O(∩_∩)O
- 这里就可以使用到本模块所要介绍的【匿名对象】了,
【语法格式】:类名()
int ret = Solution().Sum_Solution(100);
- 还有一个,像下面这种
A(n)
返回的其实也是一个匿名对象,没有实例化出一个对象名
A func(int n)
{
return A(n);
}
除了使用方法,还有一点要牢记的是:匿名对象的声明周期只有一行
- 普通对象析构
2. 匿名对象析构
- 通过观察便可以发现,对于普通对象来说,是在当前main函数即将销毁的时候才去调用的析构函数析构实例化出的对象;但是对于匿名对象而言,则是在调用函数结束后便会去调用其析构函数
四、友元
接下去我们来聊聊有关C++中友元的一些知识
温馨提示:友元提供了一种突破封装的方式,有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不宜多用。
1、友元函数
对于友元函数来说,我在Date日期类综合实战中有讲起过
- 因为cout的输出流对象和隐含的this指针在抢占第一个参数的位置。this指针默认是第一个参数也就是左操作
数了。但是实际使用中cout需要是第一个形参对象,才能正常使用。所以要将operator<<
重载成全局函数。但又会导致类外没办法访问成员,此时就需要友元来解决。operator>>
同理 - 友元函数可以直接访问类的私有成员,它是定义在类外部的普通函数,不属于任何类,但需要在类的内部声明,声明时需要加
friend
关键字
class Date
{
friend ostream& operator<<(ostream& _cout, const Date& d);
friend istream& operator>>(istream& _cin, Date& d);
public:
Date(int year = 1900, int month = 1, int day = 1)
: _year(year)
, _month(month)
, _day(day)
{}
private:
int _year;
int _month;
int _day;
};
ostream& operator<<(ostream& _cout, const Date& d)
{
_cout << d._year << "-" << d._month << "-" << d._day;
return _cout;
}
istream& operator>>(istream& _cin, Date& d)
{
_cin >> d._year;
_cin >> d._month;
_cin >> d._day;
return _cin;
}
int main()
{
Date d;
cin >> d;
cout << d << endl;
return 0;
}
以下是它的一些特性【了解一下即可,友元这一块其实用太多不好】
- 友元函数可访问类的私有和保护成员,但不是类的成员函数
- 友元函数不能用const修饰
- 因为友元函数只是一个全局函数,不属于类的成员函数,所以它没有隐藏的this指针,而
const
修饰的就是this指针,只有非静态的成员函数才能用const修饰
- 因为友元函数只是一个全局函数,不属于类的成员函数,所以它没有隐藏的this指针,而
- 友元函数可以在类定义的任何地方声明,不受类访问限定符限制
- 一个函数可以是多个类的友元函数
- 比如说一个函数需要访问多个类中的私有成员,那可以在那几个类中设置这个函数为他们的友元函数,这样就都可以访问了
- 友元函数的调用与普通函数的调用原理相同
2、友元类
除了友元函数以外,还有一个东西叫做友元类,也就是一个类也可以是另一个类的友元
- 例如下面有一个Tiime和Date类,在Date类中呢有一个Time类的对象,然后在
SetTimeOfDate
的这个函数中初始化Time类中的三个成员变量,可是呢_hour
、_minite
、_second
是属于Time类中私有的成员变量,那Date类要如何访问到呢? - 此时就可以使用【友元类】了,
friend class Date
表示在Time类中声明Date类是我的友元类,它可以访问我的私有成员变量
class Time
{
friend class Date; // 声明日期类为时间类的友元类,则在日期类中就直接访问Time类中的私有成员变量
public:
Time(int hour = 0, int minute = 0, int second = 0)
: _hour(hour)
, _minute(minute)
, _second(second)
{}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
: _year(year)
, _month(month)
, _day(day)
{}
void SetTimeOfDate(int hour, int minute, int second)
{
// 直接访问时间类私有的成员变量
_t._hour = hour;
_t._minute = minute;
_t._second = second;
}
private:
int _year;
int _month;
int _day;
Time _t;
};
然后来说说友元类的一些特性
- 友元关系是单向的,不具有交换性
- 【单向】这个词很重要,一样还是上面的Date类,因为在Time类中声明了其为它的友元类,但是在Date类中没有声明Time为它的友元类,所以Time是无法访问到Date类中私有成员的
- 若是我在Date类中声明Time类为它的友元类,此时访问就不会受限了
可能以上面这样去理解太枯燥了,我通过几个现实生活中的小案例来
- 对于友元类的这个【单向性】你可以看作是微博粉丝的关系,我是你的粉丝,我可以看你的内容,但是你不能看我的内容
- 读者大部分应该都是大学生,也可能会谈恋爱,我们知道在谈恋爱的时候可能会有一方出现【单相思】,也就是我喜欢你,但是你不喜欢我,这也是一种单向性
- 就像是舔狗一样,一个男生很喜欢一个女生,但是那个女生呢不喜欢他,所以只好当舔狗🐶
好,回归正题,我们继续来说友元类的一些特性
- 友元关系不能传递
- 这个很简单,比方说如果C是B的友元, B是A的友元,则不能说明C时A的友元
- 小C是小B的朋友,小A是小B的朋友,那可不能说明小C是小A的朋友哦🙅
- 友元关系不能继承
- 这一块我放到类的继承中细述
以上就是关于友元的一些小知识,了解一下即可,这个东西用是可以用,但是在一开始说了,友元会破坏类的封装性,因为C++使用类将成员变量和成员函数封装起来,就是为了不让外界轻易访问,但若是设置了友元的话就可以访问了,在我看来这其实是比较荒谬的
六、内部类
接下去我们来讲讲一个东西叫做【内部类】
1、概念引入
【概念】:如果一个类定义在另一个类的内部,这个内部类就叫做内部类。内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象去访问内部类的成员。外部类对内部类没有任何优越的访问权限
- 看到下面的类A,有一个成员变量
_a
,其内部还有一个类B,这个类B就叫做内部类
class A
{
private:
static int k;
int h;
public:
class B // B天生就是A的友元
{
private:
int _b;
public:
void foo(const A& a)
{
cout << k << endl; //OK
cout << a.h << endl; //OK
}
};
};
注意:内部类就是外部类的友元类,参见友元类的定义,内部类可以通过外部类的对象参数来访问外部类中的所有成员。但是外部类不是内部类的友元
- 不过有一点要额外提醒的是因为类B包在类A里面,所以要去实例化类B的对象时会受到类域的限制,此时就可以使用到之前所学的域作用限定符
::
2、特性讲解
接下去再来说说它的特性
- 内部类可以定义在外部类的public、protected、private都是可以的
- 注意内部类可以直接访问外部类中的static成员,不需要外部类的对象/类名
cout << a.h << endl;
- sizeof(外部类)=外部类,和内部类没有任何关系
- 这点尤其要注意,对于内部类来说其实和在外部直接定义一个类是一样的,不像C语言里面的结构体那样,若是在内嵌一个结构体的话
sizeof()
的大小就会不一样了
举个例子帮助理解🏠
- 可以把内部类想想成为一个别墅小区中的个人别墅,而外部类就是这个小区。每栋别墅都是别人的私人领地,没有经过允许时不可以私自闯入的。不过小区里面的业主却可以使用小区内的各种公共设施。
- 对应的就是内部类天生就是外部类的友元,可以访问外部类的私有成员,但是外部类却不可以访问内部类中的成员
3、OJ题优化
- 对于上面在static成员中讲到的一道OJ题,其实可以把这个Count类放到Solution内部作为其内部类,而且放到private里表示这个内部类只有我能访问,其他外部的类统统不可以访问
class Solution {
private:
//作为Solution的内部类
class Count
{
public:
Count()
{
sum += i;
i++;
}
static int GetSum() {
return sum;
}
private:
static int sum;
static int i;
};
public:
int Sum_Solution(int n)
{
Count c[n];
return Count::GetSum();
}
};
- 当然对于静态成员变量这一块的初始化就要再加上一层Solution域的访问
int Solution::Count::sum = 0;
int Solution::Count::i = 1;
不过这样修改其实还不够优,可以通过我们上面说到过的,内部类天生就是外部类的友元这一特点,去再进行一度优化
- 将两个静态成员变量直接作为Solution类的成员变量,因为Count可以访问到外部类中的私有成员变量,所以不需要加
::
- 而且在最后return返回结果的时候,直接
return sum
即可,因为这就是Solution自身的成员变量,它当然可以访问,都不需要内部类向外提供静态成员函数
class Solution {
private:
//作为Solution的内部类
class Count
{
public:
Count()
{
sum += i;
i++;
}
};
static int sum;
static int i;
public:
int Sum_Solution(int n)
{
Count c[n];
return sum;
}
};
int Solution::sum = 0;
int Solution::i = 1;
七、拷贝对象时的一些编译器优化
在传参和传返回值的过程中,一般编译器会做一些优化,减少对象的拷贝,这个在一些场景下还是非常有用的
【在看本模块之前若是有读者还不了解拷贝构造机制的先去看看】
- 在上面讲
explicit
关键字的时候,我有提到下面这种写法会引发【隐式类型转换】,而且还画了对应的图示,中间会通过1构造产生一个A类型的临时对象,然后用再去调用拷贝构造完成拷贝。 - 不过这一块编译器做了一个优化,将【构造 + 拷贝构造】直接转换为【构造】的形式,本模块我们就这一块来进行一个拓展延伸,讲一讲编译器在拷贝对象时期的一些优化
A aa1 = 1;
1、传值传参
首先来看到的场景是【传值传参】,对象均是使用上面
aa1
//传值传参
void func1(A aa)
{
}
💬请你思考一下这种形式编译器还会像上面那样去优化拷贝构造吗
func1(aa1);
- 可以看到,还是发生了拷贝构造,为什么呢?因为对于这种形式编译器不会去随意优化,只有像上面在一个表达式内才会去进行一个优化。【这里的析构是针对函数内部的形参
aa
,main函数中的aa1
也会析构,不做展示】
💬那如果直接传入一个3呢,会做优化吗?
func1(3);
- 可以看到,若是直接传入3的话,就不会去调用拷贝构造了,这个其实和一开始我们讲得那个差不多,把构造 + 拷贝构造优化成了直接构造,【一开始的构造不用理他,为了构造出
aa1
对象】
💬接下去我传入一个A(3)
,会发生什么呢?
func1(A(3));
- 通过观察可以发现,和上面那个是一样的,其实读者去演算一下就可以很清楚,
A(3)
就是一个很明显的有参构造,实例化出一个对象后那就是拷贝构造,但是这里因为编译器的优化,所以直接变成了一个构造
2、传引用传参
接下去来看到的场景是【传引用传参】,传入的值还是上面的这三种,只是会通过传引用来接收
- 之前的文章里有说过为什么在传参的时候最好使用【传引用传参】,原因就是在于可以减少拷贝,提高程序运行效率
//传引用传参
void func2(const A& aa) //不加const会造成权限放大
{
}
💬那通过引用接收aa1
会发生什么呢?
func2(aa1);
- 通过观察可以发现,无论是【构造】还是【拷贝构造】,都不会去调用,这是为什么呢?
- 原因就在于这里使用的是引用接收,那么形参部分的
aa
就是aa1
的别名,无需构造产生一个新的对象,也不用去拷贝产生一个,直接用形参部分这个就可以了,现在知道引用传参的好处了吧👈
💬那直接传3呢?又会发生什么?
func2(3);
- 观察得到,临时对象还是会去进行构造,不过因为引用接收的缘故,我里面的
aa
就是这个临时对象的别名,所以无需调用拷贝构造,所以也是当回到主函数中才调用析构函数,此时析构的就是这个临时对象 - 这里要顺便提一句的是,因为这个临时对象,临时对象具有常性,所以在拷贝构造的参数中一定要使用
const
做修饰,否则就会造成权限放大
💬那么A(3)
也是和上面同样的道理
func2(A(3));
看完【传值传参】和【传引用传参】,我们来总结一下
- 在为函数传递参数的时候,尽量使用引用传参,可以减少拷贝的工作
3、传值返回
接下去我们来讲讲函数返回时候编译器优化的场景,首先是【传值返回】
//传值返回
A func3()
{
A aa;
return aa;
}
💬若是直接去调用上面这个func3(),会发生什么呢?
func3();
- 这个其实和传值传参的第一个是一样的,因为在函数中对象和返回不是处于同一个表达式,所以编译器不会产生优化,调试结果如下
💬此处在函数调用的地方我使用一个对象去做了接收,那在上面【构造 + 拷贝构造】的基础上就会再多出一个【拷贝构造】,即为【构造 + 拷贝构造 + 拷贝构造】
A aa2 = func3();
- 不过通过调试可以看出,只进行了一次拷贝构造,这里其实就存在编译器对于【拷贝构造】的一个优化工作,将两个拷贝构造优化成了一个拷贝构造
这里可能比较抽象,我画个图来解说一下
- 可以看到,因为这是一个传值返回,所以一定会在构造产生临时对象。第一个是因为
aa
与A
不是同一个表示式,所以不会引发编译器的优化;对于第二个来说,因为又拿了一个A的对象作为接收,所以又会产生一个拷贝构造。在这里编译器就要出手了,它会觉得两个拷贝构造过于麻烦,所以会直接优化成一个
拷贝构造和赋值重载的辨析
- 学习过【拷贝构造】和【赋值重载】的同学应该可以知道,它们的形式很像,若是一个对象还没有被实例化出来,使用
=
便是拷贝构造;若是一个对象已经实例化出来了,使用=
便是赋值重载
A aa2;
aa2 = func3();
- 仔细观察便可以发现,在拷贝构造完成之后又去进行了一次【赋值重载】,那看上面的代码其实就很明显了,那若是一个【拷贝构造】+【赋值重载】的话,编译器其实不会去做一个优化,那这其实相当于干扰了编译器
4、传引用返回【❌】
然后来说说【传引用返回】,不过若是你知道引用返回的一些机制的话,就可以清楚我下面这样其实是错误的,因为
aa
属于局部变量,出了当前作用域会销毁,所以不可以使用传引用返回,具体以下细述
A& func4()
{
A aa;
return aa;
}
💬首先来看下直接调用的结果会是怎样的
func4();
- 可以看到因为传引用返回了,所以就减少了中间的一份临时对象的拷贝,也就没有去调用拷贝构造
💬那我若是用一个返回值去接收的话,此时就可以看出引用返回临时对象的问题了
A aa3 = func4();
- 可以看到,最后在拷贝构造结束后,对象aa3内部的
_a
就是一个随机值
- 若是使用传值返回的话,去观察就可以发现并不是一个随机值
A func4()
5、传匿名对象返回
还记得上面讲到的【匿名对象】吗,也可以使用它返回哦,效率还不低呢!
//匿名对象返回
A func5()
{
return A(); //返回一个A的匿名对象
}
💬先调用一下看看会怎么样
func5();
- 可以看到本质还是传值返回,照理来说会构造出一个临时对象然后在拷贝构造,但是却没有调用拷贝构造,原因就是匿名对象起到的作用,对于
A()
你可以就把它看做是一个表达式,一个【构造】+【拷贝构造】就被优化成了直接构造
💬如果用返回值去接收呢?编译器会优化到何种程度
A aa4 = func5();
- 可以看到,竟然也是只有一个构造。照道理分析来看的话应该是【构造 + 拷贝构造 + 拷贝构造】,不过在匿名对象返回那里已经优化成【直接构造】了,然后再外面的【构造 + 拷贝构造】由引起来编译器的优化,所以最终就只有一个构造了
- 可以看到,最后我还去掉了三次析构函数,第一次就是当然就是
aa4
,第二次是aa3
,第三次便是一开始就有的aa1
了,通过这么调试观察,希望你能真正看懂编译器的思维
而且可以观察到匿名对象返回也不会造成随机值现象,因为本质使用的还是【传值返回】,这里不可以使用【传引用返回】,因为匿名对象构建出来的也是一个临时对象,具有常性,会造成权限放大
6、小结
看完了上面这一些系列拷贝对象时编译器的优化,我们来做一个总结
对象返回总结
- 接收返回值对象,尽量拷贝构造方式接收,不要赋值接收【会干扰编译器优化】
- 函数中返回对象时,尽量返回匿名对象【可以增加编译器优化】
函数传参总结
- 尽量使用
const
+&
传参,减少拷贝的同时防止权限放大
八、再次理解类和对象
现实生活中的实体计算机并不认识,计算机只认识二进制格式的数据。如果想要让计算机认识现实生活中的实体,用户必须通过某种面向对象的语言,对实体进行描述,然后通过编写程序,创建对象后计算机才可以认识。比如想要让计算机认识洗衣机,就需要:
- 用户先要对现实中洗衣机实体进行抽象—即在人为思想层面对洗衣机进行认识,洗衣机有什么属性,有那些功能,即对洗衣机进行抽象认知的一个过程
- 经过1之后,在人的头脑中已经对洗衣机有了一个清醒的认识,只不过此时计算机还不清楚,想要让计算机识别人想象中的洗衣机,就需要人通过某种面相对象的语言(比如:C++、Java、Python等)将洗衣机用类来进行描述,并输入到计算机中
- 经过2之后,在计算机中就有了一个洗衣机类,但是洗衣机类只是站在计算机的角度对洗衣机对象进行描述的,通过洗衣机类,可以实例化出一个个具体的洗衣机对象,此时算机才能洗衣机是什么东西。
- 用户就可以借助计算机中洗衣机对象,来模拟现实中的洗衣机实体了。
举个形象点的案例,这里我在初步讲解类和对象的封装思想中也有提到,也就是外卖系统的设计
- 那么这个外卖系统中就一定存在三个类,一个是商家、一个用户,还有一个就是骑手。那我们知道对于一个类来说可以实例化出很多对象,因为全国各地都有着很多的商家、用户和骑手,当这些对象被实例化出来后,接下来要执行的就是一个匹配算法
- 也就是我在宁波,那我进到外卖平台点单的时候就会通过一些距离向量算法匹配较近一些的店,不会去匹配一些杭州、成都、西安等等的地方,不同的地方有不同的商铺。那除了距离算法之外呢,还有一些匹配算法就可以根据你日常的喜好,你喜欢吃什么?米饭 or 面条。对于用户而言有匹配算法,骑手也会存在匹配算法,肯定是给他匹配一些较近的店铺单子,就在附近几条街内的,这样也能提高效率
- 对于上面这个外卖系统,【后端】的要设计的就是这些类、功能逻辑耦合之间的关系,而距离匹配算法这些内容自然是交给【算法岗】的同学来完成,而外卖界面的设计,用户使用的界面就是交给【UI和前端】来制作
在类和对象阶段,大家一定要体会到,类是对某一类实体(对象)来进行描述的,描述该对象具有那些属性,那些方法,描述完成后就形成了一种新的自定义类型,才用该自定义类型就可以实例化具体的对象
九、总结与提炼
好,最后来总结一下【类和对象 · 提升篇】 所学习的内容📖
- 在文章的起始,我们又谈到了构造函数,在上一文学习了构造函数的基本特性后,这次我们学习的是构造函数中的一个难点,叫做【初始化列表】,面对默认构造函数对于内置类型的数据不做处理这个规则,虽然可以使用C++11中的新特性给到一个缺省值,但还是思考到了这个特性出来前此问题又是如何解决的,这就引出了初始化列表的概念。在学习了它的一些列特性和语法规约后,明白了原来声明处的缺省值就是给到初始化列表用的,无论是否有给到缺省值,编译器都会去走构造函数的初始化列表完成定义初始化的工作
- 说完初始化列表后,又研究了一会构造函数,面对隐式类型转换所带来的种种麻烦,一个【
explicit
】就可以让你服服帖帖的 - 接下去谈到了C++中的【static成员】,对于如何去计算实例化出多个个对象,确实感到有些棘手,命名空间的冲突只好将统计的变量放到类内,奇想之中就引出了
static
关键字,它可帮了大忙,但面对类内的私有成员变量,属实访问不到。此时双胞胎兄弟静态成员函数就来了,解决了我们的燃眉之急。也用这两个兄弟解决了一道在线OJ题 - 随之而来的便是【匿名对象】,取名字可太难了,博客名都是想了好久才取出来的,匿名对象真的蛮不错
- 马上又来谈谈【友元】,你把我当朋友,我可不把你当朋友,单向关系可以搞清楚哦,友元虽好,然不要用多了哦,交朋友也是要适当的👈
- 藏在里面的【内部类】真是真让人琢磨不透╮(╯▽╰)╭天生就是人家的朋友的,但又不别人当朋友,这是又自闭症呀!
- 对象太多可不好哦,要做一个专一的人,否则【编译器】一定会出手🗡
以上就是本文要介绍的所有内容,如果觉得还可以就三连支持一下吧,完结撒花✿✿ヽ(°▽°)ノ✿
Thanks for reading, see you next article
📝