本文是我在学习候捷《面向对象高级开发》课程时所做的笔记,在此分享给大家。
基于对象
类从思考方式上大致分为两类
- 不带指针。(complex)复数。内容在创建的时候已经是确定的(实部、虚部及相关的函数)
- 带指针。(string)字符串。只存了指针,当需要添加内容时,需要额外的创建空间。
设计重点
- 成员变量必须为private
- 参数尽可能的使用引用传递(const与否看情况)
- 尽可能的将返回值以引用形式传递
- 类本体里面注意应该加const的地方
- 尽量使用构造函数的初始化形式。
1 | class A{ |
基于对象 && 面向对象
Object-Based。设计单一的class。
Object-Oriented。面对多个class,涉及class与class的关系。
头文件
防卫式声明(guard)
1 |
|
只有再第一次包含头文件时,才会导入库。
布局
在头文件里,大致分为三个部分。
- 前置声明
- 类-声明
- 类-定义
私有构造函数
当把构造函数设置在private区域时,表示该类不允许被创建对象。(设计模式-Singleton)
function() const {}
设计不会改变数据内容的成员函数时(如:打印private成员变量),声明为const
同一class的各个object互为friend
强制类型转换
在实现运算符重载时,可以按照(int)(30+10.5)
的形式,来强制转成类的临时对象。(该行运行结束即销毁)Complex(5,4)
拷贝构造
类不写拷贝构造函数及拷贝赋值的话,会默认按bit进行拷贝。(带指针的类,需要自己去写)
如果没有写,只是复制了个指针(指针才是类内部的东西,字符数组是动态分配的)。
1 | //使用默认拷贝 |
上述代码中,对象a的指针指向HELLO
,对象b的指针指向WORLD
,当执行b=a时,对象b拷贝对象a,两个对象的指针都指向原来对象a所指向的HELLO
地址(浅拷贝),而WORLD
所在地址已经造成内存泄漏。
拷贝赋值
拷贝赋值时,要加上自我赋值检测(自我赋值delete->new时,delete删掉了相应的数据,无法new相同空间及拷贝内容)
1 | if(this == &str) |
new/delete
有[]
的new要搭配有[]
的delete使用。中括号表示的是数组(array)
1 | int * ptr = new int[]; |
有没有[]不影响这一个对象数组在内存中的删除,而是 有[]是表示删除整个数组,根据元素个数,调用多次分别释放各自动态分配的内存。而是数组中后面的指针所分配的内存没有被释放。
1 | String* p = new String[3]; |
上述代码中,有无[].3个String所占的内存都会被释放,而有[]会调用3次析构函数,即3个String对象中指针所指向的内存都会被释放,而delete只会调用一次析构函数,String[1],String[2]这两个对象中的指针所指向的内存并没有被释放。
static
静态变量要在类外进行定义(类内的static进行声明,不属于对象)。
多个对象的成员函数只有一份
加在数据成员、成员函数前,表示为静态。所有对象共用一个静态成员。
1 | cout <<c1.real(); |
非静态的成员,以this pointer
的形式去传,指针地址不同,调用不同的对象。
而静态的成员,属于类,是类负责存储。没有this pointer
e.g:银行系统,利率设置为静态。利息结算函数去处理静态的变量(利率)。
可以通过对象去调用静态变量,也可以通过类名直接调用。
1 | class Account{ |
复合(Composition)
比如 队列、栈是基于双端队列来设计的。故队列、栈内部会包含双端队列这个类(内部存有其他数据结构)这个概念叫做复合。一些共用的功能,可以直接返回其底层容器的东西(empty()、size()函数),不需要的功能可以不开放,不引入进来(设计模式–Adapter)
Adapter是改装后的,不是被改装者。
1 | template<typename T> |
container &component
在上述过程中,container为队列(继承者),component为双端队列(被继承者)
Container构造时,先构造Component的构造函数,再构造自己的。析构时则相反。先调用自己的析构函数,再调用Component的析构函数
1 | Container::Container(...):Component()(......) |
委托(Delegation)
又叫 Composition by reference。
某个类的内部,成员变量中含有另一个类的对象的指针。(想用那个类的函数时,可以直接通过指针调用另一个类的函数,即将该功能委托给另一个类)。
Handle/Body
衍生设计模式:
* 类的对外接口在该类内部实现,类的底层在被委托者里具体实现。
好处:对外接口不变,内部通过指针指向不同的实现类,从而改变底层。(底层变动不影响接口,从而不影响客户端。)即,底层改变时,编译时候只需要编译底层,不需要编译对外接口等。(节省时间)
而且可以多个不同的对外接口文件共用同一份底层代码。
Composition & Delegation
- 生命周期不同
- Composition直接涵盖另一个类的源码,二者同生共死。
- Delegation中另一个类对象的指针,只有调用时才创建。
继承&&虚函数
成员函数+virtual关键字。继承函数是继承的调用权
设计模式(Template Method)。比如 各种软件打开文件功能,其打开步骤都是相似的(打开对话框、选择文件、检查文件是否存在、在磁盘里读取)这些基础动作基本相同,不同的是以什么形式读取。可以直接封装一个类实现这些基础功能,读取的具体实现,取决于软件继承这个类后去重写。这个东西也叫作Application framework。(应用框架),微软MFC是一个典型例子。
继承+复合构造析构的顺序
派生类Component其余类对象时,派生类的构造/析构次序:基类构造->复合类构造->自身构造
, 自身析构->复合类析构->基类析构。
继承+委托
设计模式(Observer)某个文件内部包括多个查看操作的委托,该文件支持多个人同时查看。(内部可以增加注册、注销等功能以便于管理。)
面向对象
实现对象和非对象的运算
conversion function
类对象—>非类的对象
转换函数conversion function。(比如分数类,实现分数和小数的转换)
以operator
开头,函数名为返回值类型。不需要写返回值类型。也不需要参数。通常加上const
(因为不需要改变内容)
1 | class Frac { |
至此,可以用一个数字去这个分数类进行运算。(无需重载运算符)
non-explicit-one-argument ctor
非类的对象—>类对象
non-explicit-one-argument ctor。要重载运算符。(整数默认分母是1,可以使得分母默认为1,只输入一个实参便可以)如果只写了分数间加法时,当调用分数+小数(整数)时,会调用相应的构造函数转换成分数再加。
1 | class Frac { |
- non-explicit-one-argument ctor与转换函数并存会产生二义性,报错。
explicit-one-argument ctor
explicit关键字放在构造函数前,告诉编译器,不会将非对象的参数转换为对象。
1 | class Frac { |
类
cpp的类大致分为两类,类的对象长得像指针的,长得像函数的。
pointer-like class
像指针的类。内部一定含有指针,实现一些比指针更高级的功能(智能指针)。作用在指针上的运算符(*
、 ->
等)要进行重载。
shared_ptr(智能指针)、 iterator(迭代器)
- shared_ptr。在不同类中,实现基本一致。
1 | template<typename T> |
- iterator。根据功能不同会改动,且会重载其他运算符(如 自增、自减)
1 | template <class T, class Ref, class Ptr> |
function-like class
像函数的类。仿函数
1 | template<class T> |
namespace
命名空间。用于分隔开不同开发团队的成果(防止名字冲突,具有二义性)
模板
class template
类模板。参考复数类。成员变量类型、成员函数的返回值类型可以用模板替换。
1 | template <typename T> |
function template
函数模板。有一些函数,所实现的功能很通用。可以直接封装成函数模板。(一些其他类重载运算符)编译器会根据传入值自动推导相应的类型
1 | template<class T> |
member function
成员模板。模板里面嵌套模板。常用于标准库中,用某一个数据类型的变量来初始化另一个数据类型的变量。
比如,现在封装了一个鱼类,以及一个鲫鱼类。
我们声明了一个鲫鱼对象,用他去初始化一个鱼类对象。(构造函数更有弹性)
1 | template<class T1,class T2> |
模板特化
specialization。通过模板实现了泛化,而其中一些东西可能并不太适用,会有些不同,需要特殊处理(特化)。
1 | //通过模板泛化 |
偏特化
局部特化,可以从个数、范围上产生差异。
- 个数。比如模板的参数有2个,可以绑定部分参数。(bool类型用特定的底层做容器容易浪费,偏特化处理)
1 | template <typename T,typename Alloc=...> |
- 范围。模板支持任意类型,偏特化为 任意指针类型。(其余类型使用泛化模板,指针使用偏特化)
1 | template <typename T> |
模板模板参数
模板的一个参数也为模板。用来初始化时,容器实现底层所采用的数据类型。
1 | template <typename T, template <typename T> class Container> |
variadic templates
模板参数可变化。允许写任意个数的模板参数(用...
来省略)(每次都分为1 + n
进行运行)通过sizeof...()
函数获取n
值。
以下代码调用时,可以一直递归为1 + n
直至运行1 + 0
全部运行完,程序结束。
1 | void print() { |
auto
自动推导相应的数据类型。比如容器的迭代器,写起来过长可以直接auto
让编译器根据返回值类型自动推导相应的类型。再比如lambda表达式返回值比较复杂,可以auto推导。
for(decl:coll)
另一种遍历的形式。每次从右边的容器中取出值赋给左边的变量。默认为值传递,不会改变原来值。
1 | vector<int> vec; |
引用
引用,给变量取一个别名,取出后可以去修改。
变量和其引用大小相同,地址相同。
指针可以重新指向其他元素,而引用一旦确定不能变。
1 | int x = 0; |
指针传递、值传递、引用传递中,指针和引用可以改原始值,三者调用对象的方式有些差异。
1 | void func1(Cls *pobj) { pobj->function(); } |
同名同类型一个值传递、一个引用传递的函数不能重载。(二义性)但一个const
一个非const可以并存。
虚指针&虚表
vptr & vtbl。只要有虚函数,类内部就会有出现一个指针。虚指针用来指向虚表中的函数指针指向虚函数的地址。未重写的虚函数,基类和派生类共用。重写了的,各自虚表中指向重写后的地址。
下列代码中存在一些问题,可以不必纠结。(为了方便理解)。
通过指针,向上转型,指向虚函数。(*p->vptr[n])(p)
this指针
通过对象调用函数时,对象的地址传this
指针。
1 | class A{ |
const
非const对象可以调用所有的成员函数,const对象只能调用const成员函数。当成语函数const和非cosnt同时存在时,const对象只能调用const函数,非const对象只能调用非const函数。
operator new/delete
是运算符,可以重载。(全局,类内部 都可以)
1 | inline void *operator new(size_t size); |
重载new/delete
1 |
|
重载new(),delete()
new()可以重载出多个版本,但第一参数必须为size_t
。同时也可以重载对应的delete(),但其不会被调用。只有在new()抛出异常才会调用delete()
new && delete
二者对于基本数据类型的操作基本没有差异,而当为类的对象分配空间时,会有一些区别。
以下是候捷老师课程中关于malloc/new区别的代码以更好的区分二者。
假设我们现在已经有一个复数类,当我们用new进行分配时,首先自动获取对象所占空间,执行operator new
,在其内部调用malloc分配相应的大小。然后调用static_cast
显式的进行强制类型转换。最终再执行类的构造函数。
从图中,我们可以看出二者的一些区别:
- new无需强制类型转换和指定分配的大小,而malloc需要。
- new为一个对象开辟空间时,会调用构造函数,而malloc不会。(故,malloc为对象申请空间时,一些在构造函数里的初始化操作无法执行。)
free && delete
下面是关于free/delete的区别
可以看出,当我们对一个对象执行delete
操作时,首先要调用析构函数再通过operator delete
执行free
操作。
由此,我们可以知道
- free使用不当会造成内存泄漏。
如果该类是不带指针的类(比如复数类,成员变量只有实部和虚部),不调用类的析构函数也没有任何问题,也会正常的释放掉为对象所分配的内存空间。而如果这个类带有指针(比如 string),free时虽然会释放掉对象所占的空间,但由于string
存储的只是个指针,我们对字符串进行修改时,对象内部会为指针分配相应的空间来存储内容。我们执行free时,内存中为其具体内容分配的空间没有得到释放。(指针消亡,但指向的内存空间并没有被释放)。
delete && delete[]
当我们为一个数组new相应的空间时,使用delete
释放也会造成内存泄漏。
从图中可以看出,使用delete释放为数组开辟的空间时,我们会释放掉所分配的空间,同样的也会调用析构函数。这对于不带指针的类来说可能没有太大的区别,当我们的类带指针时,由于只能够调用一次析构函数。数组array
中,只有array[0]
所指向的内存空间被释放掉了,其余的几个指针指向的空间并没有被释放。
由此可以知道,当我们为数组new一片内存时(new xxx[]
),也要使用相应的delete[] xxx
。
设计模式
Adapter
通过复合。某个类内部含有另一个类的对象,一些可复用的功能直接通过对象去调用。
实例:栈、队列从双端队列改写代码。已实现的功能直接调用,其余自己再写。
Handle/Body
通过委托。某个类内部含有另一个类 对象的指针。这个类可以用来提供对外借口,而底层具体实现在指针指向的对象内实现。(接口改变、底层改变可以只编译一部分)
Template Method
通过继承。有些东西在每个软件中所展示出的操作没有太大区别,我们可以将其相似的功能进行编写,细微差异部分以虚函数的形式留给所继承到具体的类中去实现。
实例:打开文件操作。各个软件打开文件的次序基本一致:选择文件,检查文件是否存在,在磁盘中进行读取,xxx,关闭文件。我们可以将其余功能都封装好,对外提供一个具体读取方式的接口。
Observer
通过继承+委托。文件类内部包括多个查看操作的委托,具体到某个文件时,支持多个人同时查看。(文件内部可以增加注册、注销等功能以便于管理。)
Composite
通过继承+委托。当某个类内部可以包含多种不同内容时(自身+其余的类),我们可以将自身及 要包含的类继承自同一个父类。然后该类的内部包含基类的委托。
e.g. 类A内部要可以包含A的指针也可以包含B的指针。我们可以将A B都继承自父类C(功能多少不重要,重要的是A中可以包含B的委托),A中包含父类的委托。
Prototype
通过继承+委托。父类可以创建未来将要出现的子类。(父类是发行商自己写的,子类是客户买回去自己写的相应的类)。
派生类构造函数设置为私有,构造函数内部实现将自身的指针返回给基类,保存在基类的容器中。派生类中含有一个clone()函数,以让基类调用,生成一个副本提供给父类。还需要另外一个的构造函数(以便clone函数构造自己时调用,防止再次调用上次的私有构造 陷入死循环)