设计模式小结

2018-12-02

  这三年的开发工作中,基本上在做桌面端程序开发,一部分还是界面化编程。设计模式一直也在用,却没有好好总结一下。这段时间工作不忙了,突然发现没有总结的这种状况实在很糟糕。最近又买了一本《设计模式》,之前从老夫子旧书网淘的本英文版,被送给同学了。这种书放在手头,还是很有让人觉得很稳的感觉:有它在手,问题不愁。做开发这些年了,现在也该开始对于设计模式、架构、流程等问题做做总结了。
  对于设计模式,作者也提到了,对于不同的语言,设计模式的实现可能有差异。对于Python或LISP等动态语言中,有好几种设计模式并不存在,这是由于语言的表述能力免去了使用模式,可参考Peter Novig的研究(此处)。我们也不可迷信设计模式。即使一个人没有学习过这本书,他也可能不知不觉使用上了某些设计模式,这就是原创了,重复造了轮子。这也未尝不是好事,使人更有创造性。但是,我们没有那么多时间给一个人造轮子。必需要在学习与思考之间保持平衡。毕竟公司花钱雇人,是要做项目的。前面提到“造轮子”,说法其实也不大准确。就如微积分,我们一般也不会说是牛顿或者莱布尼茨“发明”了微积分,更多使用“发现(discovered)”或“提出”。建议多读读本书的前言。     
  多数设计模式是用来解决一些场景下特定的问题。注意,也并非非此模式不可,并不是必不可少的东西。若没有它,我们写出来的代码很可能是比较冗杂、易出错,难以拓展,但是,如果能实现基础功能,对于个人学习研究技术的项目,那就不就足够了嘛。一旦进入公司的项目,那就还必须要熟悉这些模式了。因为,当你进入公司的项目组,意味着你有项目相关的经验,基础知识和 算法都不再是唯一的重点,如何与队友协作、如何以最小代价来持续推进项目也是重点。需要注意,这本书是25年前写的。25年啊。尽信书,不如无书。
   应用程序开发难度小于lib开发,lib开发难度小于framework开发。因为framework靠近业务端,而业务端有很多不确定性,我们需要依靠经验,应用模式来应付这种不确定性。所以,我们可以在lib中见到一些最简单的设计模式(以C++为例):STL容器的iterator模式,stack/queue等的adapter模式,smart pointer的proxy模式,;boost接口与实现分离的bridge模式,asio的proactor模式。Qt既是lib,也是framework,作者在书里面讲解的界面编程,当然也可以Qt为例来讲解,所以,Qt是一个绝佳的分析对象。对于设计模式,最好还是在实践中学习。像我刚开始学习Java时,各种资料都推荐深入的学习分析Web框架,因为框架是各种模式的集合。但是,我却不知道,某一处功能,为什么要使用设计模式。事倍功半矣。
   另外,我想谈谈“GUI程序架构”。我之前不喜欢GUI开发,在学校的时候就认为:只是为了方便操作却需要写很多GUI代码、不断的修改和持续优化,这部分工作没有那么重要却会占用那么多的开发时间,的确划不来,还有更重要的偏底层的知识需要去学习呢。在工作了之后,发现不管什么工作,只要能帮助项目都是重要的,让专业的人去做专业的方向就可以了。在最近三年的CAD程序开发中,我才明白,GUI程序设计上,也是那么重要。所谓的架构,并不是单指Web架构,一个GUI程序所需要的设计和架构的能力也是很高的。不好的架构,会让代码越来越混乱,bug越来越难定位,文档越来越不管用,行为逐渐混乱,情绪逐渐失控。Web项目架构上,看似扁平,数据驱动。GUI程序则是树状,UI集结所有模块单元。Web程序的工作原理更像是函数式语言。还有,架构的经验是可以迁移的。做Web开发时,我的目标就是成为架构师。并不是这个title的原因,而是想要做成项目,自然而然的就需要这种综合、统筹的能力。架构师,需要抽象比程序设计模式更高的层次的模式。如对于各种类型的项目,如何安排好需求调研,如何安排好开发进度与质量,如何安排好开发与测试的衔接,不都有模式么。
  今年主要使用OSG和Qt了。OSG都被人评价为对于涉及模式使用的很好。所以,就从日常的笔记中总结了如下内容,对设计模式的使用做一个简单的梳理,以后还会单独写blog做详细的分析。设计模式分为三类:创建型、结构型、行为型。创建型解决了对象构造的各种问题,结构型解决建模时对象层级关系的问题,行为型解决业务逻辑如何与数据结构协作且保持代码符合SOLID原则。

