What & How & Why

C++面向对象高级编程(上)第三周

本页内容是 Boolan C++ 开发工程师培训系列的笔记。
因个人水平有限,我撰写的笔记不免出现纰漏。如果您发现错误,请留言提出,谢谢!


面向对象和类的关系

面向对象(Object Oriented) 和基于对象(Object Based)编程的两个关键的不同点在于:

  1. 面向对象编程实现了继承Inheritance)关系。
  2. 面向对象编程实现了多态Polymorphism)。

基于对象的模型往往使用的是单一类;而上文提到的继承多态实际上是在描述类和类之间的一种关系。我们可以认为,面向对象模型的实质就是在探讨类和类的关系;举个例子,我们可以把面向对象模型想象成一个大的公司,而类就像是独立存在的每一个部门。当处理一些需要多个部门的某些人员合作才能完成的任务的时候,如何设计这个公司的内部部门的关系,就是面向对象编程需要解决的问题。

类和类之间的基本关系主要分为三种:

  • 组合(Composition
  • 委托(Delegation
  • 继承(Inheritance

这三种关系既可以单一使用,也可以组合使用形成各种不同的设计模式来处理各种问题。

组合关系

类之间的组合关系可以解释为:一个类中包含了另外一个类(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 实现的,因此我们可以说 queuedeque 是组合关系(也可以从内存空间的占用上去理解)。

组合关系的构造/析构顺序

从上面的关系图可以很明显的判断出组合关系中的构造 / 析构顺序:

  • 构造由内而外。container 首先调用 component 的构造函数,然后才执行自己。
  • 析构由外而内。析构函数先调用 container 的析构函数, 然后再调用 component 的析构函数。
相关设计模式

用组合关系实现的设计模式比较常见的有 Adapter 模式 (详见 3.1)。

委托关系

委托关系和组合关系类似,也是一种用于描述包含的关系。但不同于组合关系的是,委托关系包含的方式是通过引用来实现的:即包含了指向被包含类的指针。用关系图可以描述如下:

<html>

<img src=“/_media/programming/cpp/boolan_cpp/week_3_composition_by_ref.svg” width=“700”/>

</html>

这样的包含关系直接导致了一个比较大的区别:被包含的类和包含它的类在组合关系下的生命周期是同步的;但在委托关系下则是不同步的。

相关设计模式

基于委托关系的相关设计模式比较常见的有 PIMPLPointer to implementation),详情请参考(3.2)。

继承关系

继承关系是面向对象编程概念中的重点;该关系强调了(is-a)这个关系。继承的方式按访问权限分为三种(如下表格):

public protected private
共有继承 public protected 不可见
私有继承 private private 不可见
保护继承 protected protected 不可见



继承关系在 C++ 中的声明方式如下:

class parent
{
    ....
};
class child: class parent
用关系图可以表示为:

<html>

<img src=“/_media/programming/cpp/boolan_cpp/week_3_inheritance.svg” width=“700”/>

</html>

1.3.1.继承关系下的构造/析构顺序


继承关系和组合关系调用构造/析构函数的顺序一致:

  • 构造由内而外。Derived 首先调用 Based 的默认构造函数,然后才执行自己。
  • 析构由外而内。析构函数先执行 Derived 中的析构函数, 然后再调用 Base 中的析构函数。


基类(父类 / 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 设计模式的概念就是把一个类的接口转化成我们想要的接口。这个模式适用于下列情况:

  • 以前写好了一个类,但它的接口不符合我们现在的要求。
  • 我们想写一个可以重复使用的类,使得这个类可以和以后写的类在同样的接口规范下工作。


Adapter 设计模式反映的是类之间组合的关系,即接口类包含原有类。

Pimpl

PimplPointer to implementation,又称 Handle / Body)设计模式的模式的概念和 Adapter 类似,也是处理接口类和实现类的关系,只是实现的手法不同。Pimpl 在接口类中建立了一个指针容器,用这个容器中的指针去指向实现类。这种方法良好的体现了类与类之间的委托关系的特点:接口类不会随着实现类的改变而改变,即客户端不受功能端的变化的影响

Template Method

我们在日常的软件操作中,有很大一部分操作都是重复的:比如文件系统,在读取指定文件之前的一系列操作,都属于重复性操作。为了实现开发的效率,避免重写这一部分,一些人就会把这些重复操作写成框架(Framework);而其他的用户只需要基于这个平台的功能,专注实现自己需要的功能就可以。这种将重复性工作制作为框架,只把用户需要实现的部分交给用户处理的设计模式被称为 Template Method

Template Method 主要结构可以描述为:Framework + Application。在它的实现过程中,因为要用到 Framework 写的方法,所以需要继承。而又因为对于 Framework 来说,用户的每一个 application 都有不同(比如读取文件的格式不同),所以需要多态。大致的实现如下:

  1. 在 Framework 中将交给 application 实现的函数定义为虚函数
  2. 在 application 中建立对象,并调用 Framework 中定义的虚函数
  3. 调用 Framework 中的虚函数时,编译器发现这个函数是虚函数,然后开始到 application 里查找重写的新定义
  4. 查找到以后,将这个新定义带入到 Framework,并接着执行先前调用的 Framework 中的虚函数

这一段实际就是在描述如何通过虚函数处理两个类中的继承和多态的关系。因此我们也可以看出,Template Method 设计模式是典型的继承关系下的设计模式。

Observer

有一些应用软件(比如 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 模式的大体结构如下:

  1. subject 中一般会有一个指针容器,通过 observer 来定义增加、删除、通知它旗下的具体观察者。
  2. observer 中会定义一个接口,用于更新数据。
  3. 所有的 instance 通过重写 observer 的更新函数来传递信息。


显而易见的是,Observer 设计模式是委托+继承的复合关系一种具体实现。

Composite

Composite 设计模式适用于设计树形结构的系统,比如文件系统。Composite 设计模式的结构大致如下图所示:

<html>

<img src=“/_media/programming/cpp/boolan_cpp/week_3_composite.svg” width=“400”/>

</html>
图中分三个部分:

  • primitive 表示基础部分,比如文件系统中的文件。
  • composite 表示整体、组合物,比如文件系统中的文件夹。
  • componentcompositeprimitive 共同的父接口,我们可以通过 component 来定义对 compositeprimitive 内容的操作,而具体的操作可以通过 compositeprimitive 的重写而实现。


几个需要注意的地方:

  • add 函数是作为操作的定义和实现的示范出现的;这一类型的操作函数一定会被 composite 具体的去实现,因此要定义为虚函数。但又不能设计为纯虚函数;因为 primitive 不能实现自身的 add 操作。我们可以将 add 写为一个空函数。
  • composite 在文件系统这个问题中扮演了一个很重要的角色。首先, composite 被设计为文件夹,也就是文件的组合,他需要有能力去包含文件。因此我们必须设计一个容器(这里用的 vector)来装载文件(primitive)。因为文件可能不一样大,但容器需要元素大小一致,因此我们装的就是指向文件的指针。但这里有一个问题:文件夹是可以嵌套的,这就意味着我们的 composite 不但要有包含文件 primitive 的能力,也需要有包含文件夹 composite 的能力。因此,在我们刚才设计用于装载内容的容器里,我们需要同时支持装载文件和文件夹。而 C++ 的容器在声明的时候必须要制定类的类型啊。怎么办? 这好办。直接给文件类和文件夹类找一个父类,这个父类可以根据实际的情况变化为文件和文件夹(这就是为什么在 component 中用虚函数定义操作,让下面的子类去实现的原因),然后把容器声明为装载 component 的容器,把指针全指向父类,这样一来,我们的容器又能存文件,又能存文件夹(存自己)啦。

可见的是,Composite 设计模式是委托+继承的复合关系一种具体实现。

Prototype

注:本节我感觉自己理解的还差的很远,很不透彻,以下内容感觉大部分都是在自己开脑洞强行撸逻辑。希望和大家多多交流!

Prototype 设计模式的概念是根据一个原型对象,通过克隆的方式,来创建一个新的对象。老师课中讲述的是原型模式的一种特殊的实现方法,主要处理这一类问题: 如何在一个继承体系中去创建未来才会出现的子类?

这个问题实际上跟前面说过的 Template Method 所处理的问题很相似;但仔细想一下还是有不同的。

举一个例子来说明这个问题:

我们要打印一份工作报告,上面的条条框框都是设计好了的,只需要我们填内容上交就可以了。不过有一天,上级要求添加额外的报告内容,那我们有什么解决方案呢?
常见的有两个:

  • 一是我们详细化报告的内容,比如在指定的区域里添加一些额外的内容来达到上级的要求。
  • 另外一种则是重新设计工作报告表,把上级要求的新内容添加一个区域到报告纸上。

在我看来,这两个方案的不同,其实就是 Template Method 和原型模式本质上的区别。Template Method 强调的是在不修改父类的情况下,通过修改子类来达到新功能的实现,修改的着重点在 application 上。而原型模式的关键部分则在于 clone。在工作报告要求实现新的内容以后,我们新创建了一份蓝本,然后接下来的所有工作报告只需要按照这个蓝本规划好的区域来填写就可以了。

大家也应该看出来了,这两种设计模式看上去很相似,但主要的区别还是在应用的范围上。如果你设计的程序需要新内容频繁,但要求更新子类的数量不多(比如一个月就交一份工作报告),那当然可以去一个一个修改子类的功能。但如果你需要大量具有新功能的子类(比如一天要提交N份报告),那就是时候做一个新子类,让大家照着抄了。

这个是我在课堂中学习原型模式遇到的最大困惑,因为侯老师从 Framework + Application 开始讲起,很容易让人在这两种方法中迷失。

总结一下原型设计模式的思路:

  1. 子类通过自我创建建立新子类模板
  2. 把这个新的子类模板作为一个蓝本交给父类
  3. 父类通过克隆来产生新的子类

详细一点就是:

  1. 子类在类定义中声明一个静态同名类,这个类会调用类定义中的私有构造函数,产生一个原型。
  2. 子类通过 addPrototype(this) 将原型注册到父类中。
  3. 父类中声明了一个指针容器来存放原型。
  4. 父类中声明了子类必须定义的 clone函数,用这个函数来对子类原型进行克隆操作。
  5. 操作过程中,父类通过 findAndClone 成员函数用于寻找容器中相应的原型,然后调用 clone制造副本。
  6. 由于 clone 在子类中被重写,因此去子类执行 clone() (实质上是 new 了一个自己)。
  7. 子类克隆的时候,会使用另外一个构造函数,该构造函数多出一个参数,用此来区分自己是为 clone 操作准备的构造函数,而不是为创建原型而准备的构造函数。
  8. 整个功能完成。