「设计模式」理解面向对象的七大设计原则,消除代码中的坏味道

2,232 阅读17分钟

💖持续更新中...

😄这学期新开了一门「软件体系结构与设计模式」课程,在此之前,我对设计模式也有稍微了解过,正好借这学期的课程重新深入学习一下这门通往架构师之路的必备课程。

😝为此,我新开了一个专栏:「手撕设计模式」,旨在记录与强化学习过程。

⚡本专栏任务:带你理解设计模式在构建高质量软件中的角色,掌握在特定问题中运用常见的设计模式(Gang Of Four 23 —— GOF23),并了解常见及较新的软件系统架构模式(微服务架构,云原生架构)。

😎如果你想重温大学时期的设计模式,想改变代码中的坏味道,不妨跟着我的步伐一起手撕设计模式吧!

什么是设计模式?

设计模式是反复出现问题的解决方案,不是一种拿来即用的类或库函数,这些解决方案是众多软件开发人员经过相当长的一段时间的试验和错误总结出来的。

目的是为了提高代码可重用性和可维护性,让代码更容易被他人理解、保证代码可靠性。

维基百科:在软件工程中,软件设计模式是软件设计中给定上下文中常见问题的通用的可重用的解决方案。它不是可以直接转换为源代码或机器代码的完整的设计。它是如何解决可在许多不同情况下使用的问题的描述或模板。

⭐设计模式有别于算法和软件系统架构:

  • 算法是指解题方案的准确而完整的描述,是一系列解决问题的清晰指令构成的程序;
  • 设计模式是常见问题的通用模板/指导思想,是一种更高层次的抽象;
  • 软件系统架构用于指导大型软件系统各个方面的设计,是凌驾于设计模式之上的一层更高层次的抽象。

什么是面向对象设计原则?

(1)面向对象设计原则是学习设计模式的基础,设计模式的本质是面向对象设计原则的实际运用;

(2)之后介绍的每一种设计模式都符合某一种或多种面向对象设计原则;

(3)每一种原则都涵盖一些面向对象设计的思想,可以从不同的角度提升一个软件结构的设计水平。

💧「面向对象设计原则」和「设计模式」其实都是对系统进行合理「重构」的指南针;重构也是一门很深的学问,力求在不改变代码外在行为的前提下,对代码做出修改,以改进程序的内部结构。

话不多说,接下来介绍 7 个常用的面向对象设计原则。这些原则并不是相互孤立的,它们相互依赖,相互补充。

面向对象设计原则简介(表)

设计原则名称设计原则简介重要性
单一职责原则(Single Responsibility Principle,SRP)类的职责要单一,不能将太多的职责放在一个类中⭐⭐⭐⭐
开闭原则(Open-Closed Principle,OCP)软件实体对扩展是开放的,但对修改是关闭的,即在不修改一个软件实体的基础上去扩展其功能⭐⭐⭐⭐⭐
里氏替换原则(Liskov Substitution Principle,LSP)在软件系统中,一个可以接受基类对象的地方必然可以接受一个子类对象⭐⭐⭐⭐
依赖倒转原则(Dependency Inversion Principle,DIP)要针对抽象层编程,而不要针对具体类编程⭐⭐⭐⭐⭐
接口隔离原则(Interface Segregation Principle,ISP)使用多个专门的接口来取代一个统一的接口⭐⭐
合成复用原则(Composite Reuse Principle,CRP)在复用功能时,应该尽量多使用组合和聚合关联关系,尽量少使用甚至不使用继承关系⭐⭐⭐⭐
迪米特法则(Law of Demeter,LoD)一个软件实体对其他实体的引用越少越好,或者说如果两个类不必彼此直接通信,那么这两个类就不应当发生直接的相互作用,而是通过引入一个第三者发生间接交互⭐⭐⭐

🌈单一职责原则、开闭原则、里氏替换原则、依赖倒转原则、接口隔离原则又合称为 SOLID 原则

🌏UML 类图前置知识

这里先简单介绍下 UML 类图中的各种线条与箭头代表的含义,帮助你更好地理解下文类图中类之间的交互关系。

关联

关联用来表示类之间的一种静态结构,是一种比较 “强” 的关系。

比如对象 B 是对象 A 的成员对象

继承

继承又称泛化,它用于表示子类与父类之间的关系。

实线—空心箭头,箭头指向基类

