设计模式之美-设计原则

设计模式之美-设计原则

大纲

写在前面:这是王争的《设计模式之美》阅读笔记,原文发表在极客时间,也有纸质书版本。阅读完整内容可购买正版支持。

设计模式或许显得有些“屠龙技”,但有了这些思想,能让我们站在更高的视角去看软件开发,而不是迷失在框架的细节里。

这一部分主要对“设计原则与思想:设计原则”部分进行总结。

image-20230911163224132

上图的缩写全称对应如下:

单一职责原则(Single Responsibility Principle)

开闭原则(Open Closed Principle)

里氏替换原则(Liskov Substitution Principle)

接口隔离原则(Interface Segregation Principle)

依赖倒置原则(Dependence Inversion Principle)

KISS (Keep It Simple and Stupid)

YAGNI(You Ain’t Gonna Need It)

DRY(Don’t Repeat Yourself)

LOD迪米特法则(Law of Demeter)

设计原则

单一职责原则

如何理解单一职责原则(SRP)?

一个类只负责完成一个职责或者功能。不要设计大而全的类,要设计粒度小、功能单一的类。单一职责原则是为了实现代码高内聚、低耦合,提高代码的复用性、可读性、可维护性。

如何判断类的职责是否足够单一?

不同的应用场景、不同阶段的需求背景、不同的业务层面,对同一个类的职责是否单一,可能会有不同的判定结果。实际上,一些侧面的判断指标更具有指导意义和可执行性,比如,出现下面这些情况就有可能说明这类的设计不满足单一职责原则:

  • 类中的代码行数、函数或者属性过多;
  • 类依赖的其他类过多,或者依赖这个类的其他类过多;
  • 私有方法过多;
  • 比较难给类起一个合适的名字;
  • 类中大量的方法都是集中操作类中的某几个属性。
类的职责是否设计得越单一越好?

单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、低耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。

举例,一些“配套使用”的组件,例如序列化和反序列化,若后续有修改优化的可能,应当组合起来,即便没有,组合起来也有助于让使用者意识到其应当配套使用,而不会与其他序列化反序列化组件混用。

对扩展开放,对修改封闭

如何理解“对扩展开放、对修改关闭?

本身的定义是:添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性 等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。

实际上我们要添加或者修改功能,不可能不改已有代码。但是我们不应当修改面向用户或者说面向测试用例的代码,而是在不改变整体架构的基础上,通过“注册”的方式去灵活的插入到扩展点上。

原文举了一个API告警的例子。我们需要调用check函数去检查并发起告警。也就是它是面向用户的。这时候对于它的设计就需要意识到:

我们不应该将业务逻辑耦合到check()中,其入参也不应该与具体check的内容耦合,否则我们添加或改变预警的指标或逻辑时,便不可避免的修改接口。

比较好的思路是传入ApiStatInfo,向check()函数对应的接口对象注册检查方法。

更具体一些:

java
public class Alert {

    private List<AlertHandler> alertHandlers = new ArrayList<>();

    public void addAlertHandler(AlertHandler alertHandler) {
        this.alertHandlers.add(alertHandler);
    }

    public void check(ApiStatInfo apiStatInfo) {
        for (AlertHandler handler : alertHandlers) {
            handler.check(apiStatInfo);
        }
    }
}

这样需要添加或修改业务逻辑时,比起将其耦合在check中,可能修改的位置变多了,比如需要修改AlertHandler,可能需要修改ApiStatInfo。但是这样设计如果要多次修改,可以降低心智负担,如果那个模块出问题了,也容易通过调用栈定位到问题,而不是定位到check函数的某一行,哪里出问题了还要继续翻看。

因为修改的地方变多了,我们也可能需要辅以文档说明当我们需要修改逻辑时要改哪些部分,防止出现逻辑上不一致的问题。这一点是作者没有提到的。

更容易理解的,我们可以建立一个webserver的心智模型,典型的比如golangnet/http。我们的serve()方法应当是向多路复用器去注册方法,并且将http的请求和响应封装起来。

如何做到“对扩展开放、修改关闭”?

