原创

启发式面向对象设计(上)

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/dc_726/article/details/85617733

前一阵子断断续续读完了一本老书《Object-Oriented Design Heuristics》,中文名被译作《面向对象沉思录》。虽然是一本老书,但里面的一些观点对我真的是很新奇,比如对象的动态语义、类之间的关系、关系的拓扑结构、对象树与编译器中抽象语法树的相似等,总而言之非常值得一读。


1.面向对象的积木:类与对象

面向对象范式使用类和对象的概念,作为分析、设计、实现的一致性模型

1.1 接口

这世界上有很多东西,我们每天都在用却不知道它内部是如何实现的,比如各种家电、汽车、复印机、钟表等等。正如那本书名一样:世界在你不知道的地方运转。为什么我们还能够使用它们?答案就是良好定义的用户接口。要看电视,我们就拿起遥控器。要开车出去,我们就发动、换挡、踩油门。我们不知道也不关心,电视机是如何成像,汽车是如何点火启动的。其实这时你就已经在与类(Class)打交道了,每一个电视机、汽车实体都是类的一个实例,即对象(Object)。

Heuristic 1.1 第一条启发式规则就是:所有数据都应当隐藏在类中。当我们自信地认为一个属性不会改变,而将其暴露成public时,编程界的墨菲法则就要出来惩罚我们了:你以为不会改变的,往往就是第一个需要变的地方

1.2 消息和协议

由于早期面向对象语言的实现,术语“发送消息”被用来描述一个对象行为的执行。对象行为的名字也就是对应消息(Message)的名字,比如parseInput,而对象能够响应的消息总体就被称为协议(Protocol)

当一个对象接收到一条消息时,它首先必须确认它是否理解这条消息。这种理解性的确定是在解释型语言的运行时,或者编译型语言的编译时完成的。比如你定义好了一个类,当你在IDE的窗口写代码想调用这个类的方法而签名不符时(方法名、参数个数、类型),IDE就会提示你编译错误。如果确定可以理解,那么对象就将自己作为第一个(隐含)参数,连同其他参数一起传递给对应的方法。熟悉Java和Python的同学一下子就反应过来了,这就是Java里隐式的this和Python里显式的self。

Heuristic 2.3 最小化协议中消息的数量。比如C++的LinkedList的接口里有四千多个方法。大接口的问题在于:你永远也找不到你真正想要的。所以保持小巧的接口能够使系统更易于理解,组件更易于重用。接口充当了学习一个类的行为的根基

1.3 耦合与内聚

前面的这些规则可能太过简单,下面这两条规则听起来同样简单。但个人认为可能是最重要的两条,在每本OO设计的书都少不了它。如果你读完只能记住一样东西的话,那就记住它俩吧。

Heuristic 2.8 一个类应该捕捉住一个且仅一个的关键抽象。道理大家都懂,可识别和发掘处正确的抽象就区别开了我们普通人和设计大师。在本书中,关键抽象被定义为:在问题的领域模型中的一个主要实体,通常以需求规格文档中名词的形式出现。但现实世界中的答案往往比这复杂得多,以后我们会看到如何利用时序图在动态中发现抽象,而非在需求或类图中静态地分析

Heuristic 2.9 把相关的数据和行为放在一处。当你通过get方法从别的对象里抓取数据出来时,你就违背了这条规则。仔细品味这一条并在实践中遵守,可以说是对OO设计真正理解的开端。大部分时刻严格遵守能够帮你得到一个真正的对象,其他少部分时候放宽松一些,得到少量的数据对象来另代码更清晰。

关于后者有待进一步研究,参考领域驱动设计(OOO)的资料,看看没有实际行为的“贫血”模型对象是否应该被严格禁止。另外这一条从另一角度来说就是:Heuristic 4.6 类定义的绝大多数方法在大多数时间里都应该使用着绝大多数的数据成员。

1.4 动态语义

