OO建模

DinS          Written on 2017/11/26

经过了这么长的讲解,读者应该也意识到了,OOP在技术层面的东西也就那点,真正吸引人以及折磨人的地方在于思维和设计。接下来我们将脱离“术”的层面,进入OOP之“道”。
我们将要探讨如何进行OO分析和设计,更概括地说是如何进行OO建模。
声明:本文重点参考了谭云杰:《大象-Thinking in UML》 2012年第二版。这是一本非常好的书,本人一般读书很快,但是这本书读了3个月,值得细细品味。许多内容都是根据这本书进行的扩展性思考。
另外本专题探讨的是方法论意义上的OOP,而且是个人理解,虽然涉及了部分UML术语,但是属于借船出海,所以内涵不会完全跟UML对应。

一、OO与抽象

OOP之所以难以设计,因为OO本身就是很深的一块内容。
OO是一种认识世界的方法,只有从OO的角度出发认识世界,我们才能够写出好的OOP作品。
之前说过OOP中三组重要的概念:分与合、变与不变、开与闭。
如果能够做到用OO去认识世界,这三组概念能够自然而言地达成。

那么问题来了,如何才能够掌握OO?OO的核心在于抽象。
抽象对所有人都不陌生,但是你真的理解什么是抽象吗?
抽象是认识事物的一种方法,这个很自然。既然OO是认识世界的方法,而OO的核心是抽象,那么抽象自然是认知层面的东西,不然就讲不通了。
让我们通过一个具体的例子介绍抽象:

Q:这些图中展现了什么共同的东西?
A:轮子
这个答案应该不难得出吧?
有几张图片特意突出了轮子,然后注意到每张图片上都有轮子,于是就可以得出答案。刚刚这个思考过程实际上就是抽象。
我们看到了许多具体的轮子,然后统一使用“轮子”这个概念来概括所有这些具体的轮子。
用哲学化的术语来说,抽象就是把摹本上升到理念王国的过程(柏拉图理念论)。

这个抽象过程并不难,这是对于人类而言,计算机很难做出抽象,在这个意义上来说,OO是属于人类的,FO是属于计算机的。
但是抽象就这么简单吗?当然不是。抽象有两个重要的维度:抽象角度和抽象层次

Q:这些图中展现了什么共同的东西(除了轮子外)?
问出这个问题就已经不容易了,因为最直观明显的就是轮子。然而这引出了一个暗含的结论:一个事物所携带的信息量是极大的,往往可以从不同的角度去认知。(这里说的信息就是常识中的信息,不要深究,按照正常思维思考即可)

我们认识事物时很容易被最突出的特点吸引,从而忽略其他的方面。这种认知方式是自然选择和进化的结果,无可厚非,而且在大多数场合都是有效的。但是现在让我们来突破一下这个限制,看看还能够得到什么其他结论。
A:颜色、形状、存在
可以有很多,这里仅仅举一些例子。
图中有各种颜色,黑色、红色、黄色等等,但是毫无疑问都是理念王国的“颜色”。
图中有各种形状,圆形、矩形、线条等等,但是毫无疑问都是理念王国的“形状”。
“存在”则是一个很哲学化的概括了,一切存在事物的唯一属性就是存在本身。
排除存在这一抽象不好理解外,其他的应该都没有理解上的困难。

之所以讲这些,就是为了引出抽象角度这一概念:事物可以从各个方面去认识,会得到不同的认知。
所以自然的,当我们去进行抽象时,就有了一个角度的问题。从不同的抽象角度出发,会得到截然不同的认知。
那么问题自然就来了:应该从什么角度去认知事物呢?这个问题稍后再说。

另外还有一点,无论是“颜色”、“形状”亦或是“存在”,都可以作为轮子的属性来理解。换句话说,我们抽象出来的理念可以进行组合。
理念之间的关系可以是很复杂的,一方面这对OO来说是个坏消息,但另一方面这也决定了OO的潜力是巨大的。
因为世界本身是复杂的,如果一种认识世界的方法不能够深入复杂,那么认识的世界就是不完整的。