OSG中的设计模式

visitor

NodeVisitor,Node::accept()

composite

CompositeViewer

observer

observer_ptr

prototype

KdTree,DataBase,Effect、CullVisitor、RenderBin

proxy

ProxyNode、ApplyModeProxy、osgFX\Registry、osgUtil\Optimizer、 osgViewer\GraphicsWindowWin32、plugin 机制

singleton

plugin机制、DisplaySetting、SceneSingleton、GLExtensions

strategy

osgParticle\SinkOperator  

command   

MotionCommand

decorator

SlideShowConstructor

Visitor模式解决的问题是:如何把“数据结构”和“算法”分离。
  以OSG场景树为例,一个渲染节点MyNode aNode (MyNode 继承于 public osg::Node),可能有10层的子节点,100个子节点,涉及到15种子节点class(都继承于osg::Node)。假设有个算法需要更改当前aNode的状态,简单的做法就是给MyNode增加一个method,递归遍历所有子节点,针对15种子节点的处理,需要超大的switch或者if else。至此,问题解决。
  我们假设MyNode种类似算法有5个,两三个人分别负责。那么协同开发时就糟糕了。代码冲突浪费时间。大范围 build浪费时间。MyNode中多余的成员变量、接口增加开发者脑力负担。这种做法不符合“开闭原则”,某用户(接口使用者)不需要MyNode的某算法,MyNode更新却可能强制要求用户更改程序。为了解决这些问题,可以使用Visitor模式。
  思路很简单,给osg::Node增加一个accept(Visitor v) 接口(可为virtual),表示它接受任何Visitor来访问;Visitor给出重载的apply(***) 接口(必需为virtual)。算法实现MyVisitor(继承于Visitor) 需要定制apply() 的实现。即可用C++重载替代上面的超大型if else。
  然而,Visitor模式也不是没有问题的。当Visitor中没有 新增的NewNode : public osg::Node 子类型class作为参数的apply() 重载版本函数,那么需要处理NewNode的算法Visitor就没有办法工作了。因为Node::accept(Visitor)这里,Visitor根本不会处理NewVisitor::apply(NewNode)。什么时候会出现这种情况呢?那就是你无法更改Visitor代码时,也就是你使用第三方lib的Visitor时。于此时,你只能在NewNode::accept(Visitor v)中对 v 做dynamic_cast,测试是不是NewVisitor。若是,则可以曲折的方式实现MyVisitor算法。

组合模式
这个模式是最为基础的模式了。在C语言中,没有继承,只有通过组合模式来模拟继承,实现一个物体,具有多个物体的能力。

观察者模式[wiki]
假设当 A a对象更改了,需要通知n 个其他类型的对象。最简单的做法:在A中持有这n个其他类型对象的指针,依次处理。这里明显就带来了一个问题:A依赖于其他n个类型。过于耦合了。  
改进:让这n个class都继承某接口,实现该接口函数。在a中维护一个指针数组,需要通知的对象放入数组,需要通知时,遍历数组调用该接口函数。那么A就需要依赖这个接口类。当有m个这种情况时,A就需要依赖m个接口类。很明显,m会远小于n。
OSG中典型的使用就是智能指针管理对象声明周期。一个对象a 可能被多个对象观察,这些对象就需要保持a的指针,但是,万一a对象被销毁了呢?观察的对象如何才能知道呢?OSG智能指针observer_ptr就依靠观察者模式实现。 

prototype模式[wiki]
创建型模式的一种。假设class A被创建时,大多数成员相等,为了降低创建A对象的耗时,可以直接复制一个已经准备好的“原型”,内存复制的代价小多了。可能对C/C++这样的原生语言来讲,创建小的对象根本算不上性能热点,但是对于JavaScript语言来讲,基本上所有东西都是对象,对象创建的代价就显得很大了。故JavaScript语言把prototype模式内置到语言内部了。