实现

实现是一种类与接口之间的关系。

虚线—空心箭头,箭头指向接口

依赖

依赖描述的是某个类的对象在运行期间,可能会用到另一个对象。这是一种比较 “弱” 的关系,不同于关联。

比如类 A 成员方法的形参是类 B 的实例对象

组合

组合是一种整体与部分的关系,且部分不能离开整体而单独存在,是一种比聚合还强的关系。

带实心菱形的实线,菱形指向整体

聚合

聚合也是描述整体与部分的关系,且部分可以离开整体而单独存在,区别于组合。

带空心菱形的实心线,菱形指向整体

😐单一职责原则

定义

单一职责原则是最简单的面向对象设计原则,用于控制类粒度的大小;它建议一个对象应该只包含单一的职责。

一旦某个类承担的职责越多,相当于这些职责被耦合在同一个类中,所以它能被复用的可能性就越小。

单一职责原则旨在实现高内聚、低耦合,在很多代码重构的手法中都能见到它的身影,虽然思想简单但是却最难运用,因为只有当你有较强的分析能力后,才能将一个耦合度极高的类分离成几个单独的职责明确的类。

案例分析

搭配一个实际的简单案例来体会下单一职责原则的指导思想。

🔎某 C / S 系统旨在实现带图形界面的登录功能,原始设计方案如下。

原始类图

如上图 Login 类方法说明:

  • init():初始化按钮、文本框等控件
  • display():像界面容器中增加界面控件并显示窗口
  • validate():供登录按钮调用,用于验证登录
  • getConnection():获取数据库连接
  • findUser():查找是否存在用户
  • main():系统入口

显而易见:类 Login 承担了多重职责,既包含与界面相关的方法,又包含了与数据库相关的方法,甚至包含了 main() 入口函数。

🤣 Login 承受了这个年纪不该承受的重量

😣想象一下,无论你要修改界面还是数据库连接的方法,都需要操作这个 Login 类;又或者另一个系统也要使用进行数据库连接,那么它就无法重用数据库连接那部分的代码,因为已经紧紧地和其他职责代码耦合了,无法实现高层次的复用。

🚀接下来使用单一职责原则对其进行重构:

显然重构后的复用性得到了提高,系统的可维护性也得到增强。

所以一旦类与方法的职责划分得很清晰,不但可以提高代码的可读性,更实际性地更降低了程序出错的风险,因为清晰的代码会让 bug 无处藏身,也有利于 bug 的追踪,也就是降低了程序的维护成本。

😎开闭原则

定义

一个软件实体应当对扩展开放,对修改关闭。也就是说设计的模块能在不被修改源代码的前提下,对其进行扩展。

对于任何软件而言,唯一不变的就是常变的需求,所以我们应该尽量保证代码的结构是稳定的;随着软件规模越来越大,维护成本持续增长,开闭原则也越来越凸显其不可言喻的重要性。

🎨开闭原则的切入点:将系统的可变因素封装并抽象到上层。

案例分析

🔎某图形界面系统提供了各种不同形状的按钮,客户端代码可针对这些按钮进行编程,用户可能会改变需求要求使用不同的按钮,原始设计方案如下。

原始类图

如果界面类 LoginForm 需要将圆形按钮 CircleButton 改为矩形按钮 RectangleButton,那么不仅要修改 LoginForm 中的按钮类的名字,还要修改 display() 方法适配修改后的按钮。

那如果需求一直变呢?一直修改 LoginForm 的源代码吗?显然不合适。

🚀现对该系统进行重构,使其满足开闭原则的要求:

使用开闭原则重构后,如果想要增加一个三角形按钮 Triangle,只需增加一个 Triangle 类继承 AbstractButton 抽象类即可;如果想要修改当前界面类的按钮,只需修改 config.xml 配置文件的 <className>,无需修改 LoginForm 的源代码。

总结:抽象化是开闭原则的关键点。

😋里氏替换原则

定义

里氏替换原则要求所有引用基类(父类)的地方必须透明地使用其子类的对象。

其实可以通俗地理解为:在软件中如果将基类对象替换为子类对象,程序不会出现错误 / 异常;但反之不成立。

举个栗子:我喜欢动物(基类),那我一定喜欢狗(子类);但如果我喜欢狗(子类),我不一定喜欢动物(基类),比如不喜欢蛇(也是动物)