接下来换一个看起来更离奇的问题。
Q:这些图有什么共同点?
注意,我问的是“这些图”而不是“这些图中展现了什么”。有什么区别吗?当然有,而且是本质性的区别。
这个问题属于“原问题”(meta-question)。
A:都是图片
A:都是电子图片
A:都是带有轮子的电子图片
第一个回答看起来有些愚蠢,但是你要注意到这确实是一个正确的答案。如果理解了这一点,就能够明白刚才说的一切存在事物的唯一属性就是存在本身。
第二个回答还是可以的,因为读者肯定是在电脑上阅读这篇文章的,所以是电子图片。如果是在书本上阅读,那么这个答案就会变化。
第三个回答应该是最好理解的,就是对刚才问题的另一种表述。

你应该注意到,答案分成了三个层级,从上往下走,每一个层级都携带了比上一个层级更多的信息,即更加具体,更少抽象。但是他们本质上都是围绕着“图片”这一概念进行的。
对,这就是抽象层次的概念。
抽象层次的主要特点是信息量越少->抽象层次越高->概括能力越强
信息量越多->抽象层次越低->更加具体

抽象角度与抽象层次构成了抽象的三维认知空间。
抽象层次在z抽方向延展,抽象角度在xy平面延展。
就刚刚这个例子而言,我们可以画图表现如下:

注意到越往下平行四边形越大。我想表达的意思是抽象层次越低含有的信息量越大。实际上我们还可以对这个模型本身做进一步的抽象,得到一个抽象认知的模型化描述:

最高的抽象层次是存在(Being),存在的唯一属性就是存在本身,因此在最高抽象层次上就是一个点,没有更多的信息。
随着抽象层次的降低,其对应的平面逐渐变大,蕴含的信息逐渐变多。其维度是在z轴的一维空间。在每一个抽象层次的平面内,都可以有若干抽象角度,其维度是在平面内部的xy二维空间。
抽象层次和抽象角度共同组成了抽象的三维认知空间。

为什么要花这么多时间讨论抽象的三维认知空间呢?因为这张图是理解OO的钥匙。
所谓对象,本身就是一种抽象,而且其抽象层次很高,接近于存在的抽象层次。因此当我们说OO时,很自然的就是在这个抽象的三维认知空间中去认识世界。这个空间决定了OO是一种认识世界的良好方法,比FO要好。为什么?

因为我们可以根据需要随意在这个空间内移动。什么意思?
OO认为世界是由对象组成的,世界有多复杂,对象就有多复杂。
而人类的认知能力是有限的,当对象超过了一定复杂度后就无法再有效认识世界了。但是一旦我们在这个空间中去认识世界,情况就不一样了。
我们可以调整抽象等级,把对象缩减到一个适合人类认知水平的复杂度上,在这个抽象层次去认识世界。而即使是在适合的抽象层次上,我们还可以选择必要的抽象角度去认识世界,再次降低复杂度。结论是不管面对多复杂的问题,我们都一定可以找到一个合适的抽象层次和抽象角度,完成认识世界的任务。

OO还认为对象是封闭性的,也就是说一旦我们完成了对一个对象的认知后,不论外部如何变化对象的固有性质是不变的。换句话说,我们在更高的抽象层次和角度完成认知对象的任务后,可以携带着认知成果进入更低的抽象层级。
虽然复杂度有所增加,但是我们已经认知了部分对象,这部分不需要重复劳动,于是我们可以把复杂度控制在合理范围,继续认知剩下的对象。
通过这种控制复杂度的不断迭代,我们终究可以完成对复杂世界的认知。不管初始有多复杂,一定可以完成认知任务。

正是基于这种哲学,OO才脱颖而出。一旦领悟了这种认知模式,掌握OO就水到渠成了。不过这里还有一个不小的问题,也就是上文留下的悬念。
抽象层次与信息量之间是有一个逻辑关系的,这个与OO认知模式相符,最终一定可以找到合适的程度。但是抽象角度可不一定,对象携带的信息量可以说是无穷的,因此每一个抽象层次上都可以近似地认为有N种抽象角度,N种角度可不能够推导出合理的解法。于是问题就来了:我们应该从哪种角度去认知对象呢?这里面有没有规律可寻?
这就引出了下一个问题。