我们要时刻具备扩展意识、抽象意识、封装意识。在写代码的时候,我们要多花点时间思考 一下,这段代码未来可能有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,在不改动代码整体结构、做到最小代码改动的情况下,将新的代码灵活地插入到扩展点上。

同时这条设计原则也是十分重要的。大部分设计模式都是为了解决代码的扩展性问题而存在的。最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态)。

里氏替换原则

如何理解

定义:子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。

或者换一些表述:

子类确保按照is-a的关系继承父类

子类不应当违背父类的约定

通常来说,这个原则是不太容易被违背的,只要心里有这个意识。

比如:父类Transporter用来传输网络数据,子类SecurityTransporter用来安全的传输网络数据,支持传输 appId 和 appToken 安全认证信息。

这时候,如果 appId 或者 appToken 没有设置,我们应该怎么做?

如果要保持这种继承(或者硕可替代)关系,我们就应该什么也不做,而不是抛异常。

另外一个例子,正方形继承自长方形,合理吗?或许长方形有的属性,正方形也有,但对长方形的resize操作,显然不一定对正方形合法,所以在这种情况下不应当成为严格的继承关系。

怎么做

“Design By Contract”,中文翻译就是“按照协议来设计”。子类要遵守父类的约定。

具体的:

  • 子类不应违背父类声明要实现的功能
  • 子类不应违背父类对输入、输出、异常的约定
  • 子类不应违背父类注释中所罗列的任何特殊说明

对于最后一条,作者举了个例子,和上面长方形正方形很像:

父类中定义的 withdraw() 提现函数的注释是这么写的:“用户的提现金额不得超过账户余额……”,而子类重写 withdraw() 函数之后,针对 VIP 账号实现了透支提现的功能,也就 是提现金额可以大于账户余额,那这个子类的设计也是不符合里式替换原则的。

接口隔离原则

如何理解

定义:“Clients should not be forced to depend upon interfaces that they do not use。”直译成中文的话就是:客户端不应该强迫依赖它不需要的接口。其中的“客户端”,可以理解为接口的调用者或者使用者。

接口隔离原则和单一职责原则其实很像。只是单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度也是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。

怎么做

其实接口隔离原则本身已经说的很清晰了。

下面给出作者的理解:

如果把“接口”理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口 等。如果部分接口只被部分调用者使用,我们就需要将这部分接口隔离出来,单独给这部分 调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。

如果把“接口”理解为单个 API 接口或函数,部分调用者只需要函数中的部分功能,那我 们就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数。

如果把“接口”理解为 OOP 中的接口,也可以理解为面向对象编程语言中的接口语法。那 接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。

总结一下的话,更具有实践意义的,当我们有需求通过在原有的类的基础上去除一些特性来实现新功能的话,就需要思考一下是否自己的设计有问题了。

上面的意思,是说我们不应该耦合不必要的额外功能,而不是说不能用更通用的方法实现特定的问题。

假设我们需要设计一个线性回归的库函数(比如:numpy.polyfit)。

可能大多数情况下我们得到回归的参数都是要使用得到的拟合函数用新值去预测一个东西。但是这并不意味着我们设计的库函数接口就有义务做这件事,因为即便这样,并不是所有的用户都需要去预测,或许他们只是想拿到参数。我们没有义务为需要预测结果的用户做额外的事情。根据接口隔离原则,这个函数不应该提供这个功能。

还有一个问题,这个函数返回拟合的残差来评价拟合效果,是否合理?我们知道,线性回归就是在最小化残差的平方和SSE(Sum of Squared Errors),在计算过程中得到这个残差是理所当然的。但同样的,并不是所有用户都需要这个残差。因此,是否返回残差应该通过选项的方式提供

依赖反转原则

如何理解

定义:High-level modules shouldn’t depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn’t depend on details. Details depend on abstractions.

高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节 (details)依赖抽象(abstractions)。

换一种说法就是,面向接口编程,而不要面向实现编程。这一点在第一部分已经提及过了。

怎么做

这个原则的名字很容易和当前流行的控制反转依赖注入相混淆。我的理解是,虽然其含义有所不同,但目的是一样的。控制反转,依赖注入是对依赖反转的落实

