重拾面向对象软件设计

作者: 聂晓龙

你还在用面向对象的语言,写着面向过程的代码吗?

前言

在欧洲文艺复兴时期,一位伟大的数学家天文学家-哥白尼,在当时提出了日心说,驳斥了以地球为宇宙中心的天体思想,由于思想极其超前,直到半个世纪后开普勒伽利略等人经过后期研究,才逐步认可并确立了当时哥白尼思想的先进性。

无独有偶,在软件工程领域也上演着同样的故事。半个世纪前 Kristen Nygaard 发明了 Simula 语言,这也是现在被认同的世界上第一个明确实现面向对象编程的语言,他提出了基于类的编程风格,确定了"万物皆对象"这一面向对象理论的"终极思想",但在当时同样未受到认可。Peter Norvig 在 Design Patterns in Dynamic Programming 对此予以了驳斥,并表述我们并不需要什么面向对象。半个世纪后 Robert C.Martin、Bertrand Meyer、Martin Fowler 等人,再次印证并升华了面向对象的设计理念。编程思想的演进也不是一蹴而就,但在这一个世纪得到了飞速的发展。

编程思想的演进

从上个世纪五十年代冯·诺依曼创造第一台计算机开始,一直到现在只有短短 70 年时间,从第一门计算机语言 FORTRAN,到现在我们常用的 C++,JAVA,PYTHON 等,计算机语言的演进速度远超我们所使用的任何一门自然语言。从最早的面向机器,再到面向过程,到演化为现在我们所使用的面向对象。不变的是编程的宗旨,变化的是编程的思想。

面向机器

1.png

计算机是 01 的世界,最早的程序就是通过这种 01 机器码来控制计算机的,比如 0000 代表读取,0001 代表保存等。理论上这才是世界上最快的语言,无需翻译直接运行。但弊端也很明显,那就是几乎无法维护。运行 5 毫秒,编程 3 小时。由于机器码无法维护,人们在此基础上发明了汇编语言,READ 代表 0000,SAVE 代表 0001,这样更易理解和维护。虽然汇编在机器码上更可视更直观,但本质上还是一门面向机器的语言,依然还是存在很高的编程成本。

面向过程

2.png

面向过程是一种以事件为中心的编程思想,相比于面向机器的编程方式,是一种巨大的进步。我们不用再关注机器指令,而是聚焦于具体的问题。它将一件事情拆分成若干个执行的步骤,然后通过函数实现每一个环节,最终串联起来完成软件设计。

流程化的设计让编码更加清晰,相比于机器码或汇编,开发效率得到了极大改善,包括现在仍然有很多场景更适合面向过程来完成。但软件工程最大的成本在于维护,由于面向过程更多聚焦于问题的解决而非领域的设计,代码的重用性与扩展性弊端逐步彰显出来,随着业务逻辑越来越复杂,软件的复杂性也变得越来越不可控。

面向对象

3.png

面向对象以分类的方式进行思考和解决问题,面向对象的核心是抽象思维。通过抽象提取共性,通过封装收敛逻辑,通过多态实现扩展。面向对象的思想本质是将数据与行为做结合,数据与行为的载体称之为对象,而对象要负责的是定义职责的边界。面向过程简单快捷,在处理简单的业务系统时,面向对象的效果其实并不如面向过程。但在复杂系统的设计上,通用性的业务流程,个性化的差异点,原子化的功能组件等等,更适合面向对象的编程模式。

但面向对象也不是银弹,甚至有些场景用比不用还糟,一切的根源就是抽象。根据 MECE 法则 将一个事物进行分类,if else 是软件工程最严谨的分类。我们在设计抽象进行分类时,不一定能抓住最合适的切入点,错误的抽象比没有抽象复杂度更高。里氏替换原则的创始人 Barbara Liskov 谈抽象的力量 The Power of Abstraction。

面向领域设计

真在”面向对象“吗

// 捡入客户到销售私海
public String pick(String salesId, String customerId){
    // 校验是否销售角色
    Operator operator = dao.find("db_operator", salesId);
    if("SALES".equals(operator.getRole())){
        return "operator not sales";
    }
    // 校验销售库容是否已满
    int hold = dao.find("sales_hold", salesId);
    List<CustomerVo> customers = dao.find("db_sales_customer", salesId);
    if(customers.size() >= hold){
        return "hold is full";
    }
    // 校验是否客户可捡入
    Opportunity opp = dao.find("db_opportunity", customerId);
    if(opp.getOwnerId() != null){
        return "can not pick other's customer";
    }
    // 捡入客户
    opp.setOwnerId(salesId);
    dao.save(opp);
    return "success";
}

