大家好,我是晴天。在接下来的一个多月里,我将跟大家一起学习设计模式的一些基础知识和基本应用。不要问我为什么突然想起来写一个设计模式系列的文章,问就是:爱过。。。
问题引出
作为程序猿的我们,隔三岔五的就会因为看老板不顺眼或者觉得自己英雄无用武之地而选择换一个赏识自己的老板或者能充分展示自我才华的宝地。而当我们进入一家新公司的时候,经常会因为一些历史的原因,而发现新公司的代码像一坨*一样,要么结构很混乱、要么阅读起来很困难,扩展起来也非常麻烦,重用性更是免谈,总之让人难以下咽。我们想改,但是又不敢改,无从下手,只能在上面添加新的*。这就是祖传代码带来的巨大难题,如果在初期没有一个很好的代码结构设计的话。
设计模式是什么
设计模式是研究类和本身以及类和类之间如何协作的模式,是在软件开发中常见问题的解决方案模板。这些模板是经验丰富的开发者在解决各种问题时提出的最佳实践的总结。设计模式提供了一种通用的、可重复使用的方法,可以用于解决特定类型的问题,以改善软件的结构、可维护性和可扩展性。总之一句话:设计模式是针对于面向对象编程而设计的一套代码编程规范或者说是套路。
为什么要用设计模式
它是为了解决结构混乱、代码阅读困难、代码扩展麻烦以及代码重用很复杂这几个棘手的问题的。
经过上面我粗浅的一个介绍,我们有请今天的主角:设计模式的六大法则出场。。。当当。。。
设计模式的七大原则
- 单一职责原则(类和方法,接口)
- 开闭原则 (扩展开放,修改关闭)
- 里氏替换原则(基类和子类之间的关系)
- 依赖倒置原则(依赖抽象接口,而不是具体对象)
- 接口隔离原则(接口按照功能细分)
- 合成复用原则
- 迪米特法则 (类与类之间的亲疏关系)
逐一介绍一下这六大法则
1.单一职责原则
单一职责原则指的是类的职责单一,对外只提供一种方法。
通俗理解:你让司机把车开到修理厂,并且对车进行维修,不好意思,司机只负责把车开到修理厂,不负责维修。
代码示例如下:
张三写的初版代码
package mainimport "fmt"// 单一职责:每个类只对外提供一种功能// 司机类
type Driver struct {
}// 司机类有一个开车的方法
func (d *Driver) Drive() {fmt.Println("开车")
}// 司机类还有一个修车的方法
func (d *Driver) Fix() {fmt.Println("修车")
}func main() {d := Driver{}// 司机开车d.Drive() //输出:开车// 司机修车d.Fix() //输出:修车
}
这时候张三想要调整一下代码,把修车的逻辑也改成开车的逻辑,这就是不遵循单一职责原则的坏处,修改某个类方法的逻辑时,有可能会影响到该类的其他方法的准确性,造成误解。在留下这一个大坑后,张三离职了。。。
package mainimport "fmt"// 单一职责:每个类只对外提供一种功能// 司机类
type Driver struct {
}// 司机类有一个开车的方法
func (d *Driver) Drive() {fmt.Println("开车")
}// 司机类还有一个修车的方法
func (d *Driver) Fix() {fmt.Println("开车") /***这行做了修改***/
}func main() {d := Driver{}// 司机开车d.Drive() //输出:开车// 司机修车d.Fix() //输出:开车
}
这时候,李四入职了,看到张三的代码,发现Drive方法和Fix方法的逻辑是一样一样的,于是李四也修改了一版代码,发现结果仍旧是不变的,就变成了下面这样。
package mainimport "fmt"// 单一职责:每个类只对外提供一种功能// 司机类
type Driver struct {
}// 司机类有一个开车的方法
func (d *Driver) Drive() {fmt.Println("开车")
}// 司机类还有一个修车的方法
func (d *Driver) Fix() {fmt.Println("开车")
}func main() {d := Driver{}// 司机开车d.Drive() //输出:开车// 司机修车d.Drive() //输出:开车 /**这行做了修改**/
}
这时候李四也离职了,可怜的王五入职了,他发现无论是司机开车,还是司机修车,都需要调用Drive方法,他可能就认为:哦~司机在开车和修车之前都需要调用Drive方法。这就造成了很严重的歧义。
这种歧义其实就是没有遵守单一职责原则而导致的。那么正确的写法应该怎样呢,王五做了如下修改:
package mainimport "fmt"// 单一职责:每个类只对外提供一种功能// 司机类:专门负责开车
type Driver struct {
}// 司机类有一个开车的方法
func (d *Driver) Drive() {fmt.Println("开车")
}// 修理工类:专门负责修车
type Fixer struct { /**修改:增加了一个修理工类**/
}// 修理工类有一个修车的方法
func (f *Fixer) Fix() { /**修改:对修理工类增加一个修理方法**/fmt.Println("修车")
}func main() {d := Driver{}// 司机开车d.Drive() //输出:开车// 修理工修车f := Fixer{}f.Fix() // 输出:修车
}
这样呢,无论你修改司机类的方法还是修理工类的方法,都不会影响到其他方法。
2.开闭原则
开闭原则是指类的改动是通过增加代码来实现的,而不是修改源代码。(对扩展开放,对修改关闭)通俗理解:你需要汽车司机,就去招募汽车司机,需要飞行员就去招募飞行员,你不能要求一个人既会开汽车又会开飞机,甚至以后还要求他会开坦克…
代码示例如下:
张三写的初版代码(不遵循开闭原则的)
package mainimport "fmt"type People struct {
}func (p *People) Drive() {fmt.Println("司机开车")
}func (p *People) Fly() {fmt.Println("飞行员开飞机")
}func main() {var p Peoplep.Drive() //司机开车p.Fly() //飞行员开飞机
}
这种结构有个问题就是,如果还有其他的人物角色,比如船员,那么我们只能再增加一个船员的方法
func (p *People) Ship() {fmt.Println("船员开船")
}
这样其实就对People这个类进行了修改,那么就有可能会影响到这个类的其他方法的功能。
那么应该怎么修改呢,我们来看一下王五的优化方法
package main// 开闭原则import "fmt"/*
开闭原则:
类的改动是通过增加代码来实现的,而不是修改源代码
*/
// 通过抽象出People,让其他类直接实现People的方法即可
type People interface {doWork()
}type Driver struct {
}func (d *Driver) doWork() {fmt.Println("司机开车")
}type Pilot struct {
}func (p *Pilot) doWork() {fmt.Println("飞行员开飞机")
}func main() {// 司机开车d := Driver{}d.doWork()// 飞行员开飞机p := Pilot{}p.doWork()
}
这样写的好处在于,当有一个新的职业出现时,只需要继承People的doWork方法即可,完全不会影响之前其他职业的方法。
*更进一步:开闭原则的基础上进行拓展,多态的实现
package main// 开闭原则import "fmt"/*
开闭原则:
类的改动是通过增加代码来实现的,而不是修改源代码
*/
type People interface {doWork()
}type Driver struct {
}func (d *Driver) doWork() {fmt.Println("司机开车")
}type Pilot struct {
}func (p *Pilot) doWork() {fmt.Println("飞行员开飞机")
}// 对抽象对象进行操作,用于实现多态方法
// 多态:父类指针指向子类对象,调用子类对象的方法
func PeopleDoWork(p People) { /***修改的部分***/p.doWork()
}func main() {d := Driver{}d.doWork() // 司机开车p := Pilot{}p.doWork() // 飞行员开飞机fmt.Println("---------------")PeopleDoWork(&Driver{}) // 司机开车 /**修改的部分**/PeopleDoWork(&Pilot{}) // 飞行员开飞机 /**修改的部分**/
}
当我们新增一个类时,完全不会影响到之前的代码逻辑,可以放心地进行修改。
3.里氏替换原则
里氏替换原则指的是任何抽象类/基类(interface)都可以用它的实现类来进行替换。通俗理解:任何拥有A类驾照的人,都能开C类驾照的车。(这里可以把C类驾照理解为基类)
这个原则的代码示例可以参考前面一段代码示例,People是基类,Driver和Pilot是实现类,任何People出现的地方,都可以用Diver或Pilot来替换。
4.依赖倒转原则
依赖倒转原则是实现层和业务逻辑层只依赖于抽象类(interface),不依赖于具体实现类(struct),面向接口编程。通俗理解:汽车企业造车,同一类车型(抽象),都按照相同的构造制造,不同的型号或批次(具体实现)可以添加一些不同的细节,不是直接按照每一辆车的型号进行制造。
不使用依赖倒转原则的代码
package mainimport "fmt"type BMWCar struct {
}func (c *BMWCar) Run() {fmt.Println("BMW car is running")
}type AudiCar struct {
}func (c *AudiCar) Run() {fmt.Println("Audi car is running")
}type Zhang3 struct {
}func (z *Zhang3) DriveBMW(car *BMWCar) {fmt.Println("zhang3 is driving car")car.Run()
}
func (z *Zhang3) DriveAudo(car *AudiCar) {fmt.Println("zhang3 is driving car")car.Run()
}type Li4 struct {
}func (l *Li4) DriveBMW(car *BMWCar) {fmt.Println("li4 is driving car")car.Run()
}
func (z *Li4) DriveAudo(car *AudiCar) {fmt.Println("Li4 is driving car")car.Run()
}func main() {var bmw *BMWCarvar audi *AudiCarvar z3 Zhang3var l4 Li4z3.DriveBMW(bmw)z3.DriveAudo(audi)l4.DriveBMW(bmw)l4.DriveAudo(audi)
}
大家发现问题没有,不使用依赖倒转,如果新增一个司机wang5,并且新增一个车型benz,就需要把wang5重新实现DriveAudi,DriveBMW,DriveBenz这三个方法,形成司机和车型的全组合。每新增一个用户和一个车型,都需要补充全部的方法。
使用依赖倒转原则以后,再进行扩充,只需要实现各自抽象类(interface)的方法即可,无需关注其他类都有哪些方法。
package mainimport "fmt"// 依赖倒转原则:实现层和业务逻辑层都只依赖于抽象层// 抽象层
// 抽象层之间相互依赖
type Car interface {Run()
}type Driver interface {Drive(car Car)
}// 实现层
// 汽车实现层
// Benz只需要实现Run方法即可
type Benz struct {
}func (b *Benz) Run() {fmt.Println("benz is running")
}type Bmw struct {
}func (b *Bmw) Run() {fmt.Println("bmw is running")
}// 实现层
// 司机实现层
// zhang3只需要实现Drive方法即可
type zhang3 struct {
}func (z *zhang3) Drive(car Car) {fmt.Println("zhang3 开汽车")car.Run()
}type li4 struct {
}func (l *li4) Drive(car Car) {fmt.Println("li4 开汽车")car.Run()
}// 业务逻辑层
func main() {// 只依赖于抽象层,针对抽象层编程// 抽象汽车var car Carcar = new(Benz)// 抽象司机var driver Driver// 里氏替换原则,用具体实现类替换抽象类driver = new(zhang3)driver.Drive(car) // zhang3 开汽车 benz is runningcar = new(Bmw)driver = new(li4)driver.Drive(car) // li4 开汽车 bmw is running
}
如果需要增加一个wang5,只需要让wang5实现Drive方法即可
// 司机实现层增加代码如下
type wang5 struct {
}func (l *wang5) Drive(car Car) {fmt.Println("wang5 开汽车")car.Run()
}// 业务逻辑层增加代码如下
var wang5 Driver
wang5=new(wang5)
wang5.Drive(car)
如上图所示,司机只需要实现司机的方法,汽车只需要实现汽车的方法,完全不需要关心其他司机或者其他汽车有什么方法。大大降低了耦合性。
5.接口隔离原则
接口隔离原则是指接口应该“小而专”,不应该强迫用户依赖那些用不到的接口方法。
这个原则的代码跟合成复用原则的代码合并到一起介绍。
6.合成复用原则
合成复用原则是指,如果修改父类的方法会影响子类的方法,那么这个父类和子类就不应该采用继承,而应该使用组合。
补充知识点:
如果一个struct嵌套了另一个有名结构体,那么这个模式就叫组合。
如果一个struct嵌套了另一个匿名结构体(只有类型没有名字),那么这个结构可以直接访问匿名结构体的方法,从而实现了继承。
如果一个struct嵌套了多个匿名结构体,那么这个结构可以直接访问多个匿名结构体的方法,从而实现了多重继承。
如下代码,举例说明继承和组合的关系:
package mainimport "fmt"type Dog struct {
}func (d *Dog) Eat() {fmt.Println("dog eat food")
}// 继承Dog所有的属性,其中就包括继承了Eat方法
type ChaiDog struct {Dog
}func (c *ChaiDog) Sleep() {fmt.Println("dog is sleeping")
}// 组合方式有两种
// 1.直接在结构体中组合
type JingDog struct {d Dog
}func (j *JingDog) Eat() {j.d.Eat()
}
func (j *JingDog) Sleep() {fmt.Println("jingDog is sleeping")
}// 2.通过参数传递的方式组合
type GuifuDog struct {
}
// 只把Eat方法跟Dog对象耦合,其他方法都不耦合
func (gf *GuifuDog) Eat(dog Dog) {dog.Eat()fmt.Println("guifuDog eat food")
}func main() {// 原始Dog类对象d1 := Dog{}d1.Eat()// chaiDog继承Dog类的所有cd := ChaiDog{}cd.Eat() // 调用继承过来的Eat方法cd.Sleep()jd := JingDog{}jd.Eat()gfd := GuifuDog{}gfd.Eat(d1)
}
继承的弊端:
- 灵活性低,继承容易导致代码嵌套层次很深,可维护性变差。
- 耦合性高。父类修改方法可能影响到子类行为。
- 使用继承,在子类对象调用父类方法的时候,父类的方法也会被调用(不局限于Golang语言),如果继承层级很深的话,所有祖先对象相同的方法也都会被调用一遍,大大降低了效率。
使用组合的方法,可以仅仅依赖某个对象的属性或者某个方法,能够极大降低依赖关系,减小耦合。所以一般推荐使用聚合/组合代替继承。
7.迪米特原则
迪米特原则是指一个对象应该尽量少的了解其他对象,从而降低耦合度。
这里留一个扣子,等到本系列后续介绍到外观模式的时候,再来补充迪米特法则的代码示例,敬请期待。。。
写在最后
感谢大家的阅读,晴天将继续努力,分享更多有趣且实用的主题,如有错误和纰漏,欢迎给予指正。 更多文章敬请关注作者个人公众号 晴天码字。
我们下期不见不散,to be continued…