1.4 软件设计中的抽象
软件设计是单纯的思维创造活动,其中最关键的是抽象思维。可以说,抽象是软件设计的核心,特别是在面向对象设计中,如果没有好的抽象概念,就不可能设计和编写出好的面向对象(Object Oriented,OO)程序。
1.4.1 面向对象的核心是抽象
作为当今最重要的软件工程技术之一,面向对象(Object Oriented,OO)技术实际上由3个部分组成,分别是面向对象分析(Object Oriented Analysis,OOA)、面向对象设计(Object Oriented Design,OOD)和面向对象编程(Object Oriented Programming,OOP)。
OOA是一种分析方法,这种方法利用从问题域的词汇表中找到的类和对象来分析需求,也就是我们日常说的“找名词”。当然,实际情况不仅仅是找名词这么简单,更多时间,我们需要使用抽象思维从复杂的需求中挖掘关键概念和实体。
OOD是一种设计方法,包括面向对象分解的过程和表示法,这种表示法用于展现被设计系统的逻辑模型和物理模型、静态模型和动态模型。通常使用UML提供的那套表示法工具。
OOP是我们常用并且很熟悉的,当今的编程语言基本都是面向对象的。OOP是一种实现方法,在这种方法中,程序被组织成许多组相互协作的类,类之间会通过继承、组合、使用形成一定的层次结构。
OOA、OOD和OOP之间的关系是,OOA的结果可以帮助我们设计OOD的模型,而OOD的结果可以作为蓝图,最终利用OOP方法实现一个系统。
由此可见,面向对象技术与传统的结构化设计方法是不同的,它要求以一种不同的方式来思考问题。这种思考方式对我们的抽象能力提出了更高的要求,因为不管是OOA的问题域分析,还是OOD和OOP的对象建模,编程实现都离不开抽象思维。 [1]
1.4.2 抽象设计的评判标准
类设计是一个增量迭代的过程。坦白地说,除了那些最不重要的抽象设计,我们从来没有在第一次就完全正确地定义一个类。对于最初的抽象设计,需要花一些时间来琢磨它粗糙的概念边界。当然,优化这些抽象设计是有代价的,包括系统的重新设计、系统设计的可理解性和设计结构的完整性等方面,因此我们希望在一开始就尽量正确。
怎样才能知道某个类的抽象设计是否良好呢?我们可以通过它的耦合性、内聚性、充分性和完整性4个指标来度量。
(1)耦合性 :强耦合使系统变得复杂,因为如果某个模块与其他模块过度相关,它就难以独立地被理解、变化或修正,通过降低耦合性,可以降低复杂性。在耦合和继承的概念之间存在着矛盾关系,继承引入了严重的耦合。一方面,我们希望类之间弱耦合;另一方面,继承又能帮助我们处理抽象之间的共性。我们通常说“组合优于继承”,正是因为继承的耦合性比较强。鉴于此,有些编程语言(比如Go语言)就直接取消了继承。
(2)内聚性 :内聚测量了单个模块(类、包、组件)内各个元素的联系程度。我们最不希望出现偶然性内聚,即完全无关的抽象被塞进同一个类或模块中。例如,考虑由狗和航天飞机的抽象组成的一个类。我们最希望出现功能性内聚,即一个类或模块的各元素一同工作,提供某种清晰界定的行为。如果Cow类的语义包含了一头牛的行为——完全是牛,只有牛而没有其他,那么它就是功能性内聚。
(3)充分性 :所谓充分,是指类或模块应该记录某个抽象设计足够多的特征,从而允许有意义的交互,否则将使组件变得无用。例如,如果我们设计Set(集合)类,应该包含从集合中添加、删除元素的操作,如果忘记设置这些操作,那么这个Set类的功能就是不充分的。好在只要我们构建一个必须使用这种抽象的客户,这种问题很早就会被发现。
(4)完整性 :完整是指类或模块的接口记录了某个抽象全部有意义的特征。充分性意味着最小的接口,但一个完整的接口意味着该接口包含了某个抽象的所有反向。完整性是一种主观判断,我们有可能做过头。为某个抽象提供全部有意义的操作会让用户不知所措,通常也是不必要的,因为许多高级操作可以由低级操作组合得到。例如,向集合里添加4个元素的操作就是不必要的接口,因为可以通过基础的Add操作得到同样的效果。
1.4.3 抽象缺失之基础类型偏执
基础类型偏执(Primitive Obsession)是Martin Fowler在《重构:改善既有代码的设计》一书中提到的一种典型的代码“坏味道”,意思是我们使用了太多的基础类型,导致有些应该被抽象成实体类的概念,却以基础类的形式散落在代码各处,这是一种典型的抽象缺失。
由于抽象缺失,相关的数据和行为将分散在其他抽象概念中,这将导致两个问题。
(1)暴露太多的实现细节,从而违反封装原则。
(2)数据和行为分散在代码的多个地方,导致代码重复、类之间耦合度变高、代码难以维护和理解等问题。
比如在一个图书馆信息管理应用程序中,国际标准书号(International Standard Book Number,ISBN)的存储和处理非常重要。一种自然的做法是将ISBN设计成字符串,毕竟它在数据库中的确也是以字符串形式存储的。然而,这并不是一个好的选择,为什么呢?
ISBN有两种表示方式,分别是10位和13位的,这两种形式之间可以转换。ISBN的各位都有其含义。例如,13位的ISBN由商品编号(图书产品代码978或979)、地区代码、出版社代码、书序码和校验码组成。
比如我写的第一本书《代码精进之路》,它的ISBN是978-7-115-52102-6,如图1-3所示。