这是一段 CRM 领域销售捡入客户的业务代码。这是我们熟悉的 Java-面向对象语言,但这是一段面向对象代码吗?完全面向事件,没有封装没有抽象,难以复用不易扩展。相信在我们代码库,这样的代码不在少数。为什么?因为它将成本放到了未来。我们将此称之为“披着面向对象的外衣,干着面向过程的勾当。”

在系统设计的早期,业务规则不复杂,逻辑复用与扩展体现得也并不强烈,而面向过程的代码在支撑这些相对简单的业务场景是非常容易的。但软件工程最大的成本在于维护,当系统足够复杂时,当初那些写起来最 easy 的代码,将来就是维护起来最 hard 的债务。

领域驱动设计

4.png

还有一种方式我们也可以这么来写,新增“商机”模型,通过商机来关联客户与销售之间的关系。而商机的归属也分为公海、私海等具体归属场景。商机除了有必要的数据外,还应该收拢一些业务行为,捡入、开放、分发等。通过领域建模,利用面向对象的特性,确定边界、抽象封装、行为收拢,对业务分而治之。

当我们业务上说“商机分发到私海”,而我们代码则是“opportunity.pickTo(privateSea)”。这是领域驱动所带来的改变,面向领域设计,面向对象编程,领域模型的抽象就是对现实世界的描述。但这并非一蹴而就的过程,当你只触碰到大象的身板时,你认为这是一扇门,当你触碰到大象的耳朵时,你认为是一片芭蕉。只有我们不断抽象不断重构,我们才能愈发接近业务的真实模型。

Use the model as the backbone of a language, Recognize that a change in the language is a change to the model.Then refactor the code, renaming classes, methods, and modules to conform to the new model

--- Eric Evans 《Domain-Driven Design Reference》

译:使用模型作为语言的支柱,意识到言语的改变就是对模型的改变,然后重构代码,重命名类,方法和模块以符合新模型。

软件的复杂度

5.png

这是 Martin Flowler 在《Patterns of Enterprise Application Architecture》这本书中所提的关于复杂度的观点,他将软件开发分为数据驱动与领域驱动。很多时候开发的方式大家倾向于,拿到需求后看表怎么设计,然后看代码怎么写,这其实也是面向过程的一个表现。在软件初期,这样的方式复杂度是很低的,没有复用没有扩展,一人吃饱全家不饿。但随着业务的发展系统的演进,复杂度会陡增。

而一开始通过领域建模方式,以面向对象思维进行软件设计,复杂度的上升可以得到很好的控制。先思考我们领域模型的设计,这是我们业务系统的核心,再逐步外延,到接口到缓存到数据库。但领域的边界,模型的抽象,从刚开始成本是高于数据驱动的。

The goal of software architecture is to minimize the human resources required to build and maintain the required system.

--- Robert C. Martin 《Clean Architecture》

译:软件架构的终极目标是,用最小的人力成本来满足构建和维护该系统的需求

如果刚开始我们直接以数据驱动面向过程的流程式代码,可以很轻松的解决问题,并且之后也不会面向更复杂的场景与业务,那这套模式就是最适合这套系统的架构设计。如果我们的系统会随着业务的发展逐渐复杂,每一次的发布都会提升下一次发布的成本,那么我们应该考虑投入必要的成本来面向领域驱动设计。

抽象的品质

抽象永远是软件工程领域最难的命题,因为它没有规则,没有标准,甚至没有对错,只分好坏,只分是否适合。同样一份淘宝商品模型的领域抽象,可以算是业界标杆了,但它并非适合你的系统。那我们该如何驾驭“抽象”呢?UML 的创始人 Grady booch 在 Object Oriented Analysis and Design with Applications 一书中,提到了评判一种抽象的品质可以通过如下 5 个指标进行测量:耦合性、内聚性、充分性、完整性与基础性。

耦合性

一个模块与另一个模块之间建立起来的关联强度的测量称之为耦合性。一个模块与其他模块高度相关,那它就难以独立得被理解、变化或修改。TCL 语言发明者 John Ousterhout 教授也有同样的观点。我们应该尽可能减少模块间的耦合依赖,从而降低复杂度。

Complexity is caused by two things: dependencies and obscurity.

--- John Ousterhout 《A Philosophy of Software Design》

译:复杂性是由两件事引起的:依赖性和模糊性。

但这并不意味着我们就不需要耦合。软件设计是朝着扩展性与复用性发展的,继承天然就是强耦合,但它为我们提供了软件系统的复用能力。如同摩擦力一般,起初以为它阻碍了我们前进的步伐,实则没有摩擦力,我们寸步难行。

