设计原则总结

面向对象编程语言四大特性 封装 封装也叫作信息隐藏或者数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据。对于封装这个特性,我们需要编程语言本身提供一定的语法机制来支持。这个语法机制就是访问权限控制。Java通过private、public等关键字实现,go语言通过方法和属性首字母是否大写来实现。如果我们对类中属性的访问不做限制,那任何代码都可以访问、修改类中的属性,虽然这样看起来更加灵活,但从另一方面来说,过度灵活也意味着不可控,属性可以随意被以各种奇葩的方式修改,而且修改逻辑可能散落在代码中的各个角落,势必影响代码的可读性、可维护性。类仅仅通过有限的方法暴露必要的操作,也能提高类的易用性。
抽象 讲完了封装特性,我们再来看抽象特性。 封装主要讲的是如何隐藏信息、保护数据,而抽象讲的是如何隐藏方法的具体实现,让调用者只需要关心方法提供了哪些功能,并不需要知道这些功能是如何实现的。在面向对象编程中,我们常借助编程语言提供的接口类(比如 Java和go语言 中的 interface 关键字语法)或者抽象类(比如 Java 中的 abstract 关键字语法)这两种语法机制,来实现抽象这一特性。
抽象这个概念是一个非常通用的设计思想,并不单单用在面向对象编程中,也可以用来指导架构设计等。而且这个特性也并不需要编程语言提供特殊的语法机制来支持,只需要提供“函数”这一非常基础的语法机制,就可以实现抽象特性、所以,它没有很强的“特异性”,有时候并不被看作面向对象编程的特性之一。如果上升一个思考层面的话,抽象及其前面讲到的封装都是人类处理复杂性的有效手段。在面对复杂系统的时候,人脑能承受的信息复杂程度是有限的,所以我们必须忽略掉一些非关键性的实现细节。而抽象作为一种只关注功能点不关注实现的设计思路,正好帮我们的大脑过滤掉许多非必要的信息。除此之外,抽象作为一个非常宽泛的设计思想,在代码设计中,起到非常重要的指导作用。很多设计原则都体现了抽象这种设计思想,比如基于接口而非实现编程、开闭原则(对扩展开放、对修改关闭)、代码解耦(降低代码的耦合性)等。换一个角度来考虑,我们在定义(或者叫命名)类的方法的时候,也要有抽象思维,不要在方法定义中,暴露太多的实现细节,以保证在某个时间点需要改变方法的实现逻辑的时候,不用去修改其定义。“基于接口而非实现编程”这条原则的另一个表述方式,是“基于抽象而非实现编程”。后者的表述方式其实更能体现这条原则的设计初衷。在软件开发中,最大的挑战之一就是需求的不断变化,这也是考验代码设计好坏的一个标准。越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情况下灵活应对。而抽象就是提高代码扩展性、灵活性、可维护性最有效的手段之一。
从“基于接口而非实现编程”的原则,具体来讲,我们需要做到下面这 3 点。1)函数的命名不能暴露任何实现细节;2)封装具体的实现细节;3)为实现类定义抽象的接口。
抽象类VS接口
抽象类更多的是为了代码复用,而接口就更侧重于解耦。接口是对行为的一种抽象,相当于一组协议或者契约,你可以联想类比一下 API 接口。调用者只需要关注抽象的接口,不需要了解具体的实现,具体的实现代码对调用者透明。接口实现了约定和实现相分离,可以降低代码间的耦合性,提高代码的可扩展性。实际上,接口是一个比抽象类应用更加广泛、更加重要的知识点。比如,我们经常提到的“基于接口而非实现编程”,就是一条几乎天天会用到,并且能极大地提高代码的灵活性、扩展性的设计思想。
继承 如果你熟悉的是类似 Java、C++ 这样的面向对象的编程语言,那你对继承这一特性,应该不陌生了。继承是用来表示类之间的is-a 关系,比如猫是一种哺乳动物。从继承关系上来讲,继承可以分为两种模式,单继承和多继承。单继承表示一个子类只继承一个父类,多继承表示一个子类可以继承多个父类,比如猫既是哺乳动物,又是爬行动物。继承最大的一个好处就是代码复用。假如两个类有一些相同的属性和方法,我们就可以将这些相同的部分,抽取到父类中,让两个子类继承父类。这样,两个子类就可以重用父类中的代码,避免代码重复写多遍。不过,这一点也并不是继承所独有的,我们也可以通过其他方式来解决这个代码复用的问题,比如利用组合关系而不是继承关系。从人类认知的角度上来说,is-a 关系通过继承来关联两个类,反应真实世界中的这种关系,非常符合人类的认知,而且,从设计的角度来说,也有一种结构美感。继承的概念很好理解,也很容易使用。不过,过度使用继承,继承层次过深过复杂,就会导致代码可读性、可维护性变差。为了了解一个类的功能,我们不仅需要查看这个类的代码,还需要按照继承关系一层一层地往上查看“父类、父类的父类......”的代码。还有,子类和父类高度耦合,修改父类的代码,会直接影响到子类。所以,继承这个特性也是一个非常有争议的特性。很多人觉得继承是一种反模式。我们应该尽量少用,甚至不用。
继承主要有三个作用:表示 is-a 关系,支持多态特性,代码复用。而这三个作用都可以通过其他技术手段来达成。比如 is-a 关系,我们可以通过组合和接口的 has-a 关系来替代;多态特性我们可以利用接口来实现;代码复用我们可以通过组合和委托来实现。所以,从理论上讲,通过组合、接口、委托三个技术手段,我们完全可以替换掉继承,在项目中不用或者少用继承关系,特别是一些复杂的继承关系。装饰者模式(decoratorpattern)、策略模式(strategy pattern)、组合模式(composite pattern)等都使用了组合关系,而模板模式(template pattern)使用了继承关系。
多态 【设计原则总结】多态是指,子类可以替换父类,实现可以赋值给接口,在实际的代码运行过程中,调用子类方法或接口实现的方法。
对于多态特性的实现方式,利用“继承加方法重写”是一种典型实现,还有其他两种比较常见的的实现方式,一个是利用接口类语法,另一个是利用 duck-typing 语法。不过,并不是每种编程语言都支持接口类或者 duck-typing 这两种语法机制,比如 C++就不支持接口类语法,而 duck-typing 只有一些动态语言才支持,比如 Python、JavaScrip、go 等。所谓的 duck-typing,就是两个类具有相同的方法,就可以实现多态,相同方法的定义就可以抽取出来组成一个接口,任何用到这个接口方法的地方都可以用这两个类替换。多态特性能提高代码的可扩展性和复用性。多态也是很多设计模式、设计原则、编程技巧的代码实现基础,比如策略模式、基于接口而非实现编程、依赖倒置原则、里式替换原则、利用多态去掉冗长的 if-else 语句等等。
面向对象 VS 面向过程 面向对象编程相比面向过程编程的优势主要有三个。面向对象编程一般使用面向对象编程语言来进行,但是,不用面向对象编程语言,我们照样可以进行面向对象编程。反过来讲,即便我们使用面向对象编程语言,写出来的代码也不一定是面向对象编程风格的,也有可能是面向过程编程风格的。面向对象和面向过程两种编程风格并不是非黑即白、完全对立的。在用面向对象编程语言开发的软件中,面向过程风格的代码并不少见,甚至在一些标准的开发库(比如 JDK、Apache Commons、Google Guava)中,也有很多面向过程风格的代码。不管使用面向过程还是面向对象哪种风格来写代码,我们最终的目的还是写出易维护、易读、易复用、易扩展的高质量代码。只要我们能避免面向过程编程风格的一些弊端,控制好对于大规模复杂程序的开发,程序的处理流程并非单一的一条主线,而是错综复杂的网状结构。面向对象编程比起面向过程编程,更能应对这种复杂类型的程序开发。面向对象编程相比面向过程编程,具有更加丰富的特性(封装、抽象、继承、多态)。利用这些特性编写出来的代码,更加易扩展、易复用、易维护。从编程语言跟机器打交道方式的演进规律中,我们可以总结出:面向对象编程语言比起面向过程编程语言,更加人性化、更加高级、更加智能。
它的副作用,在掌控范围内为我们所用,我们就大可不用避讳在面向对象编程中写面向过程风格的代码。
设计模式原则 经典的设计原则,其中包括,SOLID、KISS、DRY、LOD 等。
SOLID 原则并非单纯的 1 个原则,而是由 5个设计原则组成的,它们分别是:单一职责原则、开闭原则、里式替换原则、接口隔离原则和依赖反转原则,依次对应 SOLID 中的 S、O、L、I、D 这 5 个英文字母。