首先阐释一下什么是控制反转和依赖注入。Javaer可能已经不耐烦了。

控制反转(Inversion Of Control)是指,程序的流程应该由框架控制,而不是由程序员控制。这一点乍一看很反直觉,我们程序员编程,不就是要控制流程吗,事实上是说,我们可以由自己写流程控制代码,转变为“告诉框架去怎么做”,这也是框架存在的意义。

依赖注入(Dependency Injection)是一种编码技巧,也是控制反转的具体手段。我们应该将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递 (或注入)给类使用,而不是把依赖的对象<实例>写死在方法类中。

至于框架,便是控制反转容器(Inversion Of Control Container),它把我们需要对象“保存”起来,然后我们通过比如xml(Spring)或者注解(SpringBoot)的方式告诉框架怎样向方法类中去“注入"这些依赖,当需要用的时候就去向框架要。

为什么说控制反转,依赖注入是实现依赖反转的落实?当我们将依赖放到方法类内时,便很难避免方法类对依赖类实现的依赖,而通过依赖注入,至少方法类是通过依赖外部的接口使用依赖,用控制反转框架的时候,我们是去配置框架怎么去注入这些依赖,而当在业务相关的代码中实际使用到这个方法时,就只需要一个getBean("name"),而不用再关心它是怎么来的。这样上层的业务就只需要依赖方法类或者业务实例的接口,而不需要关心方法类或者业务实例的依赖是怎么处置的,当他们的依赖发生改变时,只需要改框架的配置即可。

KISS & YAGNI

前面的SOLID原则从不同的维度介绍如何设计一个易于拓展易于维护的系统,各个方面都要求我们多去想怎么设计。这一部分更像是技术鸡汤和做事哲学,告诉我们在日常实际中如何去行事。

KISS 原则的英文描述有好几个版本,比如下面这几个。

Keep It Simple and Stupid.

Keep It Short and Simple.

Keep It Simple and Straightforward.

正如KISS法则本身表述的,我们无需关心其到底是表示的那一句话,只需要知道它想表达:尽量保持简单。

在南京大学的计算机系统设计中也提到了KISS法则:

唯一可以把你从bug的混沌中拯救出来的就是KISS法则, 将其翻译为”不要在一开始追求绝对的完美“

换句话说,就是先完成,再做好。实际工作中也是如此。这里有一篇文章举出了很多的例子。

另一层意思是,从易到难, 逐步推进, 一次只做一件事, 少做无关的事。做事不应畏手畏脚。系统复杂性越来越高,人的精力又是有限的,系统能跑起来才是王道, 跑不起来什么都是浮云, 追求面面俱到只会增加代码维护的难度。这一点看似与设计原则相悖。

因此,由此引申出来与之类似的YAGNI(You Ain’t Gonna Need It) 原则不要过度设计。这不是说我们不去设计,我们还是要根据前面的设计模式,去预留拓展点,只是当我们不需要的时候,就没必要去这么做,比如我们只需要完成一次性的功能,脚本就能实现;业务需求相对固定,等到需要的时候再去为拓展进行实现或重构。一样的,并不是应急处突它就不重要,而是说做事需要权衡利弊,比如我们业务马上就要上线,不能迷失在细节或未来里,而影响了当前目标的实现。

上面是结合自身所学和经历进行的总结,下面继续来说作者是怎么讲的:

  • 不要使用同事可能不懂的技术来实现代码;

  • 不要重复造轮子,要善于使用已经有的工具类库;

  • 不要过度优化。

关于第二点,作者举了个验证IP地址的例子,并给出了三种实现:

第一种基于正则表达式,第二种用Java类库,第三种类似算法竞赛的代码,直接对字符进行处理。

实际开发中,第二种是最推荐的,第三种效率最高,但未必值得我们花费时间去做这样的优化,出了问题也难以维护。第一种正则本身效率就不高,正则引擎也是黑盒子,表达式本身也不太容易看懂,最不推荐。

这个例子很像手机号用宽松正则还是严格正则的争论,这时候简单的反而是扩展性高的,KISS法则和其他的设计模式并不矛盾。

