本页内容是 Boolan C++ 开发工程师培训系列的笔记。
因个人水平有限,我撰写的笔记不免出现纰漏。如果您发现错误,请留言提出,谢谢!
面向对象(Object Oriented) 和基于对象(Object Based)编程的两个关键的不同点在于:
基于对象的模型往往使用的是单一类;而上文提到的继承和多态实际上是在描述类和类之间的一种关系。我们可以认为,面向对象模型的实质就是在探讨类和类的关系;举个例子,我们可以把面向对象模型想象成一个大的公司,而类就像是独立存在的每一个部门。当处理一些需要多个部门的某些人员合作才能完成的任务的时候,如何设计这个公司的内部部门的关系,就是面向对象编程需要解决的问题。
类和类之间的基本关系主要分为三种:
这三种关系既可以单一使用,也可以组合使用形成各种不同的设计模式来处理各种问题。
类之间的组合关系可以解释为:一个类中包含了另外一个类(has-a)。用关系图表示如下:
<html>
<img src=“/_media/programming/cpp/boolan_cpp/week_3_composition.svg” width=“700”/>
</html>
总的说来,组合关系表达了一种包含的关系:这种包含可能是整体的包含,也可能是部分的包含。被包含的类很可能是包含它的类用于实现自己功能的一个组成部分。老师给出的例子很好的诠释了这个概念:
template<class T>
class queue
{
protected:
deque<T> c;
....
}
我们都知道 deque
是标准库的一个类,这里的类 queue
就是使用 deque
实现的,因此我们可以说 queue
和 deque
是组合关系(也可以从内存空间的占用上去理解)。
从上面的关系图可以很明显的判断出组合关系中的构造 / 析构顺序:
用组合关系实现的设计模式比较常见的有 Adapter 模式 (详见 3.1)。
委托关系和组合关系类似,也是一种用于描述包含的关系。但不同于组合关系的是,委托关系包含的方式是通过引用来实现的:即包含了指向被包含类的指针。用关系图可以描述如下:
<html>
<img src=“/_media/programming/cpp/boolan_cpp/week_3_composition_by_ref.svg” width=“700”/>
</html>
这样的包含关系直接导致了一个比较大的区别:被包含的类和包含它的类在组合关系下的生命周期是同步的;但在委托关系下则是不同步的。
基于委托关系的相关设计模式比较常见的有 PIMPL(Pointer to implementation),详情请参考(3.2)。
继承关系是面向对象编程概念中的重点;该关系强调了(is-a)这个关系。继承的方式按访问权限分为三种(如下表格):
public | protected | private | |
---|---|---|---|
共有继承 | public | protected | 不可见 |
私有继承 | private | private | 不可见 |
保护继承 | protected | protected | 不可见 |
继承关系在 C++ 中的声明方式如下:
class parent
{
....
};
class child: class parent
用关系图可以表示为:
<img src=“/_media/programming/cpp/boolan_cpp/week_3_inheritance.svg” width=“700”/>
</html>
继承关系和组合关系调用构造/析构函数的顺序一致:
基类(父类 / Base class)的析构函数必须是虚函数,否则会造成 Undefined。
基于继承关系的相关设计模式比较常见的有 Template Method,详情请参考(3.3)。
为了明确什么是虚函数,为什么要使用虚函数,我们需要先探讨一下多态的概念。
从前一节的内容可以得知,继承是由子类完全接收父类的属性。举个例子:
动物有一个属性(方法):跳,兔子是一种动物,那么兔子生来就继承跳这个属性。不止兔子,对于不同的动物(子类)来说,他们都有一个公有的属性,那就是跳。这就是继承:我们可以通过继承来共用一个属性(方法)。
不过有时候也会出问题:兔子能跳,狗也能跳。如果沿用定义在动物这个类别中的属性:跳,那无论是描述兔子跳,还是狗跳,我们都只能得到一个结果:动物跳。
怎么处理这样的情况?如果是单一类,我们可以使用函数的重载:将跳这个方法重载为兔子的版本,狗的版本……等等。但这样的话,不但改变了类的代码,而且……什么时候是个头啊?
而多态这个概念就解决这个问题。
多态实际上就是在父类中,如果我们用一个指针指向了子类对象,那么我们调用的相关函数就是子类对象定义的函数;也就是说,一个函数会根据传入参数的不同,会具有不一样的功能。只不过这种实现方式是通过子类的重写来实现的。
我们来看看多态的实现方式:通过子类重写(Override)父类函数(请注意是 override, 不是 overload)。我们不必修改动物这个类里的跳函数,只需在我们要描述的动物里重写这个函数就可以。可以看出来,重写是多态的重点;而虚函数则正是为了描述这个重写而存在的。
我们可以思考一下,多态可能会出现的方式:
对于第一种情况,我们直接继承父类就可以。而对于第二种第三种情况,我们则需要用虚函数(Virtual Function)来表达这个重写的过程了。
通过上述的重写的方式,我们可以把虚函数分为虚函数和纯虚函数(Pure Virtual Function):
虚函数实际上解决了多态中调用函数的问题。来看看为什么:
多态意味着重写。而重写代表了在多态的状态中,一定会有两个同名的函数分别存在于子类和父类中。来看看这样的调用:
Animal *ptr = new Rabbit();
ptr -> jump();
那么问题来了,我们用指针调用函数 jump()
的时候,究竟是调用父类的还是子类的?ptr → jump()
实际就是 Animal::jump(ptr)
。因此,这时候 ptr
调用的 jump()
一定是指向父类 Animal
的;这种在编译时决定调用哪个类的函数的方式叫做静态联编。
ptr
指向的对象创建出来以后再决定调用哪个函数;这样的话我们才有可能用 this
根据对象的不同来调用不同的函数。而这种方式也被称为动态联编。
ptr->jump() to Animal::jump(ptr);
而如果此时我们把父类中的 jump()
设置为虚函数的时候,以上转换就被以下的转换取代了:
ptr->jump() to (ptr->vtpl[1])(ptr);
这个过程大概可以描述为:先通过 ptr
找到对应的对象,然后再找到对象中的虚表,在从虚表中找到我们希望调用的那个函数的函数指针。在这里vtpl
表示的就是指向虚表的指针。
virtual
关键字的函数。对于同名的函数,写入的顺序则是后声明后写入。
有时候我们的基类往往是抽象的,比如形状可以作为圆,三角形,正方形等等形状的统称,但我们并不能说,形状是一种形状。这就是抽象类,这个类本身不能被实例化。纯虚函数就是用于描述这个类中的方法的;它象征这一种概念;而实例化的工作,我们交给不同的子类去做:圆类实例化圆,三角形类实例三角形…… 这些实例化的工作其实就是纯虚函数所期望的结果。
个人见解:设计模式实际上是前人为了解决问题而总结出的经验。因此,设计模式必然和具体的问题息息相关。在学习设计模式的时候,我们应该去学习其解决问题的思路,而不是单纯的去学习某个设计模式怎么实现的;而使用的时候,我们遇到的问题可能与设计模式处理的问题相差很多,因此死套设计模式并不是一个很好的选择。并且,设计模式是在程序复杂度达到一定程度以后,不得不做出的解决方案;这个过程是一个重构的过程。如果不是特别复杂的项目,使用设计模式反而会造成程序逻辑更加复杂,也就是大家常说的过渡设计。总而言之,设计模式的使用必须要基于我们对代码有足够的了解之上,即我们可以正确的评估整个项目的复杂度。
参考资料:如何正确地使用设计模式?
注:鉴于本章只是通过设计模式来阐述面向对象的概念,为了避免重复学习,设计模式的实现方法会放到后续相关课程笔记中讨论。
Adapter 设计模式的概念就是把一个类的接口转化成我们想要的接口。这个模式适用于下列情况:
Adapter 设计模式反映的是类之间组合的关系,即接口类包含原有类。
Pimpl (Pointer to implementation,又称 Handle / Body)设计模式的模式的概念和 Adapter 类似,也是处理接口类和实现类的关系,只是实现的手法不同。Pimpl 在接口类中建立了一个指针容器,用这个容器中的指针去指向实现类。这种方法良好的体现了类与类之间的委托关系的特点:接口类不会随着实现类的改变而改变,即客户端不受功能端的变化的影响。
我们在日常的软件操作中,有很大一部分操作都是重复的:比如文件系统,在读取指定文件之前的一系列操作,都属于重复性操作。为了实现开发的效率,避免重写这一部分,一些人就会把这些重复操作写成框架(Framework);而其他的用户只需要基于这个平台的功能,专注实现自己需要的功能就可以。这种将重复性工作制作为框架,只把用户需要实现的部分交给用户处理的设计模式被称为 Template Method。
Template Method 主要结构可以描述为:Framework + Application。在它的实现过程中,因为要用到 Framework 写的方法,所以需要继承。而又因为对于 Framework 来说,用户的每一个 application 都有不同(比如读取文件的格式不同),所以需要多态。大致的实现如下:
这一段实际就是在描述如何通过虚函数处理两个类中的继承和多态的关系。因此我们也可以看出,Template Method 设计模式是典型的继承关系下的设计模式。
有一些应用软件(比如 3D 软件)需要实现从多个窗口看一个物体(对象)的功能。在这种情况下,我们就需要用到 Observer (观察者)设计模式。
Observer 设计模式定义了对象之间一对多的依赖关系。当一个对象发生了状态变更,所有的依赖对象都会被通知。
打个比方:大家都围在大厨旁边看他做菜,围了里三层外三层的。当厨子的菜出锅的时候,里面的旁观群众看见了厨子出菜了,立马开始喊:厨子出菜了出菜了!这样外面看不见的人也就知道了。这里的菜就是被观察的对象,而围观的群众就是观察者。菜做好了,状态发生了变更,所有围观群众都会被通知到。可见的是,观察者和被观察的目标是多对一的关系。
<html>
<img src=“/_media/programming/cpp/boolan_cpp/week_3_observer.svg” width=“700”/>
</html>
图中的 subject
就是被观察的目标, Observer
负责对观察目标的变化作出反应。而怎么知道 subject
有变化呢? 当然是通过下面的 instance
,也就是各种具体的观察者中得到信息了。知道信息以后我们还需要和头领 Observer
进行交流,因此我们还需要一个用于交流信息的接口 update
。
因此 Observer 模式的大体结构如下:
subject
中一般会有一个指针容器,通过 observer
来定义增加、删除、通知它旗下的具体观察者。observer
中会定义一个接口,用于更新数据。instance
通过重写 observer
的更新函数来传递信息。
显而易见的是,Observer 设计模式是委托+继承的复合关系一种具体实现。
Composite 设计模式适用于设计树形结构的系统,比如文件系统。Composite 设计模式的结构大致如下图所示:
<html>
<img src=“/_media/programming/cpp/boolan_cpp/week_3_composite.svg” width=“400”/>
</html>
图中分三个部分:
primitive
表示基础部分,比如文件系统中的文件。composite
表示整体、组合物,比如文件系统中的文件夹。component
是 composite
和 primitive
共同的父接口,我们可以通过 component
来定义对 composite
和 primitive
内容的操作,而具体的操作可以通过 composite
和 primitive
的重写而实现。
几个需要注意的地方:
add
函数是作为操作的定义和实现的示范出现的;这一类型的操作函数一定会被 composite
具体的去实现,因此要定义为虚函数。但又不能设计为纯虚函数;因为 primitive
不能实现自身的 add
操作。我们可以将 add
写为一个空函数。composite
在文件系统这个问题中扮演了一个很重要的角色。首先, composite
被设计为文件夹,也就是文件的组合,他需要有能力去包含文件。因此我们必须设计一个容器(这里用的 vector)来装载文件(primitive
)。因为文件可能不一样大,但容器需要元素大小一致,因此我们装的就是指向文件的指针。但这里有一个问题:文件夹是可以嵌套的,这就意味着我们的 composite
不但要有包含文件 primitive
的能力,也需要有包含文件夹 composite
的能力。因此,在我们刚才设计用于装载内容的容器里,我们需要同时支持装载文件和文件夹。而 C++ 的容器在声明的时候必须要制定类的类型啊。怎么办? 这好办。直接给文件类和文件夹类找一个父类,这个父类可以根据实际的情况变化为文件和文件夹(这就是为什么在 component
中用虚函数定义操作,让下面的子类去实现的原因),然后把容器声明为装载 component
的容器,把指针全指向父类,这样一来,我们的容器又能存文件,又能存文件夹(存自己)啦。可见的是,Composite 设计模式是委托+继承的复合关系一种具体实现。
注:本节我感觉自己理解的还差的很远,很不透彻,以下内容感觉大部分都是在自己开脑洞强行撸逻辑。希望和大家多多交流!
Prototype 设计模式的概念是根据一个原型对象,通过克隆的方式,来创建一个新的对象。老师课中讲述的是原型模式的一种特殊的实现方法,主要处理这一类问题: 如何在一个继承体系中去创建未来才会出现的子类?
这个问题实际上跟前面说过的 Template Method 所处理的问题很相似;但仔细想一下还是有不同的。
举一个例子来说明这个问题:
我们要打印一份工作报告,上面的条条框框都是设计好了的,只需要我们填内容上交就可以了。不过有一天,上级要求添加额外的报告内容,那我们有什么解决方案呢?
常见的有两个:
在我看来,这两个方案的不同,其实就是 Template Method 和原型模式本质上的区别。Template Method 强调的是在不修改父类的情况下,通过修改子类来达到新功能的实现,修改的着重点在 application 上。而原型模式的关键部分则在于 clone
。在工作报告要求实现新的内容以后,我们新创建了一份蓝本,然后接下来的所有工作报告只需要按照这个蓝本规划好的区域来填写就可以了。
大家也应该看出来了,这两种设计模式看上去很相似,但主要的区别还是在应用的范围上。如果你设计的程序需要新内容频繁,但要求更新子类的数量不多(比如一个月就交一份工作报告),那当然可以去一个一个修改子类的功能。但如果你需要大量具有新功能的子类(比如一天要提交N份报告),那就是时候做一个新子类,让大家照着抄了。
这个是我在课堂中学习原型模式遇到的最大困惑,因为侯老师从 Framework + Application 开始讲起,很容易让人在这两种方法中迷失。
总结一下原型设计模式的思路:
详细一点就是:
addPrototype(this)
将原型注册到父类中。clone
函数,用这个函数来对子类原型进行克隆操作。findAndClone
成员函数用于寻找容器中相应的原型,然后调用 clone
制造副本。clone
在子类中被重写,因此去子类执行 clone()
(实质上是 new
了一个自己)。clone
操作准备的构造函数,而不是为创建原型而准备的构造函数。