二、参与者与用例

抽象角度这个词在OOP中不常见,更多的是对象属性。
其实所谓对象属性,即我们看待对象的视角,只不过换了种说法而已。
于是刚才的从哪种角度去认知对象就变成了我们应该选择哪些对象属性。

在直接回答这个问题前,先来看看如何描述一个事物。
通常而言有两种描述事物的方式。
一是科学主义,即从尽量客观的角度描述事物。比如就轮子而言,以科学主义描述的话我们会讲到轮子呈圆形,半径是多少,材质是什么等等。
二是功利主义,即从使用者的角度描述事物。还是轮子,以功利主义描述的话我们会讲到轮子可以用来运输重物,可以加快我们的移动速度等等。

请注意,科学主义的描述方法是没有尽头的,因为事物携带的信息量是无穷的。
功利主义的描述方法在某种意义上来说也是没有尽头的,因为使用者是无穷的,每个人都有自己的使用方法。但是在另一个意义上,针对一个特定的使用者而言,功利主义的描述方法是有穷的,而且可以肯定不会太多。
为什么这么讲?可以从两方面来论证。第一,使用者是人,人在时空范围内能够处理的信息是有限的;需求是人产生的一种信息,所以必然是有限的。
第二,使用者要使用一个事物,必然有一个主要目的,附带着可以有一些辅助目的,从主要目的来描述已经可以完成大部分任务了。

在了解了描述方法后,对刚才的问题读者应该已经有答案了。
抽象角度确实有N种,如果使用科学主义则必死无疑。但如果使用功利主义,我们可以把抽象角度局限到比较合理的范围。
换句话说,我们只处理那些我们感兴趣的角度,剩下的视而不见。而感兴趣的角度就是需求,比如你要去汽修厂换轮胎,那么只需要报出轮胎型号即可,其余的都不必关注。

再说的概括一点,通过引入需求的概念,我们可以把N种抽象角度压缩到常数个,从而让抽象的三维认知空间变成一个可以实际操作的认知模式。
但是这里又出现了一个新问题:我们引入了“需求”这个概念,那么这个概念会带来新的麻烦吗?
庆幸的是“需求”本身是可解的,而且在求解过程中我们可以找到对象,因此OOP的套路可以概括为如下:

通过这样一种认知模式,我们终究可以处理任意复杂度的问题。

那么现在来说说如何求解需求。
功利主义,即从使用者的角度描述事物。从这个定义出发就可以确定求解需求的思路。
这里有两个要紧的东西:使用者和使用者为什么使用、如何使用特定事物。
对于前者,我们称其为参与者(actor)。
对于后者,我们称其为用例(use case)。

参与者定义为:系统之外的与系统交互的某人或某事物。
这里的系统指什么呢?系统就是被使用的那个东西,没有什么高深的。
比如我要喝水,我就是参与者,水就是系统了,这里系统就是一个东西。
再比如我要在线支付,我是参与者,网上银行就是系统了,这里系统内部包含了很多东西,但是网上银行仍然是被使用的那个东西,这点不会变化。
很显然参与者是最重要的,如果没有人那么根本谈不上功利主义的描述方式,因为不存在目的。
参与者一般而言是很好找的,不成问题,把握一条原则即可:
参与者一定是直接并且主动向系统发出动作并获得反馈的。
通常使用一个小人(图示)代表参与者。

用例这个概念稍微复杂一点,而且比较微妙。
先看看上面的表述方式:使用者为什么使用、如何使用特定事物。
乍看上去包含了两方面:为何和如何。
但是实际上这两方面联系非常紧密,通常而言知道了为何就可以知道如何。
为何描述参与者的目的,也就是说参与者使用特定事物是为了干什么。既然已经知道要拿某件事物干什么,那么根据对该事物已有的认知就可以推断出使用者将如何使用该事物了。
比如说一个人要去ATM取钱,这是目的,那么很显然你也知道了他需要做哪些事才能成功取到钱。因此用例可以定义为:参与者的目的以及达到目的的活动集合。从英文也可以来辅助理解:use case。use是使用,也就是使用事物的目的,case来描述use的过程,即行动集合。

