之前看别人封装组件的源代码时总感觉,这些代码非常的有条理、逻辑清晰,所以决定做一次设计模式的分析,分别按照前端业务与java代码来分析。
简单工厂模式
在接触到简单工厂模式(SFP)的时候,我想大家都会遇到经典的设计计算器的例子,要求输入两个数和运算符号,并得到结果
一开始想的时候,我们会设置三个变量,然后通过一串的if…else…语句进行符号的判断
但是这也就意味着每个分支条件都需要进行一次判断,造成资源的浪费
然后就是进行修改的过程,可能你会说:“将if…else…改为switch就可以了…"这样确实可以,不过是否真的符合题目目标呢?
// main.java
...
输入运算符,符号
···
switch(ch){
case "+": 加法
case "-": 减法
case "*": 乘法
case "/": 除法
}
...
展示结果
···
我们在看到需求的时候,就会想着,首先输入两个数和运算符号,然后判断符号类型,接着对这两个数进行运算。这就是一种站在过程角度去思考问题的方法。这样的程序不容易维护,不容易扩展,也不容易复用,灵活性也差
而面向对象思想,通过封装、继承、多态可以把程序的耦合度降低。以传统的印刷术举例,所有的字都刻印在同一个版面上,如果要修改某一个字就需要将整个版面全部重新印刷,耦合度太高了。而采用设计模式就好比用了活字印刷,可以随心所欲的添加、修改文字
业务的封装
好,现在我们再来想想,之前的switch解法中,有哪些和控制台无关,只与计算器有关?
或者说,是将负责显示的部分,和计算的部分分开呢;准确的说,是让业务逻辑与界面逻辑分开。
// Operation.java
public class Operation{
public static double getResult(double numberA, double numberB, char ch){
double result = 0;
switch(ch){
case "+": 加法
case "-": 减法
case "*": 乘法
case "/": 除法
}
return result;
}
}
// main.java
...
输入运算符,符号
···
result = Operation.getResult(numberA,numberB,ch);
...
展示结果
···
现在我们成功的将一个文件的代码,按照逻辑结构和展示结构分成了两个子文件。
这里采用了面向对象中封装的思想。
紧耦合与松耦合
现在做进一步的优化。
如果现在需要增加一个开根的运算,需要如何实现呢?
一开始会想到,直接找到Operation类,然后在里面添加一个判断分支就可以了。
可是,在添加新运算符的过程中,我们需要让加减乘除的运算重新参与一次编译,如果在这个过程中,我们不小心修改了其他运算符的代码,会改变原有的良好功能,比如:
switch(ch){
case "+": add() + 1000; // 改变了原先的正常代码
case "-": 减法
case "*": 乘法
case "/": 除法
}
所以,如果要添加新的功能,最好的方法是:将加减乘除等运算分离,修改其中一个而不会影响到另外几个,增加/删除时也不会接触到其他运算符的代码,做到具体的每个功能分离开来。
因此使用继承的思想去修改一下Operation类:
// Operation.java
public class Operation{
private double numberA = 0;
private double numberB = 0;
getA() setA()
getB() setB()
//1.在基类中定义了virtual方法,但在派生类中没有重写该虚方法。在对派生类实例的调用中,该虚方法使用的是基类定义的方法。
//2.在基类中定义了virtual方法,然后在派生类中使用override重写该方法。在对派生类实例的调用中,该虚方法使用的是派生重写的
public virtual double getResult(){
double result = 0;
return result;
}
}
将运算类声明为只有两个number属性,以及getResult这个方法,这一部分作为一种类型的规范。
而具体的逻辑运算部分(指加减乘除)单独细分为几个类去继承Operation类。
// 这里以加法类举例
class OperationAdd extends Operation{
@override
public double getResult(){
double result = 0;
result = numberA + numberB;
reuturn result;
}
}
经过这样的更改后,如果我们要求改任意一个算法,都不会修改到其他方法。
每一个具体的逻辑处理,其实就是一个对象
但是,将之前的加减法switch判断单独封装成加减法类后,需要怎样做,才能告诉计算器我使用的是哪一种算法呢?
简单工厂模式的定义
首先介绍定义。
定义:定义一个工厂类,他可以根据参数的不同返回不同类的实例,被创建的实例通常都具有共同的父类。需要什么,只需要向工厂内传递一个正确的参数,就可以获取所需要的对象,而不需要知道对象内的实现过程。
回到上面的问题
将之前的加减法switch判断单独封装成加减法类后,需要怎样做,才能告诉计算器我使用的是哪一种算法呢?
我们现在如果想要进行一次运算,就必须要使用到相应的对象。
具体要实例化哪个对象,以及以后的增加/修改对象,都需要考虑使用一个单独的类来做这个创造实例的过程,这就是工厂。
我作为厂长,只需要关心需要的是加法还是减法,具体的实现由员工来完成。如果需要加法相关的运算,只需要给员工 “+” 号,,然后员工可以生产一个加法类给我。
public class OperationFactory{
public static Operation createOperation(string operate){
Operation oper = null;
switch(operate){
case "+":
oper = new OperationAdd();
case "-":
oper = new OperationSub();
}
return oper;
}
}
最终展示
经过了上面的封装继承后,我们来看看最终客户端需要的代码
Operation oper;
oper = OperationFactory.createOperation("+"); // 利用多态接收到了加法的实例对象
oper.numberA = 1;
oper.numberB = 2;
double result = oper.getResult();
现在如果要更改加法运算,该OperationAdd即可;如果要增加平方根的运算,增加相应的子类,并且在工厂的switch中增加分支即可。
而在使用简单工厂之前,我们的计算器长这样:
switch(ch){
case "+": 加法
case "-": 减法
case "*": 乘法
case "/": 除法
}
在switch中有诸多具体的运算操作,查阅/修改起来都不方便
策略模式
策略模式(SP)对应的经典问题是商场促销问题:营业员根据客户所购买商品的单价和数量向客户进行收费。
看到这个问题,首先会想到
- 用两个数据负责接收单价和数量
- 然后设置一个数据接收费用总和
- 用个列表框来记录商品的清单,就可以了
确实可以,现在假设商店所有的商品打八折呢?
——在费用总和后面 * 0.8就行了吧。
这样也可以,但是如果经常打六折七折八折九折,那每一次打折是否都需要重新去商店安装一次机器呢?
——那设置一个switch,在多个判断中计算最后的总额就可以了吧。
如果又加上了满300-100,满200-50这样的优惠活动呢?
…
使用简单工厂实现
有这么多种子选择,我们是否可以先写一个父类,然后在父类中定义一个抽象方法,具体的子类再去继承这样的方法呢?
当然可以。可是这样的话,我们需要设置九折、八折、七折…以及满300送100…这些子类,有多少个打折方法就需要写多少个类。
这样的问题就在于,如果既打八折,又可以满300-100,那岂不是得做排列组合,再添加一个子类?
面向对象的编程,并不是类越多越好,类的划分是为了封装,但分类的基础是抽象,具有相同属性和功能的对象的抽象集合才是类。
之前在计算器中,设置一个负责计算的父类,他的子类加减乘除之间是没有相同点了的,最小的变化就是符号的变化。而在这里,折扣之间的变化是数字的变化,是可以抽离出来的。
所以,打一折和打九折只是形式的不同,抽象分析出来,其实所有的打折算法都是一样的,所以打折的整体可以看作是一个类。
因此,现在可以写出这些类:
- 负责现金收费的抽象父类 CashSuper
- 表示正常收费的子类 CashNormal
- 表示打折收费的子类 CashRebate
- 表示返利收费的子类 CashReturn
- 现金收费工厂类(在工厂中通过优惠的模式,new出来上面介绍的对象)
上面的思路是使用简单工厂来实现的,但是简单工厂模式常常用来解决对象的创建问题。
在这里工厂本身包括了所有的收费方式,而商场可能经常性的改变折扣方式,会改变这个工厂,所以这其实并不是最好的方式。
使用策略模式实现
策略模式是一种定义一系列算法的方法,所有的这些算法完成的都是相同的工作,只是实现不同,它可以以相同的方式调用所有的算法,减少了各种算法类之间的耦合。
说人话就是,某个对象的行为,在不同的场景中有不同的实现方式,这样就可以将这额实现方式定义成一组策略,每个实现类对应一个策略;在不同的场景使用不同的实现类。
在这里的商场促销问题中,使用工厂来生成折扣的算法对象,这并没有错,但是算法本身其实只是一种策略,这些算法都是可以相互替换的。既然可以相互替换,就意味着这些变化点是可以进行封装的。
// Strategy.java
public interface Strategy{
//算法方法
public void AlgorithmInterface();
}
// 具体的某个优惠策略
class StrategyA implements Strategy{
@override
public abstract void AlgorithmInterface(){
...实现算法
}
}
再来看看之前使用简单工厂实现的内容,CashSuper可以作为抽象策略,另外的三个子方法就是三个具体策略算法
由抽象策略到具体的策略方法之间,可以创建一个Context负责维护一个对策略的引用。
class Context{
private Strategy st;
// 初始化时保存具体的策略对象
public Context(Strategy st){ this.st = st; }
// 上下文接口
publid void contextInterface(){ st.AlgorithmInterface() }
}
此时客户端中的代码如下所示:
public static void main(string[] args){
Context context;
context = new Context(new StrategyA())
context.AlgorithmInterface();
}
适配器模式
适配器模式将一个类的接口转换成客户希望的另一个接口。它使得原本由于接口不兼容,而不能一起工作的那些类可以一起工作了
简单来说就是,我们现在需要的接口是存在、却无法使用的,而短期内又无法去改造他,于是需要去适配它。
在软件开发中,系统数据和行为都规范,但接口不符的时候,我们应该考虑使用适配器去复用一些现存的类,去适配目标对象的需求。
前端业务
这里我先用前端业务来举例:
当我们使用第三方库的时候,常常会遇到当前接口和第三方接口不匹配的情况,比如使用一个 Table
的组件,它要求我们返回的表格数据格式如下:
{
code: 0, // 业务 code
msg: '', // 出错时候的提示
data: {
total: , // 总数量
list: [], // 表格列表
}
}
但是后端返回过来的数据可能长这样:
{
code: 0, // 业务 code
message: '', // 出错时候的提示
data: {
total: , // 总数量
records: [], // 表格列表
}
};
由于接口规范的不同,我们很难将数据进行赋值操作,此时就需要适配器模式做一个接口的转换了
这里将数据对象做一个新的封装即可:
...
responseProcessor(res) {
return {
...res,
msg: res.message, // 出错时候的提示
data: {
...res.data
list: res?.data?.records || [], // 表格列表
}
};
},
Servlet设计
现在再来看看Servlet中关于适配器模式的用法:
package javax.servlet;
public abstract class GenericServlet implements Servlet, ServletConfig {
// 将init中Tomcat创建的ServletCongig保存下来
// 保存后可以提供给service方法,或者是子类使用
private ServletConfig config;
// 通过 getServletConfig 方法返回 ServletConfig
// 子类可以通过ServletConfig config = this.getServletConfig()拿到
@Override
public ServletConfig getServletConfig() {
return config;
}
/**
* 关键!
* 子类无法重写父类的init方法
* 但是子类可以重写非父类的init方法
*/
@Override
public void init(ServletConfig config) throws ServletException {
this.config = config;
// Tomcat首先调用init()有参方法,然后调用init()无参方法
this.init();
}
/*目的是让子类重写init()方法的无参构造方法。*/
public void init() throws ServletException {
}
@Override
public abstract void service(ServletRequest req, ServletResponse res)
throws ServletException, IOException;
}
适配器将原先Servlet接口中的五个方法剔除了出去,代码里面仅放需要使用的接口方法
同时最为关键的是,子类想重写init方法的时候,是在新方法中调用了原有接口的方法。我们在调用这个子类实例对象的时候,执行的是子类的init方法,但是实际上在这个方法内部执行无参的init方法
总结,**子类按照接口的命名规范去实现某个方法,但是在这个方法体中,调用的是适配器类去执行适配后的方法,看起来像是一种套娃。**不过,如果能事前控制好接口的规范,又何必再后期再弥补呢?所以只有碰到无法改变原有涉及和代码的情况时,才考虑适配。
代理模式
这个模式的介绍主要参考这篇文章代理模式总结,动态代理部分写的特别好,本文仅介绍简单的静态代理,或许以后学了Spring会补上
代理模式给某一个对象提供一个代理对象,并由代理对象控制对原对象的引用
在某些情况下,一个客户类不想或者不能直接引用一个委托对象,而代理类对象可以在客户类和委托对象之间起到中介的作用,其特征是代理类和委托类实现相同的接口。他的特征是:代理类(proxy)与委托类有同样的接口,代理类主要负责为委托类预处理消息、过滤消息、把消息转发给委托类,以及事后处理消息等,更简单来说,代理类接收调用的信号,然后通知委托类去执行某个特定的方法。
代理模式最主要的特征有:
- 代理对象和委托类共同的公共接口
- 在代理类中执行具体类的方法
java分析
现在以上交班费举例:假如一个班的同学要向老师交班费,但是都是通过班长把自己的钱转交给老师。这里,班长代理学生上交班费,班长就是学生的代理。
-
按照代理模式的特点,我们首先创建一个具体的接口来定义代理类和委托类的公共接口
public interface Person { //上交班费 void giveMoney(); }
-
被代理的委托类:
public class Student implements Person { private String name; public Student(String name) { this.name = name; } @Override public void giveMoney() { System.out.println(name + "上交班费50元"); } }
-
代理类实现接口,完成委托类预处理消息、过滤消息、把消息转发给委托类,以及事后处理消息等
/** * 在代理类中创建被代理的对象 * 实际调用代理对象的方法时,本质上还是执行函数体中被代理对象的方法 */ public class StudentsProxy implements Person{ //被代理的学生 Student stu; public StudentsProxy(Person stu) { // 只代理学生对象 if(stu.getClass() == Student.class) { this.stu = (Student)stu; } } //代理上交班费,调用被代理学生的上交班费行为 public void giveMoney() { stu.giveMoney(); } }
-
客户端代码:
public class StaticProxyTest { public static void main(String[] args) { //被代理的学生张三,他的班费上交有代理对象monitor(班长)完成 Person zhangsan = new Student("张三"); //生成代理对象,并将张三传给代理对象 Person monitor = new StudentsProxy(zhangsan); //班长代理上交班费 monitor.giveMoney(); } }
在客户端看来,上交班费这一行为由班长来(代理)完成。我们之前介绍过,代理类实现接口,完成委托类预处理消息、过滤消息、把消息转发给委托类,以及事后处理消息等。就这个例子来说,加入班长在帮张三上交班费之前想要先反映一下张三最近学习有很大进步,通过代理模式很轻松就能办到:
public class StudentsProxy implements Person{
//被代理的学生
Student stu;
public StudentsProxy(Person stu) {
// 只代理学生对象
if(stu.getClass() == Student.class) {
this.stu = (Student)stu;
}
}
//代理上交班费,调用被代理学生的上交班费行为
public void giveMoney() {
/**
* 重点!
* 在代理访问实际对象时引入一定程度的间接性。通过这种间接性,可以附加多种用途。
*/
System.out.println("张三最近学习有进步!");
stu.giveMoney();
}
}
只需要在代理类中帮张三上交班费之前,执行其他操作就可以了。这种操作,也是使用代理模式的一个很大的优点。最直白的就是在Spring中的面向切面编程(AOP),我们能在一个切点之前执行一些操作,在一个切点之后执行一些操作,这个切点就是一个个方法。这些方法所在类肯定就是被代理了,在代理过程中切入了一些其他操作。其实也是切面思想的主要思路。
前端业务
前端中使用代理模式感觉挺常见的,比如我现在需要为axios请求动态绑定token
,并设置响应与请求拦截器:
import Axios, { AxiosRequestConfig, AxiosResponse } from 'axios'
// 对 AxiosRequestConfig 属性进行填充
export type RequestConfig = AxiosRequestConfig & {
showLoading?: boolean
}
type ResponseBody = Record<string, any>
// 定义接口请求函数 请求返回的是一个promise
export type RequestMethodFunction = (url: string, data?: any, config?: RequestConfig) => Promise<ResponseBody>
// 定义接口请求工厂
export interface RequestFactory {
(config: RequestConfig): Promise<Record<string, any>>,
get: RequestMethodFunction,
post: RequestMethodFunction,
put: RequestMethodFunction,
delete: RequestMethodFunction
}
Axios.defaults.timeout = 5000
// 设置响应拦截器
Axios.interceptors.response.use(
(res: AxiosResponse<ResponseBody>) => {
if(res.code === 0)
return Promise.resolve(res.data)
else
return Promise.reject(res.data)
}
)
// 暴露封装后的axios方法
const request: RequestFactory = function (config: RequestConfig) {
return new Promise((resolve, reject) => {
Axios(config).then((res: LzzAxiosResponse<ResponseBody>)=> {
if (res.code = 0) {
resolve(res.data)
} else {
reject({ message: '网络异常' })
}
resolve(res)
}).catch(err => {
reject(err)
})
})
}
request.get = (url: string, data?: any, config?: RequestConfig) => {
return request({
...config,
url,
params: data || {},
method: "get",
})
}
request.post = (url: string, data?: any, config?: RequestConfig) => {
return request({
...config,
url,
data,
method: "post",
})
}
export default request
可以看到,我们将原本的axios
封装为了一个request
对象并导出,在其他页面中通过使用request.get()
或request.post()
时,本质上还是使用request
函数体内通过promise返回的axios
对象。
装饰模式
动态地给一个对象添加一些额外的职责,就增加功能来说,装饰模式比生成子类更为灵活:每个装饰对象只关心自己的功能,不需要关心如何被添加到对象链当中
我们在设计系统的初始阶段,当系统需要新功能的时候,是向旧的类中添加新的代码。这些新加的代码通常装饰了原有类的核心职责或主要行为。但这种做法的问题在于,它们在主类中加入了新的字段,新的方法和新的逻辑,从而增加了主类的复杂度,而这些新加入的东西仅仅是为了满足一些只在某种特定情况下才会执行的特殊行为
装饰模式却提供了一个非常好的解决方案,它把每个要装饰的功能放在单独的类中,并让这个类包装它所要装饰的对象,因此,当需要执行特殊行为时,客户代码就可以在运行时根据需要有选择地、按顺序地使用装饰功能包装对象了
那么装饰模式的优点总结下来就是,把类中的装饰功能从类中搬移去除,这样可以简化原有的类。这样做更大的好处就是有效地把类的核心职责和装饰功能区分开了。而且可以去除相关类中重复的装饰逻辑
前端函数应用
由于js的特性,我们可以直接为函数的原型增加修饰器,让这个函数被修饰。
这里我们给Function
原型添加before和after方法,确保新函数在原函数以前或以后执行
Function.prototype.before = function(beforeFn) {
// 为方便链式调用,需要保留外侧的this
const _self = this
// 修饰后的函数,让传递进来的函数在原函数以前执行
return function() {
// 在前面执行的方法
beforeFn.apply(this, arguments)
// 执行调用者函数 其实_self保存的this就是调用者函数本身
return _self.apply(this, arguments)
}
}
Function.prototype.after = function(afterFn) {
const _self = this
return function() {
const ret = _self.apply(this, arguments)
afterFn.apply(this, arguments)
return ret
}
}
function one(){
console.log('1');
}
function two(){
console.log('2');
}
function three(){
console.log('3');
}
one = one.before(two).before(three)
one() // 3 2 1
one = one.after(two).after(three)
one() // 1 2 3
有了这样的修饰器,我们在做一些有先后顺序的操作时,直接调用方法即可:
let clickHandle = () => alert("点击成功!");
const sendLog = e => console.log(`点击元素为:`, e.target.innerHTML);
// 让发送日志这个行为在点击后执行
clickHandle = clickHandle.after(sendLog);
export const Buttons = () => (
<div>
<Button onClick={clickHandle}>log1</Button>
<Button onClick={clickHandle}>log2</Button>
</div>
);
前端业务
这一部分的内容是转载的
如何开发出《荒野之息》这样细节丰富的游戏,下面我们就使用React搭配装饰器来模拟一下游戏中的细节实现。
我们先实现一个Person组件,用来代指游戏的主角,这个组件可以接收名字,生命值,攻击类等初始化参数,并在一个卡片中展示这些参数,当生命值为0时,会提示“游戏结束”。并且在卡片中放置一个“JUMP”按钮,用点击按钮模拟主角跳跃的交互。
import React from "react";
import { Card, Button, message, Slider, Radio } from "antd";
const RadioGroup = Radio.Group;
class Person extends React.Component {
constructor(props) {
super(props);
this.state = {
name: "Link",
hp: 1,
atk: 1,
def: 1
};
this.onJump = this.onJump.bind(this);
}
shouldComponentUpdate(nextProps, nextState) {
if (nextState.hp === this.state.hp) return false;
return true;
}
componentDidMount() {
this.init(this.props);
}
componentWillReceiveProps(nextProps) {
this.init(nextProps);
}
init = props => this.setState({ ...this.state, ...props });
onJump() {
message.success("jump success!");
}
render() {
if (this.state.hp <= 0) setTimeout(() => message.error("GAME OVER!"), 200);
return (
<Card>
{Object.keys(this.state).map(key => (
<div key={key}>{`${key}: ${this.state[key]}`}</div>
))}
<Button onClick={this.onJump}>JUMP</Button>
</Card>
);
}
}
调用这个组件的时候:
<Person name="塞尔达" hp="100"/>
接下来我们想要模拟游戏中的天气和温度变化,需要实现一个“自然环境”的组件Natural,这个组件自身有天气(wat)和温度(tep)两个状态(state),并且可以通过输入改变这两个状态,我们之前创建的Person组件作为后代插入这个组件中,并且接收Natural的wat和tep状态作为属性。
class Natural extends React.Component {
state = {
tep: 30, // 温度
wat: "sun" // 天气
};
render() {
const { tep, wat } = this.state;
return (
<div style={{ width: "500px", margin: "10px auto" }}>
<Slider
marks={marks}
value={tep}
onChange={value => this.setState({ tep: value })}
/>
<RadioGroup
value={wat}
onChange={e => this.setState({ wat: e.target.value })}
>
<Radio value="sun">晴天</Radio>
<Radio value="rain">下雨</Radio>
</RadioGroup>
<Person name="塞尔达" hp="100" tep={tep} wat={wat} />
</div>
);
}
}
但是现在改变温度和天气对主角并不会造成任何影响,接下来我们想在不改变原有Person组件的前提下,实现两个功能:第一,当温度大于50度或者小于10度的时候,主角生命值慢慢下降;第二当天气是雨天的时候,主角每跳跃3次就失败1次。
先来实现第一个功能,温度过高和过低时,主角生命值慢慢减少。我们的思路是实现一个装饰器,用这个装饰器在外部装饰Person组件,使得这个组件可以感知温度变化。先给出实现:
const decorateTep = WapperedComponent => {
return class extends React.Component {
state = {
hp: this.props.hp,
tep: this.props.tep
};
componentDidMount() {
setInterval(this.checkTep, 1000);
}
componentWillReceiveProps(nextProps) {
const { tep } = nextProps;
this.setState({ tep });
}
checkTep = () => {
const { hp, tep } = this.state;
if (tep > 50 || tep < 10) {
const nhp = hp - 10;
this.setState({ hp: nhp > 0 ? nhp : 0 });
}
};
render() {
const { hp, ...ext } = this.props;
return <WapperedComponent hp={this.state.hp} {...ext} />;
}
};
};
仔细观察decorateTep函数,它接收一个组件(A)作为参数,返回一个新的React组件(B),在B内部维护了一个hp和tep状态 ,在tep处于临界值时,改变B的hp,最后render时用B的hp代替原来的hp属性传递给A组件。
这不是就是高阶组件(HOC)么?!没错,当装饰器去装饰一个组件时,它的实现和高阶组件完全一致。通过返回一个新组件的方式去增强原有组件的能力,这也符合React提倡的组件组合的设计模式(注意不是mixin或者继承)
接下来我们来实现第二个功能,下雨时跳跃会偶尔失败,这里我们换一个策略,不再装饰Person组件,而是装饰组件内部的onJump跳跃方法。代码如下:
const decorateWat = (target, key, descriptor) => {
// console.log(target, key, descriptor)
let i = 0;
var method = descriptor.value;
descriptor.value = function(...args) {
if (this.state.wat !== "rain") return method.apply(this, args);
i++;
if (i === 4) {
message.error("jump fail!");
i = 0;
} else {
return method.apply(this, args);
}
};
return descriptor;
};
区别之前的decorateTep,这个decorateWat装饰器的重点是第三个参数descriptor,之前提到,descriptor参数是被装饰方法的描述对象,它的value属性指向的就是原方法(onJump),这里我们用变量method保存原方法,同时使用i记录点击次数,通过闭包延长这两个变量的生命周期,最后实现一个新的方法代替原方法,在新方法内部通过apply调用原方法并重置变量i,注意decorateWat最后返回的是改变以后的descriptor对象。
设计模式六大原则
单一职责原则
我们的手机可以照相、打游戏、看视频、发讯息等等,似乎这一个设备是照相机、游戏机等设备的集合。
但是,在日常生活中,虽然手机可以拍照,但是他的清晰程度、曝光度等等属性远远比不上照相机。
大多数时候,一件产品简单一些,职责单一一些,获取是最好的选择。
这就是单一原则(SRP)的宗旨。
就一个类而言,应该仅有一个引起他变化的原因。
比如在写一个窗体应用的类时,我们常常会把显示窗体、运算的算法、访问数据库等等写道这个类中。这也就意味着,无论出现了何种需求,你都需要改变这个窗体类,这是很难维护,也缺乏灵活度的。
所以,我们可以将界面的变化,和逻辑算法分离开来
开闭原则
开闭原则(OCP)又叫开放-封闭原则。
以一国两制的制度来举例,香港回归时,咱们正确合理有效的社会主义制度是不能修改的,但是在殖民统治的时间中,香港采用的是资本主义制度。为了回归的大局,采用了一个国家,两种制度的思想,优秀的社会主义制度不能修改,但是可以扩展其他的制度。
开闭原则指的是,软件的实体(类、模块、函数等)可以扩展,但是不可修改。即:对于扩展是开放的,对于更改是封闭的。面对需求,对程序的改动是通过增加新代码进行的,而不是通过更改现有的代码。
我们在设计制作一个系统的时候,不会指望系统一开始的时候需求就能确定不变。既然需求常常会改变,那我们如何在面对需求变化时,设计的软件可以相对容易修改,而不是新需求一来,整个程序都得推倒重来呢?
——多扩展,少修改。
当然了,开闭原则也不是意味着说,在设计时需要考虑到需求的种种变化,在编码前就将需求的问题考虑周全;而是在设计的时候时刻考虑让类尽可能地足够好,写完了就不去修改了,尽量做到写完了一个类就基本上没什么修改了。
这样一看,似乎还是需要提前猜测程序可能会发生的变化。我们可以在发生小变化的时候,尽早想办法去应对更大变化的可能,也就是说,等到变化的时候可以立即采取行动。
具体的做法可以是,当编码过程中发生变化时,创建一个抽象类来隔离以后发生的同类变化。
比如在写计算器应用的时候,一开始在Calculator类中写上了加法,后来需要添加减法的时候,就又需要更改这一个类,这就违反了开闭原则。所以后来我们创建了抽象的运算类,构建了计算器工厂来隔离具体的加减法。
依赖倒转原则
电脑内存坏了,我们不会更换CPU,电脑各个零件的职责是明确的,这个满足单一职责原则。
电脑内存不够,我们可以直接添加内存条,而不会去换主板,这个满足开闭原则。
无论是主板、CPU、内存还是硬盘,都是针对接口设计的,即使是不同商家的内存,在将他安装于主板上时,接口都是对应的,这就是依赖倒转原则。
抽象不应该依赖于细节,细节应该依赖于抽象。
讲人话就是,我们应该针对接口编程,而不是对实现编程
在面向过程的开发时,为了使常用的代码可以复用,我们一般会把这些常用的代码写成许许多多个函数的程序库(平时在写Vue项目的时候感触很深)。这样我们在做新项目时,去调用这些低一层的封装函数就可以了。比如项目大部分都需要访问数据库,那我将访问数据库的代码写成了函数,每次请求数据库时就调用这些函数即可。
而这就叫做高层次模块依赖于低层次模块。
可是,如果现在想复用高一层的模块怎么办呢?高层模块和低层的绑定在了一起。还是以电脑举例,如果电脑的CPU、内存等都需要依赖具体的主板进行设计,那主板一坏,所有的部件都无法使用了,这显然是不合理的。
所以,不管是高层还是低层模块,他们都需要将可以复用的部分抽离成接口。
这也正是**里氏代换原则(LSP)**所强调的。
里氏代换原则
在软件中,如果将父类都替换成它的子类,程序的行为没有变化,即子类型能够替换掉他们的父类型。
它提倡的是面向抽象层编程而不是具体实现层,通过在子类中调用父类的公有方法来获取一些内部状态变量,而不是直接使用它。
通常打破这条原则的情况发生在修改父类中在其他方法中使用的,与当前子类无关联的内部或者私有变量。这通常算得上是一种对于类本身的一次潜在攻击,而且这种攻击可能是你在不经意间自己发起的,而且不仅在子类中。
一个ts举例
更为形象的例子,如果现在有鸟类
和企鹅类
,鸟可以飞,但是企鹅不能飞,那么企鹅类无法继承鸟类
里氏代换原则,使得子类可以自由的在父类基础上增加新的行为,让开闭原则成为了可能。
接口隔离原则
一个类对另一个类的依赖应该建立在最小的接口上
- 一个类不应该依赖他不需要的接口。每个接口不存在子类用不到却必须实现的方法,否则要将接口拆分
- 接口的粒度要尽可能小,如果一个接口的方法过多,可以拆成多个接口
在具体应用接口隔离原则时,应该根据以下几个规则来衡量。
- 接口尽量小,但是要有限度。一个接口只服务于一个子模块或业务逻辑。
- 为依赖接口的类定制服务。只提供调用者需要的方法,屏蔽不需要的方法。
- 了解环境,拒绝盲从。每个项目或产品都有选定的环境因素,环境不同,接口拆分的标准就不同深入了解业务逻辑。
- 提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。
迪米特法则
又叫作最少知识原则;一个类尽量不要与其他类发生关系
- 一个类对其他类知道的越少,耦合越小。
- 当修改一个类时,其他类的影响就越小,发生错误的可能性就越小。
参考书籍
《大话设计模式》