除了数据与行为,在运行时对象还有本地状态。一个类对象的所有可能状态,加上合法的状态转换就叫做类的动态语义(Dynamic Semantics)。一个面向函数编程语言里的纯函数的语义比较易于理解,因为它是不可变的,所以运行时没有副作用,更像是一个数学函数。而对象则完全不同,语意的不确定导致正确性证明的困难。这也是我之前一直被困扰的问题,甚至私底下要远离OO了。

但是换一个角度思考,当对象的方法执行时,如果你将对象的本地状态也看作入参的一部分,突然间柳暗花明。从一个看似“不确定”的行为,从更大的图景上,变成了纯函数一般。所以问题的关键,即对象的语义和正确性,就在于:尽可能将类做成不可变(Immutable),如果一定要有可变状态,确定对象状态转换的状态机,避免任何非法的转换导致对象进入一个未知的状态,从而表现出无法预料的行为

1.5 角色

最后要说的一个重要问题就是:类的角色(Role)。—待续


2.面向过程vs.面向对象:拓扑角度

2.1 范式与程序员

面向过程或者动作(Procedure/Action-Oriented)通常有着意大利面条一般的数据,开发者强调这是因为他们从功能角度出发,直到必要时再加上数据结构。同时,他们批评OO开发者只关注数据而忽略实际的功能需求,抽象出不必要或者不够细的对象。

首先我们要达成一点共识,就是什么是好的设计和实现。很多指标大家都懂,最重要的两点就是正确性和可维护性。而OO提出的方法确实能够帮助我们达成这个目标,所以我们要明确OO的思想或者部分思想至少是好的。但现实世界中,不理解好的设计思想的开发者,用什么方法论、编程语言、工具都可能写出坏代码。

2.2 正确的面向过程

当我们提到面向过程编程的种种问题时,许多人可能会反驳说那不是他们的方式。比如两种形式的上帝类(God Class),两者都是OO新手易犯的问题。

  • 数据方面的上帝类就像一个巨大的全局变量。
  • 反之,行为方面的上帝类则封装了过多的行为。

这些优秀一些的开发者会说,即便使用面向过程,他们依旧会将数据结构和函数根据功能放在单独的位置。也就是我们上面说的,而面向过程就是给了开发者自由,只提供了底层的机制加上好程序员间的约定俗成。

Heuristic 3.1 尽可能均匀地在水平方向上分散系统的智能(Distribute system intelligence horizontally as uniformly as possible),也就是说顶层的类要尽可能地协作一起完成工作。原文不是很好翻译,所以就附上了原文。这条启发式规则就是说,如果你看到一个系统设计的类关系树,是一颗十分纤细一直到底层才展开的树的话,那很可能这个设计是有问题的。因为展开意味着此处有多个类协同工作,而继续深入则意味着类的聚合重用。

Heuristic 3.2 警惕你系统中的上帝类(God Class),尤其是那些名字里包含Driver、Manager、System等。

2.3 正确的面向对象

下面就说OO中好的思想。正确的OO分析和设计过程是:通过系统的行为来驱动分析过程,从而解构数据,最终结果是一个“去中心化”的一个个拥有良好接口包裹的数据切片。不仅有指导思想,OO语言还提供了拥有封装、继承、多态三大特性的类。

当然OO绝不完美,一大问题就是类激增(Class Proliferation)。一个著名的玩笑就是:(当我准备重用一个类时)我想要的只是一根香蕉,OO却给强加给我一个大猩猩和整片热带雨林。解决办法简单来说就是:要从分析实际的系统行为出发,发现真正必要的抽象(类),避免模拟现实的类,避免没有行为的属性类,避免没有行为的纯数据类。分别举例子来说:

  • 比如咖啡机,为了模拟现实我们为其每个零件都创建一个类,除了好玩似乎没有任何用,因为我们只关心最外层咖啡机的行为。
  • 比如颜色,明明可以变成使用它的类中的一个属性(类或枚举),有时我们却昏了头为每个颜色都建一个类。