图1-3 ISBN示例
ISBN的最后一位是校验码,其计算方式如下:从第一位开始,奇数位的值保持不变,而偶数位的值乘以3,将所有这些值相加再除以10,用10减去得到的余数就是最后一位的值。因此,给定一个ISBN,我们可以通过这种方式校验它是否有效。
对于图书馆管理系统来说,ISBN并不是一个简单的字符串,它本身就是业务核心,包含了一系列业务逻辑,比如关于ISBN的创建、验证、处理和转换,以及通过ISBN获取地区信息、出版社信息、书号等。如果将ISBN设计为基础类型字符串,那么这些处理逻辑将重复分散在很多地方。这种不将ISBN封装为类的行为,将带来因为抽象缺失导致的一系列不良后果。
因此正确的做法是,我们要对ISBN建立合理的抽象(类)概念,创建一个ISBN的接口,其中包含通用的抽象操作:

并创建子类ISBN-10和ISBN-13,它们都扩展了超类ISBN,如图1-4所示。

图1-4 ISBN设计类图
再比如,假设现在要实现一个功能,让A用户可以给用户B支付x元,可能的实现如下:

如果这是境内转账,并且境内的货币永远不变,该方法似乎没什么问题。但如果有一天货币变更了(比如欧元区曾经出现的问题),或者我们需要做跨境转账,该方法有明显的bug,因为money对应的货币不一定是CNY。
在这里,当我们说“支付x元”时,除了x本身的数字,实际上还有一个隐含的概念,那就是货币“元”。但是在原始的入参里,之所以只用了BigDecimal,是因为我们认为CNY货币是默认的,是一个隐含的条件。然而在我们写代码时,需要把所有隐性的条件显性化。
所以当我们实现支付功能时,实际上需要的一个入参是“支付金额+支付货币”。我们可以把这两个概念组合成为一个独立的完整概念——Money。

而原有的代码则变为:

通过将默认货币这个隐性的概念显性化,并且和金额合并为Money这个抽象概念,我们可以避免很多当前看不出来但未来可能会“爆雷”的bug。
将前面的案例升级一下,假设用户可能要做跨境转账(从CNY到USD),并且货币汇率随时在波动:

现在最大的问题在于,金额的计算被包含在了支付的服务中,涉及的对象也有2个Currency、2个Money、1个BigDecimal,总共5个对象。这种涉及多个对象的业务逻辑,需要一个新的抽象概念进行封装。
我们可以考虑将转换汇率的功能封装到一个叫作ExchangeRate的DP(Domain Primitive) [2] 里:

ExchangeRate汇率对象通过封装金额计算逻辑及各种校验逻辑,使原始代码变得极其简单:

1.4.4 抽象缺失之重复代码
如果说抽象源于对共性的提取,那么代码中的重复代码是不是就意味着抽象缺失呢?
重复代码是典型的代码坏味道,其本质问题就是抽象缺失。使用“Ctrl+C”加“Ctrl+V”的工作习惯导致没有对共性代码进行抽取,或者虽然抽取了,但没有设置一个合适的名字,没有正确地反映这段代码所体现的抽象概念,这些都属于抽象不到位。
有一次,我在审查团队代码的时候,发现有一段组装搜索条件的代码,如图1-5所示,这段代码在几十个地方都有重复。

图1-5 组装搜索条件的重复代码
这个搜索条件比较复杂,是以元数据的形式存在于数据库中的,因此组装的过程分为两步。
(1)从缓存中把搜索条件列表取出来。
(2)遍历这些条件,将搜索的值填充进去。

简单的重构无外乎就是把这段代码提取出来,放到一个Util类中以便复用。然而我认为这样的重构只是完成了一半的工作——只是做了简单的归类,并没有做抽象提炼。
简单分析,不难发现, 此处我们缺失了两个概念 :一个是用来表达搜索条件的类——SearchCondition,另一个是用来组装搜索条件的类——SearchConditionAssembler。只有配合命名,显性化地将这两个概念表达出来,才是一个完整的重构。
重构后,搜索条件的组装会变成一种非常简洁的形式,几十处的代码复用只需要引用SearchConditionAssembler就好了:

由此可见, 提取重复代码只是重构工作的第一步。对重复代码进行概念抽象,寻找有意义的命名才是我们工作的重点 。
因此,每次遇到重复代码需要重构的时候,你都应该感到兴奋,这是一次锻炼抽象能力的绝佳机会。
1.4.5 抽象设计要完整
好的抽象设计是内聚而完整的。为了支持相关的方法,可能会影响抽象的内聚性和完整性。例如,要在数据结构中添加和删除元素,抽象该数据结构的类型必须同时支持方法add()和remove();如果只支持相关方法中的一个,那么抽象设计就不是内聚和完整的。
例如,在JDK 1.1的接口javax.swing.ButtonModel中,只提供了setGroup()方法,而没有提供getGroup(),这是一种典型的“不完整的抽象设计”坏味道。修复它的最理想的方法是在这个接口中定义方法getGroup(),然而由于JDK是公开的API,在接口上添加方法将破坏实现了该接口的既有类。为了向后兼容,在JDK 1.3中,将方法getGroup()加入了派生类DefaultButtonModel中。
这个例子告诉我们:修改接口是一件很难的事情,因此在最初设计API的时候,要尽量做到抽象完整。
有一种检查抽象设计是否完整的方法,是查看接口或类是否缺少“互补和对称”。如果缺少,则可能存在着“不完整的抽象设计”。表1-1列出了一些常见的互补方法对,请注意这些方法名会根据不同的情况而有差异。例如,在表示栈的类中,会使用操作名push和pop;而在数据流中,同样的操作可能使用名称source和sink。
表1-1 一些常见的互补方法对

1.4.6 不要为了抽象而抽象
抽象的前提是共性抽取,抽象思维之所以如此重要,因为它涉及软件设计的方方面面,小到一个方法、一个类的设计,大到系统架构。 有时,不合理地抽象比没有抽象对系统的伤害更大 。
假如某互联网公司同时开展了电商业务和电影票业务,每条业务线都有独立的C端系统、后台交易系统(包括商品管理、订单管理、营销管理)来支持业务。为了追逐潮流,公司决定将两条业务线的订单中心合并,实现订单中台,如图1-6所示。

图1-6 并不一定正确的订单中台架构
实际上,公司经营的B2C电商业务和电影票业务,在交易形态上有较大的区别,尤其体现在订单模块的设计上,订单的状态机、数据模型和财务账务处理模式完全不同。两者并没有太多的共性模块和功能,强行将两者合并后,最终只是表面上看起来实现了订单中台,但是其中的功能模块各自独立运转,完全没有实现抽象和复用。
现在,公司管理者以为拥有了强大的订单中台,可以为快速开展新业务提供支持。很快,公司决定开展机票售卖业务,针对机票业务,有独立的C端、商品管理、促销管理。
但是当产品经理和工程师开始期待订单中台的强大功能时,却遗憾地发现:订单中台无法给机票业务提供任何现成的功能复用能力,机票的订单模型和电商、电影票都不相同。
机票业务线的设计人员面临一个尴尬的局面。
● 要么按部就班地将机票订单中心纳入订单中台,统一建设——但实际上这会严重降低开发效率,因为中台研发团队肯定不会像机票业务研发团队那样重视新业务的开展。
● 要么抛弃订单中台,机票业务研发团队独立开发订单模块,但这样做又会显得订单中台没有产生应有的价值。
此时的系统架构如图1-7所示。

图1-7 加入机票业务后的订单中台架构
可见,在不同的业务模式下,订单中心并不一定适用于中台化建设,设计人员要有足够的思辨能力,判断产品形态上是否值得抽象下沉、是否能够提供复用能力。然而,这也是软件工程设计中非常难的部分。
任何软件系统的设计都基于归纳法,而非演绎法,即软件设计人员总是通过对现有世界和业务的总结提炼,而无法通过推测演绎完成软件设计。设计人员无法对业务的未来做出预测,只能基于有限的经验,尽量保证设计的灵活性和正确性。
理解这一点非常重要,这会让你在软件设计、产品设计时心存敬畏,不会因一味地追求短期无法论证的结论而产生严重的过度设计。在实践中,对于基于抽象复用的平台建设,有以下几条建议。
(1)对于明显具备共性的模块,尽早抽象。
在B端产品的体系化设计中,很多形态的产品是具备明显共性的,我们可以尽早地进行抽象设计,这样在系统架构建设的早期就能做出正确的设计方案,而且并不会过多地增加研发工作量,相反会让未来的系统扩展更加轻松。
例如,业务系统中的统一权限管理系统、单点登录系统、组织架构系统、公告系统、短信系统等,都应该尽早完成抽象建设。
(2)对于共性不确定的模块,事后抽象。
对于统一客户视图、订单中心、商品系统等软件模块,很难判断在多业务线场景下是否能够完全复用。如果对于是否进行抽象拿不准主意,那么完全可以先不做,等业务渐渐明确后,有足够的信息做出充分的分析和判断时,再决定是否合并抽象设计。