摘要:设计原则与思想
参考:
《设计模式之美》
JavaScript 设计模式核⼼原理与应⽤实践
Java设计模式:23种设计模式全面解析(超级详细)
JavaScript设计模式es6(23种)
23种设计模式——创建型设计模式(5种)
设计模式:可复用面向对象软件的基础
等等
面向对象
面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石。面向对象编程语言则是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性(封装、抽象、继承、多态)的编程语言。
严格上说,有现成的语法支持类、对象、四大特性才能叫作面向对象编程语言,但宽泛意义上,只要某种编程语言支持类、对象语法机制就可以说是面向对象编程语言。
面向对象编程一般使用面向对象编程语言来进行,但面向对象编程的语言也可以写面向过程编程风格的代码。
除了面向对象编程(OOP),还有面向对象分析(OOA)和面向对象设计(OOD)。面向对象分析就是要搞清楚做什么,面向对象设计就是要搞清楚怎么做,再加上面向对象编程具体来实现,正好对应面向对象软件开发要经历的三个阶段:面向对象分析、设计、编程(实现)。
四大特性
1、封装
封装也叫作信息隐藏或者数据访问保护,类通过暴露有限的访问接口,授权外部仅能通过类提供的方式来访问内部信息或者数据,但这需要编程语言提供权限访问控制语法来支持。封装特性存在的意义,一方面是保护数据不被随意修改,提高代码的可维护性;另一方面是仅暴露有限的必要接口,提高类的易用性。
2、抽象
抽象是讲如何隐藏方法的具体实现,让使用者只需要关心方法提供了哪些功能,不需要知道这些功能是如何实现的。抽象可以通过接口类或者抽象类来实现,并不需要特殊的语法机制来支持,它没有很强的“特异性”,有时候并不被看作面向对象编程的特性之一。抽象存在的意义,一方面是提高代码的可扩展性、维护性,修改实现不需要改变定义,减少代码的改动范围;另一方面,它也是处理复杂系统的有效手段,能有效地过滤掉不必要关注的信息。
3、继承
继承是用来表示类之间的 is-a 关系(xx是xxx),分为单继承和多继承两种模式。单继承表示一个子类只继承一个父类,多继承表示一个子类可以继承多个父类。为了实现继承这个特性,编程语言需要提供特殊的语法机制来支持。继承主要是用来解决代码复用的问题。
4、多态
多态是指子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。多态这种特性也需要编程语言提供特殊的语法机制来实现。多态可以提高代码的扩展性和复用性,是很多设计模式、设计原则、编程技巧的代码实现基础。
除了抽象是一个非常通用的设计思想,其他三个特性都需要特殊的语法机制来支持,因而抽象有时候并不被看作面向对象编程的特性之一。
面向过程
面向过程编程是以过程(或方法)作为组织代码的基本单元,其最主要的特点就是数据和方法相分离。面向对象风格的代码被组织成一组类,方法和数据结构被绑定一起,定义在类中。面向过程风格的代码被组织成了一组方法集合及其数据结构(struct User),方法和数据结构的定义是分开的,而且不支持面向对象编程的特性。
面向对象编程比面向过程编程的优势:
1、面向对象编程更能应对大规模复杂类型的程序开发。因为这类程序的处理流程是错综复杂的网状结构,而非单一的一条主线。
2、面向对象编程具有更丰富的特性,利用这些特性编写的代码更加易扩展、易复用、易扩展
3、面向对象编程更人性化、更高级、更智能,因为面向对象编程是以人的角度来考虑问题,更加能聚焦到业务本身,而非以面向过程编程这种计算机思维方式考虑如何设计指令。
三种违反面向对象编程风格的典型面向过程的代码设计:
1、滥用 getter、setter 方法
除非真的需要,否则尽量不要给属性定义 setter 方法,此外,如果 getter 返回的是集合,也要方法集合内部数据被修改的风险
2、Constants 常量类、Utils 工具类的设计问题
对于这两种类的设计,尽量做到职责单一,定义一些细化的小类,而非一个大而全的类,能规划到其他业务类中最好
3、基于贫血模型的开发模式
数据和操作是分开定义在 VO/BO/Entity 和 Controler/Service/Repository 中
抽象类与接口
抽象类:
1、抽象类不允许被实例化,只能被继承。它可以包含属性和方法。方法既可以包含代码实现,也可以不包含代码实现。不包含代码实现的方法叫作抽象方法。子类继承抽象类,必须实现抽象类中的所有抽象方法。
2、抽象类是对成员变量和方法的抽象,是一种 is-a 关系,是为了解决代码复用问题。
3、如果要表示一种 is-a 的关系,并且是为了解决代码复用问题,就用抽象类。
接口(协议):
1、接口不能包含属性,只能声明方法,且方法不能包含代码实现。类实现接口的时候,必须实现接口中声明的所有方法。
2、接口仅仅是对方法的抽象,是一种 has-a 关系,表示具有某一组行为特性,是为了解决解耦问题,隔离接口和具体的实现,提高代码的扩展性。
3、如果要表示一种 has-a 关系,并且是为了解决抽象而非代码复用问题,那就用接口。
基于接口而非实现编程
编写代码时要遵从“基于接口而非现实编程”的原则:
1、函数的命名不能暴露任何实现细节,命名要足够通用、抽象。
2、封装具体的实现细节,与具体业务相关的流程不应该暴露给调用者。
3、给实现类定义抽象的接口。具体的实现类都依赖统一的接口定义,使用者依赖接口,而不是具体的实现类来编程。
多用组合少用继承
组合优于继承。继承主要有三个作用:表示 is-a 关系,支持多态特性,代码复用。而这三个作用都可以通过组合、接口、委托三个技术手段来达成。如果类之间的继承结构稳定,层次比较浅,关系不复杂,就可以使用继承。反之,尽量使用组合来替代继承,使代码更易维护。还有一些设计模式、特殊的应用场景,会固定使用继承或组合。
贫血模型与充血模型
后端项目 MVC 三层架构将整个项目分为三层:展示层、逻辑层、数据层。前后端分离的项目中,Repository 层负责数据访问,Service 层负责业务逻辑,Controller 层负责暴露接口。
贫血模型是指对象只用于在各层之间传输数据使用,只有数据字段和 Get/Set 方法,没有逻辑在对象中。数据和操作是分开定义在 VO/BO/Entity 和 Controler/Service/Repository 中。其中 Service 层包含 Service 类和 BO 类两部分,BO 是贫血模型,只包含数据,不包含具体的业务逻辑,业务逻辑集中在 Service 类中。
充血模型是面向对象设计的本质,一个对象是拥有状态和行为的。跟贫血模型的区别主要在 Service 层,Service 层包含 Service 类和 Domain 类两部分。Domain 就相当于贫血模型中的 BO,但 Domain 既包含数据,也包含业务逻辑。Service 类也部分业务逻辑移到 Domain 中而变得单薄,但它并不会完全移除,仍负责一些不适合放在 Domain 类中的功能。充血模型与贫血模型在 Controller 层和 Repository 层的代码基本上相同,因为 Repository 层的 Entity 生命周期有限,Controller 层的 VO 只是单纯作为一种 DTO,作为接口的数据传输承载体,将数据发送给其他系统,这两部分的业务逻辑都不会太复杂,没必要改为充血模型。
基于贫血模型的传统的开发模式,重 Service 轻 BO,是典型的面向过程的编程风格;基于充血模型的 DDD 开发模式,轻 Service 重 Domain,是典型的面向对象的编程风格。业务不复杂的系统使用贫血模型开发即可,业务复杂的系统使用充血模型开发更具优势。
UML 中类之间的关系
UML 统一建模语言中定义了六种类之间的关系:泛化、实现、关联、聚合、组合、依赖。
1、泛化(Generalization)可以简单理解为继承关系
2、实现(Realization)一般是指接口和实现类之间的关系
3、聚合(Aggregation)是一种包含关系。A 类对象包含 B 类对象,B 类对象的生命周期可以不依赖 A 类对象的生命周期
4、组合(Composition)也是一种包含关系。A 类对象包含 B 类对象,B 类对象的生命周期依赖 A 类对象的生命周期
5、关联(Association)是一种非常弱的关系,包含聚合、组合两种关系。如果 B 类对象是 A 类的成员变量,那 B 类和 A 类就是关联关系
6、依赖(Dependency)是一种比关联关系更加弱的关系,包含关联关系。不管是 B 类对象是 A 类对象的成员变量,还是 A 类的方法使用 B 类对象作为参数或者返回值、局部变量,只要 B 类对象和 A 类对象有任何使用关系,我们都称它们有依赖关系。
精简一下,保留四个关系:泛化、实现、组合、依赖。相当于忽略组合、聚合,并重新命名关联关系为组合关系:只要 B 类对象是 A 类对象的成员变量,那么 A 类跟 B 类是组合关系。这样也与“多用组合少用继承”中的“组合”统一含义。
SOLID 设计原则
共有五项原则:
1、单一职责原则(Single Responsibility Principle)
2、开闭原则(Opened Closed Principle)
3、里式替换原则(Liskov Substitution Principle)
4、接口隔离原则(Interface Segregation Principle)
5、依赖反转原则(Dependency Inversion Principle)
主要关注“单一职责原则”和“开闭原则”
单一职责原则(SRP)
单一职责原则针对的是模块、类、接口的设计,一个模块、类、接口只负责完成一个职责或者功能。单一职责原则是为了实现代码高内聚、低耦合,提高代码的复用性、可读性、可维护性。不要设计大而全的类,要设计粒度小、功能单一的类。但如果分得太细,反而会降低代码内聚性、可维护性。
不同的实际场景下,职责是否单一有不同的判断结果。有些指标可以判断当前类不满足单一职责原则:
1、类中的代码行数、函数或者属性过多;
2、类依赖的其他类过多,或者依赖类的其他类过多;
3、私有方法过多;
4、比较难给类起一个合适的名字;
5、类中大量的方法都是集中操作类中的某几个属性。
开闭原则(OCP)
对扩展开放、对修改关闭。添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。但并不是完全杜绝修改,而是以最小的修改代价来完成新功能的开发。同样的代码改动,在粗代码粒度下,可能被认定为“修改”;在细代码粒度下,可能又被认定为“扩展”。最好在代码设计时就预留好扩展点,设计模式也大多是以提高代码的扩展性为最终目的的。
里式替换原则(LSP)
子类对象能够替换程序中父类对象出现的任何地方,并且保证原来程序的逻辑行为不变及正确性不被破坏。也就是说父类定义了函数的“约定”(或者叫协议),那子类可以改变函数的内部实现逻辑,但不能改变函数原有的“约定”。这里的约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。虽然从定义描述和代码实现上来看,多态和里式替换有点类似,但它们关注的角度是不一样的。多态是面向对象的一大特性,它是一种代码实现的思路。而里式替换是一种设计原则,它是一种用来指导继承关系中子类的设计思想,要求子类要能够替换父类,且不改变原有程序的逻辑及不破坏原有程序的正确性。
拿父类的单元测试去验证子类的代码,如果某些单元测试运行失败,就有可能说明,子类的设计实现没有完全地遵守父类的约定,子类有可能违背了里式替换原则。
接口隔离原则(ISP)
“接口”有三种理解:
1、指一组接口集合,如果部分接口被部分调用者使用,那么就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。
2、指单个 API 接口或函数,如果部分调用者仅使用函数中的部分功能,那么就需要将函数拆分成颗粒度更小的多个函数,让调用者只依赖需要的那个颗粒度函数。
3、指 OOP 中的接口,即理解为面向对象编程语言中的接口语法。那么接口的设计要尽量单一,不要让接口的实现类和调用者依赖不需要的接口函数。
接口隔离原则与单一职责原则有两点不同:
1、接口隔离原则更侧重于接口的设计;
2、单一接口原则是从自身角度出发,而接口隔离原则则是从调用者的角度出发。
接口隔离原则还提供了一种判断接口是否满足职责单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不满足单一职责原则。
依赖反转原则(DIP)
依赖反转原则主要用来指导框架层面的设计。简单来说,在调用链上的调用者属于高层模块,被调用者属于低层模块。在平时的业务代码开发中,高层模块依赖底层模块是没有任何问题的。但在框架层面的设计时,高层模块不依赖低层模块,它们共同依赖同一个抽象。抽象不要依赖具体实现细节,具体实现细节依赖抽象。比如:Tomcat 是运行 Java Web 应用程序的容器,Tomcat 就是高层模块,Web 应用程序代码就是低层模块。但 Tomcat 和应用程序代码之间并没有直接的依赖关系,两者都依赖同一个“抽象”,也就是 Servlet 规范。Servlet 规范不依赖具体的 Tomcat 容器和应用程序的实现细节,而 Tomcat 容器和应用程序依赖 Servlet 规范。
KISS、YAGNI、DRY、LOD
KISS 原则(Keep It Simple and Stupid)要求代码保持简单易懂。不要使用同事可能不懂的技术来实现代码;不要重复造轮子,要善于使用已经有的工具类库;不要过度优化。
YAGNI 原则(You Ain’t Gonna Need It)的意思是你将不会需要它,核心是不要做过度设计。不要去设计当前用不到的功能;不要去编写当前用不到的代码。
DRY 原则(Don’t Repeat Yourself)的意思是不要重复,不要写重复的代码。有三种代码重复的情况:实现逻辑重复、功能语义重复、代码执行重复。实现逻辑重复,但功能语义不重复的代码,并不违反 DRY 原则。实现逻辑不重复,但功能语义重复的代码,算违反 DRY 原则。代码执行重复也算是违反 DRY 原则。
LOD (Law of Demeter)迪米特法则,又叫最小知识原则(The Least Knowledge Principle)。不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。
编程规范
最重要的是团队一定要制定统一的编码规范
命名
1、命名的关键是能准确达意。一些默认的、大家都比较熟知的词或临时变量推荐使用缩写,对于作用域比较大的比如类名推荐使用长的命名。
2、借助类的信息来简化属性、函数的命名,利用函数的信息来简化函数参数的命名,比如在 User 类这样一个上下文中,不用在属性变量的命名中重复添加“user”这样一个前缀单词,直接使用 name、age。
3、命名要可读、可搜索,名字要比较常见易读、命名习惯要统一。
4、接口命名,一种是添加前缀“I”,表示一个 Interface;另一种是实现类添加后缀“Impl”。
5、抽象类的命名,可以是普通类名,也可以添加前缀“Abstract”表示是抽象类。
注释
1、注释的内容主要包含三个方面:做什么、为什么、怎么做。如果很复杂还要加上示例。
2、注释本身有一定的维护成本,所以并非越多越好。类和函数一定要写注释,而且要写得尽可能全面、详细,而函数内部的注释要相对少一些,一般都是靠好的命名、提炼函数、解释性变量、总结性注释来提高代码可读性。
代码风格
1、函数的代码行数不要超过一屏幕的大小,比如 50 行。类的大小限制比较难确定。
2、一行代码最好不要超过 IDE 显示的宽度。
3、善用空行分割单元块,让不同模块的代码之间的界限更加明确。
4、最好两格缩进,关键是团队要统一
5、推荐将大括号放到跟上一条语句同一行,关键是团队要统一
6、类中成员的排列顺序,依赖类按照字母序从小到大排列。类中先写成员变量后写函数。成员变量之间或函数之间,先写静态成员变量或函数,后写普通变量或函数,并且按照作用域大小依次排列。
编码技巧
1、把代码分割成更小的单元块,符合先看整体再看细节的阅读习惯
2、避免函数参数过多。如果参数大于 5 个,考虑函数是否职责单一,是否要拆分,或者将函数参数封装成对象。
3、函数勿用布尔参数来控制逻辑,将 true 或 false 两个逻辑拆成两个函数更好,同样也勿用 null 参数来控制逻辑
4、函数设计要职责单一,能多单一就多单一。
5、移除过深的嵌套层次,这里面有很多优化点,比如调整判断顺序,优先执行判空逻辑;使用编程语言提供的 continue、break、return 关键字,提前退出嵌套;有多层判断条件时,看能否合并 if 判断条件,或者将部分嵌套逻辑封装成函数调用。
6、使用解释性变量,比如用常量取代魔法数字,用解释性变量来解释复杂表达式
设计模式分类
GoF 将 23 种设计模式根据两条准则对模式进行分类:
1、目的准则,即模式是用来完成什么工作的。分为“创建型”、“结构型”和“行为型”三类。创建型模式与对象的创建有关,结构型模式处理类或对象的组合,行为型模式对类或对象怎样交互和怎样分配职责进行描述。设计模式的核心思想就是“封装变化”,创建型模式封装了创建对象过程中的变化,结构型模式封装的是对象之间组合方式的变化,行为型模式则将是对象千变万化的行为进行抽离。
2、范围准则,指定模式主要是用于类还是用于对象。类模式处理类和子类之间的关系,这些关系通过继承建立,是静态的,在编译时刻便确定下来了。对象模式处理对象间的关系,这些关系在运行时刻是可以变化的,更具动态性。从某种意义上来说,几乎所有模式都使用继承机制,所以“类模式”只指那些集中于处理类间关系的模式,而大部分模式都属于对象模式的范畴。创建型类模式将对象的部分创建工作延迟到子类,而创建型对象模式则将它延迟到另一个对象中。结构型类模式使用继承机制来组合类,而结构型对象模式则描述了对象的组装方式。行为型类模式使用继承描述算法和控制流,而行为型对象模式则描述一组对象怎样协作完成单个对象所无法完成的任务。
创建型
单例(Singleton)模式:某个类只能生成一个实例,该类提供了一个全局访问点供外部获取该实例,其拓展是有限多例模式。
工厂方法(FactoryMethod)模式:定义一个用于创建产品的接口,由子类决定生产什么产品。
抽象工厂(AbstractFactory)模式:提供一个创建产品族的接口,其每个子类可以生产一系列相关的产品。
建造者(Builder)模式:将一个复杂对象分解成多个相对简单的部分,然后根据不同需要分别创建它们,最后构建成该复杂对象。
原型(Prototype)模式:将一个对象作为原型,通过对其进行复制而克隆出多个和原型类似的新实例。
除了工厂方法模式是类创建型模式,其他都属于对象创建型模式。
结构型
代理(Proxy)模式:为某对象提供一种代理以控制对该对象的访问。即客户端通过代理间接地访问该对象,从而限制、增强或修改该对象的一些特性。
装饰器(Decorator)模式:动态地给对象增加一些职责,即增加其额外的功能。
适配器(Adapter)模式:将一个类的接口转换成客户希望的另外一个接口,使得原本由于接口不兼容而不能一起工作的那些类能一起工作。
桥接(Bridge)模式:将抽象与实现分离,使它们可以独立变化。它是用组合关系代替继承关系来实现的,从而降低了抽象和实现这两个可变维度的耦合度。
外观(Facade)模式:为多个复杂的子系统提供一个一致的接口,使这些子系统更加容易被访问。
享元(Flyweight)模式:运用共享技术来有效地支持大量细粒度对象的复用。
组合(Composite)模式:将对象组合成树状层次结构,使用户对单个对象和组合对象具有一致的访问性。
除了适配器模式分为类结构型模式和对象结构型模式两种,其他的全部属于对象结构型模式。
行为型
观察者(Observer)模式:多个对象间存在一对多关系,当一个对象发生改变时,把这种改变通知给其他多个对象,从而影响其他对象的行为。
策略(Strategy)模式:定义了一系列算法,并将每个算法封装起来,使它们可以相互替换,且算法的改变不会影响使用算法的客户。
状态(State)模式:允许一个对象在其内部状态发生改变时改变其行为能力。
迭代器(Iterator)模式:提供一种方法来顺序访问聚合对象中的一系列数据,而不暴露聚合对象的内部表示。
模板方法(Template Method)模式:定义一个操作中的算法骨架,将算法的一些步骤延迟到子类中,使得子类在可以不改变该算法结构的情况下重定义该算法的某些特定步骤。
职责链(Chain of Responsibility)模式:把请求从链中的一个对象传到下一个对象,直到请求被响应为止。通过这种方式去除对象之间的耦合。
中介者(Mediator)模式:定义一个中介对象来简化原有对象之间的交互关系,降低系统中对象间的耦合度,使原有对象之间不必相互了解。
命令(Command)模式:将一个请求封装为一个对象,使发出请求的责任和执行请求的责任分割开。
备忘录(Memento)模式:在不破坏封装性的前提下,获取并保存一个对象的内部状态,以便以后恢复它。
解释器(Interpreter)模式:提供如何定义语言的文法,以及对语言句子的解释方法,即解释器。
访问者(Visitor)模式:在不改变集合元素的前提下,为一个集合中的每个元素提供多种访问方式,即每个元素有多个访问者对象访问。
除了模板方法模式和解释器模式是类行为型模式,其他的全部属于对象行为型模式。