proxy模式[wiki]
当我们复制一个对象的代价非常大的时候,我们希望用一个假的东西暂时替代它。这个东西就是proxy。

策略模式
策略模式一般用于算法替换。问题场景:假设针对一个对象a,根据界面参数设置不同,会采取三种不同的算法,且不同的算法所需参数不完全一致。简单的处理办法是,在算法调用处,如class A::foo(),根据选择的算法不同,多个 if else 完成算法。OK,解决问题。
但是,当不同的算由多人分别负责时,这么小范围的代码冲突绝对让你觉得很无语。且多个if else 累计的长篇代码觉得不会让人舒服。
解决方法:让每一个算法封装为一个class,称之为策略,都继承于某接口,使用统一的接口函数使用算法实现。让class A持有一个策略接口的指针,当UI指定不同的算法时,此指针指向不同的策略实例。但是,每个策略class都需要参数,可以把这些参数打包为一个struct C,传递给策略接口的函数作为参数。如此,可解决问题。当新增算法类型时,只需要添加新的策略class,可能向C增加一些参数。

  这里还需要提到Mixin,严格来说它不是一种设计模式,只是一种语言特性,在D语言中,甚至有mixin 关键字。
mixin和组合模式、多继承相关。它试图解决多继承的问题。多继承有三个问题:结构复杂化、优先顺序模糊、 功能冲突(参看《松本行弘的程序世界》)。所以,C++的多继承很复杂,非常复杂,一些公司甚至禁止使用多继承。Java解决多继承的方式是只extend一个class,却implement 多个interface(虽然Java8之后变得更复杂了)。ruby默认采用mixin。这是语言层面尝试解决问题的方案。
   在C++代码中,使用mixin的方式就结合Java Interface的特点。由Mixin父类型提供implementation。第一父类提供接口,内部由mixin实现。这样做的好处就是,可以自由的更改class 实现。相较于组合模式,它更简单、方便点,同时也比组合模式确稍了一些灵活性。例子:由于osg::Geometry的默认使用的osg::Vec3/Vec4表示的 vertex array,color array、normal array都会单独占用CPU内存,从业务对象复制而来,若业务对象很大,例如我遇到了需要渲染上亿点云的问题,完全没有必要使用osg::Vec3,这会导致几个G的内存浪费。所以,我只需要自己实现一个class,继承osg::Array,自己定制mixin,提供osg::Array虚接口的实现,就能实现业务对象内存直接共享给显示对象,再上传到GPU。由于osg::TemplateArray 就是采用mixin方式实现的。这个过程还是很简单的。当然,osg::TemplateArray也可以使用组合设计模式来实现。
  上世纪80年代的Lisp/CLOS语言,就已经实现了面向切面编程(Aspect-Oriented Programming),Lisp/CLOS那时就已经在语言层面支持了mixin,[link1],[wiki]

Qt中的设计模式

mediator

语言增强 实现

composite

 

观察者模式

moc、signal/slot

command

QAction

Abstract Factory

 

Singleton

qApp

Façade

FileTagger

我们可以把Qt拆为两个部分来看:1,增强的C++语言;2,基础GUI库。

观察者模式
  上面OSG部分讨论了观察者模式,发现还是存在耦合的。那么Qt就从语言层面实现了观察者模式,解决了耦合依赖问题。而且,Qt的signal/slot系统还解决了跨线程通信的问题。这部分我将会单独写blog讲。Qt moc编译器的确是我很感兴趣的语言特性。
Qt 使用的设计模式不是很多。

  我不建议直接从开源库或软件学习设计模式,虽然我以前这么干的。因为开源的东西,或多或少带有历史包袱,会给人错误的指导。最好的方式,就是结合书本,在实践过程中(工作项目或者业余项目)把每一个设计模式都使用上。

 

  1. http://openscenegraph.sourceforge.net/documentation/OpenSceneGraph/doc/MindMaps/DesignPatterns/DesignPatterns.html
  2. https://zhuanlan.zhihu.com/p/26799645
  3. https://www.zhihu.com/question/20148405
  4. https://draveness.me/mvx
  5. 《An Introduction to Design Patterns in C++ with Qt 4》
如果有任何意见,欢迎留言讨论。


[ 主页 ]
COMMENTS
POST A COMMENT

(optional)



(optional)