《设计模式:可复用面向对象软件的基础》是一本关于面向对象设计影响广泛的书,GOF在该书中把常用的设计模式划分为创建型、结构型、行为型3大类23种。设计模式是为了帮助工程师更快更好地完成软件设计和实现,避免或减少重复设计,提高系统复用性,使得软件更优雅、更灵活、更坚固、更具可读性、可维护性、可扩展性。
复用
类继承和对象组合是实现功能复用的两种常用技术手段,也是软件对现实世界的抽象和模拟。通过继承可以扩充基类的功能,也可改写被复用的基类实现,类继承是在编译时刻被静态确定的,父子类之间是如此紧密的依赖关系,以至于父类的任何变化常导致子类发生变化。对象组合可以获得被组合对象的引用而在运行时刻动态变化,可以要求对象之间遵守约定接口,而不破坏封装性。
组合优先于继承,继承和组合相辅相成,设计者往往过度使用继承,但依赖于对象组合的设计往往有更好的复用性。
高内聚低耦合
好的面向对象设计软件应该是高内聚低耦合的,内聚是从功能角度来度量模块内的联系,一个好的内聚模块应当恰好做一件事,它描述的是模块内的功能联系。耦合是软件结构中各模块之间相互连接的一种度量,耦合强弱取决于模块间接口的复杂程度。
简而言之:一个模块(类、函数)应该专注于一件事情,提供单一功能,避免编写万用函数、巨类;模块之间应减少依赖、降低耦合度,这样在一个模块发生变化时,才不会引起广泛的连锁反应,从而提高系统的稳定性。
具体到实现层面,设计类的时候要最小化接口数量,把承担内部实现作用的成员函数私有化,模块之间只通过接口通信,接口应尽量稳定,且数量要少,要符合最小知识原则,不要跟陌生人说话,这样做最终目的是为了隔离。
开闭原则
开闭原则是面向对象设计需要遵守的基本规则,对扩展开放,对修改封闭,它主要解决扩展性问题。
良好的软件设计应该是有有利于扩展的,开闭原则提供了方便扩展、而又不容易引入错误的解决思路。
示例和问题:画板的保存与恢复
下面将以一个具体应用为例,给出解决该问题的几种做法,阐述对象创建型模式的理念,并对比几种方式的优劣,在解决问题过程中,浅显易懂的把问题讲解清楚。
假设要开发一个画板程序,画板上有圆、正方形等几何图形,画板提供保存和恢复功能,点击保存的时候,会将画板上的图形保存到磁盘文件。
假设画板上有一个圆和一个正方形,存盘格式如下:
- 首先保存一个int32的图形数量,因为只有一个圆和一个正方形,所以数值为2。
- 然后保存圆,圆心位置(float x,y)+半径(float radius),可以唯一确定一个圆,在x86_32系统上,float占4字节,所以需要12字节。
- 然后保存正方形,左下角位置(float x,y)+边长(float length)可以唯一确定一个正方形,所以保存正方形也需要12字节。
如果依照上述格式保存到文件之后,恢复画板图形的时候,从文件流,首先读到4字节的图形数量,数值为2,此时,我们知道,之前画板上一共有2个图形,然后程序应该依次读取2个图形的存储数据,但下一步怎么办?如何恢复图形?假设接着读取12字节,但并不能区分下一个图形是什么?也就是说图形类型信息丢失了,所以,我们需要为每个图形额外保存类型信息,比如4字节的type(实际上可能一个字节的char就够了,取决于类型的数量),我们把圆定义为type值等于1,正方形为type值等于2。
如上所述,保存画板上的图形(一个圆+一个正方形),存储的数据格式是:
图形数量n(4字节)+图形1存储数据+图形2存储数据+...图形n存储数据
画板上图形1是圆,那它的存储格式是:图形类型(4字节int32 type)+圆心(4字节float x+4字节float y)+半径(4字节float radius)。
图形2是正方形,格式与此类似。
恢复的时候,首先读取到图形数量,然后for i=1,n循环依次恢复每个图形实例。
恢复每个图形实例的时候,先读取图形类型,然后基于类型做分支,不同类型有不同存储格式,对应不同的恢复方法,且有不同的绘制方法(Draw)。
其中有一个基于类型做分支的问题,即读取图形类型后,如何创建对应图形的实例?然后再加载对应图形实例的内部数据(比如圆心位置、半径等)。
最简单的if + else if + ...,稍微高级一点的有switch case,以后新加一种图形类型,便加一个else if或者case,但这样修改很土,违背了开闭原则里的对修改封闭条款,当然如果你要抬杠说这样也挺好,那我无话可说。
对象创建模式
有没有更高端一点的做法?有,这正是对象创建模式中工厂和原型要解决的问题。
工厂模式
抽象工厂的做法大概是这样的,工厂(Factory)负责生产(Create)产品,提供一个工厂抽象基类,该抽象基类提供生产的接口,为每一类图形,提供一个对应的工厂子类,生产对应的产品,比如圆工厂(CircleFactory)生产圆、正方形工厂(SquareFactory)生产正方形,这样的话,问题分解为2步。
1. 映射,通过图形类型(type)找到对应的工厂,即type=1,找到CircleFactory实例,type=2,找到SquareFactory示例,这就需要建立图形类型到对应工厂实例的映射(Map)。
2. 生产,调用对应工厂实例的Create方法,创建相应的产品,比如圆Circle实例或者正方形Square实例。
Create方法很容易定义,对应C++语言,直接new一个相应类型实例返回即可。
Circle* CircleFactory::Create() const { return new Circle; }
Square* SquareFactory::Create() const { return new Square; }
为了满足抽象工厂的生产(Create)接口定义,需要构建几何形状的派生体系,圆Circle和正方形Square都从抽象基类Shape派生,因为C++的虚函数Create,支持子类override版本返回子类对象指针。
至此,问题变成如何建立图形类型到对应工厂实例的对应关系。
我们可以写一个工厂管理器(FactoryManager),在工厂管理器里维护这个对应关系(map很容易做映射),然后创建该工厂管理器的唯一实例(单例)。
这个对应关系(map)我们可以在FactoryManager的构造函数里构建映射,比如这样
FactoryManager(){ map[type1] = new CircleFactory; map[type2] = new SquareFactory;}
但更好的方法是在Factory里的构造函数里通过FactoryManager提供的Register方法往FactoryManager的Map里添加,因为这样做,扩展的时候便不需要修改FactoryManager的构造函数,添加类似map[typex] = new XFactory之类的语句,而且FactoryManager所在的文件不需要包含定义工厂子类的所有头文件,依赖关系上更加合理。
具体而言,需要3步。
1. 构建几何形状类的派生体系
Shape是抽象基类,故最好定义虚析构函数,然后定义ReadFromFileStream接口用于从文件流读取数据成员,定义Draw接口用于绘制,Shape类及子类忽视了成员变量的定义。
2. 定义工厂的派生体系
抽象工厂定义Create接口,返回Shape指针,具象工厂(CircleFactory等)从抽象工厂派生,实现Create接口,并定义具象工厂的唯一变量,该全局变量的构造函数里,会将形状类型到对应工厂对象的对应关系注册到FactoryManager的map中。
3. 定义工厂管理器
工厂管理器维护形状类型到形状对应工厂的映射关系,提供Create接口,供Client代码使用,FactoryManager是一个单例,static Instance方法返回该唯一实例。
用抽象工厂模式改造后,从文件恢复画板的函数便变成了下面这样
以后要扩展,就不需要修改Restore函数本身了,只需要扩展Shape子类和对应的ShapeFactory子类,并定义一个ShapeFactory子类实例,就可以了,这即是遵循开闭原则的体现。
当然,上面的程序很多缺陷,比如,它没有做异常处理,FactoryManager的单件的写法没有禁用构造函数,以及其他考虑不周详的地方,但是它不并影响我们解释工厂模式,我们把关注点放在模式的原理和结构上。
原型模式
原型模式主要基于clone技术,也就是复制,C++程序语言通过拷贝(复制)构造函数支持clone。
它就像日常生活中的复印,比如要复印一份学历证书,你得先有一份学历证书,而用于复印的学历证书,就是原型,需要为每个产品(形状)创建一个原型对象,然后跟工厂模式类似,它也需要维护形状类型到原型对象的对应关系,这个对应关系,我们也可以保存在ProtoTypeManager的成员变量(map)里,通过形状类型找到原型,然后调用原型的clone方法,基于已有原型对象创建新的对象。
跟工厂模式类似,它也需要定义产品(形状)的类继承体系,它要需要维持类型到实例的对应关系。
跟工厂模式不一样的地方是:
- 原型模式需要产品提供拷贝构造的能力
- 原型模式不需要定义工厂类继承体现
- 原型模式需要定义产品原型实例,而工厂模式需要定义各种工厂实例
我们也可以在形状的构造函数里,把形状类型到原型对象对应关系添加到map,在ProtoTypeManager里提供Create接口给client代码调用。
原型模式会为每种产品产生至少一个原型,这样便有许多小对象,而且产品实例的技术,会比应用程序创建的多一个。
单例模式
单例有多种经典写法,如前文所示,用static local variable是一种惯用手法,另一种是通过单例类静态成员的指针变量的方式,每次获取单件实例的时候,先判断该指针,如果为空,则new出来并赋值给static成员指针变量,然后返回该变量。
单例模式还需要注意以下问题:
- 需要解决限制该类型创建多个实例,一般通过私有化构造函数,禁拷贝构造的方式完成。
- 在多线程环境下,要解决并发创建的问题,你可以在进程启动,还没有创建其他线程的时候,把单例都创建出来,也可以通过多线程同步机制,确保只有一个实例被创建,这会引起一些额外的性能开销。
- 在复杂的软件环境下,如果有大量不同类型的单件,要处理好,他们之间的构造顺序问题。
总结
设计模式曾经很火,大家认为它是解决设计问题的好方法,于是便有很多人生搬硬套,然后,便有人反设计模式。
我觉得不能过于追捧设计模式,也不宜一味否认设计模式,它是解决工程问题的一些套路,提供了富有经验、行之有效的解决方法,了解设计模式,对于理解面向对象设计还是很有帮助的。
本篇主要讲了创建类设计模式,工厂、原型和单例,另外一种是builder模式,感觉builder价值不大,所以便没有展开讲了。
周末写技术文章,真的是蛮累的,如果觉得还不错,请点赞加个关注吧,我会持续更新,提供有价值的技术硬货,有任何错误和疑问,也请评论指正。
最后,用一句名言,跟诸君共勉!