⭐开闭原则的核心是对系统进行抽象化,并且从抽象化导出具象化,从抽象化到具象化的过程则需要里氏替换原则的指导

☢由此可见,里氏替换可以检查继承的合理性,避免继承的滥用。

案例分析

🔎某 CS 游戏系统在实现开闭原则的前提下,需要再对其枪械的种类进行扩展,扩展玩具手枪 ToyGun 类的原始设计方案如下。

原始类图

看似合情合理,当把 AbstractGun 替换成其子类 ToyGun 后,竟然发现 ToyGun 可以 killEnemy ?!明显不合理,显然这是滥用继承的一个典型例子。

🚀现对该系统进行重构,使其满足里氏替换原则:

重构后的系统完美符合了开闭原则与里氏替换原则。

😮依赖倒转原则

定义

高层模块不应该依赖低层模块,它们都应该依赖抽象。抽象不应该依赖于细节,细节应该依赖于抽象。

简单理解依赖倒转原则,就是要针对接口编程,不要针对实现编程

高层依赖低层❌

高低层皆依赖于抽象✔

依赖倒转原则旨在通过对接口编程颠覆传统的过程型系统:高层依赖低层。

其实你也应该很容易想到依赖倒转原则跟 IOC 挂钩,其常用的实现方式之一是在代码中使用抽象类,而将具体类 (实现类) 放在配置文件中。而这就不得不提到依赖注入的几种方式:

  • 构造注入
  • 设值注入
  • 接口注入

案例分析

🔎某系统旨在让 (上层) 司机能够随心地驱车驾驶 (下层) 各式品牌的汽车,原始设计方案如下。

原始类图

试想这么一个场景,添加奥迪 Audi 品牌的车,那么就需要再对 Driver 类动刀,这显然不利于代码结构的稳定性。

🚀现对该系统进行重构,使其满足依赖倒转原则:

在实现依赖倒转原则指导的重构后,通过抽象来搭建框架,减少类间的耦合性,避免牵一发而动全身的情况出现,从而提高代码的可维护性。

😏接口隔离原则

定义

接口隔离原则要求我们将一些较大的接口进行细化,使用多个专门的接口来替换单一的总接口,即使用某接口的客户端仅需知道与之相关的接口。

使用接口隔离原则拆分接口时,首先必须满足单一职责原则,将一组相关的操作定义在一个接口中,且在满足高内聚的前提下,接口中的方法越少越好。

可以在进行系统设计时采用定制服务的方式,即为不同的客户端提供宽窄不同的接口,只提供用户需要的行为,而隐藏用户不需要的行为。

案例分析

🔎下面展示一个拥有多个客户类的系统,在系统中定义了一个巨大的接口 AbstractService 来服务所有的客户类。

原始类图

如果 ClientA 类只需针对方法 operatorA() 进行编程,但由于提供的是一个胖接口,AbstractService 的实现类 ConcreteService 必须实现在 AbstractService 中声明的三个方法,而且 ClientA 可以看到 operatorA() 以外的两个不相关的方法 operatorB()operatorC(),极大地降低了系统的封装性。

🚀现使用接口隔离原则对其进行重构:

无论是使用一个实现类(如图)还是三个实现类,对于 ClientA 都只能访问它对应的方法,无法访问与之业务无关的另外两个,因为它们是针对抽象接口进行编程的。

☢在使用接口隔离原则应注意接口的粒度,如果太小会使得接口泛滥,不利于维护;如果接口太大又会违背接口隔离原则,灵活性较差。

🤪合成复用原则

定义

尽量使用对象组合,而不是继承来达到复用的目的。

合成复用原则(又称组合 / 聚合复用原则)也是一条相当重要的原则,为了降低系统中类之间的耦合度,该原则倡导在复用功能时多使用关联关系,少使用继承关系。

对比一下两种复用机制:

  • 继承复用:子类只需覆盖父类方法,但关键问题在于父类的内部细节完全暴露给子类,破坏系统的封装性;而且一旦超类发生变化,子类也不得不随之变化,使得子类不够灵活。
  • 组合 / 聚合复用:由于组合 / 聚合是将其他对象 B 转化为某对象 A 的成员对象,所以其他对象 B 的内部实现细节对于 A 是完全不可见的,实现了系统的封装性。

案例分析

🔎某教学管理系统的数据库访问类的原始设计方案如下。

