设计模式
https://github.com/DeutschBall/DesignModel
六原则
单一职责原则
一个类负责的职责应该尽可能少,最好是单一功能。类应该只有一个引起自己变化的原因。
否则如果有两个以上功能,当用户只是用其中一个功能时还要导入其他无用功能
✨开放封闭原则
对拓展开放, 对修改关闭
已经写好的代码应该尽可能保持不变,新功能以拓展的形式实现,不应该以修改的形式出现
比如向有些if-else中添加判断条件就不满足改原则
依赖倒置原则
针对接口编程, 不针对实现编程
变量的声明类型尽量是抽象类或接口,这样我们的变量引用和实际对象间,就存在一个缓冲层,利于程序扩展和优化。
✨里氏替换原则
一个软件实体如果使用的是一个父类的话,那么一定适用于其子类,而且它察觉不出父类对象和子类对象的区别。也就是说,在软件里面,把父类都替换成它的子类,程序的行为没有变化.
高层模块不能依赖底层模块, 高层模块和底层模块都应依赖抽象.
考虑这么一个问题: 鸟类能否作为企鹅类的父类?
鸟类必须实现fly接口, 如果企鹅类继承鸟类, 意思就是企鹅也有fly接口, 但是企鹅显然不会飞. 因此这继承就不对.
也就是说, 只有子类可以完全替代父类, 才能使用继承.
迪米特法则
最少知识原则
1.最低访问权限
2.两个类彼此不通信则两个类不应当发生直接的相互作用
✨✨✨合成/聚合复用原则
优先使用对象的合成/聚合关系,而不是类继承关系
聚合(组合):弱拥有关系,A对象中可以包含B对象,也可以不包含。A对象和B对象离开对方都能独立存在。 个体与群组。
合成:强拥有关系,严格的部分和整体关系,两者共存亡。 器官与身体
优点是类的继承层次比较小。保持每个类被单独封装,集中精力面对单个任务。
UML类图
类关系 | 解释 | 例子 |
---|---|---|
继承 | 继承一个父类 | 鸟类继承动物类 |
实现 | 实现一个接口 | 鸟类实现IFlyAble接口 |
依赖 | 类B作为类A方法的参数\局部变量,在A中临时存在 | |
关联 | 类B作为类A的成员变量,在A中永久存在 | 链表类中有一个附加头节点类的引用 |
聚合 | 个体可以离开整体存在 | |
组合 | 个体和整体共存亡,不可分开 | |
设计模式
创建型模式
创建对象同时隐藏创建逻辑,避免直接使用new运算符
工厂模式
简单工厂模式
简单工厂方法的弊端:违反了开放-封闭原则
简单工厂类需要接受一个类型参数,然后通过switch-case判断返回具体对象
如果要加入新的类型,那么就得改写这个switch-case逻辑,这就违反了开放-封闭原则
1 | static CashSuper* createCash(const std::string& type){ |
工厂方法模式
工厂方法模式是对简单工厂模式的改进,需要满足开放-封闭原则
抽象工厂模式
适用场景:
考虑这么一种场景
有两家工厂, 苹果工厂和联想工厂
两家工厂都生产计算机, 智能手机等电子产品
用户想要一款苹果手机
就是有多种品牌系统(在这里是联想和苹果),他们都能生产同种类的产品
此时就可使用抽象工厂模式
抽象工厂模式的实现流程:
1.定义抽象产品类, 在本例中是智能手机和计算机基类. 可以规定产品通用的功能接口, 比如手机可以打电话
2.定义具体产品类, 在本例中是苹果手机,苹果电脑, 联想手机,联想电脑
3.定义抽象工厂类, 规定任意工厂都能生产的产品类型, 在本例中是计算机和智能手机.
4.定义具体工厂类, 具体工厂类实现抽象工厂. 在本例中具体工厂就是苹果工厂和联想工厂.
具体工厂中生产具体产品, 实际上是实现抽象工厂规定的生产接口
抽象工厂规定要生产智能手机
苹果工厂中实现生产苹果手机
单例模式
保证一个类只有一个实例, 并提供一个全局访问点
懒汉模式: 在第一次使用单例时才创建它
饿汉模式: 在main函数之前, 也就是程序初始化时创建它
在多线程环境下, 懒汉模式会面临线程安全问题, 但是饿汉不会
因为饿汉创建单例是在main函数之前, 此时绝对不会有多个线程
但是懒汉模式下两个线程可能同时创建单例
1 | static SingletonLazy& getSingletonLazy(){ |
懒汉模式下的线程安全问题
可以使用双重锁解决这个问题
1 | static SingletonLazy& getSingletonLazy(){ |
两次判空的作用是?
加锁和上锁操作开销比较大, 应该尽量避免
假设有两个线程A,B同时通过了第一次判空
A线程首先持有m锁, 并通过了第二次判空, 创建了对象
B线程等A释放m锁后再持有, 此时B判空发现对象已经创建好了, 自己就不用再创建了
如果去掉外层判空, 依然是线程安全, 但是这样会导致, 不管对象是否被创建, 每个线程来了都先等锁, 即使不需要创建对象, 也要无意义等锁
如果去掉内层判空, 就不是线程安全的了, 考虑上述AB两个线程都经过了外层判空, A首先持有锁创建对象, 然后B持有锁看, B都不看是否有对象了就创建.还是会造成线程安全问题
静态局部变量的线程安全性
c++11之后静态局部变量的初始化是线程安全的
因此懒汉模式直接这样写, 也是线程安全的:
1 | static SingletonLazy& getSingletonLazy(){ |
实现原理:
1 | 0x00000000004012cf <+23>: lea rax,[rip+0x2ed2] # 0x4041a8 <_ZGVZN13SingletonLazy16getSingletonLazyEvE8instance> |
在第11行调用构造函数的前后, 编译器自动加上了guard
guard
底层由SYS_futex
系统调用实现
✨建造者模式
设计n个类表现汽车:
- 所有汽车都具备”行驶”的功能
- 汽车的”车门数量”可能不同
- 汽车按照能源类型分为”燃油车”和”电动车”,燃油车具备”加油”的能力,电动车具备”充电”的能力
- 汽车按照用途分为”轿车”和”卡车”,轿车没有额外功能,卡车具备”装载货物”的功能最终一辆4门电动卡车是如何表达的
车的组成 - 桥接模式
所有车辆共有的车门, 引擎, 能源等组件, 均以槽位的形式虚位以待 , 也就是车与这几个组件都是组合关系
车相关的类按照用途划分为卡车类和轿车类, 其中卡车类多一个loaders
槽, 用于组合集装箱类
车的生产 - 建造者模式
用户实际上不关心车辆的构造, 用户只需要关心charge
充能和run
耗能行驶就够了
用户只需要提出需求“四门电动卡车”, 然后等着提货就完了
对于每种用户需求, 可以建立一个专门的Builder
来满足需求
比如ElectricTruckBuilder
Builder
也可以有类体系:
AutoBuilder
表示所有车辆通用的建造者, 用于组装车辆共有部分比如门
TruckBuilder
表示卡车类建造者, 用于组装集装箱
ElectricTruckBuilder
表示电卡车, 在卡车基础上组装电动力
只有具体的建造者类才能实例化, 任何父类都含有抽象方法, 有抽象函数未实现的类实例化无法通过编译.
所有建造者应该由一个指导者管理, 决定到底采用哪个构造者,
用户只需要跟指导者说自己想要什么样的车, 指导者去找对应的建造者
原型模式
通过一份原型,克隆出多个副本
比如简历,可以打印一份,复印多份
就是继承一个Cloneable接口,实现一个clone函数
根据需要实现深拷贝和浅拷贝
结构型模式
关注对象之间的组合和关系, 构建灵活且可复用的类和对象结构
组合模式
用树形结构管理系统
用户对父节点的操作和对子节点的操作具有一致性
以语法分析树为例, 各个节点均实现visit接口
Operand就是叶子类
Operator就是内部节点类
这样将内部节点和叶子节点分类讨论, 是安全模式
如果将叶子节点也归为内部节点, 不再分类讨论, 则变成了透明模式, 此时实际上的叶子节点的handle和getBase等函数没有意义
问题场景:
描述如下公司结构
总部有自己的人力和财务, 总部还管理多个分公司
分公司也有自己的人力和财务,分公司管理多个办事处
办事处也有自己的人力和财务
透明模式
这样实现的不足之处是, 人力, 财政等部门, 没有子部门或者子公司,
departments
成员和addDepartment
方法不应该被继承. 违反了里氏替换原则
安全模式
适配器模式
将一个类的接口按需求转换为另一个类的接口
电源适配器:不管入户电压多少伏都转换为需要的电压比如36v
deque改成queue
vector改成stack
用户需求的接口以Target接口给出
Adapter类实现Target接口
Adapter类内部封装一个Adaptee类
✨桥接模式
本节最初描述了这么一件事情:
1.不同品牌的手机
2.每种手机都有mp3,游戏,通讯录等等功能
最初的设计方式是纯使用继承实现
有两种继承设计方案, 一个是品牌在高层,一个是功能在高层
但是这样设计就会面临一个问题:
当有新品牌手机出现, 或者新手机功能出现时, 会导致类的数量急剧膨胀
书上是这样说的:
“是呀,就像我刚开始学会用面向对象的继承时,感觉它既新颖又功能强大,所以只要可以用,就都用上继承。这就好比是‘有了新锤子,所有的东西看上去都成了钉子。”
这说的太对了。
然而仔细考虑这种继承关系的缺点:
编译时就确定了子类和父类的继承关系,导致子类和父类必然有紧密的依赖关系,父类的改动必然导致子类的改动。
因此设计模式中的另一条设计原则:合成/聚合复用原则
仔细考虑还违反了单一职责原则,通过继承增加的新功能,就是在增加类的职责。
因此这个情景应该用聚合解决
手机是主体, 不管什么品牌, 都可以安装不同的软件, 软件可以视为主体上的空槽,可以任意安装
考虑四门电动卡车问题时,实际上也是这个情况
按照能源继承后按照用途继承或者按照用途继承后按照能源继承,都是滥用继承设计
应该将车看成主体,将动力系统,运载系统等等看成槽位。空槽可以自定义添加模块.
这里Auto表示汽车类, Car和Truck表示汽车品牌
将Door,Engine,Container等等视为汽车的配件槽
实现系统可能有多角度分类,每一种分
类都有可能变化,那么就把这种多角度分离出来让它们独立变化,
减少它们之间的耦合
装饰模式 - 咖啡点单
考虑如下场景:
形象设计, 人有多种衣服可以穿, 可以只穿一条裤衩子, 也可以穿地全副武装, 也可以裤衩子外穿装超人.
最初我的设计是这样的:
1 | class Wear{}; |
然而这样设计的问题是:
1.人不一定只穿一件上衣, 可能穿了秋衣然后又穿了外套, 甚至可以不穿上衣
2.没法体现穿衣服的先后顺序
3.假设现在有一种新的装饰, 戒指Ring类, 那么Avator类无法表示
也就是说, 我们不能预估人会穿多少装饰物, 可能一件不穿, 可能穿的雍容华贵
我们不能站在人自己的角度来聚合装扮
应该站在装扮的角度往一个木偶身上套娃
1 | (木偶) |
对于每个装饰物来说, 他只需要知道目前套娃什么样, 然后自己套上去, 成为新的套娃
类似的思想也可以用于咖啡点单
1 | (美式) |
那么这种模式应该如何表示呢
这里ConcreteComponent就是最初的美式, 也就是套娃的核
然后加糖加冰加牛奶都是Decorator的子类, 每个表示一层套娃
此后如果有新的咖啡,比如卡布奇诺,继承Coffee兵实现toString即可
如果有新的口味,比如椰果,继承Flavour并实现getName即可
外观模式
子系统对外不可见, 由外观类Facade对外提供接口提供访问, 客户不需要关心子系统细节
Facade相当于一个高级代理
外观模式和代理模式的区别:
代理类只对一个实际对象进行代理, 更加专一
但是外观类中管理了很多子系统, 并提供不同方法组合对一个或多个子系统的调用
外观模式可以用在基金和股票场景中
客户是白痴理财人
股票是子系统
基金是外观类
基金经理会挑选几只股票押宝, 不再一棵树上吊死. 对应到外观类可以组合子系统的调用
客户只需要和基金经理打交道, 买入或者卖出. 对应到客户类只需要访问外观类提供的接口
享元模式
享元模式,FlyWeight
,实际上是轻量级的意思
1 |
|
实际上编译器在rodata段只会生成一个“helloworld”字符串,而不是两个
a和b指针实际上指向相同的内存地址
类似的场景有写时复制:
写时复制(Copy-on-write,简称COW)是一种计算机程序设计领域的优化策略。其核心思想是,如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的。此作法主要的优点是如果调用者没有修改该资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源。
享元模式实际上是工厂模式的改进, 工厂会记忆用户的需求
如果相同的需求之前已经满足过了, 那么直接返回之前构建的对象
如果是新需求则创建新对象
代理模式
书上举得这个例子实在不怎么样,别天天折磨人家女娃了,纯人机
意思就是给RealSubject类裹了一层Proxy代理类,代理类实现相同的Request接口
这个代理类在执行Request动作前后可以自己增加pre和post操作
行为型模式
关注对象之间的通信与交互,解决对象之间的责任分配与算法封装
模板方法模式
不变的部分搬到父类
去除子类中的重复代码
1 | class Flavour :public Component{ //Decorator |
这里getName就是模板方法
观察者模式
发布者-订阅者模式
状态模式
控制一个对象状态转移的表达式过于复杂时(如果简单就不需要用状态模式)
将状态判断的if-else逻辑, 转移到表示不同状态的一系列类中表示
备忘录模式
迭代器模式
命令模式
命令模式将一个请求封装为一个对象, 对象数组就能模拟请求队列, 可以实现延迟请求, 请求排队
同时, 将请求对象化, 方便了记录请求日志, 对象记录请求内容, 也就支持了撤销操作
Receiver: 实际执行命令的对象
Command: 命令类型父类, Command可以有不同子类定义实际的命令类型
Invoker: 使用Command的入口, 也就是封装命令的对象
如图所示, 这是一个shell命令执行器.
其中Executor就是实际命令执行器, 也就是Receiver, 其execute参数接受一个shell命令字符串, 并调用system函数执行之
Command是命令类型基类, 其getCommand类型汇报自己对应的shell命令, 其execute负责拼接shell命令与参数 ,并交由Executor执行. Command类中持有一个Executor的引用executor
Invoker是封装的一次命令执行, 在Invoker中设置好参数与对应命令类, 即可在任意时刻调用invoke执行命令
invoke和call的区别:
两者都用于表示“调用”
call通常指直接的函数调用, 直接使用函数名调用
invoke通常指动态上下文中,使用反射/委托/回调等场景
职责链模式
过滤器和拦截器均采用职责链模式
以过滤器为例, 过滤器通常应用于用户权限检查/防止乱码/设置响应编码等场景
在javaweb编程中注册一个过滤器非常方便:
1.实现过滤器类
2.在web.xml中注册过滤器映射
1 | <filter> |
完美符合开放-封闭原则
看一下时序图, 可以发现各个Filter是递归调用的, 而不是平行地遍历了一遍
1 |
|
UML类图表示为:
职责链模式有一个链管理器, 也就是FilterChain, 其中可以有多个职责类, 理想状态下每个指责类只负责一种职责, 比如字符过滤器只负责过滤输入中的危险字符, 大写过滤器只负责将输出中所有小写字符转化成大写
各个职责可以采用递归嵌套, 也可以平行遍历.
但是在Filter这里由于有pre和post两个处理函数, 只能采取递归
中介者模式
中介者模式通过引入中介者类,将原本模块之间互相调用的关系, 转化为经过中介者沟通的模式, 实现了模块间解耦
房地产交流平台是“房地产中介公司”提供给“卖方客户”与“买方客户”进行信息交流的平台,比较适合用中介者模式来实现。
聊天室中server作为中介者
实际上是星状结构
解释器模式
再别多说, 给定一个语言, 定义其语法表示, 并定义解释器, 用解释器来解释该语言中的句子
访问者模式
以Antlr4的实现为例, 学习其访问者模式的实现
访问者模式的作用时机
编译器前端由词法分析器lexer和语法分析器parser组成, 前端的作用时, 输入一段目标语言的源代码, 输出语法树
1 | source code ====> front end ====> grammer tree |
编译器后端就是语义分析, 在antlr4中语义分析可以采用访问者模式 或者 监听者模式实现. 后端的作用是, 输入一个语法树, 进行语义分析, 也就是解释
1 | grammer tree ====> back end ====> semantic analyze |
其中visitor就在编译器后端====>语义结果
这一步发挥作用
语法树节点结构
那么visitor的参数就是语法树的root节点
所有语法树节点的接口:
1 | public interface ParseTree extends SyntaxTree { |
所有语法节点类的抽象父类:
1 |
|
具体的语法节点类由.g4规则文件给出, 这个g4文件就是人工撰写的词法语法规则文件, 比如:
1 | program : ( statement SEMICO )* EOF ; |
antlr4会在处理g4文件时, 将program
语法对应建立一个ProgramContext
语法树节点类
同样statement
语法也会建立一个StatementContext
语法树节点类
语法树节点的accept接口
在ParseTree
接口中规定, 为了能够使用visitor
模式, 需要每个语法树节点实现accept
接口
1 | <T> T accept(ParseTreeVisitor<? extends T> var1); |
比如ProgramContext
节点类是这样实现的:
1 | public static class ProgramContext extends ParserRuleContext { |
也就是直接调用了DrawGraphVisitor.visitProgram()
又比如StatScaleContext
节点类这样实现:
1 | public static class StatScaleContext extends ScaleStatmentContext { |
也就是直接调用了DrawGraphVisitor.visitStatScale()
整个过程是这样的:
访问者: 节点, 我能访问你吗? 我应该怎么访问你呢?
StateScaleContext
节点: 同意访问, 你得去用你的visitStatScale
方法来访问我.
为什么访问者不能直接调用自己的visitStatScale
方法, 而是先调用节点的accept方法呢?
这是因为, 当访问者来到当前节点家门口时, 访问者并不知道当前节点是个什么具体类型的节点, 访问者只知道节点的多态基类.
因此访问者不知道应该对当前节点进行什么操作, 因此节点需要在自己的accept函数中, 告知访问者访问协议, 也就是由节点指引访问者访问当前节点的方法
那么接下来的问题是, visitProgram
这种具体的访问协议是如何实现的?
类型 | 类名 | 职责 | 位置 |
---|---|---|---|
接口 | ParseTreeVisitor | 声明visit接口, visitChildren接口等 | jar库 |
抽象类 | AbstractParseTreeVisitor | 实现visit接口, visitChildren接口等 | jar库 |
类 | DrawGraphBaseVisitor | 实现默认的visitStatScale等具体协议 | antlr4命令生成 |
类 | EvalVisitor | 自定义visitStatScale等具体协议 | 程序员撰写 |
DrawGraphBaseVisitor
由antlr4自动生成, 其中定义了默认的访问协议方法
1 | public class DrawGraphBaseVisitor<T> extends AbstractParseTreeVisitor<T> implements DrawGraphVisitor<T> { |
可见默认的方法是直接访问子节点去, 走马观花
真正的访问协议需要程序员撰写, 继承DrawGraphBaseVisitor
重写同名函数
1 | public class EvalVisitor extends DrawGraphBaseVisitor<Double> |
访问者控制流
下面跟随访问者的控制流, 看一下accept和visit是如何配合的
1 | //BackEnd入口: |
1 | //in class AbstractParseTreeVisitor |
1 | // in class ProgramContext |
1 | //in class DrawGraphBaseVisitor |
1 | //in class AbstractParseTreeVisitor |
到此程序控制流又进入了accept
方法中, 只不过这次应该是StatementContext.accept
方法
1 | //in class StatementContext |
…
也就是说在具体的访问协议, 比如visitStatement
或者visitStatScale
等等, 如果程序员有在EvalVisitor
中重写, 则调用程序员自定义的
否则调用antlr4
在DrawGraphBaseVisitor
中生成的默认的
访问器模式总结
1.访问者类的visit接口, 用于与语法树节点类accept接口建立连接, 协商针对该节点的具体访问协议
2.访问者类要知道所有可能的语法树节点类型, 并实现所有具体的访问协议
3.语法树的节点类要实现accept接口, 用于与访问者的visit接口协商本节点的具体访问协议, 告知访问者本节点的具体类型
1 | visitor.visit(node) |
设计模式关系
图片来自菜鸟教程