Appearance
第8章 建模步骤C-2 识别类的关系
8.3 建模步骤 C-2 识别类的关系
首先重复本章开头所提到的: 虽然本书先讲解“识别类和属性”,再讲解“识别类的关系”,但在 实际工作中,先“识别类和属性”再“识别类的关系”这个思考顺序只 是一个微小的思考周期内的顺序。建模一张类图,需要很多个思考周期。 也就是说,识别类和属性→识别类的关系→识别类和属性→识别类的关 系→……是交错进行的。
我们阅读用例规约或其他素材,一边思考一边建模,不管识别出类、 属性还是关系,画上去就是,并不需要假装看不见类的关系,非得先把 类和属性都识别完了,再来识别类的关系。
8.3.1 类的关系
8.3.1.1 泛化、关联和依赖
类的关系有三种:泛化(Generalization)、关联(Association) 和依赖(Dependency)。
泛化和关联是类的静态关系。这是系统要在一段时间内记住的关系, 或者说,这两个关系属于系统要维护的“数据”。即使系统当前没有运 行需要用到这些关系的用例,这些关系依然存在,随时等待着被“使用”。
注意,此处并没有暗示“记住多久”、“如何记住”。前文说过,分 析工作流中,任何计算所需的时间都是 0,“系统要在一段时间内记住” 的这段时间大于 0 即可。就像前文所举的电梯例子,系统需要记住电 梯和楼层之间的“目标楼层”关联,记住的时间可能只有几十秒。
- 泛化表示集合关系。 两个类形成泛化,意味着超类的对象集合包含 子类的对象集合。B、C 泛化到 A(或者说,B、C 继承自 A),意味着 A 的对象集合包含 B 和 C 的对象集合。
★也可以换一种说法:子类的特征集合包含超类的特征集合。
由于是集合关系,泛化关系是没有 1 对多、多对多等多重性的。 任何集合都是本身的子集,但此处的“包含”不考虑这种情况,也 就是说,泛化的“包含”指真子集的“包含”。泛化关系只能发生在不 同的类之间,不能同一个类形成自反泛化,否则就会导致批量刷废话 ——每个类都无条件地刷一个自反泛化。
间接的“自反泛化”也是不允许的,A 的子类或子类的子类不能成 为 A 的超类。也就是说,泛化关系是传递和非对称的:对象集合 A、B、 C,如果 A⊃B,B⊃C,那么可以得出 A⊃C,且 C⊅B,C⊅A,B⊅A。
- 关联表示个体关系。 关联可以发生在不同类之间,也可以发生在同 一个类之间,意味着类的对象个体之间有关系。因为关联是类的对象个 体之间的关系,所以会有 1 对多、多对多等多重性。
是集合关系还是个体关系,这是泛化和关联的本质区别,从自然语 言的表达来推断有时是不可靠的。
例如,自然语言"人有男有女"说的是泛化关系。"人有男有女"的意 思不是一个人的个体里有若干男人个体和若干女人个体,而是说人的对 象集合包含了男人的对象集合和女人的对象集合。
自然语言"人有手有脚"说的是关联关系。"人有手有脚"的意思不是 人的对象集合包含了手、脚的对象集合,而是说一个人的个体组装了若 干手和脚的个体。
读者可以自行体会一下“人有车有房”和“人有高富帅有屌丝” 的区别。
对于比较熟悉的领域,例如刚才的男女、手脚,拍脑袋就可以知道 是泛化还是关联,那拍脑袋就可以了。如果进入陌生的领域,回溯到集 合和个体的本质区别是必要的。
泛化和关联的进一步内容,后文还会单独分节讲述。
再来看依赖关系:
如果类 B 变化,类 A 也需要变化,那么可以认为类 A 依赖于类 B。
从这个定义来看,泛化和关联也是依赖关系。泛化是子类依赖于超 类,关联的依赖看关联的方向。不过,泛化和关联有另外的表示法,所 以一般说的依赖指除了泛化和关联之外的其他依赖,例如调用、实例化 等。 类之间的依赖关系用带箭头的虚线表示,线上可以标注依赖的类型 (可选)。
★显然,只有在信息系统的分析或设计模型中才会出现调用、实例 化等依赖关系,纯粹描述领域知识的领域模型是不需要的。
这些依赖关系并非时刻都存在,而是在信息系统执行某个用例的某 个步骤时才会产生,而且持续的时间非常短——从分析工作流的假设 来说,就是趋近于 0。因此,要描述依赖关系,仅在类图上描述“A 依 赖于 B”是不够的,还要具体到某个场景。
例如,要描述 A 会调用 B、C 的操作, 在类图上画依赖 的虚线箭头,不是不可以,但还不够。
应该在某个用例的某张序列图(或通信图)上描述,在什么场景, 进行到哪个步骤时,A 需要调用 B 的什么操作,在什么场景,进行到 哪个步骤时,A 需要调用 C 的什么操作。
有了序列图,类图上的依赖虚线就没有必要画出来了, 类图上画泛化和关联关系即可。动态关系,借助序列图来描述,类图更多 是为了表示静态关系。
经常看到这样的类图:上面布满了依赖的虚线,却没有泛化和关联。 如果这样的类图描述的是不同领域的类之间的协作,那还 可以接受,如果类图上都是核心域的概念,那就要警惕了,可能建模人 员根本没有去寻找泛化和关联关系。
比如 ABCD 四个类,可以随意编排它们之间的依赖,不 管哪一种,都可以写出代码来,编译器也不会报错。但哪一种更合理, 要尽量从静态关系来找依据。
时不时会有开发人员向我介绍他所做系统的“架构”,“您看,A 调 用 B,B 调用 C……”,然后就没了。为什么要这样调用,也讲不出什么 理由,反正我就是这样做了,而且做出来了,好像也能用,就行了呗, 管它洪水滔天!
8.3.1.2 区分几个术语
在使用连接、关系、关联、链接等几个看起来很相似的术语时,要 注意它们之间的区别:
- (1)connection(连接)
普通用词,在 UML 中无特别含义。也就是说,connection 是这 几个里面最垫底的、最基础的。
- (2)relationship(关系)
定 义 为: 模 型元 素 之间 的 连接 。 注意 定 义中 的 用词 是 “连 接 (connection)”,而不是“关联”(association) ,更不是“链接”(link), 因为这些是在“关系(relationship)”之后定义的。
关系的含义很广,包括类的泛化、关联、依赖,用例的扩展、包含, 包的依赖、导入等等。
- (3)association(关联)
定义为:实例之间存在连接的类元(classifier)关系。
这里的类元不一定是类,当然,最常见的关联就是类之间的关联。
- (4)link(链接)
定义为:关联的实例。对象之间的关联,和对象是类的实例类似。在一般的分析类图,和 领域类图中,是没有链接关系的。
8.3.1.3 推荐的关系建模顺序
根据上一小节的知识,推荐识别类的关系时,顺序如图 8-97。
text
图 8-97 建模类关系的顺序
@startuml
actor "建模人员" as modeler
actor "建模工具" as tool
loop 分析工作流
loop 静态建模
note left
[直到认为可以转到动态建模]
end note
modeler -> modeler : 思考......
note right
以下各种思考略去不画
end note
modeler -> tool : 建模分析类图上的关联
modeler -> tool : 建模分析类图上的泛化
end loop
note left
[直到认为类图不需要根据动态建模修改]
end note
modeler -> tool : 建模分析序列图
note right
依赖在这里体现即可
end note
modeler -> tool : 建模分析状态机图
end loop
@enduml
★注: *图中的人脑思考——也就是本章的知识才是最宝贵的,但为了节 省空间,就不一一画出了。
*建模类关系之前的“识别类和属性”,图中略去
*动态建模和静态建模一样,也有多个思考和建模周期,图中略去, 后文再关注其中细节。
- 先静态,后动态
建模时,应该先在类图上建模静态关系,即泛化和关联。然后,做 动态建模。通过分析序列图,把用例规约上的系统责任分配到类图上的 类,并决定类之间的协作(依赖关系在这里体现即可),如有必要,还 要建模状态机图。动态建模不但会给类添加行为,可能还会要求重新思 考原来的静态关系。
上一段描述可以看作是识别类关系的一个建模周期,这样的建模周 期在分析工作流中可能会重复很多次。
- 先关联,后泛化
而在“建模静态关系”的一个小建模周期中,应该先建模关联关系, 再建模泛化关系。同样,这样的小建模周期在“建模静态关系”时也可 能会重复多次。
每一个思考周期中的每一个思考步骤,我们都要充分利用已有的信 息来思考,不要急于往下走。
8.3.2 建模关联关系
8.3.2.1 关联优先
尽量通过类之间的关联(属性可以看作和基本类型的关联)来表达 核心域知识,在建模人员没有能力做到或者建模人员有能力做到但权衡 利弊认为不合适的地方,再考虑引入泛化。
如“男人”和“女人”泛化到“人”;也可以,“人”和“性别”关联。
假设男人和女人计税的规则不同,上方的模型把变化放在“计税” 操作的实现中,操作的实现里会有 0.1、0.09 等数字,而非核心域概念。 而下方的模型提炼出 0.1、0.09 背后的概念“税率”,男人和女人的“计 税”操作实现是一样的。
公式完全不同,而且很复杂。
严格来说,都可以用关联表达
★如果觉得以上例子比较偏离实际,也可以改成“结婚”操作,男 人的操作实现中有“年龄<22”的内容,女人的操作实现中有“年龄 <20”的内容,背后的概念是“性别.法定婚龄” 。
GoF 的《设计模式》中说到:
Favor object composition over class inheritance.
优先使用对象组合而不是类继承。
意思和“关联优先”差不多,但 GoF 的《设计模式》中所说的仅 仅类似于把模板方法转成策略,并没有进一步提炼核心域概念。
现实中,有的人学习了 Fowler 的《重构》 ,把条件语句转成泛化 结构,就觉得自己“架构师”了,然后又学了“优先使用对象组合而不 是类继承”,搞出若干“策略”,更觉得自己是“高级架构师”了,但这 还是换汤不换药。
本书所说的“关联优先”,需要更深入的思考。
8.3.2.2 关联的 UML 知识点
图 8-99 是根据《软件方法(上)》2018 版本的用例规约得出的分 析类图的一部分。
图 8-99 根据《软件方法(上)》2018 版本的用例规约得出的分析类图(部分)
图 8-99 上标注的建模元素,可以看作建模关联时所要思考和表达 的基本元素。其他相对“高阶”的建模元素,在讲述相应知识点时再介 绍。
8.3.2.2.1 关联名和角色名
如果两个类 A 和 B 之间的关联,用“A 的 B”和“B 的 A”来称呼, 就足以表达领域知识,那么可以不加关联名称和角色名称;否则,应该 加上关联名称或角色名称。
如图 8-100 中,“订单的发票”、“发票的订单”可以表达领域知识, 那就不用再加其他信息,但“订单的人员”会有歧义,则需要加关联名 称或角色名称。如果两个类之间存在多个关联,那么关联名称或角色名 称更是必须的了。
text
图 8-100 可以和不可以忽略关联名称或角色名称的情况
@startuml
class 订单
class 发票
class 人员
订单 "1" -- "*" 人员 : -下单人
订单 "1" -- "*" 人员 : -审核人
订单 "*" -- "*" 发票
@enduml
我们说的是“角色名称或关联名称”,也就是说,这两个标注一个 即可。 角色名称是名词,如图 8-99 中的“当前所在组织”。映射到编程 语言时,它相当于一个类在另一个类中的属性名称。角色名称是更严谨 的表达,有可能的话,应该优先标注角色名称。
关联名称是动词,如图 8-99 中的“举办”。映射到编程语言时, 关联名称没有意义,但在映射关系数据库时,多对多关联的关联名称可 以映射为中间表的表名,有的语言(如英语)可能还需要变换一下词性。
不过在很多情况下,由于系统要关注关联的细节,多对多关联在分 析工作流已经被分解为两个一对多关联,这时候原来的关联就已经变成 了一个类。这个类和原来的两个类之间的关联,往往不需要额外加关联 名或角色名(已有的依然保留),如图 8-101。
text
图 8-101 把关联变成类后,不需要额外加关联名或角色名
@startuml
class 人 {
}
class 公司 {
}
人 "雇员 *" -- " 雇主 *" 公司 :雇佣
@enduml
@startuml
class 人 {
}
class 公司 {
}
class 雇佣 {
- 开始日期
- 结束日期
}
人 "1" -- "*" 雇佣 : 雇员
公司 "1" -- "*" 雇佣 : 雇主
@enduml
当然,如果证据暂时不足,我们可以先不标注角色名称,而是标注 关联名称。
我们进一步探讨图 8-100 中“订单”和“人员”的关联。
在“人员”一端标注“下单人”、“审核人”(如图 8-102 左上), 证据其实是不足的。为什么不是在“订单”一端标注“所下订单”、“所 审核订单”(如图 8-102 右上)?如果暂时无法判定方向,为了严谨两 边都加上(如图 8-102 左下),图上就会密密麻麻,这时还不如只写关 联名称“下”、“审核”,等到有足够证据时,再在 合适位置加上角色名称。
不管是标注角色名称还是关联名称,都要付出更多的思考。
有的建模人员可能并不愿意。你问他“A 和 B 是什么关联”,他可 能回答“是一个 1 对多的关联”,就已经觉得自己足够精细了。
有的建模人员即使命名,也是没有经过思考的废话,例如关联名称 一律为“有”、 “属于”,角色名称就是类名前面加一个 a 或 m。 如果是这样,还不如留空,等到映射到设计时再按照套路批量 映射。
8.3.2.2.2 多重性
首先要说一下 Multiplicity(多重性)和 Cardinality(基数)的区 别。
多重性指关联端所允许的对象数量范围,如果数量大于 1,还包括 其有序性和唯一性。基数指某个关联实例中,关联端的具体对象数量。
也就是说,多重性规定了一个范围,基数必须在这个范围之内。显 然,类图上出现的应该是多重性,讨论对象和链接时,才使用基数。
★《UML 参考手册》中,cardinality 词条只是简略地说了一句, 而 multiplicity 有 3 页。
常用的多重性范围表示如下。
| 表示 | 含义 |
|---|---|
| 1 或 1..1 | 1 |
| 0..1 | 0 到 1 |
| * | 0 到多 |
| 1..* | 1 到多 |
| 具体数字如 2..8 | 2 到 8 |
建模中我们往往更关注的范围的上限,所以一般情况下使用 1(涵 盖 0..1 和 1..1)和*(涵盖 0..和 1..)就好,以免图上出现大量的 0 影响阅读。等到有足够的证据说明某个关联端必须清晰标出下限,再改 为更严格的上下限形式。
对于多重性,建模人员容易出现的问题是把不同时间的链接快照合 并成一个。
例如,男人和女人可以有“夫妻”关联,那“夫妻”关联的多重性 是多少?中国是一夫一妻制度,按道理应该 1 对 1,如图 8-105 上部。 但有的建模人员会想,不对呀,有的人一生结好几次婚,那不是有几个 配偶吗?是否应该多对多?
在任何时间拍摄快照,拍到的应该是一 个男人最多和一个女人有“夫妻”链接,不会拍摄到一个男人和多个女 人存在“夫妻”链接,对女人来说,也是如此。
不过,拍摄快照确实可以拍到一个男人和多个女人存在“前夫妻” 链接,如果要关注男人和女人的“前夫妻”关联,这个关联的多重性是 多对多的.
8.3.2.2.3 关联的方向
如果关联不标注方向,则缺省认为是双向可导航的。 毕竟,双向都不可导航的“关联”没有实际意义。 如果关联标注了从一端指向另一端的方向,缺省认为没有标注的另 一端是不可导航的,A 指向 B,说明 A 可以导航到 B,B 不能导航到 A——当然,仅指通过这个关联不能,不代表 B 一定没有 其他路径导航到 A。
建模的开始,如果对领域内涵把握不到位,可以暂时设为双向关联。 随着对领域内涵的深入理解,会逐渐把一部分关联设为单向关联。 如何判断关联的合适方向?我们先来看看常被讨论的人和狗的例 子。 日常生活中,我们可以说人养了宠物狗,也可以说狗有主人,假设 目标系统要记住人和狗的关联,而且尽可能设成单向关联,那么, 哪一个更合适呢?
人和狗之间可能一下子不好判断,两者都是胎生恒温哺乳动物,一 亿年前才发生进化分叉。特别是最近一些年,由于白左思想的影响,在 某些人的心中,猫狗的地位已经比人高(自己猫狗的利益甚至高于他人 的生命)。
我们把问题往极端推,可以看得更清楚。比如,人有一个 属性“姓名”,类型为 string(字符串) ,也可以看作人和 string 有一 个关联,字符串的角色名是“姓名”。从平时的经验看,“人”知道 string 是合理的,但是背后的原因不是“人”比 string 更“复杂” (如“8.2.5.4 属性是否在本领域内可分解”所述),而是目标系统很可能更关注人的 状态变化。
回到人和狗的关联。是人知道狗还是狗知道人,判断的标准是看目标 系统更有责任和有兴趣关注谁的状态。
如果目标系统更有责任和有兴趣关注“人”的状态,那么“人”应 该知道“狗”;如果目标系统更有责任和有兴趣关注“狗”的状态,那 么“狗”应该知道“人”。如果都有责任和有兴趣关注,那就互相知道。 不能武断地说“怎么会关注狗呢?目标系统当然更关注人的状态 了!”,也许目标系统是一个狗舍管理系统,也许目标系统是一款狗的游 戏——以前还有一款奇葩游戏叫《坏蟑螂(Bad Mojo)》呢,主角就 是一只蟑螂。
实际上,当前的绝大多数信息系统中,和现实中有生命的“人”对 应的类,与其相关的逻辑所占比例非常少。承担系统关键责任、封装复 杂逻辑、我们需要为之画出复杂状态机的类,往往在现实中对应的是无 生命的事物,例如“订单”、“设备” 、“房间”等。
即使将来信息系统发展到更高复杂度,“人”相关的逻辑所占比例 依然会很少。人类建造的信息系统,封装的是人类对宇宙万事万物(当 然包括人类自身)的认识或想象,而人类自身(甚至包括其他生命体在 内)的内容在这宇宙万事万物中能占多少比例呢?
另外,如果在信息系统中,人的状态机非常仔细,那就意味着人的 各种自然属性和社会属性的各种细节都被信息系统掌握,这到底是福是 祸?参见 Westworld(西部世界)、The Matrix(黑客帝国)等影视作 品。
了解了这一点,我们再来看看一个例子。 “单位”和“设备”,谁知道谁更合适?如果目标系统是一个设备 监测系统,愿景是降低设备过期漏检的比率。目标系统更有责任和有兴 趣关注“设备”的状态,因此“设备-->人”更合适。
判断关联方向时容易出现的误解:
(1)把关系数据库中的“谁知道谁”误解为关联中的“谁知道谁” 如,类图中“订单”知道“订单项”:而在关系数据库建模中,哪个表中有 另一个表的标识作为外键,判 断原则是多重性。由于“订单”一端多重性为 1,“订单项”一端多重 性为*,映射到关系数据库时,结果如图 8-113,却是“订单项”表中 有“订单 ID”作为外键。也就是说,订单项”知道“订单”,和类图 是相反的。
有的情况下关系数据库和类图又是一致的。 以“单位” 和“设备”为例,如果加上多重性,应该是一个单位安装了多台设备, 映射到关系数据库的结果是“设备”表中有“单位 ID”作为外键。
总之,判断谁知道谁的关键应该是抛开设计的影响的,不能因为考虑 数据库怎么设计,影响分析建模。如前面提到的axbxc,其实是加重了 自己的分析负担,可能会犯其他错误.
- (2)把关联名称的阅读方向误解为关联的方向
如果关联端还有角色名称以及其他修饰,就会显得比较拥挤。当然, 也不至于产生误读,毕竟角色名称和关联名称是容易区分的。
8.3.2.3 识别关联的思考
之前在审查类和属性时,以下审查可能已经推导出了一些关联:
8.2.5.2 属性是否直接描述类; 8.2.5.4 属性是否可以从其他地方推导; 8.2.5.5 属性是否在本领域内可分解;
在进一步识别可能存在的关联时,并没有什么妙招或捷径。
严格的做法是针对每两个类以及每个类自身,思考系统是否要维护 类和类之间的关联。如果确实需要,再进一步考虑关联的多重性、关联 名称、两端的角色名称。 不过这样做工作量很大。类图中有 n 个类,就需要思考 C n +n=n(n+1)/2 次。后面加的 n 是自反关联的思考次数。按这样计算, n=5 时,是 15 次,n=10 时,就是 55 次了!
不过,绝大多数情况下不会这样去做。只需要凭借对核心域知识的 理解,迅速定位最应该建立的关联。如果有遗漏,后面在结合用例规约 画分析序列图时也会发现。
关联不只可以发生在不同的类之间,还可以发生在同一个类上。两 端都是同一个类的关联叫自反关联。
根据多重性的不同,自反关联可以表达不同的形状,如下 所示。
| 多重性 | 对象图形状 | 类图例子(PlantUML) |
|---|---|---|
| 对 | 网 | @startuml 商品 "n 部件" <-- "n 组合" 商品 @enduml |
| 1对* | 树 | @startuml 职位 "1 上级" <-- "n 下级" 职位 : 向...汇报 @enduml |
| 0..1对0..1 | 队列 | @startuml 楼层 "0..1 下层" --> "0..1 上层" 楼层 @enduml |
| 1对1 | 环 | @startuml 扑克玩家 "上家 1" --> "1 下家" 扑克玩家 @enduml |
(注意,仅有多重性并不能保证对象图形状,还需要其他的约束,这个内容后文再详谈。)
自反关联对简化模型很有帮助。在许多分析模式和设计模式中,都 可以看到自反关联。例如,当类结构中的泛化层次很深时,就可以通过 把泛化结构转为更高级抽象的自反关联。 
8.3.3 关联的进一步讨论
8.3.3.1 关联是不得不记住的关系
前面已经说过,泛化和关联是类的静态关系,是系统要记住的关系。
假设目标系统是一个企业管理系统。
这个系统可能需要知道一个字符串是否包含另一个字符串,例如 “马宝国”里面是否包含“马宝”?
是否需要像,建立字符串的自反关联?
不需要,因为这个问题已经有人解决了,例如 .NET中的 String.Contains。目标系统不需要一个个记住字符串之间的包含关系。
再来看.NET 的这个 Contains,它是怎样实现的?
是不是通过维护类似下面的字符串之间的关联:
{(“马宝国”,“马”),(“马宝国”,“宝”),(“马宝国”,“国”), (“马宝国”,“马宝”),(“马宝国”,“马国”),……}
然后查询这些关联得到结果?
同样不是。Contains 从来没有,也不需要建立和维护这样的“关 联”,它的结果是通过已经掌握的规律计算出来的。
★.NET(或 Java)的 String.Contains(背后是 IndexOf)如何实 现,读者可自行查询。
目标系统可能还需要计算一个数的立方,例如,x=3,x 的立方等 于多少?
同理,系统也不需要记住{(1,1),(2,8),(3,27),(4,64),……} 这样的对应。
★花絮:国内的某本领域驱动设计名著说,函数式编程的加 1 函数 add1,是根据 x 的值进行模式匹配得到结果的,还给出代码:
def add1(x: Int): Int=>x match{ case 0 =>1 case 1 =>2 case 2 =>3 case 3 =>4 //... } 既然说是“模式匹配”,“模式”这个词蕴含把许许多多个例归纳或 分类的意思。模式的个数应该是少量的,例如一个枚举“商品类型”。 可是,这个 add1 函数,其定义域和值域都是整数,那可是一个无 限集。把每一个整数做模式匹配,岂不是有无限个模式? 我不是函数式编程专家,只是提出疑问,各位读者有空赐教。
回到正题。
目标系统可能还要知道,如果一名员工的工号是 20249527,那么 他的直接上级的工号是什么?
这个就不一样了,我们能找到像下面这样的“工号→直接上级工号” 的计算规律吗?
先判断工号的奇偶,如果是奇数,则乘以 3 再加 1,如果是偶数, 除以 2,重复以上过程,直至得到 1。把停止后得到的数字序列按照 UTF-8 编码转成字符串,即可得到直接上级工号。
可惜,目前并没有发现这样的规律。
这时候,才需要我们建立关联来解决问题。 系统得老老实实维护“员工-->员工”之间的“直接上级(直接下级)” 关联,例如数据库的“员工”表有这样的一些行(假设以工号作为主键):
然后,目标系统在需要时查询这些数据。
我们要建模的,是当前无法计算的内容。
世界上万事万物,只要我们乐意,都可以找出它们之间的关系,但 我们要建模的关联,是系统不得不一个个记住的内容,如果不记住它们, 系统当前就无法完成满足需求的计算。
注意“当前”二字。
“工号→直接上级工号”到底有没有一个计算公式呢?
也许是有的,如果我们宇宙是由神级文明创造和控制的,一切运行 都有其规律。如果有一天,我们掌握了其规律,很可能系统只需要记住 少量参数,就可以完成任意“工号→直接上级工号”的计算。 我们用椭圆来类比。如果不了解椭圆的规律,要记住一个椭圆,需 要记住很多个点的坐标值,而且得到的椭圆还不精确。后来,我们掌握 了椭圆的方程,只需要记住 4 个值:两个焦点 F1 和 F2,两个轴长 a 和 b.
了解了以上的知识,我们在建模关联的时候,要学会分辨哪些是不 得不记住的,哪些是可以计算得到的。
text
图 8-127 删去冗余“关联”后的结果
@startuml
顾客 "1" -- "*" 订单
订单 "1" -- "*" 订单项
订单项 "1" -- "*" 商品
@enduml
当然,这种情况下,没有能力做复杂思考只会刷废话的无能之辈可 能会祭出“性能”的遮羞布,前文已讲过应对方法。
8.3.3.2 关联的进一步细分
是否进一步细分各种关联,各种面向对象方法学观点不同。有的认 为关联就是关联,不用再细分,有的则认为需要进一步细分。
例如,James J. Odell 就把聚合分为 6 种并详细讨论。 UML 规范采取的是中间路线,把关联分为三种:普通关联、聚合 (Aggregation)和组合(Composition)。
用图形表示,普通关联是一根直线,聚合有一端是空心菱形,组合 有一端是实心菱形.
在 UML 元模型中,把它们视为属于三个不同的 AggregationKind.
从元模型上看,“聚合”应该叫作“分享型聚合”,“组合”应该叫 作“组合型聚合”,但本书还是使用“聚合”、“组合”的说法,原因后 文会叙述。 聚合和组合都表示“整体-部分”关联,在类图中,菱形一端表示 整体,另一端表示部分。 相对于聚合,组合还有两条额外的约束:
(1)在同一时刻,部分对象只属于一个整体对象; (2)整体对象被销毁,部分对象也要销毁;
虽然 UML 定义了聚合的概念,但实践中要不要使用聚合,经常会 引起争论。在聚合关联中,部分对象同一时刻可以被多个整体对象共享, 使得“整体-部分”的概念变得模糊,和普通关联难以区分。
James Rumbaugh 等人在《UML 参考手册(第 2 版)》中认为聚 合是建模的“安慰剂”。 Craig Larman 认为不需要使用聚合,在合适的情况下使用组合即 可。
8.3.3.3 关于“整体-部分”关联
8.3.3.3.1 “整体-部分”关联的作用
之所以在关联关系中进一步划分出一个“整体-部分”关联,是希 望把小的对象进一步组装成更大的对象,以获得更大的复用单元。
如果把关联定义为“整体-部分”的关联,意味着部分对象成为整 体对象的部件,外部的对象不能发消息给部分对象,只能发给整体对象, 再由整体对象分解和分配给组成它的部分对象。 类图上有很多类,类之间的密切程度会有所不同。如果根据目前责 任分配的情况,判断某些类之间协作的频率远超过它们和外部其他类协 作的频率,而且预判将来也可能是这样,那么通过建立组合关联来强制 把它们封装成一个整体来分配责任,是合算的。
而“目前责任分配的情况”也不是随意得到的,需要结合类的属性 和关联来分配。这个过程会包括多次互相尝试和互相验证,详细内容在 行为建模部分再讲述。
建立组合关联和公司的部门划分有类似之处。
公司不划分部门,老总一个个员工派任务也能达到目标,只是效率 不高,而且不管出现什么变化都要打开老总的“代码”来修改。
划分部门之后,老总就省心多了,只需要给各部门分配大任务,部 门把任务分解,再分配给部门内的各小组,各小组再把任务分解,分配 给小组内的小小组……这样,各种逻辑就会分散到各个部门、小组、小 小组……
当然,这是有代价的。划分部门之后,上级就不要越过下级去找更 下级,下级也不能想找谁就找谁,都要讲基本法。
如果部门内各下级之间的协作频率远高于和其他部门协作的频率, 说明这样的代价是值得付出的,部门划分以及责任的分解和分配是合理 的。反之则说明不合理。
8.3.3.3.2 本书关于“整体-部分”关联的使用建议
- 建议一:在没有足够证据的情况下,一律使用普通关联,不用组合(聚合)关联。
只有经过序列图、状态机图等进一步建模核心域逻辑之后,有足够 证据支持定义为组合(聚合)关联更有利,才定义组合(聚合)关联用 于指导后续其他用例的责任分配。
经常看见有“架构师”随意使用组合,是一张学员发给 我评点的类图。可以看到,图上到处都是菱形。
如果公司老总在没有充分调研员工能力以及公司业务的情况下,着 急过一把官瘾,胡乱划分部门,提拔干部,会大大损害所有人的利益, 很容易激起反抗。
然而,如果“架构师”因为偷懒或炫耀,胡乱定义组合(聚合)关 联,并不会激起各个代码片段的反抗。计算机程序目前还没有产生自我 意识,没有 Neo(电影“The Matrix”,《黑客帝国》),特别乖,爱怎 么整都可以。
如果建模为普通关联,还得给关联想个合适的名字。 算了,懒得想,貌似说“订单有顾客”也说得通嘛,“有”那不就是组 合(聚合)吗?干脆加个菱形吧,这样还省事,而且相对于一根直线, 菱形让人有高大上的感觉!
最近一些年,由于 DDD 话语对“聚合”过度吹嘘,某些软件开发 人员把“划分聚合”看成“有架构师能力”的表现,于是在没有足够证 据的情况下,兴奋地把“聚合”到处用——哈哈,我会切割系统了, 我架构师了!
这些人的思维经常是颠倒的:先拍脑袋定“聚合”,然后就按 DDD 话语的建议来使用,包括外部对象的访问、创建、访问数据等,然后再 用实现的代码(show me the code 嘛)来“证明”之前划分的“聚 合”是正确的,形成“完美”闭环。
用公司类比,相当于公司老总拍脑袋把张三、李四、王五等人划分 成一个部门,并任命张三为部门领导,然后通过张三发号施令,再用这 个“事实”来“证明”张三作为部门领导是正确的。
当然,组合(聚合)和部门的类比不完全贴切,后文批评“聚合根” 时还会提到。
- 建议二:有必要表达“整体-部分”关联时,仅使用组合,不使用聚合。
这一点和 Larman 是一致的。 我用下面的例子来说明本书的两条建议:
如,把“微信群”和“微信账户”建模为聚合,而且多 重性为多对多。理由是“微信群有微信账户”,而且微信群解散,微信 账户还在。
在没有足够证据时,应该建模为普通关联.
如果一定要使用“整体-部分”关联,使用如图 8-138 的组合。
text
图 8-138 使用组合
@startuml
微信群 "1" *-- "*" 微信群员
微信群员 "1" --> "1" 微信账户 : 使用
@enduml
图 8-138 将“微信群员”和“微信账户”分离,“微信群员”仅属 于一个“微信群”。如果“微信群”对象消失,“微信群员”对象及相关 属性值也就消失了,但“微信账户”还在。
注意,即使是图 8-138,也要有足够证据,而不是为了偷懒和炫 耀。
8.3.3.4 类关系再整理
8.3.3.4.1 UML
有了前面的知识,我们需要再整理一下类的关系。用类图表示 UML 中类的关系如图 8-140。
text
图 8-140
@startuml
关系 <|-- 泛化
关系 <|-- 关联
关系 <|-- 依赖
关联 <|-- 普通关联
关联 <|-- 聚合
聚合 <|-- 共享聚合
聚合 <|-- 组合
@enduml
从图 8-140 可以看到,泛化、关联和依赖在一个抽象级别,普通 关联和聚合在一个抽象级别,共享聚合和组合(即独占聚合)在一个抽 象级别。因此,我们在表达的时候要注意,说“泛化和关联”可以,但 说“泛化和聚合” 、“泛化和组合”或“继承和组合”是不合适的。
8.3.3.4.2 本书的观点
本书的观点是取消“共享聚合”的概念,将其合并到“普通关联” 中。此时,“组合”就等同于“聚合”,于是, “聚合”的概念也可以取 消,得到图 8-141。
text
图 8-141 本书观点的类关系
@startuml
关系 <|-- 泛化
关系 <|-- 关联
关系 <|-- 依赖
关联 <|-- 普通关联
关联 <|-- 组合
@enduml
8.3.3.4.3 和《设计模式》用语的区别
GoF 所 写 的 ”Design Patterns: Elements of Reusable Object-Oriented Software”(《设计模式》),第 1 章中有一句被广为 流传的话:
Favor object composition over class inheritance. 优先使用对象组合而不是类继承。 这句话常让人误解组合和继承是一个级别的,其实,根据 GoF《设 计模式》的用词,这句话中的“组合”应该近似于 UML 中的“关联”。
在 GoF《设计模式》中,给出这句话之后,作者接 下来讨论了 aggregation(聚合)和 acquaintance(认识)的区别, 并且说 acquaintance 有时也被称为 association(关联)或 using(使 用)。然而,在后面的内容中,作者把这几个词全部抛弃,一律使用 composition。
8.3.4 识别泛化关系
8.3.4.1 识别泛化的思路
8.3.4.1.1 直接形成
类图中的两个类可能会直接形成泛化关系,和 识别关联相似,我们可以针对每两个类,思考“A 是 B 的一种吗?”, 再反过来思考“B 是 A 的一种吗?”如果类图中有 n 个类,就需要思 考 2Cn =n(n-1)次。n=11 时,就是 110 次。
实际上,类图上已有的两个类有泛化关系但未识别的情况并不多, 因为之前在识别类和属性以及识别类之间的关联的时候很有可能已经 发现了。
8.3.4.1.1 自下而上(从特殊到一般)
更多的情况是发现类图上已有的两个或多个类有共同特征,于是抽 象出共同的超类.
关联也可以看作类的属性,关联的角色名相当于类的属性名称。如 果多个类关联到同一个类而且角色名相同,也可以考虑泛化出共同的超 类。注意,角色名要相同,否则即使类型相同也不是同一属性。
8.3.4.1.3 自上而下(从一般到特殊)
自上而下-一个类分裂出子类,这个识别思路就是 8.2.5.6 属性是否对所有对 象都有意义里的思路。
8.3.4.2 警惕拼凑泛化
您可能注意到,以上我们尽量通过属性(包括关联)来解释泛化关 系。虽然泛化带来的好处是落在行为上,但如果抛开属性直扑行为,很 可能会带来“伪泛化”、“伪面向对象” 。
例如,因为 X 和 Y 都有操作 op1,所以泛化出 A,把 op1 提上去成为抽象操作。这个没有问题。
问题出在前面,怎么知道 op1 作为 X 和 Y 的操作是合适的?最终 的依据还是 X 和 Y 的属性(包括关联)。
有一种偷懒遮羞布,就是胡乱安排操作,然后拼凑出泛化关系,根 本不顾安排的操作是否合理。
一些伪面向对象实践经常得到这样的类:它的名称的最后是 or 或 er(汉语则为“器” )。,警惕,一旦自己建模出XXX器,XXXX策略的时候 就要想想是不是在瞎安排。
开发人员可能一开始按照面向过程的思路噼里啪啦把代码写出来, 然后出于赶时髦需要“面向对象”,他就把过程名称加上 or 或 er 作为 类名称,然后把原来的过程作为 or 或 er 类的操作。当存在多个类似过 程时,加上一些泛化关系来做点缀,这样看起来就更有“面向对象”的 味道了。
更赤裸裸的,连类都不要了,直接把行为变成“XX接口”、“XXable” 或“可xx”。典型的例子是常被用于 GoF 模式举例的 Flyable(可飞行) 。 然后往“xx接口”、“xxable”或“可xx”上面拼凑“实现”关系,相当 于泛化关系的变体。
可能在某些开发人员眼里, 这样很有格调,可以用来吹嘘的 高大上词汇有:OCP(开放-关闭原则)、DIP(依赖倒置原则)、模板 方法模式……等。这些由泛化关系衍生出来,被网红圈子广泛吹嘘的原 则和模式作用十分有限,指望了解被包装出来的“SOLID 原则”之类 就能应对软件复杂性,那真是太天真了。
★关于 Robert C. Martin 和他的书《敏捷软件开发-原则、方法与 实践》的评价,参见第 1 章关于“敏捷”染色的阐述。
or 或 er 类往往没有属性,只有操作,大量的逻辑仍然隐藏在子类 操作的实现中。当然,开发人员也可能觉得这是好事,“这说明我有算 法啊!”,好像很高大上,但这所谓的“算法”(其实是核心域逻辑)正 是建模的重点,结果被开发人员完美地遮掩过去了。
和其他的偷懒遮羞布类似,这种 or 或 er 的做法一一对应,思考工 作量小,还有各种高大上词汇护法,于是开发人员洋洋得意,感觉自己 已经很厉害了,连称“受用”,纷纷去拥抱这种偷懒遮羞布。
or 或 er 类有时会使用“策略模式”作为伪装,哇, 我可以灵活组装各种策略!顺便再吹一通“组合优于继承”之类,其实 还是换汤不换药,核心域逻辑仍然隐藏在子类操作的实现中。那些“策 略”才是建模的重点,同样被开发人员完美地遮掩过去了。
回到前面所说的,泛化是集合关系。如果泛化结构中的类是类似上 面的这些类,它们封装行为,却没有属性,“类的对象集合”没有了意 义。泛化关系的产生已经脱离核心域知识,很容易出现第 1 章所说的 “思维颠倒”的现象。 如果一个类的命名中有类似这些内容:er、or、器、策略、Strategy、 Policy、规则、Rule、算法、Algorithm……然后这个类或其子类的操 作中有长长的“算法”,那么应该思考一下,长长的“算法”中到底定 义了哪些变量?哪个部分的代码最复杂?
背后往往就是候选的实体类以及需要封装的操作,尽量分离出实体 类,把各种逻辑尽量封装在实体类的操作中。这样的思考更辛苦,但也 更有价值。
有心的读者还可以仔细观察一下你接触到的领域驱动设计相关的 文章,看看有没有这样的现象:
全篇文章没有剖析复杂一点的逻辑,但会有“XX策略”、“XX规则” 这样的类或者组件存在。莫非这些“XX策略”、“XX规则”是别人已经做 好的,他拿来就用?追问下去,多半不是,而是他要负责解决的问题。
你猜我怎么知道是这样?因为我接触的开发团队和开发人员太多 了,追问下去,脓包破裂的概率极高。
8.3.5 泛化的一些重点讨论
8.3.5.1 子集的不相交和完整
泛化是集合关系,在建模泛化关系时,我们对泛化关系中的子类(子 集)的要求应该严格到什么程度?例如,不同子集可以有相同的元素吗? 子集的并集必须等于全集吗?
UML 规范对此持宽松的态度,泛化关系缺省是无约束的。如果要 表达一个元素只能出现在一个子集中,需要给泛化关系加{disjoint}约 束,如果要表达一个元素必须出现在一个子集中,需要给泛化关系加 {complete}约束.
- (1)不相交
本书的观点是:同一个泛化集中的任意子类之间不相交,应该是缺 省的做法。或者说,尽量把泛化建立在不相交的类之上。
例如,自然语言“员工有调度员、装卸工、配货员”,可能有的人 识别成组合关联: 这当然是错误的。“员工有调度员、装卸工、配货员”指的是“员 工”的对象集合包含了“调度员”、 “装卸工”、“配货员”的对象集合, 不是指一个“员工”对象由“调度员”对象、“装卸工”对象、“配货员” 对象组成。这个“有”是集合的“有”,是泛化关系。
但图 8-174 也不是合适的建模结果,因为很可能允许员工身兼数 职,同时既是调度员也是配货员。此时,“员工”的任意子集之间的交 集不再为空,应该调整泛化结构所在的位置,例如改为图 8-175。
text
图 8-175 调整泛化关系的位置
@startuml
员工 "*" --> "*" 职位 : 担任
职位 <|-- 调度员
职位 <|-- 装卸工
职位 <|-- 配货员
@enduml
图 8-175 中, “调度员”、“装卸工”、“配货员”的概念和前面 相比含义已经不同,它们变成了“职位”的一种。“职位”的任意子集 之间的交集应该为空。
如果“调度员”、“装卸工”、“配货员”取“职位”的概念,那么类 似图 8-173 的图 8-176 其实也不是不可以。
text
图 8-176 员工关联到具体职位
@startuml
员工 "*" --> "0..1" 调度员 : 担任
员工 "*" --> "0..1" 装卸工 : 担任
员工 "*" --> "0..1" 配货员 : 担任
@enduml
读者可以思考一下,如果把图 8-176 中的“员工”换成图 8-86 的“人”,下方的类换成“▲▲”和“〇〇” ,可以吗?
如果对于不同职位,系统关注的差别在属性值上就可以体现,不需 要在行为上体现,那么,图 8-175 的泛化关系可以取消,只保留“职 位”,如图 8-177。
text
图 8-177 删去子类以及泛化关系
@startuml
员工 "*" --> "*" 职位 : 担任
@enduml
★图 8-175 到图 8-177 的“职位”,严格来说应该是“职位规格”。 可以看得出来,“职位”实例的属性值不会随具体员工而变化。如果要 记住张三做装卸工和配货员分别干了多少活,拿了多少绩效,还需要另 外的类来区分。例如“员工-任职-职位”或“员工-职位-职位规格”。
- (2)完整
本书认为,不需要要求子类完整,否则会导致批量刷废话。
例如一开始 A 有两个子类 B 和 C,知道应该是不完整的。 如果为了完整,强行加一个口袋子类“其他 A”,那么很可能所有的泛 化关系都会在结尾加一个“其他*”,这种批量的废话不如不要,缺省认 为其不完整即可。
而且,“其他 A”的集合元素是不定的,现在的含义是“非 B 非 C”, 假设以后 A 再加一个子类 D,“其他 A”的含义就变成“非 B 非 C 非 D” , 元素也变少了。
当然,如果添加 D 为 B 或 C 更下一级的子类,是可以的。如果“其 他 A”经过时间的沉淀变成了一个领域术语,D 作为“其他 A”的子类 也无妨——第 6 章所列的需求分类中的“非功能需求”就是这样的“其 他 A”的例子。
8.3.5.2 Liskov 替换原则和矩形-正方形问题
1988 年,Liskov 在“Data abstraction and hierarchy”文章中 提出了一个判断子类型的标准,后来被称为 Liskov 替换原则(LSP):
如果对于每个类型 S 的对象 o1,都有类型 T 的对象 o2,对于所 有以 T 的形式定义的程序 P,当用 o1 替换 o2 时,P 的行为不变,那 么 S 是 T 的子类型。
很多书和文章中提到 Liskov 替换原则时,会以矩形和正方形(有 时会换成椭圆和圆)的问题为例。
假设把正方形看作矩形的子类。
图 8-180 正方形作为矩形的子类
设置某矩形的 A 边长为 4,再设置 B 边长为 5,按照设想,此时求 面积应该得到 4×5=20。如果用正方形代替矩形,经过上面两次设置 后,最终得到的面积是 5×5=25。根据 Liskov 替换原则判断,图 8-180 不合适。
关于这个问题,网络上搜索到的文章大多是从实现技巧的角度来解 释和解决。 本书从领域知识和集合的角度来谈一谈这个问题。
从领域知识上看,矩形的定义是:有一个角是直角的平行四边形。 由此衍生的性质有:对边平行且相等、对角线互相平分且相等、面积= 长×宽……。正方形也确实符合矩形的定义并具有矩形的性质,所以, 正方形是矩形的子类(子集) ,是正确的。
但是,矩形还有其他没有画出来的子类(子集),而我们不知不觉 地把没画出来的矩形子集的性质当成了所有矩形的性质,导致了冲突。
如,A1 是 A 的子类,因为 A1 有公认的名字(例如正方 形),所以被显式画出来。但 A 集合除了 A1 子集之外,还有其他子集, 可能没有想好名字,没画出来。
假设此时我们想到一个对象 x,x 是 A 集合的一个元素,但不是 A1 集合的元素。也就是说,x 可能是A的子类.
如果图上没有显式画出?,只有 A1 和 A,既然 x 不属于 A1,那 就只剩下 A 了。于是,我们在意识中记住了“x 是 A 的元素”,忘记了 和 A1 同一级别的表述应该是“x 是?的元素”,然后就会产生?和 A 相等的错觉。
正如上一小节所说,在建模泛化关系时,没有必要为了完整硬要加 一个“其他*”什么之类的,但我们心里必须有这个意识。
合适的处理方法是,把不属于超类的性质从超类移除出去。
可以如图 8-182。正方形是矩形的子类,除此之外,还有其他子 类,如自由矩形、正方形和黄金分割矩形(边长比为黄金分割比 0.618····: 1)等。超类不实现“设置 A 边(a)”、设置 B 边(b)”的操作,也不应 先入为主地认为操作和属性会一一对应,例如“设置 A 边(a)”操作只 会影响属性“A 边长”的值。
text
图 8-182 改进后的矩形泛化类图
@startuml
class 矩形 {
- A边长
- B边长
+ 设置A边(a)
+ 设置B边(b)
+ 求面积()
}
class 自由矩形 {
+ 设置A边(a)
+ 设置B边(b)
}
class 正方形 {
+ 设置A边(a)
+ 设置B边(b)
}
class 黄金分割矩形 {
+ 设置A边(a)
+ 设置B边(b)
}
class "X" {
+ 设置A边(a)
+ 设置B边(b)
}
矩形 <|-- 自由矩形
矩形 <|-- 正方形
矩形 <|-- 黄金分割矩形
矩形 <|-- "X"
@enduml
更彻底的如图 8-183,把属性的定义也放到子类。
text
图 8-183 属性的定义放到子类
@startuml
class 自由矩形 {
- A边长
- B边长
}
class 正方形 {
- 边长
}
class 黄金分割矩形 {
- A边长
}
class X {
}
矩形 <|-- 自由矩形
矩形 <|-- 正方形
矩形 <|-- 黄金分割矩形
矩形 <|-- X
@enduml
还有一个经常容易造成困惑的地方。
一个“自由矩形”对象,一开始的边长是(3,4),设置 A 边,让 边长变成(4,4),它是不是就应该变成一个“正方形”对象?后面还能 再自由设置边长吗?
它依然是“自由矩形”,只不过其两个边长在某个时刻碰巧相等, 它并不是一个边长为 4 的“正方形”对象。这两个对象的属性值虽然 相同,但遵循的行为规则是不同的。
类不止定义了属性,还定义了和这些属性相关的行为规则。
一把手牌 3 张 K,您觉得这个牌怎么样?
如果是“斗地主牌”,算是好牌,如果是“21 点牌”,就已经爆了。
还有一种做法,把“等边”、“黄金分割”等都看成状态,如图 8-185。
text
图 8-185 用状态来表达不同矩形
@startuml
state 矩形 {
state "等边" as equal
state "黄金分割" as golden
state "自由" as free
[*] --> equal : 设置边长a(a)or设置边长a(b)[A边长/B边长 == 1]/a边长=a or b边长=b
[*] --> golden : 设置边长a(a)or设置边长a(b) == φ 或 1/φ]/a边长=a or b边长=b
[*] --> free : 设置边长a(a)or设置边长a(b)[else]/a边长=a or b边长=b
}
@enduml