当然并不意味着复杂的坏,作者举了个例子比如KMP算法,是很复杂,(比KMP更高效的更复杂),但它解决的是字符串匹配的常见问题,因此是相当有价值的。这又涉及到性能优化的原则了。

DRY原则

定义:Don’t Repeat Yourself。

这里主要强调的,代码的重复和逻辑的重复不等同,我们需要避免的是逻辑的重复。

作者举了个例子,验证用户名和验证密码。isValidUserName()isValidPassword()两个函数,虽然从代码实现逻辑上看起来是重复的,但是从语义上并不重复。所谓“语义不重复”指的是:从功能上来看,这两个函数干的是完全不重复的两件事情,一个是校验用户名,另一个是校验密码。尽管在目前的设计中,两个校验逻辑是完全一样的,但如果将两个函数的合并,虽然减少了代码冗余,但就会存在潜在的问题。在未来的某一天,如果我们修改了密码的校验逻辑,比如,允许密码包含大写字符,允许密码的长度为 8 到 64 个字符,那这个时候,isValidUserName() 和 isValidPassword() 的实现逻辑就会不相同。我们就要把合并后的函数,重新拆成合并前的那两个函数。可以看出,合并的代码违反了“单一职责原则”和“接口隔离原则”

我们可以通过抽象成更细粒度函数的方式来部分的解决代码冗余。比如将校验只包含 az、09、dot 的逻辑封装成 boolean onlyContains(String str, String charlist);函数。

作者还举了个逻辑重复的例子:在同一个项目中,其中一个同事在不知道已经有了 isValidIp() 的情况下,自己又定义并实现了同样 用来校验 IP 地址是否合法的checkIfIpValid() 函数。这种逻辑重复有可能会造成代码中有的地方调用了isValidIp() 函数,有些地方又调用了 checkIfIpValid() 函数,维护起来就会比较困难。可以看出,必要的注释、文档和团队的交流在多人开发中是十分必要的。

迪米特法则

Law of Demeter,缩写是 LOD。单从这个名字上来看,我们完 全猜不出这个原则讲的是什么。不过,它还有另外一个更加达意的名字,叫作最小知识原则,英文翻译为:The Least Knowledge Principle。

关于这个设计原则,我们先来看一下它最原汁原味的英文定义:

Each unit should have only limited knowledge about other units: only units “closely” related to the current unit. Or: Each unit should only talk to its friends; Don’t talk to strangers.

我们把它直译成中文,就是下面这个样子:

每个模块(unit)只应该了解那些与它关系密切的模块(units: only units “closely” related to the current unit)的有限知识(knowledge)。或者说,每个模块只和自己 的朋友“说话”(talk),不和陌生人“说话”(talk)。

这个法则的体现便是”高内聚,松耦合“。

所谓高内聚,就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一类中。相近的功能往往会被同时修改,放到同一个类中,修改会比较集中。所谓松耦合指的是,在代码中,类与类之间的依赖关系简单清晰。即使两个类有依赖关系,一个类的代码改动也不会 或者很少导致依赖类的代码改动。

总结

在这一部分作者说,技术人也要有产品的思维。顾名思义,技术是”术“,设计原则则是”道“。最后,作者结合了简单的实例讲了通用框架开发的需求分析和设计。

虽然设计原则处处都充满了抽象,但对于系统设计来说,借鉴 TDD(测试驱动开发)和 Prototype(最小原型)的思想,先聚焦于一个简单的应用场景,基于此设计实现 一个简单的原型更容易下手。

这就好比做算法题目。当我们想要一下子就想出一个最优解法时,可以先写几组测试数据, 找找规律,再先想一个最简单的算法去解决它。虽然这个最简单的算法在时间、空间复杂度上可能都不令人满意,但是我们可以基于此来做优化,这样思路就会更加顺畅。

也不应该害怕重构,正如KISS原则所说,先完成,再做好。只是不能将追求仅仅停留在完成上,学习设计原则之于系统设计,就像数据结构和数学模型之于算法题目,在进行优化时能够有巨大的裨益。


设计模式之美-设计原则
http://lunaticsky-tql.github.io/posts/27697/
作者
Lunatic sky
发布于
2023年9月17日
许可协议