但是微妙就微妙在如何理解目的本身。必须强调,这里的目的是最终的愿望,而不是作为手段的过程。
比如ATM取钱,插卡不是目的,取钱才是目的,插卡只是取钱过程中的一个必要环节。当然这个例子是比较明显的,但是情况一复杂目的往往不是特别好找。这时就要问自己:找到的目的背后是否还有目的?

如何找到用例?第一步确定参与者,第二步确认参与者的目的,第三步梳理参与者为了达到目的要进行的活动。这个过程往往需要仔细阅读文档以及进行访谈。
对于找到的用例,可以用以下标准来确认一下是否有效:
(1)完整地达成了参与者的目的,即杜绝目的背后的目的
(2)必须是参与者主动发起,并且执行结果对参与者有意义
(3)对用例的描述必然是动宾短语
用例一般用一个椭圆形配上文字来表示。

将参与者与用例画到一张图上就是用例视图:

这张图上有三种概念:参与者、边界、用例。
边界虽然没怎么提到,但是应该容易理解,画一个框框表示系统边界。
因为用例是参与者对系统的目的,所以必然处于边界之内。

解释一下这个图,这张图来源于一个真实的项目。
现在都流行云,所以现在要为单机软件建立云端存储,这个图就是为了完成这个项目而产生的。
尽管过程可能会很多,考虑的问题也很多,但是从用例的角度来看,只有上传和下载是用例,即完整的目的。其他的诸如登录、安全认证等等都不能算作用例。
接下来的讲解会以这个项目为蓝本。

三、时序图与分析模型

我们得到了参与者与用例,但是需求还没有求解完成。
用例视图还太过于笼统,我们需要找到对象才能进一步往下走。

现在要做的就是进入用例内部去分析,这里以上传文件为例。

如何深入用例内部呢?我们有需求文档,要做的就是把这个需求翻译成一张图:时序图。文档就不列出来了,直接看看时序图长什么样。

大图点这里

这张图很直观,而且包含了许多重要的信息,下面一一来分析。
在最上面列出了许多业务实体。业务实体是参与者在执行用例时需要的东西,也就是将来能够成为对象的候选人。
这些东西实际上就是从需求文档里摘录出来的。描述业务流程时通常会有动宾短语,其中名词部分就是潜在的业务实体。
另外这里有一个常用的模式:MVC。将一个应用粗略分为三部分:用户界面-控制中心-数据中心。这样分解的好处是用户界面和数据中心可以独立变化,二者仅仅依靠控制中心间接交互。不过这个不是本文的重点。
换一种方式去理解,业务实体也就是业务中的关键概念。

再往下看,每一个实体下面对应一条线,有些还有长方形。这个线就是表示实体的生命周期。实体之间有箭头,上面写有字,这就是流程的表述方式。
流程总是跟时间有关,随着时间的推移事物状态在发生着变化,这也就是时序图的名字来源。

看到时序图,可以知道业务流程、业务实体以及实体之间交互的方式
注意到,我们绘制出的时序图实际上就是在推进求解需求的过程。
一开始的用例图得不到太多的信息,只是对整个项目的一个初步分析,抓到项目要点。之后我们结合需求文档,将其翻译为一张时序图,在这个过程中我们确定了实体和交互方式。
知道了这些信息后,离抽象出对象和确定抽象角度只差一步了,下一步要做的就是将时序图转换为分析模型。

何谓分析模型?分析模型就是展示分析类与其相互之间关系的模型。
这个说明肯定不够,为了解释一个概念又引出了另一个概念,都是套路。那么什么是分析类?
分析类,个人理解就是从需求到实现的中间概念。
把需求具体化,但是并没有具体到(代码)实现层面,而是一种适中的抽象。再说的直白一点,刚才时序图中画出来的业务实体,对其加工一下,就可以得到分析类。
分析类是一个项目中必须认真做的部分,找到了合适的分析类OOP就成功了一半。
那么如何对业务实体进行加工得到分析类?

一般而言有如下套路,可以把分析类粗略分解为三大类,分别对号入座:

边界类:处理交互的类,如转换事件、接口等。任何有交互关系的关键对象之间都应该考虑建立边界类。

实体类:存储信息和相关行为的建模类。很多时候可以从业务实体直接得到实体类,当然还需要后续处理。实体类通常位于数据持久层。

控制类:对用例所特有的控制行为进行建模。描述用例的动宾短语中的动词是控制类的来源。

在边界类和实体类之间应建立控制类,将相关处理逻辑放到控制类中。

方法基本上就是这样,用起来还要看个人造化。
主要就是把时序图中的业务实体该合并的合并,该分离的分离,该保留的保留,就是分析类了。

有了分析类,分析模型就完成了一半,剩下一半是交互关系。
这个并不难,因为在时序图中已经画出了业务实体之间的交互关系,只要对照着刚刚得到的分析类类推一下即可。
下面看一张分析模型图:

大图点这里

对照刚才的时序图理解理解,应该不难得到。
5个分析类就是直接把业务实体拿来得到的,另外这里有两个新的分析类。
因为时序图中有一个加密环节,所以这里单独把加密拿出来成为一个加密器类。
数据中心在时序图中虽然没有体现,但是隐含的肯定是有的。实际上任何模块都有一个数据中心。
再来看交互关系,也都是从时序图中推理出来的,只不过把时序图中的中文换成了英文,后面加一个()更加贴近成员函数的味道。
其实这张图里最关键的是网络通信实体的交互:Execute()
在时序图中网络通信实体根据所处情况会发出不同的动作,但是这里只列出了一个Execute(),这就是对交互关系的一种抽象。为什么要在分析模型中做这种抽象?
因为从需求文档和时序图中可以看出,网络通信实体在未来的变化有可能很大,因此这里做一个抽象,避免后期麻烦。下面会就这个问题深入展开。

分析模型,是在需求和实现之间架起的桥梁
看到分析图,你应该感觉到距离代码实现很近了,但是同时也可以理解需求。
这就是分析模型的重要意义。我们通过时序图,推理出了分析模型,实际上此时我们已经完成了抽象出对象和确定抽象角度两个任务。
分析类就是对象,分析类之间的交互就是抽象角度。

最后一步就是认知对象了,而认知完成的标志就是设计模型。

四、设计模型

何谓设计模型?设计模型就是展示设计类与其相互之间关系的模型。
设计模型跟分析模型类似,定义都差不多。差别在于分析类还主要属于概念层次,而设计类已经是语言层次了。对于c++而言,设计类就对应class。

如何得到设计类?很简单,就是根据之前的分析模型。
把分析模型中的分析类和相互关系结合具体的语言,进行类推,就得到设计类和相互关系。所以说如果前面几步做好了,设计模型是水到渠成的事。

但是并不是说设计模型是机械性地照搬分析模型,在推理过程中也需要flash of insight。就本专题举的例子而言,那个网络通信实体就不能够照搬过来。
为了得到代码级别的网络通信实体,你需要更具体的设计,这里就是使用已有的设计模式的知识。
实际上,可以想到这个NetPortal是一个抽象基类,向上联系InfoManager可以想到状态模式,每一种实体都是一个特定的状态,执行特定的动作。
向下联系,一个动作实际上包含了若干步骤,因此可以使用命令模式。
于是我们可以得到这样的设计模型(只涉及网络通信实体部分):

大图点这里

NetPortal作为抽象基类,派生出针对不同状态(目的)的网络Portal。其内部含有一个动作序列,不同网络Portal通过重载虚函数GenearteCommands()生成不同的动作序列,完成不同的目的。上层的InfoManager在不同的接口生成派生类的网络Portal然后执行Execute()即可完成不同目的。下层的Command是动作的抽象基类,后续可以分成若干不同动作。动作之间互不知晓,只是根据传入参数执行某些代码即可。

这样一来未来扩展、维护都很轻松了。针对不同目的,派生NetPortal即可,选择需要的动作序列组合起来即可。动作可以复用,也可以派生新动作以适应新需求。
还有一个小点:传入参数问题。我在实际项目中使用的是json,这个相当于万能数据结构,抽象基类Do()传入json的引用,因此相当于准备好json传入,然后经过一系列动作,json中就存储了有用的结果。我非常推荐使用nlohmann::json作为OOP的信使,可见《nlohmann::json万能数据结构》。

