用Swift整理GOF设计模式(一)--扫盲设计模式
一、什么是设计模式
"每一个模式描述了一个在我们周围不断重复发生的问题以及该问题的解决方案的核心.这样,你就能一次又一次地使用该方案而不必做重复的劳动". ---Christopher Alexander
我想告诉大家的是:
能看懂设计模式的代码,你往往只是懂了皮毛,设计模式真正教给你的是,告诉你的是什么是设计原则,针对哪种变化、哪种场景使用哪种设计模式。
现实中的场景不会让你在程序设计之初,一上来便套用设计模式,这往往十分不靠谱,更为实际的做法是,"Refactoring to Patterns",结合你身边的代码,使用设计模式来重构代码。
二.分清设计模式与架构模式
刚开始接触编程的新人往往分不清什么是设计模式,什么是架构模式。甚至只知道架构模式,而不是设计模式,这里罗列出从低到高的三种关系。
1.设计习语Design Idioms
Design Idioms描述与特定编程语言相关的底层模式、技巧、惯用法.
(举个栗子来说的话,就像OC中的block,Swift中的函数编程、闭包、guard,不一一列举)
2.设计模式Design Patterns
Design Patterns主要描述的是"类与相互通信的对象之间的组织关系,包括它们的角色、职责、协作方式等方面"
(如Delegate)
3.架构模式Architectural Patterns
Architectural Patterns描述系统中与基本结构组织关系密切的高层模式,包括子系统划分,职责,以及如何组织它们之间的关系规则
(如MVVM、Redux、VIPPER、响应式Rx等等)
三、什么是GOF
设计模式的经典名著——Design Patterns: Elements ofReusable Object-Oriented Software,中译本名为《设计模式——可复用面向对象软件的基础》的四位作者Erich Gamma、Richard Helm、Ralph Johnson,以及John Vlissides,这四人常被称为Gang of Four,即四人组,简称GoF。
该书描绘了23种经典的设计模式,创立了模式在软件设计中的地位,通常所说的设计模式隐含地表示"面向对象设计模式".但不并表示就是等于"面向对象设计模式"
四、软件设计的复杂和解决途径
伴随着下面4个不可避免的变化(客户需求的变化、技术平台的变化 、开发团队的变化、市场环境的变化)
那么我们又该如何解决复杂性?
1.分解:人们面对复杂性有一个常见的做法:即分而治之,将大问题分解为多个小问题,将复杂的问题分解为多个简单的问题
2.抽象:更高层次来讲,人们处理复杂性有一个通用的技术,即抽象.由于不能掌握全部复杂的对象,我们选择忽视它的非本质细节,而去处理泛化和理想化了的对象
五、面向对象设计原则
设计模式的原则才是最重要的,而不像算法,可以去套用,衡量一个程序的好坏,需要我们来对照这些原则的尺子去一一丈量。
变化是复用的天敌。而设计模式的存在是抵御变化,但并不意味没有变化,而是将变化的范围逐步缩小。
1、依赖倒置原则(DIP)
- 高层模块(稳定)不应该依赖于低层模块(变化),二者都依赖于抽象(稳定)
- 抽象(稳定)不应该依赖于实现细节(变化),实现细节应该依赖于抽象(稳定)
下面为代码示例:
我们没有人是生而知之的,在了解是什么是抽象类之前,我们一定都写过这样的代码:
class DrawingBoard{//绘画板,代表高层模块
var lineArray:Array<Line>?
var rectArray:Array<Rect>?
func onPaint(){
for lineInstance in lineArray{
event.Graphics.DrawLine(Pens.Red,
lineInstance.leftUp,
lineInstance.width,
lineInstance.height)
}
for rectInstance in rectArray{
event.Graphics.DrawRect(Pens.Red,
rectInstance.leftUp,
rectInstance.width,
rectInstance.height)
}
}
}
class Line{//底层模块(代表容易变化的模块)
func Draw(){ ... }
}
class Rect{//底层模块(代表容易变化的模块)
func Draw(){ ... }
}
而这个设计原则告诉我们应该像这样去思考:
class DrawingBoard{//绘画板,代表高层模块
var shapeArray:Array<Shape>?
func onPaint(){
for shape in shapeArray{
shape.Draw();
}
}
}
protocol Shape{//抽象接口,同时也是一种稳定的模块(高层和低层都依赖抽象类)
func Draw(){ }
}
class Line:Shape{//底层模块(代表容易变化的模块)
override func Draw() { }//实现细节应该依赖于抽象
}
class Rect:Shape{//底层模块(代表容易变化的模块)
override func Draw() { }//实现细节应该依赖于抽象
}
结构就变成了这样,看看现在是不是这样的规则:
高层模块(稳定)不应该依赖于低层模块(变化),二者都依赖于抽象(稳定)
抽象(稳定)不应该依赖于实现细节(变化),实现细节应该依赖于抽象(稳定)
2.开放封闭原则(OCP)
- 对扩展开放,对更改封闭.
- 类模块应该是可扩展的,但是不可修改.
假如我们来一个新的需求时,如果不使用设计模式,我们经常会在原有代码结构上进行更改。根据这个原则,我们应该避免这种更改,而选择去扩展。
因为更改的代价往往是十分大的,
class DrawingBoard{
var lineArray:Array<Line>?
var rectArray:Array<Rect>?
//新的改变需求
var circleArray:Array<Circle>?
func onPaint(event:PaintEventArgs){
//旧代码
for lineInstance in lineArray{
//同下
}
for rectInstance in rectArray{
//同下
}
//新代码
for circleInstance in circleArray{
event.Graphics.DrawCircle(Pens.Red,
circleInstance.leftUp,
circleInstance.width,
circleInstance.height)
}
}
}
class Line{//底层模块(代表容易变化的模块)
//...
}
class Rect{//底层模块(代表容易变化的模块)
//...
}
class Circle{
}
这种代码就违反了开放封闭原则,它是在改变代码,这就意味着这块代码需要重新编译、重新测试、重新部署,改变的代价十分高昂。
我们依旧像之前那样,重新修改代码:
class DrawingBoard{//绘画板,代表高层模块
var shapeArray:Array<Shape>?
func onPaint(){
for shape in shapeArray{
shape.Draw();
}
}
}
protocol Shape{//抽象接口,同时也是一种稳定的模块(高层和低层都依赖抽象类)
func Draw(){ }
}
class Line:Shape{//底层模块(代表容易变化的模块)
override func Draw() { }//实现细节应该依赖于抽象
}
class Rect:Shape{//底层模块(代表容易变化的模块)
override func Draw() { }//实现细节应该依赖于抽象
}
class Circle:Shape{//底层模块(代表容易变化的模块)
override func Draw() { }//实现细节应该依赖于抽象
}
第二种方法明显就是一种以扩展的方式应对新的需求,这就是来自面向对象的智慧。
9155AFEB-E0C8-4CC0-B11A-225AA1B5D46A.png上图红色的部分代表修改&新增。
3.接口隔离原则(ISP)
- 不应该强迫客户程序依赖它们不用的方法
- 接口应该小而完备
不要去暴露不该暴露的接口,需要我们去考虑什么使用private,internal,public。如果库开发程序员无节制的public 方法给iOS应用开发程序员,iOS应用开发程序员就会和一些不应该public的接口产生依赖,这样你的接口就都需要保持稳定。
所以接口应该小而完备。
4.优先使用对象组合,而不是类继承
- 类继承通常为"白箱复用",对象组合通常为"黑箱复用"
- 继承在某种程度上破坏了封装性,子类父类耦合度高。
- 而对象组合则只要求被组合的对象具有良好定义的接口,耦合度低
许多初学面向对象的程序员都非常喜欢使用继承。因为面向对象中的继承更符合我们直观的世界观。
就像相较于函数式编程,我们更加会适应命令式编程,因为函数式编程的数学思想不容易被接受,使用命令式编程更明显地看到如何将真实世界中的对象和程序语言中的对象一一对应。
而关于组合优于继承的例子,我在装饰模式一文已经提及。
5.单一职责原则(SRP)
- 一个类应该仅有一个引起它变化的原因
- 变化的方向隐含着类的责任
如果我们一个类充满了几十个方法和成员时,这明显是不正常的,这就代表隐含了多个责任,就像iOS开发中如果将ViewController和View混淆在一起,这明显是不对的,当隐含多个责任时,很明显会出问题.
之后写的文章 桥模式和装饰模式就会遇到类的责任问题,新手开发者如果轻视责任的问题,甚至会造成整个程序的设计出现问题。
6.Liskov替换原则(LSP)
- 子类必须能够替换他们的基类(IS-A)
- 继承表达类型抽象
一般而言,这个原则看起来似乎天经地义,子类替换父类似乎是理所当然的,的确如此,但是不排除有以下情况的出现:
class 乐器{
func 奏乐() -> Void {
}
func 调音() -> Void {
}
}
class 武器:乐器{
override func 奏乐() -> Void {
fatalError("无法奏乐")
}
override func 调音() -> Void {
fatalError("无法调音")
}
}
这个设计看上去似乎十分可笑,但是很多程序员在现实设计时,会发现子类有时候确实就是不应该使用父类的方法,于是直接抛出异常。例子看上去很傻瓜,但当真实投入实践,有时候我们就会犯糊涂。
这显然就违背了我们的原则,证明了武器这个类压根就不应该设计为子类。
7.封装变化点
- 使用封装来创建对象之间的分界层,让设计者可以在一侧进行修改,而不会对另外一侧产生不良的影响
这里依旧拿库开发程序员举例,如果库开发程序员不封装变化点,对外接口不是稳定的,而是变化的,那么每次修改,都会导致iOS开发程序员同时进行修改。
这里我拿Swift中的官方代码举例:
public func assert(
_ condition: @autoclosure () -> Bool,
_ message: @autoclosure () -> String = String(),
file: StaticString = #file, line: UInt = #line
) {
_assertionFailed("assertion failed", message(), file, line,
flags: _fatalErrorFlags())
}
}
_assertionFailed可以是变化的,而assert是稳定的
8.面向接口编程,而不是针对实现编程
- 客户程序无需获知对象的具体类型,只需知道对象所具有的接口。
- 减少系统中各部分的依赖关系,从而实现"高内聚、松耦合"的类型设计方案。