最近系统地复习了下设计原则、设计模式,记录总结了部分学习笔记
设计模式学习笔记
设计原则与思想
面向对象
01|面向对象基础概念
1.什么是面向对象编程
面向对象编程是一种编程范式或者编程风格,它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计的基石。
2.什么是面向对象编程语言
面向对象编程语言是支持类或者对象的语法机制,并有现成的语法机制,能方便地实现面向对象四大特性(封装、抽象、继承、多态)的编程语言。
3.如何判定一个编程语言是否面向对象编程语言
如果按照严格的定义,需要有现成的语法支持类、对象、四大特性才能叫做面向对象编程语言。如果放宽要求的话,只要某种编程语言支持类、对象语法机制,那基本上就可以说这种编程语言是面向对象编程语言了,不一定非得要求具有所有的四大特性。(比如go就放弃了继承)
4.面向对象编程和面向对象编程语言之间有何关系
面向对象编程一般使用面向对象语言来进行,但是,不用面向对象编程语言,我们照样可以进行面向对象编程。反过来讲,即使我们使用面向对象编程语言,写出来的代码也不一定是面向对象编程风格的,也有可能是面向过程编程风格的。
5.什么是面向对象分析和面向对象设计
面向对象分析就是要搞清楚做什么,面向对象设计就是搞清楚怎么做。两个阶段最终的产出是类设计,包括被拆解为哪些类,每个类有哪些属性方法、类与类之间如何交互等。
02|封装、抽象、继承、多态
1.封装特性
封装也叫做信息隐藏或者数据保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方式来访问内部信息或者数据。它需要编程语言提供权限访问控制语法来支持,例如:private、protected、public关键字。封装特性存的意义,一方面是保护数据不被随意篡改,提高代码的可维护性;另一方面是仅暴露有限的必要接口,提高类的易用性。
2.抽象特性
封装主要讲如何隐藏信息、保护数据,那抽象就是讲如何隐藏方法的具体实现,让使用这只需要关心方法提供了哪些功能,不需要知道这些功能是如何实现的。抽象可以通过接口类或者抽象类来实现,但是也并不需要特殊的语法机制来支持。抽象存在的意义,一方面是提高代码的可扩展性、维护性,修改实现不需要改变定义,减少代码改动范围;另一方面,他也是处理复杂系统的有限手段,能有效地过滤掉掉不必要关注的信息。
3。继承特性
继承是用来表示类之间is-a关系,分为两种模式:单继承和多继承。单继承表示一个子类只继承一个父类,多继承表示一个子类可以继承多个父类。为了实现继承这个特性,编程语言需要提供特殊的语法机制来支持。继承主要是用来解决代码复用的问题。
4.多态特性
多态是指子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。多态特性也需要编程语言提供特殊的语法机制来实现,比如集成、接口类、duck-typing。多态可以提高代码的扩展性和复用性,是很多设计模式、设计原则、编程技巧的代码实现基础
03|面向对象相比面向过程的优势
1.什么是面向过程编程?什么是面向过程编程语言?
实际上,面向过程编程语言和面向过程编程语言并没有严格的官方定义。理解这两个概念最好的方法是跟面向过程和面向对象编程语言进行对比。相较于面向对象编程以类为组织代码的基本单元,面向过程编程则是以过程(或方法)作为组织代码的基本单元。它最主要的特点就是数据和方法零分离。相较于面向对象编程语言,面向过程编程语言最大的特点就是不支持丰富的面向对象特性,比如封装、继承、多态。
2.面向对象编程相比面向过程编程有哪些优势?
面向对象编程相比起面向过程编程的优势主要有三个。
- 对于大规模复杂程序的开发,程序的处理流程并非单一的一条主线,而是错综复杂的网状结构。面向对象编程比起面向过程编程,更能应对这种复杂类型的程序开发。
- 面向对象编程相比面向过程编程,具有更丰富的特性(封装、抽象、继承、多态)。利用这些特性编写出来的代码,更加易扩展、易复用、易维护。
- 从编程语言跟机器打交道的方式的演进规律中,我们可以总结出:面向对象编程语言比面向过程编程语言,更加人性化、更加高级、更加智能。
04|三种违反面向对象编程风格的典型代码设计
1.滥用getter、setter方法
在设计类的时候,除非真的需要,否则尽量不要给属性定义setter方法。除此之外,尽管getter方法相对比setter方法要安全写,但是如果返回的是集合容器,那也要防范集合内部数据被修改的风险。
2.Constants类、Utils类的设计问题
对于这两种类的设计,我们尽量能做到职责单一,定义一些细化的小类,比如RedisConstants、FileUtils,而不是定义一个大而全的Constants类、Utils类。除此之外,如果能将这些类中的属性和方法,划分归并到其他业务类型中,那是最好不过的了,能极大地提高类的内聚性和可复用性。
3.基于贫血模型的开发模式
为什么贫血模型这种开发模式是彻彻底底的面向过程风格的呢?这是因为数据和操作是分开定义在VO、BO、Entity和Controller、Service、Repository中的。
05|接口vs抽象类的区别
1.抽象类和接口的语法特性
抽象类不允许被实例化,只能被继承。它可以包含属性和方法,方法既可以包含代码实现,也可以不包含代码实现。不包含代码实现的方法叫做抽象方法。子类继承抽象类,必须实现抽象类中的所有抽象方法。接口不能包含属性,只能声明方法,方法不能包含代码实现。类实现接口的时候,必须实现接口中声明的所有方法。
2.抽象类和接口存在的意义
抽象类是对成员变量和方法的抽象,是一种is-a关系,是为了解决代码复用问题。接口仅仅是对方法的抽象,是一种has-a关系,标识具有莫一组行为特性,是为了解决解耦问题,隔离接口和具体的实现,提高代码的扩展性。
3.抽象类和接口的应用场景区别
什么时候该用抽象类?什么时候该用接口?实际上,判断的标准很简单。如果要标识一种is-a的关系,并且是为了解决代码复用问题,我们就用抽象类;如果要表示一种has-a关系,并且是为了解决抽象而非复用问题,那我们囧接口
06|基于接口而非实现编程
1.“基于接口而非实现编程”,这条原则的另一个表述方式,是“基于抽象而非实现编程”。后者的表述方式其实更能体现这条原则的设计初衷。我们在做软件开发的时候,一定要有抽象意识、封装意识、接口意识。越抽象、越顶层、越脱离具体的某一实现的设计,越能提高代码的灵活性、扩展性、可维护性。
2.我们定义接口的时候,一方面,命名要足够通用,不能包含跟具体实现相关的字眼(每个优秀的程序员都知道,不应该定义一个attackBaghdad() ‘袭击巴格达‘ 的方法,而是应该把城市作为函数的参数 attack(city));另一方面,与特定有关的方法不要定义在接口中。
3.“基于接口而非实现编程”这条原则,不仅仅可以知道非常细节的编程开发,还能知道更加上层的架构设计、系统设计等。比如,服务端与客户端之间的“接口”设计、类库的“接口”设计。
07|多用组合少用继承
1.为什么不推荐使用继承?
继承是面向对象的四大特性之一,用来表示类之间的is-a关系,科技解决代码复用的问题。虽然继承有诸多作用,但是继承层次过深、过复杂,也会影响代码的可维护性。在这种情况下,我们应该尽量少使用,甚至不用继承。
2.组合相比继承有哪些优势?
继承主要有三个作用,表示is-a关系,支持多态特性,代码复用。而这三个作用都可以通过组合、接口、委托三个技术手段来达成。除此之外,利用组合还能解决层次过深、过复杂的继承关系影响代码可维护性的问题。
3.如何判断该用组合还是继承?
尽管我们鼓励多用组合少用继承,但是组合也不是完美的,继承也并非一无是处。在实际的项目开发中,我们还是要根据具体的情况,来选择该用继承还是组合。如果类之间的继承结构稳定,层次比较浅,关系不复杂,我们就可以大胆地使用继承。反之,我们就尽量使用组合来代替继承。除此之外,还有一些设计模式、特殊的应用场景,会固定使用继承或者组合。
08|业务开发常用的基于贫血模型的MVC架构违背OOP吗?
1.什么是贫血模型?什么是充血模型?
- 贫血模型(Anemic Domain Model)
- 贫血模型是一种领域模型,其中领域对象包含很少或者没有业务逻辑,是一种面向过程的编程风格,即不包含业务逻辑的类,都是基于贫血模型设计的
- Q:为什么不包含业务逻辑的类都是面向过程的编程风格?
- A:因为其破坏了面向对象的封装特性,是一种典型的面向过程编程风格
- Q:为什么破坏了面向对象的封装特性?
- A:封装特性,又叫做信息隐藏或者数据访问保护,当前类由于数据跟业务分离,没有实现数据访问保护功能,可以被其他类任意修改
- 贫血模型是一种领域模型,其中领域对象包含很少或者没有业务逻辑,是一种面向过程的编程风格,即不包含业务逻辑的类,都是基于贫血模型设计的
- 充血模型(Rich Domain Model)
- 贫血模型中,数据和业务逻辑被分隔到不同的类中。充血模型正好相反,数据和对应的业务逻辑被封装在同一个类中。因此这种充血模型满足面向对象的封装特性,是典型的面向对象编程风格
- 基于贫血模型的开发模式,重Service轻BO;基于充血模型的DDD开发模式,轻Service重Domain
2.基于贫血模型的传统开发既然违反了OOP,那又为何如此流行?
- 大部分情况下,我们开发的系统业务可能都比较简单,简单到就是基于SQL的CRUD操作,所以根本不需要动脑子精心设计充血模型,贫血模型足以应付这种简单的开发工作。除此之外,因为业务比较简单,即使使用了充血模型,那模型本身包含的业务逻辑也并不会很多,设计出来的领域模型也会比较单薄,跟贫血模型差不多,意义不大
- 充血模型的设计要比贫血模型更加有难度,因为充血模型是一种面向对象的编程风格。我们从一开始就要设计好针对数据要暴露哪些操作,定义哪些业务逻辑。而不是像贫血模型那样,我们只需要定义数据,之后有什么功能开发需求,我们就在Service层定义什么操作,不需要事先做太多设计
- 思维已固化,转型有成本。基于贫血模型的传统开发模式经历了这么多年,已经深得人心、习以为常。如果转向充血模型、领域驱动设计,那势必有一定的学习成本、转型成本。很多人在没有遇到开发痛点的情况下,是不愿意做这件事情的
09|如何进行面向对象设计?
面向对象分析(OOA)、面向对象设计(OOD)、面向对象编程(OOP),是面向对象开发的三个主要环境
如何进行面向对象分析?
难点
- 需求不明确,给的需求过于模糊、笼统,不够具体、细化,离落地到设计、编码还有一定距离
- 缺少锻炼
方法
如果做需求分析,先从最简单的方案想起,然后再优化,比如把整个分析过程分为循序渐进的四轮,每一轮都是对上一轮的迭代优化,最后形成一个可执行、可落地的需求列表
- 第一轮基础分析
- 第二轮分析优化
- 第三轮分析优化
- 第四轮分析优化
- 最终确定需求
如何进行面向对象设计?
划分职责进而识别出有哪些类
- 根据需求描述,把其中涉及到的功能点,一个一个罗列出来,然后再看哪些功能点职责相近,操作相同的属性,是否应该归为同一个类
定义类及其属性和方法
- 识别出需求描述中的动词,作为候选的方法,再进一步过滤筛选,我们可以把功能点中涉及的名词,作为候选属性,然后同样进行过滤筛选
定义类与类之间的交互关系
- 类与类之间都有哪些交互关系呢?UML统一建模语言定义了六种类之间的关系。它们分别是:泛化、实现、关联、聚合、组合、依赖。
- 泛化
- 可以简单理解为继承关系
- 实现
- 一般是指接口和实现类之间的关系
- 聚合
- 是一种包含关系,A类对象包含B类对象,B类对象的生命周期可以不依赖A类对象生命周期,比如课程与学生之间的关系
- 组合
- 也是一种包含关系。A类对象包含B类对象,B类对象生命周期依赖A类对象的生命周期,B类对象不可单独存在,比如鸟与翅膀之间的关系
- 关联
- 是一种非常弱的关系,包含聚合、组合两种关系
- 依赖
- 是一种比关联关系更加弱的关系,包含关联关系
- 泛化
- 类与类之间都有哪些交互关系呢?UML统一建模语言定义了六种类之间的关系。它们分别是:泛化、实现、关联、聚合、组合、依赖。
将类组织起来并提供执行入口
- 类定义好了,类之间必要的交互关系也设计好了,接下来就要将所有的类组装在一起,提供一个执行入口,通过这个入口,我们能出发整个代码跑起来
如何进行面向对象编程?
面向对象设计完成之后,我们已经定义清晰了类、属性、方法、类之间的交互,并且将所有的类组装起来,提供了统一的执行入口。接下来,面向对象编程的工作,就是将这些设计思路翻译成代码实现
设计原则
01|对于单一职责原则,如何判定某个类的职责是否够“单一”?
如何理解单一职责原则?
单一职责原则的英文是 Single Responsibility Principle,缩写为 SRP。这个原则的英文描述是这样的:A class or module should have a single responsibility。如果我们把它翻译成中文,那就是:一个类或者模块只负责完成一个职责(或者功能)
单一职责的定义描述非常简单,也不难理解。一个类只负责完成一个职责或者功能。也就是说,不要设计大而全的类,要设计粒度小、功能单一的类。换个角度来讲就是,一个雷包含了两个或者两个以上业务不相干的功能,那我们就说他职责不够单一,应该拆分成多个功能更加单一、粒度更细的类
如何判断类的职责是否足够单一?
评价一个类的职责是否足够单一,我们并没有一个非常明确的、可以量化的标准,可以说,这是一个非常主观的、仁者见仁智者见智的事情。实际上,在真正的软件开发中,我们也没有必要过于未雨绸缪,过渡设计。所以,我们可以先写一个粗粒度的类,满足业务需求,随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多,这个时候,我们可以将这个粗粒度的类,拆分成几个更细粒度的类。这就是所谓的持续重构
下面几条判断原则,比起很主观地区思考类是否职责单一,要更有指导意义、更具有可执行性:
- 类中的代码行数、函数、或者属性过多,会影响代码的可读性、可维护性,我们就需要考虑对类进行拆分;
- 类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,我们就需要考虑对类进行拆分;
- 私有方法过多,我们就要考虑能否将私有方法独立到新类中,设置为public方法,供更多的类使用,从而提高代码的复用性;
- 比较难给类起一个合适的名字,很难用一个业务名词概括,或者只能用一些笼统的Manager、Context之类的词语来命名,这就说明类的职责定义得可能不够清晰;
- 类中大量的方法都是几种操作类中的几个属性,比如,在UserInfo中,如果一般的方法都是在操作address信息,那就可以考虑将这几个属性和对应的方法拆分出来。
类的职责是否设计得越单一越好?
单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同事,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、低耦合。但是如果拆分的过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性
02|如何做到“对扩展开放、修改关闭”?扩展和修改各指什么?
如何理解“对扩展开放、对修改关闭”?
添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。关于定义,我们有两点要注意。第一点是,开闭原则并不是说完全杜绝修改,而是以最小的修改代价来完成新功能的开发。第二点是,同样的代码改动,在粗粒度下可能被认定为“修改”;在细粒度下,可能又被认定为“扩展”。
如何做到“对扩展开放、修改关闭”?
我们要时刻具备扩展意识、抽象意识、封装意识。在写代码的时候,我们要多花点时间思考下,这段代码未来课鞥呢有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,在不改动代码整体接口、做到最小代码改动的情况下,将新的代码灵活地插入到扩展点上。
很多设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的的。特别是23种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则的。最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如:装饰、策略、模板、职责链、状态)。
03|里氏替换跟多态有何区别?哪些代码违背了LSP?
LSP与多态有何区别
虽然从定义描述和代码实现上来看,多态和里氏替换有点类似,但它们的关注角度是不一样的。多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里氏替换是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有的程序逻辑以及不破坏原有程序的正确性
哪些代码明显违背了LSP?
- 子类违背父类声明要实现的功能
- 子类违背父类对输入、输出、异常的约定
- 子类违背父类注释中所罗列的任何特殊说明
04|接口隔离原则
如何理解“接口隔离原则”?
理解“接口隔离原则”的重点是理解其中的“接口”二字。这里有三种不同的理解。如果把“接口”理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口等。如果部分接口只被部分调用者使用,我们就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。如果把“接口”理解为单个 API 接口或函数,部分调用者只需要函数中的部分功能,那我们就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数。如果把“接口”理解为 OOP 中的接口,也可以理解为面向对象编程语言中的接口语法。那接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。
接口隔离原则与单一职责原则的区别
单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度也是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
05|依赖反转原则
高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)
控制反转
实际上,控制反转是一个比较笼统的设计思想,并不是一种具体的实现方法,一般用来指导框架层面的设计。这里所说的“控制”指的是对程序执行流程的控制,而“反转”指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程通过框架来控制。流程的控制权从程序员“反转”给了框架。
依赖注入
依赖注入和控制反转恰恰相反,它是一种具体的编码技巧。我们不通过 new 的方式在类内部创建依赖类的对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类来使用。
依赖注入框架
我们通过依赖注入框架提供的扩展点,简单配置一下所有需要的类及其类与类之间依赖关系,就可以实现由框架来自动创建对象、管理对象的生命周期、依赖注入等原本需要程序员来做的事情。
依赖反转原则
依赖反转原则也叫作依赖倒置原则。这条原则跟控制反转有点类似,主要用来指导框架层面的设计。高层模块不依赖低层模块,它们共同依赖同一个抽象。抽象不要依赖具体实现细节,具体实现细节依赖抽象。
06|KISS、YAGNI
如何理解KISS原则
KISS 原则的英文描述有好几个版本,比如下面这几个。
- Keep It Simple and Stupid.
- Keep It Short and Simple.
- Keep It Simple and Straightforward.
它们要表达的意思其实差不多,翻译成中文就是:尽量保持简单。
我们知道,代码的可读性和可维护性是衡量代码质量非常重要的两个标准。而 KISS 原则就是保持代码可读和可维护的重要手段。代码足够简单,也就意味着很容易读懂,bug 比较难隐藏。即便出现 bug,修复起来也比较简单。
如何理解YAGNI
YAGNI 原则的英文全称是:You Ain’t Gonna Need It。直译就是:你不会需要它。实际上,这条原则的核心思想就是:不要做过度设计。
YAGNI 原则跟 KISS 原则并非一回事儿。KISS 原则讲的是“如何做”的问题(尽量保持简单),而 YAGNI 原则说的是“要不要做”的问题(当前不需要的就不要做)
如何写出满足KISS原则的代码,有以下几条指导原则:
- 不要使用同事可能不懂的技术来实现代码;
- 不要重复造轮子,要善于使用已经有的工具类库;
- 不要过渡优化
规范与重构
01|什么情况下要重构?到底重构什么?又该如何重构?
重构的目的:为什么要重构(why)?
我们可以把重构理解为,在保持功能不变的前提下,利用设计思想、原则、模式、编程规范等理论来优化代码,修改设计上的不足,提高代码质量
对于项目而言,重构可以保持代码质量持续处于一个可控状态,不至于富华到无可救药的地步。对于个人而言,重构非常锻炼一个人的代码能力,而且是一件非常有成就感的事情。它是我们学习的经典设计思想、原则、模式编程规范等理论知识的练兵场。
重构的对象:重构什么(what)?
按照重构的规模,我们可以将重构大致分为大规模高层重构和小规模低层次的重构。大规模高层次的重构包括对代码分层、模块化、解耦、梳理类之间的交互关系、抽象复用组件等等。这部分工作利用的更多的是比较抽象、比较顶层的设计思想、原则、模式。小规模低层次的重构包括规范命名、注释、修正函数参数过多、消除超大类、提取重复代码等等编程细节问题。主要针对类、函数级别的重构。小规模低层次的重构更多的是利用编码规范这一理论基础知识。
重构的时机:什么时候重构(when)?
我们一定要建立持续重构意识,把重构作为开发必不可少的部分,融入到日常开发中,而不是等到代码出现很大问题的时候,再大刀阔斧地重构。
重构的方法:如何重构(how)?
大规模高层次的重构难度比较大,需要组织、有计划地进行,分阶段地小步快跑,时刻让代码处于一个可运行的状态。而小规模低层次的重构,因为影响范围小,改动耗时短,所以,只要你愿意并且有时间,随时随地都可以去做。