在设计模型的推导中可以使用设计模式的知识,当然也可以自己思考,结合上个专题介绍的原则,你也许能找到新的设计模式也说不定。有了设计模型,就已经很像代码了,可以拿来直接开发。

完成了设计模型,也就完成了认知对象,等到代码开发完成,就得到了一个对象成品,然后就可以进入下一次迭代。
在下一次迭代中,这次的对象就不需要考虑细节了,直接拿来用即可。因此下一次的复杂度也就一定处于可控范围。

五、OO建模流程总结

上面的几个部分展示了OO建模的若干步骤和细节,是时候来一个总结了。
再次把上面这个流程列出来。

这是一个原理性的概括和总结,确保你已经理解了其中的每一个步骤及其意义,然后再来看看具体性的可操作性步骤。

大图点这里

这里还需要强调迭代的重要性。
在OO看来,软件开发的过程应该是迭代式开发+用例驱动。
核心是迭代过程。先搭建一个非常简陋的但是可运行的系统。每一次迭代都产生一个可运行的系统,哪怕是功能不完善的。之后每一次迭代都在先前的系统上增加一部分功能。
这样的好处是每一次得到的结果都是有意义的,可以测试,可以交付客户做试运行、评估项目进度等。如果按照模块划分,每一次只做出了一个不可考察的模块,意义不大。

为了实现这种迭代,需要以用例为驱动做开发。
因为用例本身就是一个可独立执行的单元,相性非常好。按照用例优先级排序,然后以用例为驱动做迭代。

另一个好处是,经过每次迭代,最核心的用例会最完善。
即使项目不能如期完成,客户至少得到了最核心的完善系统,不会暴跳如雷。

软件的主要风险就是需求变更或者需求理解错误,如果在项目后期发生这种问题将是灾难性的。
什么情况下会发生呢?最大的可能就是在客户使用了系统之后。与其把风险后压,不如在前期爆发,这样能争取更多的时间和灵活性。
传统开发模式必然是最后才得到一个可运行系统,也就必然意味着风险后后期骤然增大。迭代式开发则不同,因为每一次迭代都得到一个可运行系统,所以可以在项目的早期就遇见风险。

六、补充:规则

业务规则是整个项目中最不稳定的因素,处理的不好后期麻烦很大。
可以分成三类处理:

全局规则,即对系统大部分业务或系统设计起约束作用的规则。对全局规则的处理往往在软件架构中体现。
比如要求历史版本备份,则需要设计师设计在架构中设计一个东西,比如备份框架,然后底层的所有程序都调用。

交互规则,即用例场景中对象状态改变、交互时的限制性条件。
这部分最让人头疼,一是很不稳定,后期经常可能变化,如果写到程序逻辑里,后期改动起来会牵扯一大片。二是经常跨用例,导致模块之间形成相互依赖,整个项目变成了蜘蛛网。项目中经常发生的情况:A模块的某个业务规则需要B模块产生的结果。于是程序员在A模块中直接去获取B的数据,导致A、B程序逻辑混合。这样改任何一个模块都可能导致另一个模块失败。
为了解决这种司空见惯的头疼问题,可以制作规则类,两个用例依然独立,依靠规则类交互。
实际应用中交互规则往往很多,完全可以设计一个业务规则库来管理和解决所有交互业务规则。比如说使用工厂模式,当程序需要应用业务规则时,向规则工厂传入一个规则ID,工厂根据ID创建具体的业务规则类并依照接口返回,然后程序执行该类,该类中进行具体计算和逻辑判断,程序得到最终结果。
好处是业务程序只需要维持规则ID即可,统一简单,灵活。如果规则变了,只要维持ID不变,另写一个规则类替换即可。
更复杂的情况是多重规则共同作用,但是解决思路也是一致的。

内禀规则,即不因外部交互而变化的规则。
内禀规则非常类似对象的私有属性,可以考虑直接封装在类内部。
当然还是建议单独写成一个方法或类,不要散落在逻辑中。