设计模式是软件设计中常见问题的典型解决方案。它们就像能根据需求进行调整的预制蓝图,可用于解决代码中反复出现的设计问题。
模式的概念是由克里斯托佛·亚历山大在其著作《建筑模式语言》中首次提出的。本书介绍了城市设计的 “语言”,而此类 “语言” 的基本单元就是模式。模式中可能会包含对窗户应该在多高、 一座建筑应该有多少层以及一片街区应该有多大面积的植被等信息的描述。
埃里希·伽玛、约翰·弗利赛德斯、拉尔夫·约翰逊和理查德·赫尔姆这四位作者接受了模式的概念 1994 年,他们出版了《设计模式:可复用面向对象软件的基础》一书,将设计模式的概念应用到程序开发领域中。该书提供了 23 个模式来解决面向对象程序设计中的各种问题,很快便成为了畅销书。由于书名太长,人们将其简称为 “四人组(Gang of Four,GoF)的书”。
不同设计模式的复杂程度、细节层次以及在整个系统中的应用范围等方面各不相同。
最基础的、底层的模式通常被称为惯用技巧。 这类模式一般只能在一种编程语言中使用。
最通用的、高层的模式是构架模式。开发者可以在任何编程语言中使用这类模式。与其他模式不同,它们可用于整个应用程序的架构设计。
此外, 所有模式可以根据其意图或目的分为:
- 创建型模式,提供创建对象的机制, 增加已有代码的灵活性和可复用性。
- 结构型模式,将对象和类组装成较大的结构, 并同时保持结构的灵活和高效。
- 行为模式,负责对象间的高效沟通和职责委派。
设计模式有 6 大原则,经典的 23 种设计模式目的就是为了使软件系统能达到这些原则。
六大原则
1. 开闭原则
软件应该对扩展开放,对修改关闭。
开闭原则的实现方法:可以通过“抽象约束、封装变化”来实现开闭原则
对系统进行扩展,而无需修改现有的代码。这可以降低软件的维护成本,同时也增加可扩展性。
因为抽象灵活性好,适应性广,只要抽象的合理,可以基本保持软件架构的稳定。而软件中易变的细节可以从具体的实现类来进行扩展,当软件需要发生变化时,只需要根据需求重新实现新的类来扩展就可以了。
一个示例:
type Rectangle struct {
Height float64
Width float64
}
func calcArea(r Rectangle) float64 {
return r.Height * r.Width
}
上面示例计算矩形的面积,如果此时又多了一个圆形:
type Rectangle struct {
Height float64
Width float64
}
type Circle struct {
Radius float64
}
func calcArea(i interface{}) float64 {
switch v := i.(type) {
case Rectangle:
return v.Height * v.Width
case Circle:
return math.Pow(v.Radius, 2) * math.Pi
default:
return 0
}
}
我们将 calcArea
的参数改为 interface{}
接收任意类型,在内部针对不同断言的类型进行计算面积。为了支持圆形却涉及到了原本的矩形的修改。
calcArea
函数在实际生产中可能会拥有相当的复杂实现,牵一发动全身,可以通过使用接口进行改进,抽象出一个 Areaer
:
type Areaer interface {
Area() float64
}
type Rectangle struct {
Height float64
Width float64
}
func (r Rectangle) Area() float64 {
return r.Height * r.Width
}
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 {
return math.Pow(c.Radius, 2) * math.Pi
}
func calcArea(a Areaer) float64 {
return a.Area()
}
这样,当需求再有变更时,我们只需实现一个新的接口即可,不用修改现有代码。
2. 里氏替换原则
里氏替换原则主要阐述了有关继承的一些原则,也就是什么时候应该使用继承,什么时候不应该使用继承
里氏替换原则通俗来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能,这样使用子类替换掉父类时就不会产性任何异常或错误,只要父类能出现的地方子类就可以出现。
比如某个方法接受一个 Map型参数,那么它一定可以接受HashMap、LinkedHashMap 等参数,但是反过来的话,一个接受HashMap的方法不一定能接受所有Map类型参数。
里氏替换原则是对开闭原则的补充,实现开闭原则的关键步骤就是抽象化,基类与子类的关系就是要尽可能的抽象化。
里氏替换原则的定义可以总结如下:
- 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法
- 子类中可以增加自己特有的方法
- 当子类的方法重载父类的方法时,方法的前置条件(即方法的输入参数)要比父类的方法更宽松
- 当子类的方法实现父类的方法时(重写/重载或实现抽象方法),方法的后置条件(即方法的的输出/返回值)要比父类的方法更严格或相等
如果程序违背了里氏替换原则,则继承类的对象在基类出现的地方会出现运行错误。
这时其修正方法是:取消原来的继承关系,重新设计它们之间的关系。
3. 依赖倒置原则
依赖倒置原则:高层模块不应该依赖低层模块,都应该依赖抽象。抽象不应该依赖细节,细节应该依赖抽象。其核心思想是:要面向接口编程,不要面向实现编程。
依赖倒置原则要求我们在程序代码中传递参数时或在关联关系中,尽量引用层次高的抽象层类,即用接口和抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等,而不要用具体类来做这些事情。
这是为了减少类间的耦合,使系统更适宜于扩展,也更便于维护。
4. 单一职责原则
单一职责原则(Single Responsibility Principle,SRP)又称单一功能原则。
这里的职责是指类变化的原因,单一职责原则规定一个类应该有且仅有一个引起它变化的原因,否则类应该被拆分。
该原则提出对象不应该承担太多职责,如果一个对象承担了太多的职责,至少存在以下三个缺点:
- 一个职责的变化可能会削弱或者抑制这个类实现其他职责的能力;
- 当客户端需要该对象的某一个职责时,不得不将其他不需要的职责全都包含进来,从而造成冗余代码或代码的浪费。
- 一个类承载的越多,耦合度就越高。如果类的职责单一,就可以降低出错的风险,也可以提高代码的可读性。
单一职责原则的核心就是控制类的粒度大小、将对象解耦、提高其内聚性。如果遵循单一职责原则将有以下优点:
- 降低类的复杂度。一个类只负责一项职责,其逻辑肯定要比负责多项职责简单得多。
- 提高类的可读性。复杂性降低,自然其可读性会提高。
- 提高系统的可维护性。可读性提高,那自然更容易维护了。
- 变更引起的风险降低。变更是必然的,如果单一职责原则遵守得好,当修改一个功能时,可以显著降低对其他功能的影响。
对于代码中的接口而言,按职责进行拆分,再封装到不同的类或模块中。
type UserService interface {
Login()
Register()
LogError()
SendEmail()
}
这段代码很显然存在很大的问题,UserService 既要负责用户的注册和登录,还要负责日志的记录和邮件的发送,并且后者的行为明显区别于前者。
因此我们需要进行拆分,根据具体的职能可将其具体拆分如下:
UserService: 只负责登录注册
type UserService interface {
Login()
Register()
}
LogService: 只负责日志
type LogService interface {
LogError()
}
EmailService: 只负责发送邮件
type EmailService interface {
SendEmail()
}
对于类型来说,根据类型名,确保里面提供的方法都是属于这个类型的。
对于方法,不要把不相关的对象实例作为参数传进来。如果你发现某个方法依赖某个不相关的对象,那么这个方法的实现可能就存在问题。
5. 最少知道原则
最少知道原则又称迪米特法则,迪米特法则的定义是:只与你的直接朋友交谈,不跟“陌生人”说话。其含义是:如果两个软件实体无须直接通信,那么就不应当发生直接的相互调用,可以通过第三方转发该调用。其目的是降低类之间的耦合度,提高模块的相对独立性。
迪米特法则中的“朋友”是指:当前对象本身、当前对象的成员对象、当前对象所创建的对象、当前对象的方法参数等,这些对象同当前对象存在关联、聚合或组合关系,可以直接访问这些对象的方法。
简单来说就是一个实体应当尽量少地与其他实体之间发生相互作用。还是为了降低耦合,一个类与其他类的关联越少,越易于扩展。
但是,过度使用迪米特法则会使系统产生大量的中介类,从而增加系统的复杂性,使模块之间的通信效率降低。所以,在釆用迪米特法则时需要反复权衡,确保高内聚和低耦合的同时,保证系统的结构清晰。
从迪米特法则的定义和特点可知,它强调以下两点:
- 从依赖者的角度来说,只依赖应该依赖的对象。
- 从被依赖者的角度说,只暴露应该暴露的方法。
所以,在运用迪米特法则时要注意以下 6 点。
- 在类的划分上,应该创建弱耦合的类。类与类之间的耦合越弱,就越有利于实现可复用的目标。
- 在类的结构设计上,尽量降低类成员的访问权限。
- 在类的设计上,优先考虑将一个类设置成不变类。
- 在对其他类的引用上,将引用其他对象的次数降到最低。
- 不暴露类的属性成员,而应该提供相应的访问器(set 和 get 方法)。
- 谨慎使用序列化(Serializable)功能
6. 接口分离原则
接口隔离原则(Interface Segregation Principle,ISP)要求程序员尽量将臃肿庞大的接口拆分成更小的和更具体的接口,让接口中只包含使用者感兴趣的方法。
避免同一个接口占用过多的职责,更明确的划分,可以降低耦合。高耦合会导致程序不易扩展,提高出错的风险。
接口隔离原则是为了约束接口、降低类对接口的依赖性,遵循接口隔离原则有以下 5 个优点:
- 将臃肿庞大的接口分解为多个粒度小的接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。
- 接口隔离提高了系统的内聚性,减少了对外交互,降低了系统的耦合性。
- 如果接口的粒度大小定义合理,能够保证系统的稳定性;但是,如果定义过小,则会造成接口数量过多,使设计复杂化;如果定义太大,灵活性降低,无法提供定制服务,给整体项目带来无法预料的风险。
- 使用多个专门的接口还能够体现对象的层次,因为可以通过接口的继承,实现对总接口的定义。
- 能减少项目工程中的代码冗余。过大的大接口里面通常放置许多不用的方法,当实现这个接口的时候,被迫设计冗余的代码。
在具体应用接口隔离原则时,应该根据以下几个规则来衡量。
- 接口尽量小,但是要有限度。一个接口只服务于一个子模块或业务逻辑。
- 为依赖接口的类定制服务。只提供调用者需要的方法,屏蔽不需要的方法。
- 了解环境,拒绝盲从。每个项目或产品都有选定的环境因素,环境不同,接口拆分的标准就不同深入了解业务逻辑。
- 提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。
关于模式的争议
设计模式自其诞生之初似乎就饱受争议,针对模式的最常见批评:
一种针对不完善编程语言的蹩脚解决方案
通常当所选编程语言或技术缺少必要的抽象功能时,人们才需要设计模式。在这种情况下,模式是一种可为语言提供更优功能的蹩脚解决方案。
例如,策略模式在绝大部分现代编程语言中可以简单地使用匿名(lambda)函数来实现。
低效的解决方案
模式试图将已经广泛使用的方式系统化。许多人会将这样的统一化认为是某种教条,他们会 “全心全意” 地实施这样的模式,而不会根据项目的实际情况对其进行调整。
不当使用
如果你只有一把铁锤,那么任何东西看上去都像是钉子。
这个问题常常会给初学模式的人们带来困扰:在学习了某个模式后,他们会在所有地方使用该模式,即便是在较为简单的代码也能胜任的地方也是如此。