3.类和对象的关系

前面我们谈到的都属于OO设计的第一大步,即关键抽象及其接口的发现。那么第二大步就是要确定这些抽象之间的关系。在OO编程中,关系可以分为四类:

  1. 基于对象的使用关系(Use Relationship)
  2. 基于对象的包含关系(Containment Relationship)
  3. 基于类的继承关系(Inheritance Relationship)
  4. 基于对象的关联关系(Association Relationship)

上面所谓的基于对象或类的关系,指的是是否所有对象都必须遵守这种关系。

3.1 使用关系

简单来说,只要一个对象发送消息给另一个,那么我们就说前者与后者是使用关系。从此可以看出,使用关系在软件系统中是非常广泛的,常见的有六种实现方式:

  1. 包含:对象A包含对象B,比如ATM对象包含Keypad。

后面我们将会看到许多使用关系都可以细化为包含关系,类似的,包含关系也经常先已使用关系的形式存在。一些OO高手声称:发现使用关系是分析过程,而细化使用关系成包含关系则是后续的设计过程。当然,另一些人反对这样简单的划分。

但包含关系并不适用于所有使用关系,比如要加油,汽车Car不能包含加油站GasStation。所以除此之外,还有另外五种实现方式:
2. 入参:将GasStation传入getGasoline(),Car就可以直接使用它了。
3. 注册表:Car去第三方查找GasStation,有点类似于J2EE里的JNDI。但事实上这并没有彻底回答我们的问题,我们如何知道这个注册表呢?
4. 全局:有一个全局的GasStation,所有Car都直接去调用它。
5. 局部:这种方式是给“富人”预备的,他们可以买块地、建一个加油站、加油然后毁掉。这里用了比喻,其实就相当于在Car.getGasoline()中new一个GasStation对象,调用方法,然后对象在方法栈退出后被销毁或稍后被垃圾回收。这种方式在这个例子有些不太恰当,但是在很多领域里创建本地对象去执行某个功能是非常有用的

Heuristic 4.1 最小化一个类需要协作的其他类的个数。最极端的情况下,一个系统只包含最基本的类,所有的类都使用其他的类。这样的话,系统的顶层设计将会非常难以理解,因为人的短时记忆力是有限的,即所谓同时最多关注的7加减2个事情。正因如此,一旦使用关系导致的协作发生,具体类之间有多少次、多少种消息的交互就不重要了,因为复杂度已经增加到系统里

3.2 包含关系

首先必须说明一点就是:包含关系必须是使用关系,即对象向被包含对象发送消息,否则被包含对象就没有意义了。也可能某个getter会将其暴露给外部,从而违法了前面提到的Heuristic 2.9。

Heuristic 4.8 垂直地分布系统智能到深而窄的包含层次里(Distribute system intelligence vertically down narrow and deep containment hierarchies)。让我们比较一下宽和窄的层次结构的区别,两者对重用性有重要影响。对于宽来说,当我们发现一个类无法直接重用时,我们会打开这个黑盒,试着看能否找到能够直接重用的类,结果却发现散落了一地其他的类和支离破碎的逻辑。于是我们费力从中挑选出需要的,或者直接放弃然后从头新写一个。而窄的结构的好处是,当我们打开黑盒,发现少数几个封装完好的小黑盒,其中一个看似可以重用但还不完全匹配我们的需求,于是我们继续打开它。

Heuristic 4.14 相同语义范围内(Lexical Scope)的对象不应有使用关系。所谓范围就是指被包含在同一个对象内的所有其他对象,它们之间不应该再有额外的协作。对象应该尽可能地使用已经存在的关系去完成工作。

文章最后发布于: 2019-01-02 15:13:12
展开阅读全文
0 个人打赏

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 编程工作室 设计师: CSDN官方博客

分享到微信朋友圈

×

扫一扫,手机浏览