设计原则名称 定 义 补充
单一职责原则(Single Responsibility Principle, SRP) 一个类或模块只负责一个功能领域中的相应职责,不要设计大而全的类,要设计粒度小、功能单一的类。 这也是灵活的前提,如果我们把类拆分成最小的职能单位,那组合与复用就简单的多了,如果一个类做的事情太多,在组合的时候,必然会产生不必要的方法出现,这实际上是一种污染。单一职责的潜台词是:拆分到最小单位,解决复用和组合问题。在现实的业务开发中,我们通常先写一个粗粒度的类,满足业务需求,随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多,这个时候,我们就可以将这个粗粒度的类,拆分成几个更细粒度的类。这就是所谓的持续重构。
开闭原则(Open-Closed Principle, OCP) 软件实体应对扩展开放,而对修改关闭。添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等) 如果每次需求变动都去修改原有的代码,那原有的代码就存在被修改错误的风险,当然这其中存在有意和无意的修改,都会导致原有正常运行的功能失效的风险,这样很有可能会展开可怕的蝴蝶效应,使维护工作剧增。我们写完的代码,不能因为需求变化就修改。我们可以通过新增代码的方式来解决变化的需求。开闭原则是设计模式的第一大原则,它的潜台词是:控制需求变动风险,缩小维护成本。
里氏代换原则(Liskov Substitution Principle, LSP) 所有引用基类对象的地方能够透明地使用其子类的对象,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。 此原则的含义是子类可以在任何地方替换它的父类。解释一下,这是多态的前提,我们后面很多所谓的灵活,都是不改变声明类型的情况下,改变实例化类来完成的需求变更。从定义描述和代码实现上来看,多态和里式替换有点类似,但它们关注的角度是不一样的。多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。我们必须保证我们的子类和父类划分是精准的。里氏替换原则的潜台词是:尽量使用精准的抽象类或者接口。
接口隔离原则(Interface Segregation Principle, ISP) 使用多个专门的接口,而不使用单一的总接口,函数的设计要功能单一,不要将多个不同的功能逻辑在一个函数中实现。 接口隔离原则可以说是单一职责的必要手段,它的含义是尽量使用职能单一的接口,而不使用职能复杂、全面的接口。很好理解,接口是为了让子类实现的,如果子类想达到职能单一,那么接口也必须满足职能单一。相反,如果接口融合了多个不相关的方法,那它的子类就被迫要实现所有方法,尽管有些方法是根本用不到的。这就是接口污染。接口隔离原则的潜台词是:拆分,从接口开始。
依赖倒转原则(Dependence Inversion Principle, DIP) 抽象不应该依赖于细节,细节应该依赖于抽象。高层模块和低层模块应该通过抽象来互相依赖。 依赖倒置原则就是要求调用者和被调用者都依赖抽象,这样两者没有直接的关联和接触,在变动的时候,一方的变动不会影响另一方的变动。其实,依赖倒置和前面的原则是相辅相成的,都强调了抽象的重要性。依赖倒置的潜台词是:面向抽象编程,解耦调用和被调用者。
合成复用原则(Composite Reuse Principle, CRP) 尽量使用对象组合,而不是继承来达到复用的目的 如果只是达到代码复用的目的,尽量使用组合与聚合,而不是继承。这里需要解释一下,组合聚合只是引用其他的类的方法,而不会受引用的类的继承而改变血统。继承的耦合性更大,比如一个父类后来添加实现一个接口或者去掉一个接口,那子类可能会遭到毁灭性的编译错误,但如果只是组合聚合,只是引用类的方法,就不会有这种巨大的风险,同时也实现了复用。组合聚合复用原则的潜台词是:我只是用你的方法,我们不一定是同类。
迪米特法则(Law of Demeter, LoD) 一个软件实体应当尽可能少地与其他实体发生相互作用 迪米特原则要求尽量的封装,尽量的独立,尽量的使用低级别的访问修饰符。这是封装特性的典型体现。一个类如果暴露太多私用的方法和字段,会让调用者很茫然。并且会给类造成不必要的判断代码。所以,我们使用尽量低的访问修饰符,让外界不知道我们的内部。这也是面向对象的基本思路。另外,迪米特原则要求类之间的直接联系尽量的少,两个类的访问,通过第三个中介类来实现。迪米特原则的潜台词是:不和陌生人说话,有事找中介。
KISS(Keep It Short and Simple) 无论代码、模块设计,应尽量保持简单 代码的可读性和可维护性是衡量代码质量非常重要的两个标准。而 KISS 原则就是保持代码可读和可维护的重要手段。代码足够简单,也就意味着很容易读懂,bug 比较难隐藏。即便出现 bug,修复起来也比较简单。
DRY 原则(Don’t Repeat Yourself) 不要写重复的代码 提高代码可复用性的手段有很多,包括不限于 减少代码耦合、满足单一职责原则、模块化、业务与非业务逻辑分离、通用代码下沉、面向对象4大特性、应用模板等设计模式
在学习设计原则时需要注意以下几点:
a) 高内聚、低耦合和单一职能的“冲突”
实际上,这两者是一回事。内聚,要求一个类把所有相关的方法放在一起,初看是职能多,但有个“高”,就是要求把联系非常紧密的功能放在一起,也就是说,从整体看,是一个职能的才能放在一起,所以,两者是不同的表述而已。
这里很多人理解成复合类,但复合类不是高内聚,而是杂乱的放在一起,是一种设计失误而已。
b)多个单一职能接口的灵活性和声明类型问题
如果一个类实现多个接口,那么这个类应该用哪个接口类型声明呢?应该是用一个抽象类来继承多个接口,而实现类来继承抽象类。声明的时候,类型是抽象类。
c) 迪米特原则和中介类泛滥两种极端情况
这是另一种设计的失误。迪米特原则第一要义:从被依赖者的角度来说,只暴露应该暴露的方法或者属性,即在编写相关的类的时候确定方法/属性的权限。迪米特原则第二要义:从依赖者的角度来说,只依赖应该依赖的对象。迪米特原则要求不直接相关类之间要用中介来通讯,但类多了以后,会造成中介类泛滥的情况,这种情况,我们可以考虑中介模式,用一个总的中介类来实现。当然,设计模式都有自己的缺陷,迪米特原则也不是十全十美,交互类非常繁多的情况下,要适当的牺牲设计原则。
d) 继承和组合聚合复用原则的“冲突”
继承也能实现复用,那这个原则是不是要抛弃继承了?不是的。
继承更注重的是“血统”,也就是什么类型的。而组合聚合更注重的是借用“技能”。并且,组合聚合中,两个类是部分与整体的关系,组合聚合可以由多个类的技能组成。这个原则不是告诉我们不用继承了,都用组合聚合,而是在“复用”这个点上,我们优先使用组合聚合。
工程结构原则 贫血模型是指使用的领域对象中只有setter和getter方法(POJO),所有的业务逻辑都不包含在领域对象中而是放在业务逻辑层。有人将我们这里说的贫血模型进一步划分成失血模型(领域对象完全没有业务逻辑)和贫血模型(领域对象有少量的业务逻辑),我们这里就不对此加以区分了。充血模型将大多数业务逻辑和持久化放在领域对象中,业务逻辑(业务门面)只是完成对业务逻辑的封装、事务和权限等的处理。
充血模型的层次结构和上面的差不多,不过大多业务逻辑和持久化放在Domain Object里面,Business Logic只是简单封装部分业务逻辑以及控制事务、权限等,这样层次结构就变成Client->(Business Facade)->Business Logic->Domain Object->Data Access。
优点是面向对象,Business Logic符合单一职责,不像在贫血模型里面那样包含所有的业务逻辑太过沉重。
胀血模型是基于充血模型上取消Service层,只剩下domain object和DAO两层,在domain object的domain logic上面封装事务。
在这四种模型当中,失血模型和胀血模型应该是不被提倡的。而贫血模型和充血模型从技术上来说,都已经是可行的了。事务封装还是尽量放在Service层(我们的manage层)。胀血模型将对象的序列化行为封装到领域层,即domain object会调用domain acess层,同时domain access层又依赖domain object的结构,所以胀血模型中domain object层会和domain access层双向依赖。
我们平时做 Web 项目的业务开发,大部分都是基于贫血模型的 MVC 三层架构,称为传统的开发模式。之所以称之为“传统”,是相对于新兴的基于充血模型的DDD 开发模式来说的。基于贫血模型的传统开发模式,是典型的面向过程的编程风格。相反,基于充血模型的 DDD 开发模式,是典型的面向对象的编程风格。不过,DDD 也并非银弹。对于业务不复杂的系统开发来说,基于贫血模型的传统开发模式简单够用,基于充血模型的 DDD 开发模式有点大材小用,无法发挥作用。相反,对于业务复杂的系统开发来说,基于充血模型的 DDD 开发模式,因为前期需要在设计上投入更多时间和精力,来提高代码的复用性和可维护性,所以相比基于贫血模型的开发模式,更加有优势。基于充血模型的 DDD 开发模式跟基于贫血模型的传统开发模式相比,主要区别在 Service层。在基于充血模型的开发模式下,我们将部分原来在 Service 类中的业务逻辑移动到了一个充血的 Domain 领域模型中,让 Service 类的实现依赖这个 Domain 类。不过,Service 类并不会完全移除,而是负责一些不适合放在 Domain 类中的功能。比如,负责与 Repository 层打交道、跨领域模型的业务聚合功能、幂等事务等非功能性的工作。基于充血模型的 DDD 开发模式跟基于贫血模型的传统开发模式相比,Controller 层和Repository 层的代码基本上相同。这是因为,Repository 层的 Entity 生命周期有限,Controller 层的 VO 只是单纯作为一种 DTO。两部分的业务逻辑都不会太复杂。业务逻辑主要集中在 Service 层。所以,Repository 层和 Controller 层继续沿用贫血模型的设计思路是没有问题的。
事务脚本VS领域建模模式
单业务逻辑比较简单时,失血模型和贫血模型基本一样,所有的业务逻辑集中在service层,编写一个称为事务脚本的方法来处理来自表示层的每个请求,这种设计风格是高度面向过程的,这种方法适用于简单的业务逻辑。
采用事务脚本会随着业务逻辑变得复杂,代码也会难以维护。就像单体应用程序不断增长的趋势一样,事务脚本也存在同样的问题。很多类同时包含状态和行为,通过将用户的状态和行为收敛到对象领域模型上,实现逻辑上的高内聚,同时代码逻辑也会更高复用。

    推荐阅读