《设计模式》书评:可复用面向对象软件的基础
Posted 夜天之书
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了《设计模式》书评:可复用面向对象软件的基础相关的知识,希望对你有一定的参考价值。
《设计模式》这本书盛名在外,它在二十年前面向对象编程方兴未艾的时候对面向对象编程中的通用概念进行总结,归纳了一系列软件开发中久经考验的设计模式,启蒙了一代程序员更好地理解模式、实践和可复用软件的利用,将程序设计带进了长达二十年的面向对象编程时代。
不过,随着软件从业人员的增长,教条机械的复刻书中设计模式的行为泛滥,甚至将本书中的设计模式认定为唯一的不可再扩充的设计模式,也给此书带来的污名。甚至设计模式本身成为刻板、教条、冗余的代名词。我曾经也有过这样的倾向。但是,在工作学习当中接触到工业级面向对象程序代码,若隐若现地感觉到其中规律,我也不自主地走上总结通用模式的道路。在这个时候重新遇到了《设计模式》,其中的论述给了我豁然开朗的感觉。
在本文中,我们不会去罗列或者细致地讨论《设计模式》这一本书中举出的模式,而是关注它的副标题【可复用面向对象软件的基础】,来看看这样一本奠基级别的书籍是如何总结面向对象编程范式中通用概念。我相信不管从现在仍然被面向对象编程统治的商业软件开发环境,还是掌握软件开发思维的一般规律,再次阅读《设计模式》并发现其中的精华还是有意义的。
主要软件的分类
正如《如何阅读一本书》所言,好的书籍必然在开篇就明了自己的论述重点,好的作者无法拒绝在结尾表达自己私藏的真知灼见。在以今天的视角重新审视《设计模式》中关于可复用面向对象软件的细节之前,我们有必要再读一读它的开篇和结尾。实际上,这本书能够吸引我看下去的原因就是它在回答【设计模式是什么】的时候所表现出来的对软件开发的洞见。
《设计模式》的第一章引言回答了【设计模式是什么】的问题,篇幅所限我们不会雨露均沾的讨论其中的每一个要点。这一章中时至今日还有重大价值的是第六节《设计模式怎样解决设计问题》。对于面向对象编程的开发者来说,这里面对于类的继承和对象的组合、动态绑定与多态、继承和参数化类型以及面向对象中依赖的管理的讨论都是很精彩的。不过在这一节里面还有对于主要软件的分类的论述,非常精彩。
《设计模式》将主要软件分为应用程序、工具箱和框架三种。应用程序(Application Program)是最终投产使用的软件。应用程序的设计是最自由的,因为它直接对应最终的业务逻辑,不需要考虑来自其他软件的复用依赖。当然,在这里设计模式能够帮助开发者抽象和分离出业务逻辑的特点,强化内部复用性、可维护性和可扩充性。不过,对于应用程序的良好设计,更优秀的内容存在于《领域驱动设计》一书中。
应用程序通常会依赖一系列的工具箱(Toolkit),工具箱指的是一组可复用的程序,这些程序提供了通用的功能。举个例子,Google 的 Guava 库,C 的 libuv 库,甚至换个角度看语言的标准库例如 Java 的并发工具包都算是工具箱。工具箱并不强制应用采用某个特定的设计,只是为应用程序提供功能上的帮助,工具箱强调的是代码复用。工具箱的设计比应用程序要更难,因为它要面对来自未知的应用程序集合的复用依赖。工具箱的设计者并不知道什么应用使用该工具箱,以及它们会怎么使用。因此,工具箱的设计必须避免过分的假设和无端的依赖,否则会限制工具箱的灵活性、适用性和效率。
框架(Framework)是构成一类特定软件的可复用设计的软件。框架规定了应用程序的体系结构。举个例子,Spring MVC 框架,Apache Spark 框架和 Kubernetes 框架都是框架的典型例子。框架做出了大量的设计决策,定义了组件的责任和拆分,协作的流程和控制的流转等等,而应用程序必须根据框架规定的方式来填充业务逻辑。这就是常说的依赖反转(Inversion of Control),使用框架时,应用程序复用框架作为其主体,编写主体调用的代码。业务逻辑必须以规定的方式编写,而作为交换,不需要做出复杂的设计决策。以例子而言,使用 Spring MVC 框架,应用程序无需关心对象如何被初始化和装配,网络请求怎样被传输、转发和处理;使用 Apache Spark,应用程序无需关心作业如何被调度、分发和计算。
框架比工具箱更难以设计,因为框架的设计者必须冒险决定一个要适应该领域的所有应用的体系结构。任何对框架设计的实质性修改都会极大地降低框架所带来的好处,因为框架对应用的最主要贡献在于它所定义的体系结构。因此设计的框架必须尽可能地灵活并可扩充。这方面的反例是 Apache Hadoop 和 Apache Flink 框架。Hadoop 2.x 到 3.x 的巨大改变使得生产环境必须非常谨慎的做出升级的决定,其未经雕琢的依赖管理也使得上层应用和框架疲于处理依赖版本问题;Flink 1.2-1.5/1.5-1.7/1.7-1.9 都有框架结构和 API 层面上巨大的变化,早期采用的公司在升级 Flink 版本以获取新功能或修复缺陷时往往面临巨大的迁移成本。
设计模式的使用能够帮助减少框架演进时对应用程序的影响。成熟的框架通常使用了多种设计模式。设计模式有助于获得无需重新设计就可适用于多种应用的框架体系结构。当框架所采用的设计模式与框架的设计一起文档化的时候,了解设计模式的人就能通过设计模式的经验更快的洞悉框架。即使是不了解设计模式的人,也可以从框架文档的结构中收益。
此外,不同于我们提到设计模式被滥用之后给自己带来的污名,本书作者在开篇即明确提到了设计模式的适用性。
程序设计语言的选择非常重要,它将影响人们理解问题出发点。我们的设计模式采用了 Smalltalk 和 C++ 的语言特性,这个选择实际上决定了哪些机制可以方便地实现,而哪些则不能。若我们采用过程式语言,可能就要包括诸如【继承】【封装】和【多态】的设计模式。相应地,一些特殊的面向对象语言可以直接支持我们的某些模式,例如 CLOS 支持多方法(Multi Method)的概念,这就减少了 Visitor 模式的必要性。事实上 Smalltalk 和 C++ 已有足够的差别来说明对某些模式一种语言比另一种语言表述起来更容易一些。
设计模式将带来什么
按照上一节我们提到的思路,下一步我们要看到的是《设计模式》一书的结尾,即【设计模式将带来什么】的全书总结。
《设计模式》一书在以前一段时间确实过誉了,以至于抨击它的教条遵从者的声音得到放大。现如今,这本书确实被过度污名化了,我们必须援引一部分原文来看看作者们究竟是怎么表达他们的观点的。
...它并没有提出任何前所未见的新算法或者新的程序设计技术。
...它只是将现有的一些设计加以文档化。
...设计模式的分类整理是重要的,它为我们使用的各种技术提供了标准的名称和定义。如果我们不研究软件中的设计模式,就无法对它们进行改进,更难以提出新的设计模式。
...本书仅仅是一个开始...更应起到抛砖引玉的作用。我们希望这将标志着一场把软件从业人员专门知识和技能加以文档化的运动的开始。
可以看到,作者们并没有认为书中的设计模式就如同黄金十二宫一样既定而不可更改。反而认为这是开路先锋,最终目的是总结现有的设计模式并提出新的设计模式。在下一段中我们会看到书中的一些设计模式在软件开发大环境的演变下已经过时,需要批判的审视。尽管如此,《设计模式》结尾提到的两个观点仍然如其所愿指导了文档化运动的发展方向。
第一个观点是一套通用的设计词汇。这个观点同时见于《领域驱动设计》的【通用语言】以及《代码大全》中的【隐喻】。毫无疑问,我们应当对软件开发活动中的通用概念进行一致的命名。命名能够极大地简化开发人员之间的沟通成本,并且优化开发人员之间的沟通差异。这是因为简短的词汇能够高效地表达复杂的含义,一个文件的句柄或者一个负载均衡器代表了许多的含义,相信看到这两个词能够理解的开发人员能够心领神会。
第二个观点是书写文档和学习的辅助手段。《设计模式》认为设计模式能够使开发人员更容易理解已有的系统,因为大多数规模较大的面向对象系统都使用了这些设计模式。其实在我刚接触 Java 语言编写的系统的时候,我还不会使用 IDEA,当时在 GitHub 上点击文件读代码,完全不了解面向对象代码组织的形式,对着一堆 Helper 类和薄接口类尬看,看了半天找不到头绪。在一段时间的接触之后了解了面向对象的代码组织形式,同时辅助以 IDEA 检索、跳转和调用分析的帮助,能够抓大放小,快速地抓到逻辑的中心节点并扩展开来了解系统的职责划分和控制链。至于文档,在实践当中则是接口的设计文档和实现的 trick 文档相结合。充分的文档能够减少开发者用类似于逆向工程的方式猜测原始作者意图的理解负担。
可复用面向对象软件
在这一节中,我们将按照《设计模式》中的分类方法,以如今的软件开发环境批判地重读书中的设计模式。不过我们不会纠结与细节,对于一些明显落后于时代的模式,也不会过多的去讨论和花时间理解。本节倾向于和现在编程语言语法上的发展以及软件工程的实践相结合,讨论设计模式被固化到语法中的部分以及结合实例来看到设计模式在又一个二十年当中的应用。
创建型模式
创建型模式是跟面向对象编程耦合最深的一类模式,因为它直接关系到对象的创建,而对象是面向对象编程特有的一种组织概念的形式。从创建型模式开始,《设计模式》就强调了每一种模式在类层面和对象层面的差异,即每一类模式既有类层面的、也有对象层面的,例如类创建型模式使用继承改变被实例化的类,而对象创建型模式将实例化委托给另一个对象。
对象的创建可以对比面向对象编程以外的范式里数据结构的创建。对象的创建之所以给人感觉这么麻烦,很大程度上具体的语言实现有关。在典型的实现 C++ 和 Java 中,构造函数太特殊又太不特殊了。一方面,它不仅要求有特殊的名字,无需返回值类型,还要跟特殊的关键字搭配;另一方面,它又是一个普通的函数,引诱程序员在其中做任意的动作,而不是像 Rust 或 Haskell 那样只支持赋值构造数据结构,将初始化等准备过程和组装对象或数据结构明确地分离开来。此时我们再回顾前面提到的语言影响模式,其深远可见一斑。
Factory 模式没有书里写的那么复杂。关于这个模式,代码中能够出现 XxxFactoryFactory 的 Factory 爱好者 Apache Flink 可以作为一个现实的例子,一言以蔽之,Factory 通过对象化构造函数将构造行为实例化。这其实是一个动词名词化的过程,《程序员的呐喊》中《名词王国里的死刑》一文对此有深入的讨论。
这种实例化在函数式编程流行的今天不是很有必要,我们完全可以通过一个 Supplier 或者 Function 以至于抛弃名词的说法,接受若干参数或者单个参数容器并产生对应对象的方法来替代 Factory。不过话又说回来,通过抽象为 Factory 为这样的一个方法起名在 Java 这样的语言中还是很有必要的。Hazelcast Jet 的 API 设计是极其函数式的,由于 Java 没有类型重命名的功能(Haskell 中的 newtype 或者 type,取决于是否需要类型隔离),这使得代码中特定功能的类型以一般的名称出现。具体地说,一个可替换的 CreateDispatcher 方法簇却要以 Function<Parameter, Dispatcher> 的方式出现,这样一个参数化的类型是不好通过类型索引来检索的,其结果是依赖于方法名称的检索,而我们知道名称对于编译器来说并不重要,重要的是类型。这也算是 Factory 在 Java 这样具体的语言中给更清晰的命名和索引带来的好处吧。
最后,这里讨论的是现代软件开发中的 Factory 模式,《设计模式》书中的提法 Abstract Factory 和 Factory Method 的区分在实际生产中并没有发现什么显著的区别,这可能是因为所谓的基于 Prototype 和基于 Factory Method 的 Abstract Factory 在实践中与语言是基于原型实现的面向对象还是基于类实现的面向对象有关,隐藏在语言层面之下了。Prototype 模式是基于原型的面向对象的基石,这里没有太多好说的。强,无敌。内容参考 javascript 爱好者的各种文章即可。
Builder 模式现在也没有书中说的什么构建逻辑的抽象那么玄乎,现在在 Java 等语言中基本充当一个命名参数的构造函数的作用,或者一次配置,多次构造的构造器,后者的出现频率甚至都非常低。不过 Builder 在 Protobuf 中的广泛使用确实是一个利好。忘记诡异的构造函数语法吧,让它成为私有的,永远只和 Builder 打交道,你的生活会更轻松一点。至少你在新加字段的时候不用蛋疼地写一个重载的构造函数,虽然这部分是 Java 不像 Python 或 Scala 那样支持丰富的参数格式的问题。同样,设计模式的爱好者 Apache Flink 有邮件讨论如何写出好的 Builder 模式。
Singleton 模式可以说是经久不衰的直白模式了,不需要多做介绍。除了在纯粹面向对象里处理全局变量/常量的问题以外,对于一般的软件开发,它定义了一种类型到实例一对一的关系,这种限制在我们只能操作实例而不是类型的时候还是有用的,不过类似于 Haskell 等语言中可以在定义类型是使用无参构造器来模拟出 Singleton 的部分功能。这个模式在面向对象以外的世界里并不是那么吃香。
结构型模式
面向对象的结构型模式设计如何组合类和对象以获得更大的结构,它讲的是名词和名词之间的组合方式。同样地,结构型模式既有类层面的,也有对象层面的。结构型类模式采用继承来组合接口或实现,典型地例如多重继承;结构型对象模式描述了组合一组对象从而实现新功能的方法。对象的组合可以在运行时灵活改变,所以它具有更大的灵活性。了解结构型模式,能够帮助厘清项目代码中不同组件模块之间的主从关系,帮助我们快速理解从属地位的组件,扫读代码。同时,结构型模式通常用于解耦组合的两侧,使得一侧发生变化或需要发生变化时,改变结构型模式的中间层,避免另一侧也需要做出改变。
Adapter 和 Decorator 模式的存在感较强。前者是因为在项目发展的过程当中经常需要复用以前的代码,但是以前的类或结构不适应新的接口,需要做一定的调整。在这种情况下,我们通过引入一个适配器作为以前的代码与现在的接口之间的一个中间层,来完成接口到接口之间的适配工作。我此前写过一篇对比结构化类型(Structural Typing)和 Adapter 模式的文章,结构化类型的典型例子 Go 语言会自动将实现接口方法的结构认定为实现某一个接口,这在某种程度上就减少了模板化适配器的需要。如果不考究类型系统和编译期检查的细节,Python 语言中的 Duck Typing 也是一种抹消模板化适配器的语法特性,只要在调用的接收者上能够查找到对应的方法,调用就可以发生。不过,Adapter 模式对于适配两端需要做出细微变化,例如在调用相似接口时需要主动改变状态,或者接口签名不一致的情况下,仍然有更灵活的适配空间。
后者 Decorator 模式则主要是在 Python 当中以语法形式出现而有其存在感。在实用过程当中,Decorator 模式与行为型模式中的责任链模式有相似的地方,实际上是一个对象在一系列的装饰器或处理器中经过被处理。不过 Decorator 强调为对象添加新的信息,而责任链模式强调处理对象以完成特定的动作。在 Flink on Kubernetes 的部署实现中,就采用了名为 Decorator 但是实际是上面所提的两种模式混合的一种设计模式。初始化一个 Pod 和 Container,在应用配置以装饰最终部署的 Pod 和 Container 的同时,操作 Pod 和 Container 以完成某些资源准备等动作。在 Decorator 链中把 Pod 和 Container 既作为附加信息的装饰对象,又作为执行准备动作的资料来源。由于装饰和处理在处理链条中交替出现,分别使用两种模式不仅是冗余的,更加难以协作。
该部分的其他模式已经渗透到程序设计的方方面面,分别单独列出来反而显得很奇怪。就像在汇编语言的时代,循环恐怕都是一个值得一提的设计模式。如今的开发环境里面,组合、桥接、共享对象和代理,都已经是和文件句柄或套接字这样深入人心的概念了,这里不再赘述。
行为型模式
如果说《设计模式》中有一章的内容是最有价值的,那毫无疑问是讲解行为型模式的这一章。行为型模式涉及算法和对象间职责的分配,不仅描述对象或类的模式,还描述了他们之间的通信模式。行为型模式刻画了在运行时难以追踪的复杂的控制流,其实是将行为或者动作这样的动词转换为名词来抽象,也就是将控制流转移到对象间的联系。同样地,行为型模式既有类层面的,也有对象层面的。类行为型模式使用继承机制在类间分派行为,对象行为型模式使用对象组合。在行为型模式中,对象行为型模式占据主要的部分。
可以说,行为型模式是面向对象编程的设计模式中与面向对象技术本身耦合最松散的一类模式。因为动词在所有软件开发行为中都存在,如何抽象动词是一个恒久的问题。Pascal 的作者 Niklaus Wirth 说程序设计 = 数据结构 + 算法,如果说创建型和结构型模式强调如何处理数据结构的构造和组合,那么行为型模式就强调算法的抽象和组合。
责任链模式在结构型模式中跟装饰器模式已经一并讲过了,其实就是数据流经一个链条的时候做信息的添加还是信息的利用。这方面典型的例子是网络程序中的 Handler 处理链,例如 Netty 当中串接起来的 InboundHandler 和 OutboundHandler,或者 ZooKeeper 中用于处理客户端发来请求的 RequestProcessor 等。另一些例子存在于所谓的面向切面编程(Aspect-Oriented Programming)当中,甚至面向对象编程中调用积累同名方法首先处理也可以视作责任链模式的一个实例。这一模式能够在一系列对同一对象的处理流程中灵活的增减处理的步骤,从而达到某种程度上算法可插拔的效果。
Iterator 和 Observer 模式在反应式编程(Reactive Programming)中被发扬光大,广泛应用于 RxJava 和 PubSub 乃至各种消息中间件中。前者早早的就被纳入 Java 语言标准当中,也是各种函数式编程语言基石级别的组成成分;后者与前者的结合在反应式编程大红大紫之后以 Reactive Streams 的标准被明确的定义和使用。总的来说,Iterator 提供了一种依次访问集合中每一个元素的方式,而 Observer 定义了变化到响应变化的依赖关系。迭代器在如今已经深入人心,而随着反应式编程的进一步大众化,响应变化而变化的编程思想早晚也会成为一个入门级的思维。
Visitor 模式经常被成为《设计模式》中最复杂的设计模式,但其实还好,因为它的适用场景非常的特殊。抽象地说它适用于数据结构不怎么发生变化而操作经常发生变化的场景。实际上,这种场景用责任链也很好搞定,如果对象是单一的话。Visitor 模式的特点在于其所处理的元素不是单一类型的元素,而是一簇同构的数据。以典型的例子语法树来说,语法树的每个节点都有丰富的特有信息,但是它们作为树节点的结构却是相同的。Visitor 能够用 visit 和 accept 定义出一套固定的框架,通过实现 Visitor 中对新元素的处理方法或添加新的 Visitor,以及引入新的元素时扩展 Visitor 的处理方法(对于已有的 Visitor,定义默认处理逻辑)从而实现算法和数据结构两侧的扩展性。不过这种扩展性在算法侧是数目和内容的任意扩展,在数据结构侧则主要是数目的扩展。如果改变已有数据结构的内容,则可能导致所有 Visitor 都要重写相应的逻辑。Visitor 模式广泛应用在语法树解析和其他层次结构的遍历中,典型的例子如 ANTLR 的语法树访问器,Calcite 中解析 SQL 语句的访问器,以及 Flink 中打印 DAG 流图的逻辑。
《设计模式》开篇提到支持多方法的 CLOS 并不过分依赖 Visitor 模式,多方法也称为多分派,指的是代码中函数调用的分派取决于运行时参数的类型。这不同于编译时就要确定分派的分派模式,也不同于多态取决于运行时接收者的分派模式,在比较知名的程序设计语言中,支持多分派的语言包括 Common Lisp(也就是 CLOS 的来源)、Julia(核心范式)、Perl 6 和 C#、Scala 等等。多分派的场景下,方法的调用根据运行时参数类型确定,我们可以编写针对不同类型的方法,即 Visitor 中处理不同类型元素的逻辑,它们将拥有相同的名字,在代码中使用统一的方式编写函数调用,在实际发生函数调用时根据运行时的类型采用对应的实现。这也就是 Visitor 模式想要达成的目的。
本节中其他的模式,State 模式可以参考 Actor Model 的内容,定义了对象状态改变时行为也改变的语义。这与 Actor Model 中的第三条,Actor 可以改变其自身行为以响应下一条信息是相同的,实际上,Actor Model 就是书中 State 模式的一个绝佳例子。Interpreter 模式当今的发展可以参考其他讲解 DSL 的书籍,例如《DSLs in Action》等 Debasish Ghosh 的书籍。Command/Strategy 模式是算法动词的名词化,不多谈论。其他的模式也融入到更一般的编程实践当中,不再需要作为设计模式被隆重介绍。
下一站是何方
《分布式系统应用设计》中提到,硬件不断的演变与现实世界需求的变化促进着软件开发的发展,为了更好地理解模式、实践和可重用的组件是如何重塑以前的系统开发的,审视类似转换发生的历史是很有帮助的。
本文中,我们重新审视了二十年前面向对象编程方兴未艾的时候进行模式总结的《设计模式》一书,并逐一介绍了其中的精华和罗列的模式在今天的应用情况。就像《如何阅读一本书》中讲解具体书籍类型的阅读方法可能很快过时,但四阶段阅读的理论却长青一样,《设计模式》中的具体模式随着时间的演进已经和最初的时候大不相同,但是其对于主要软件的分类方法,三大类模式的区分和解决的主要问题这些思想却能够一直流传。
你可以用设计模式的思维来观察所在软件开发领域的通用概念。下一步,在实践中,可以运用本文提到的思考方式审视你所面对的新代码,试图将它们归类、整理和分析,放入自己的思维殿堂;在理论上,可以总结其他编程范式或者特定领域中出现的模式。后者也是本系列文章后续要做的工作,即讨论领域驱动设计、函数式程序设计和分布式系统应用设计等等的模式。这也算是对《设计模式》在二十年前提到的,将软件从业人员专门知识和技能加以文档化,这一愿望的回应。希望我们能够将思考的结晶保存下来,真正地把软件开发行业建设成一个人人都可学习,学习都有章可循的成熟行业。
以上是关于《设计模式》书评:可复用面向对象软件的基础的主要内容,如果未能解决你的问题,请参考以下文章