内聚性

内聚性与耦合性都是结构化设计中的概念,内聚性测量的是单个模块里,各个元素的的联系程度。高内聚低耦合,是写在教科书里的观点,但我们也并非何时何地都应该盲目追求高内聚。

内聚性分为偶然性内聚与功能性内聚。金鱼与消防栓,我们一样可以因为它们都不会吹口哨,将他们抽象在一起,但很明显我们不该这么干,这就是偶然性内聚。最希望出现的内聚是功能性内聚,即一个类或模式的各元素一同工作,提供某种清晰界定的行为。比如我将消防栓、灭火器、探测仪等内聚在一起,他们是都属于消防设施,这是功能性内聚。

充分性

充分性指一个类或模块需要应该记录某个抽象足够多的特征,否则组件将变得不用。比如 Set 集合类,如果我们只有 remove、get 却没有 add,那这个类一定没法用了,因为它没有形成一个闭环 。不过这种情况相对出现较少,只要当我们真正去使用,完成它的一系列流程操作后,缺失的一些内容是比较容易发现并解决的。

完整性

完整性指类或模块需要记录某个抽象全部有意义的特征。完整性与充分性相对,充分性是模块的最小内涵,完整性则是模块的最大外延。我们走完一个流程,可以清晰得知道我们缺哪些,可以让我们马上补齐抽象的充分性,但可能在另一个场景这些特征就又不够了,我们需要考虑模块还需要具备哪些特征或者他应该还补齐哪些能力。

基础性

充分性、完整性与基础性可以说是 3 个相互辅助相互制约的原则。基础性指抽象底层表现形式最有效的基础性操作(似乎用自己在解释自己)。比如 Set 中的 add 操作,是一个基础性操作,在已经存在 add 的情况下,我们是否需要一次性添加 2 个元素的 add2 操作?很明显我们不需要,因为我们可以通过调用 2 次 add 来完成,所以 add2 并不符合基础性。

但我们试想另一个场景,如果要判断一个元素是否在 Set 集合中,我们是否需要增加一个 contains 方法。Set 已经有 foreach、get 等操作了,按照基础性理论,我们也可以把所有的元素遍历一遍,然后看该元素是否包含其中。但基础性有一个关键词叫“有效”,虽然我们可以通过一些基础操作进行组合,但它会消耗大量资源或者复杂度,那它也可以作为基础操作的一个候选者。

软件设计原则

抽象的品质可以指导我们抽象与建模,但总归还是不够具象,在此基础上一些更落地更易执行的设计原则涌现出来,最著名的当属面向对象的五大设计原则 S.O.L.I.D。

开闭原则 OCP

Software entities should be open for extension,but closed for modification

-- Bertrand Meyer 《Object Oriented Software Construction》

译:软件实体应当对扩展开放,对修改关闭。

开闭原则是 Bertrand Meyer 1988 年在《Object Oriented Software Construction》书中所提到一个观点,软件实体应该对扩展开放对修改关闭。

我们来看一个关于开闭原则的例子,需要传进来的用户列表,分类型进行二次排序,我们代码可以这样写。

public List<User> sort(List<User> users, Enum type){
    if(type == AGE){
        // 按年龄排序
        users = resortListByAge(users);
    }else if(type == NAME){
        // 按名称首字母排序
        users = resortListByName(users);
    }else if(type == NAME){
        // 按客户健康分排序
        users = resortListByHealth(users);
    }
    return users;
}

上述代码就是一个明显违背开闭原则的例子,当我们需要新增一种类似时,需要修改主流程。由于这些方法都定义在私有函数中,我们哪怕对现有逻辑做调整,我们也需要修改到这份代码文件。

还有一种做法,可以实现对扩展开放对修改关闭,JDK 的排序其实已经为我们定义了这样的标准。我们将不同的排序方式进行抽象,每种逻辑单独实现,单个调整逻辑不影响其他内容,新增排序方式也无需对已有模块进行调整。

6.png

依赖倒置 DIP

High level modules shouldnot depend upon low level modules.Both should depend upon abstractions. Abstractions should not depend upon details. Details should depend upon abstractions

--- Robert C.Martin C++ Report 1996

译:高层模块不应该依赖低层模块,两者都应该依赖抽象;抽象不应该依赖细节,细节应该依赖抽象。

Robert C.Martin 是《Clean Code》《Code Architecture》两本经典书籍的作者,1996 年他在 C++ Report 中发表了一篇名为 The Dependency Inversion Principle 的文章。他认为模块间的依赖应该是有序的,高层不应该依赖低层,低层应该依赖高层,抽象不应该依赖细节,细节应该依赖抽象。

