文章目录
C++面向对象编程(上)
1. 头文件的书写规范
- 防御式声明
- 防止一个程序多次引入一个头文件,导致编译器编译多次(多次把头文件和源文件整合)。
2. 重载函数
- 对于两个real函数,可以实现函数重载,虽然看起来函数名相同,但编译器会根据它们的参数个数、类型
定义不同的名字
。 - 对于两个构造函数,一个有默认参数,一个无参,它们不可以实现函数重载;使用无参的构造函数,编译器会不知道该调用哪个。
3. 编写类的注意点
-
构造函数使用列表初始化;
-
数据放在private;
-
成员函数如果不会改变数据,一定要加const【const成员函数,加在 ()后,{}前 】,这样const对象才能调用;
-
函数传参尽量用reference,要不要用const看情况;
-
函数返回值尽量用reference;
- 如果是返回局部变量(不是已有空间,在函数内才创建的空间 ),则只能返回value。
-
函数简单就加 inline
,inline是给编译器的建议,不是要求; -
重点:
-
相同class的各个object(对象)互为friend(友元);
-
在class内访问同类的其他对象,可以
直接访问private数据
。
-
4. 运算符重载
-
有些运算符需要返回引用
- 要求有返回,不能返回void:
- 使运算可以连续进行,要求运算符一定要求有返回即可;
- 如果运算不要求有连续运算,则返回void也是可以的;
- 要求返回引用:
- 其中一个点:使运算连续进行不会出错,且不会因为返回的是临时变量,导致结果出错;比如:(a += b) += c
- 另一点:避免执行额外的拷贝构造和析构函数。
- 要求有返回,不能返回void:
-
对于赋值符号:
- 如果左边的变量之前
已经定义过
,则是调用 = 运算符,不是拷贝构造; - 如果左边的变量之前
没有定义过
,则是调用拷贝构造;
- 如果左边的变量之前
-
运算符函数是设计为 class memebr 函数 或者 global 函数:
-
一般情况下都可以;
-
如果设计为成员函数,对于二元运算符,只需要传递一个参数,因为有一个隐藏的this指针参数,不需要声明出来,但在函数内可以直接使用this。
-
但运算符 member 函数不能对称地处理数据(运算符两边数据类型不同),程序员必须在(参与运算的)所有类型的内部都重载当前的运算符;这样做不但会增加运算符重载的数目,还要在许多地方修改代码,这显然不是我们所希望的,所以 C++ 进行了折中,允许以全局函数(友元函数)的形式重载运算符;
-
特殊的运算符 ” << “(输出运算符)
-
必须设计为全局函数:
- 按照常规写法 count << object,<< 是由库函数调用的,cout 不认识除了内置类以外的新类;
- 所以重新定义 << 运算符;
-
如果写成 class member 函数:
- ① 在标准库重载,意味着修改标准库,显然不理想;
- ② 在 object 中重载,这样想要调用 << 时,会不符合使用习惯:class object<< cout;
-
同时考虑到可能有连续使用” << “的情况(下图),函数一定要有返回,不能是void(图右)。
-
对所有运算符都要考虑连续运算的可能
;- 例如:cout << s1 << s2;
- 例如:cout << s1 << s2;
-
-
5. 带有指针的类
1. 一定要重写的函数
- 需要重写的函数:拷贝构造函数;赋值构造函数;析构函数;
- 【对于没有指针的类,一般编译器提供的默认拷贝构造函数/赋值构造函数/析构函数就够用;】
- 对于有指针的类,一定要重写这三个函数;
-
因为默认拷贝构造函数只会copy指针(属于浅拷贝),这样会导致
两个类的指针指向相同的地址,即两个类中的指针指向一个数据
; -
默认赋值构造函数:只会改变原指针的值,不会改变指针指向的内容,最终会导致原来的指针指向的数据没有释放,新赋值的指针和赋值的指针指向同一个数据;
-
默认析构函数
只会释放指针,指针指向的内容没有释放
。
-
2. 构造函数和析构函数
-
字符串除了内容以外,还有最后一位终止符号 ’\0'
; -
构造函数:
- 为指针数据动态分配空间;
- 进行初始化;
-
构造函数动态分配的形式,和析构函数释放的形式相同;
-
new数组对应delete数组,[] 搭配 []。
3. 拷贝构造函数
-
默认拷贝构造函数 == 浅拷贝(位拷贝)
- 造成内存泄漏;
- 两个指针指向一个数据,一个进行改变或析构,另一个会收到影响。
-
重写拷贝构造函数 == 深拷贝(值拷贝)
4. 拷贝赋值函数
-
重写拷贝赋值函数
-
步骤:
- 检查自我赋值,通过指针判断
- 删除自身指针指向的数据;
- 重新分配内存空间;
- 拷贝内容。
-
一定要有返回值,应对连续赋值的情况。
6. stack(栈)和heap(堆)
1. stack(栈)和heap(堆)介绍
2. 生命周期
-
stack(栈)objects的生命周期:
- 也被叫做auto objects,在作用域结束时,会自动调用析构函数;
-
静态局部对象(static)的生命周期:
-
全局对象的生命周期:
-
heap对象的生命周期:
- 动态分配得到的指针,在离开作用域后就会死亡,但指向的数据不会释放;
- 所以要手动释放heap对象:左边正确,右边错误(内存泄漏);
3. heap对象生成和销毁过程
-
new过程:
- 先
使用operator new分配空间
,底层使用malloc函数(分配的实际空间大小见下文); - 进行
指针类型转换
; - 再
调用构造函数
:对于带有指针的类,通过构造函数,分配指针指向的数据;
- 先
-
delete过程:
- 先
调用析构函数
:对于带有指针的类,通过析构函数,释放指针指向的数据; - 再
使用operator delete释放分配的空间
,底层使用free函数。
- 先
4. malloc过程
-
平台:vc,malloc后的大小是16的倍数。
-
对于cookie(可以理解为是内存空间块的开始和结束标志)的值 == 分类后大小的十六进制:
如果该空间已经分配出去,则再 + 1
;- 对于调式模式下的Complex类,由于64的16进制 == 40h,又因为该空间已经分配出去,所以cookie == 40h + 1 == 41h。
-
单个类
分配的内容:- 在调试模式下(debug):
-
类本身
需要的大小空间(针对类中的数据); -
类前面32个字节的debug信息,类后面4个字节的debug信息;
-
头尾各4个字节的cookie;
-
针对
Complex(实数类)
而言:-
Complex* p = new Complex();
-
大小 == 8(类本身)+(32+4)(debug信息)+(4*2)(cookie) = 52;
-
取16的倍数 == 64
,图中的pad就是填充。
-
-
针对
String(字符串类)
而言:-
String* p = new String();
-
大小 == 4(类本身)+(32+4)(debug信息)+(4*2)(cookie) = 48。
-
-
- 非调试模式下:
- 没有debug信息,其他都有;
Complex大小
== 8(类本身)+(4*2)(cookie) = 16,已经是16的倍数;String大小
== 4(类本身)+(4*2)(cookie) = 12 == 16;
- 在调试模式下(debug):
5. malloc数组的过程(动态分配new)
-
平台:vc,malloc后的大小是16的倍数。
-
数组array
分配的内容:-
在调试模式下(debug):
-
多个类
需要的大小空间(针对类中的数据); -
类前面32个字节的debug信息,类后面4个字节的debug信息;
-
类前面,记录数组数量的4个字节(整数);
-
头尾各4个字节的cookie;
-
针对
Complex(实数类)
而言:-
Complex* p = new Complex[3];
-
大小 == 8*3(3个类)+(32+4)(debug信息)+(4*2)(cookie)+ 4(记录数组个数) = 72 == 80;
-
80的十六进制 == 50h,+ 1 == 51h。
-
-
针对
String(字符串类)
而言:-
String* p = new String[3];
-
大小 == 4*3(3个类)+(32+4)(debug信息)+(4*2)(cookie)+4(记录数组个数) = 60 == 64。
-
-
-
非调试模式下:
-
没有debug信息,其他都有;
-
Complex大小
== 8*3(3个类)+(4*2)(cookie) + 4 = 36 == 48; -
String大小
== 4*3(3个类)+(4*2)(cookie)+ 4 = 24 == 32;
-
-
6. array new一定要搭配array delete
-
回顾delete过程:① 调用析构函数;② 释放分配的空间;
-
array delete会在 ①
调用多次析构函数
,次数 == 数组的大小;② 只调用一次; -
针对带有指针的类
【一定要array new一定要搭配array delete !!!】:- 正确使用(左图):成功释放三个string中的指针,指向的数据;再释放分配的空间;
- 错误使用(右图):
只释放了一个string中指针指向的数据
;再释放分配的空间。
-
对于不带有指针的类,如果你使用了array new,但没有使用array delete,也不会造成内存泄漏;
-
为了以防万一和统一,不管类是否带有指针,使用了array new就一定要搭配array delete。
7. 进一步补充
1. static补充
-
普通成员数据和成员函数:
- 普通成员数据:创建多份对象时,会为每个对象分配一份数据,普通数据在内存占用多份;
- 普通成员函数:
在内存中只有一份
,所有类公用;普通成员函数有this指针参数,通过传递this指针
就可以访问不同对象的数据; - 类中的所有除了static成员数据和成员函数以外的数据和函数,都需要通过this指针进行调用和访问。
-
static成员数据和成员函数:
- static成员数据:
脱离类
,不管创建多少个对象,static数据在内存中只占一份; - static成员函数:和普通成员函数一样,在内存中只占一份;区别在于,static函数没有this指针参数,所以它没办法访问对象中的普通数据,只能访问static数据。
- static成员数据:
-
static成员数据和成员函数使用方法:
-
static成员数据:必须在类外进行定义(下图黄色部分);
-
static成员函数:
-
通过创建的对象调用;
-
直接通过class name调用。
-
-
-
单例模式(设计模式)
-
需求:希望某个类只有一个对象,其他人无法创建;
-
构造函数放在private中
; -
唯一的一份对象使用
static数据
; -
对外使用static函数访问这一份数据;
-
为了在有人使用时,才创建这个唯一的类,将创建静态类写在静态函数中。
-
2. cout补充
-
为什么cout可以输出各种类型?
-
因为cout实现了很多 << 运算符的
重载
。
3. template补充
-
class template:
-
使用类模板时,必须指明 T 的类型;
-
指明一个类型,相当于生成一份 T == 对应类型的新的类代码。
-
-
function template:
-
使用函数模板时,
不需要指明 T 的类型
,编译器会根据传入的参数,进行参数推导; -
推导后,也相当于生成一份 T == 对应类型的新的函数代码。
-
4. namespace补充
-
三种使用方法:
- using 整个空间;
- using 某个部分;
- 全名使用。
8. 类之间的三种关系
1. Composition(复合)
-
复合就是拥有,表示has-a。
-
Composition的构造和析构
-
构造函数
从内到外
:- 首先执行组件的构造函数,再执行自己的;
-
析构函数
从外到内
:- 首先执行自己的析构函数,再执行组件的;
-
下图红色部分,编译器会自动添加
:- 对于内部的构造函数,编译器只会调用
默认构造函数
; - 如果默认构造函数不满足需求,就要自己写内部的对应构造函数。
- 对于内部的构造函数,编译器只会调用
-
2. Delegation(委托)
- 委托就是 Compositon by reference(复合内容使用指针相连);
- 左边是对外开放的可见类(Handle),右边是具体实现类(Body),这种模式称为pImpl(Pointer to implementation):
- pImpl 也称为编译器防火墙:
- 降低文件间的编译依赖关系,减少编译时间,加快编译速度;
- 改变实现类的代码,不需要重新编译可见类,可见类的内容并没有改变。
- pImpl 也称为编译器防火墙:
3. Inheritance(继承)
-
继承表示is-a
- 数据的继承直接体现在占用内存(子类大小);
- 函数的继承是继承调用权;
-
三种继承方式:
- public:
- private
- protected
-
Inheritance的构造和析构(和复合的很类似)
-
子类的对象包含父类的成分
。 -
构造函数
从内到外(由父类到子类)
:- 首先执行父类的构造函数,再执行自己的;
-
析构函数
从外到内(由子类到父类)
:- 首先执行自己的析构函数,再执行父类的;
-
父类的析构函数必须是virtual;
-
-
下图红色部分,编译器会自动添加
:- 对于父类的构造函数,编译器只会调用
默认构造函数
; - 如果默认构造函数不满足需求,就要自己在红色位置写内部的对应构造函数。
- 对于父类的构造函数,编译器只会调用
4. 虚函数(和继承相关,不是类间关系)
-
虚函数和继承
息息相关; -
父类的成员函数有三种选择:
- 非虚函数:子类不能重新定义;
- 虚函数:子类可以重新定义;
- 纯虚函数:子类必须重新定义;
9. 利用类关系组合引出设计模式
1. Template Method设计模式(利用继承关系)
-
父类的函数执行了一些固定的动作,在关键的部分延缓实现,让子类来实现;【关键部分设置为虚函数】
-
这种函数叫做 Template Method(这和C++的模板不是一个概念);
-
子类实现虚函数后,调用父类的函数(Template Method),在关键部分就能调用自己实现的(看下图中的灰色箭头)。
- 为什么关键部分能调用自己的呢?
- 因为在调用OnFileOpen()函数时,传入了子类的this指针(成员函数的隐藏参数);在执行关键部分Serialize()时,就能通过子类的指针调用对应的Serialize()函数。
2. Inheritance和Composition组合
-
不常用,Delegation和Inheritance组合更常用;
-
构造和析构函数顺序:
- 子类继承父类,同时
子类有复合内容
,构造和析构顺序;- 构造:父类 → 复合部分 → 子类;
- 析构:和构造相反。
- 子类继承父类,同时
父类有复合内容
,构造和析构顺序:- 构造:复合部分 → 父类 → 子类;
- 析构:和构造相反。
- 子类继承父类,同时
3. Observer设计模式(Delegation和Inheritance组合)
-
想解决的问题:为同一份数据,提供多个obverser(观察者),
obverser可以是同种也可以不同种
。(下图为某个具体例子)
-
实现:
- 数据类:
- 包含放置observer指针的数组(Delegation关系);
- 包含observer登记、注销的功能;
- 通知observer更新的功能。
- observer类:
- 可以作为父类(Inheritance关系);
- 有更新功能,且为虚函数。
- 数据类:
4. Composite设计模式(Delegation和Inheritance组合)
-
想解决问题:
- 以文件系统为例,目录里既可以放文件,也可以放目录。
-
实现:
-
Primitive类可以理解为文件,Composite类理解为目录;
-
Composite类中包含一个容器,需要既可以放置Primitive类,也可以放置Composite类自身;
- 将Primitive和Composite,继承同一个父类(Inheritance关系),容器存放类型为父类指针(Delegation关系);
-
注意点:
-
父类中的add(virtual)函数不能是pure virtual函数,这会强制Primitive类重新定义;
-
以文件系统为例,add函数为目录添加内容,这对文件没有意义(文件不能包含文件或目录)。
-
因此令add virtual函数为空函数,这样Primitive类调用add函数就什么都不会做;
-
-
5. Prototype设计模式(Delegation和Inheritance组合)
- 解决的问题:用原型实例来指定创建对象的种类,并且通过拷贝这些原型创建新的对象。【不太好懂】
- Prototype(抽象原型类):它是声明克隆方法的接口(clone虚函数),是所有具体原型类的公共父类,可以是抽象类也可以是接口,甚至还可以是具体实现类;
- ConcretePrototype(具体原型类):它实现在抽象原型类中声明的克隆方法,在克隆方法中返回自己的一个克隆对象;
- 下图中的符号说明:
- 下划线:静态类;
- 负号:private;
- #:protected;
- 默认没有符号 or 正号:public
- Prototype(抽象原型类)的内容:
存放自身类指针的数组
:用于存放每种具体原型类的一个对象(prototype[10]);数组添加
具体原型类对象的函数(addPrototype);clone虚函数
:用于具体原型类重新定义;- 根据存放的具体原型类对象,
进行克隆的函数
(findAndClone):调用具体原型类对象的clone函数,返回一个新的具体原型类对象;
- ConcretePrototype(具体原型类)的内容:
static的自己
;private的默认构造函数
( LandSatImage() ),同时要调用父类的插入数组函数(addPrototype);static对象会调用这个构造函数,把自己传给父类(抽象原型类);另一个private or protected的构造函数
,为了和上面的构造函数区分,加入一个无用的参数( LandSatImage(int) );重新定义父类的clone函数
:用于返回新的自身对象;新的对象会调用另一个构造函数,因为不能重复将自身加入父类的数组。