原始类图

在该类图中,DBUtil 类用于连接数据库,提供了 getConnection() 方法用于返回数据库连接对象。由于在 StudentDAOTeacherDAO 都需要连接数据库,所以继承 DBUtil 类以复用 getConnection() 方法

设想这么一个场景:如果需要更改数据库连接方式为数据库连接池连接,那么则需要修改 DBUtil 类源代码;又或者 StudentDAOTeacherDAO 二者的连接方式不同,则需要增加一个新的 DBUtil,违背了开闭原则,扩展性极差。

🚀现使用合成复用原则对其进行重构:

如需增加新的数据库连接方式,只需给 DBUtil 增加一个子类即可,完全符合开闭原则与合成复用原则。

🤨迪米特法则

定义

迪米特法则指导软件实体应当尽可能少地与其他实体发生相互作用。

迪米特法则(又称最少知识原则)要求不要和 “陌生人” 说话,只与你的朋友直接通信。

朋友的定义如下:

  • 当前对象本身(this)
  • 依赖对象(以参数形式传入成员方法的对象)
  • 当前对象的成员对象
  • 若成员对象是集合,那么集合中的元素也是朋友
  • 当前对象所创建的对象

任何一个对象,满足以上条件即为 ”朋友“,反之为 ”陌生人“。

⭐即使当前对象与 ”陌生人“ 保持着微乎其微的依赖关系,它们之间仍是耦合状态,这就需要第三方对象 ”朋友“ 来转发调用二者之间的通信,以降低系统的耦合度,使得类与类之间保持松散的耦合关系;但同时也会降低系统不同模块间的通信效率。

案例分析

🔎如下是当前对象与 ”朋友“ 和 ”陌生人“ 之间同时存在耦合的系统。

原始类图

public class Someone {
    private Stranger stranger = new Stranger();
    
    public void operation1(Friend friend) {
        Stranger stranger = friend.provideStranger();
        stranger.operation3();
    }
}

Someone 类需要调用 ”陌生人“ 的 operation3() 方法,于是通过 ”朋友“ 的 provideStranger() 方法获取到了 Stranger 对象,然后调用该对象的 operation3()

看似很常见且合理,因为平常我们也是像这样编写代码的,但常见就一定合理吗?其实 SomeoneStranger 之间存在一层弱弱的依赖关系,因为 Friend 提供的 Stranger 对象还是在 Someone 方法中进行了调用,仍然存在耦合,违背了迪米特法则的指导思想。

🚀现使用迪米特法则对该系统进行重构:

public class Someone {
    public void operation1(Friend friend) {
        friend.forward();
    }
}
public class Friend {
    private Stranger stranger = new Stranger();
    
    public void forward() {
        stranger.operation3();
    }
}

通过 Friend 的转发调用,使得 SomeoneStranger 解耦,一旦 Stranger 被替换掉,也不会影响 Someone 源代码的修改。

后期涉及的「门面模式」与「代理模式」实际上就是迪米特法则的应用

「手撕设计模式」专栏目录

理解上述 7 种面向对象设计原则后,我会在接下来的文章中逐一介绍 23 种设计模式,此处对「手撕设计模式」专栏做一个简单的目录概要,记录持续更文的过程...

🌅GOF23 设计模式可分为以下三类:

创建型模式

创建型模式提供创建实例化对象的机制,从软件模块中剥离出对象的创建过程,使得软件结构更加清晰。

结构型模式

结构型模式介绍了如何将对象和类组装成较大的结构,并同时保持结构的灵活和高效。

行为模式

行为模式负责对象间的高效沟通与责任委派。

  • 观察者模式(Observer)
  • 策略模式(Strategy)
  • 责任链模式(Chain of Responsibility)
  • 命令模式(Command)
  • 迭代器模式(Iterator)
  • 中介者模式(Mediator)
  • 备忘录模式(Memento)
  • 状态模式(State)
  • 模板方法模式(Template Method)
  • 访问者模式(Visitor)

最后

🔮当你可以驾轻就熟地运用面向对象设计原则后,你就会发现:好的代码就像好的笑话一样——无需解释

🛕推荐阅读:Design-Patterns-for-Humans

🚧如果你想使用 Enterprise Architect 作为你画图工具,请见:Enterprise Architect 入门指南

🚫笔者水平有限,若有表述错误的地方,请不吝赐教。