7.png

怎么理解 Robert C.Martin 的这一观点?我们看上面这张图,我们的手可以握住这个杯子,是我们依赖杯子吗?有人说我们需要调杯子提供的 hold 服务,我们才能握住它,所以是我们依赖杯子。但我们再思考一下,棍子我们是不是也可以握,水壶我们也可以握,但猫狗却不行,为什么?因为我们的杯子是按照我们的手型进行设计的,我们定义了一个可握持的 holdable 接口,杯子依赖我们的需求进行设计。所以是杯子依赖我们,而非我们依赖杯子。

8.png

依赖倒置原则并非一个新创造的理论,我们生活的很多地方都有在运用。比如一家公需要设立“法人”,如果这家公司出了问题,监管局就会找公司法人。并非监管局依赖公司提供的法人职位,它可以找到人,而是公司依赖监管局的要求,才设立法人职位。这也是依赖倒置的一种表现。

其他设计原则

这里没有一一将 S.O.L.I.D 一一列举完,大家想了解的可以自行查阅。除了 SOLID 之外,还有一些其他的设计原则,同样也非常优秀。

PLOA 最小惊讶原则

If a necessary feature has a high astonishment factor, it may be necessary to redesign the feature

-- Michael F. Cowlishaw

译:如果必要的特征具有较高的惊人因素,则可能需要重新设计该特征。

PLOA 最小惊讶原则是斯坦福大家计算机教授 Michael F. Cowlishaw 提出的。不管你的代码有“多好”,如果大部分人都对此感到吃惊,或许我们应该重新设计它。JDK 中就存在一例违反 PLOA 原则的案例,我们来看下面这段代码。

/**
 * Set a <tt>Formatter</tt>.  This <tt>Formatter</tt> will be used
 * to format <tt>LogRecords</tt> for this <tt>Handler</tt>.
 * <p>
 * Some <tt>Handlers</tt> may not use <tt>Formatters</tt>, in
 * which case the <tt>Formatter</tt> will be remembered, but not used.
 * <p>
 * @param newFormatter the <tt>Formatter</tt> to use (may not be null)
 * @exception  SecurityException  if a security manager exists and if
 *             the caller does not have <tt>LoggingPermission("control")</tt>.
 */
public synchronized void setFormatter(Formatter newFormatter) throws SecurityException {
    checkPermission();
    // Check for a null pointer:
    newFormatter.getClass();
    formatter = newFormatter;
}

在分享会上,我故意将这行注释遮盖起来,大家都猜不到 newFormatter.getClass() 这句代码写在这里的作用。如果要检查空指针,完全可以用 Objects 工具类提供的方法,实现完全一样,但代码表现出来的含义就千差万别了。

public static <T> T requireNonNull(T obj) {
    if (obj == null)
        throw new NullPointerException();
    return obj;
}

KISS 简单原则

Keep it Simple and Stupid

-- Robert S. Kaplan

译:保持愚蠢,保持简单

KISS 原则是 Robert S. Kaplan 提出的一个理论,Kaplan 并非是一个软件学家,他是平衡积分卡 Balanced Scorecard 创始人,而他所提出的这个理论对软件行业依然适用。把事情变复杂很简单,把事情变简单很复杂。我们需要尽量让复杂的问题简明化、简单化。

写在最后

软件设计的最大目标,就是降低复杂性,万物不为我所有,但万物皆为我用。引用 JDK 集合框架创办人 Josh Bloch 的一句话来结束。学习编程艺术首先要学会基本的规则,然后才能知道什么时候可以打破这些规则。

You should not slavishly follow these rules, but violate them only occasionally and with good reason. Learning the art of programming, like most other disciplines, consists of first learning the rules and then learning when to break them.

--- Josh Bloch 《Effective Java》

译:你不该盲目的遵从这些规则,应该只在偶尔情况下,有充分理由后才去打破这些规则

学习编程艺术首先要学会基本的规则,然后才能知道什么时候可以打破这些规则

参阅书籍

1、《Object Oriented Analysis and Design with Applications》

https://niexiaolong.github.io/Object%20Oriented%20Analysis%20and%20Design%20with%20Applications.pdf

2、《Clean Architecture》

https://detail.tmall.com/item.htm?id=654392764249

3 、《A Philosophy of Software Design》

https://www.amazon.com/-/zh/dp/173210221X/ref=sr_1_1?qid=1636246895

posted @ 2022-09-02 16:25  阿里巴巴中间件  阅读(52)  评论(0编辑  收藏  举报