2、编写整洁且可维护的go代码

编写整洁且易测易维护的代码,乍一看挺简单,实际做起来不容易。不过可幸的是,go语言诞生就伴随了一套最佳实践的集合可供我们学习和参考。

这些最佳实践对代码质量衡量有着正向的作用,且可以减少技术债的增加。

本章将从下面几个话题:

· 从go角度去理解SOLID原则

· 从包级别去组织代码

· 一些编写易维护的代码的建议和工具

面向对象设计的SOLID原则

SOLID是5调原则的合集,全称是:

· Single responsibility 单一职责

· Open/closed 开闭原则

· Liskov substitution 李氏替换

· Interface segregation 接口隔离

· Dependency inversion 依赖反转

Single responibility 单一职责

SRP的描述:

『In any well-designed system, objects should only have a single responsibility.』

『一个设计良好的系统中,每个对象的职责应该是单一的,统一的,清晰的』

简单说,对象的功能实现应该是聚焦,且理解起来是高效的。聚焦代表着专注,功能单一;如何才能高效,小且团结才能高效,如果功能太过庞杂势必乱且低效。

下面举个无人机配送系统的反面例子,下面的片段是一开始尝试定义的无人机类型的方法集合:

// NavigateTo applies any required changes to the drone's speed // vector so that its eventual position matches dst.
func (d *Drone) NavigateTo(dst Vec3) error { //... }
// Position returns the current drone position vector. 
func (d *Drone) Position() Vec3 { //... }
// Position returns the current drone speed vector. 
func (d *Drone) Speed() Vec3 { //... }
// DetectTargets captures an image of the drone's field of view (FoV) using // the on-board camera and feeds it to a pre-trained SSD MobileNet V1 neural
// network to detect and classify interesting nearby targets. For more info // on this model see:
// https://github.com/tensorflow/models/tree/master/research/object_detection 
func (d *Drone) DetectTargets() ([]*Target, error) { //... }

上面的代码违反了SRP,因为它将巡航能力图像识别能力杂糅在了一个职责里。

这个例子里或许是可行的,但是耦合了图像识别后就从维护的意义上讲,增加了难度。比如,我想换个其他的神经网络模型,评估对图像识别结果的影响,怎么办?我想把这个识别算法用在其他类型的无人机上,怎么办?

如何应用SRP去改造上面的代码?前提是假设所有的无人机都自带一个摄像头,这样就可以支愣出一个方法用于无人机拍照和图片输出。说到这里,你可能会说,拍照和巡航不也是两个功能吗?确实如此,不过这完全就是思考角度的不同造成的理解上的差异。一个对象的职责的描述和分配,本身就是原则的核心,且很主观,这个主观是基于每个人的编码经验而的来的。你可以换个角度想,无人机的巡航依赖于各种传感数据,而拍照算其中数据。

第二步,将图像识别的代码抽象出来,作为神经网络的一个方法。剩下的无人机类的方法改动不大,如下:

// NavigateTo applies any required changes to the drone's speed vector // so that its eventual position matches dst.
func (d *Drone) NavigateTo(dst Vec3) error { 
//... 
}
// Position returns the current drone position vector. 
func (d *Drone) Position() Vec3 { 
//... 
}
// Position returns the current drone speed vector. 
func (d *Drone) Speed() Vec3 { 
//... 
}
// CaptureImage records and returns an image of the drone's field of // view using the on-board drone camera.
func (d *Drone) CaptureImage() (*image.RGBA, error) { 
//... 
}

另一个文件(或许在另一个包),定义一个MobileNet的类型,里面有实现图像识别的方法:

// MobileNet performs target detection for drones using the
// SSD MobileNet V1 NN.
// For more info on this model see:
// https://github.com/tensorflow/models/tree/master/research/object_detection 
type MobileNet {
// various attributes... 
}
// DetectTargets captures an image of the drone's field of view and feeds // it to a neural network to detect and classify interesting nearby
// targets.
func (mn *MobileNet) DetectTargets(d *drone.Drone) ([]*Target, error){
//... 
}

这样,清晰地将两个职责分离开,甚至物理分离到两个文件,构成单一的职责。

Open/Close principle 开闭原则

开闭原则的描述:

『A software module should be open for extension but closed for modification.』

『程序模块应该对扩展是开放的,但对修改是谨慎的。』

你可以基于我,去实现自己的版本,但是不能把修改我的权限给你开放。这样我可以收拢职责,保障数据的安全性。

几乎所有的go程序都会引用和使用别的包里定义的类型,有的是go的标准包,有的是三方包。开发人员需要注意的是,保证不要去修改这些被导出的类型。尽管有的语言可以通过monkey patching的方式来修改类型,但是go的设计者对这种行为是严格禁止的,并对此设计了安全机制。

基于此,你可能会发问,对于一些限定在包级别的代码,也需要遵循『闭』原则吗?此外,go是如何实现『开』原则的?根据开闭原则的描述,我们写代码应该遵循面向对象的思想,比如通过继承或组合对已有的功能进行扩展,避免修改原有的功能。但是go是不支持继承的,所以只能通过组合来扩展。

下面以一个游戏的代码片段作为例子,看下如何理解和实现开闭原则:

type Sword struct {
       name string // Important tip for RPG players: always name your swords!
}
// Damage returns the damage dealt by this sword. 
func (Sword) Damage() int {
    return 2 
}
// String implements fmt.Stringer for the Sword type. 
func (s Sword) String() string {
    return fmt.Sprintf("%s is a sword that can deal %d points of damage to opponents", s.name, s.Damage()) 
}

设计需求上要求剑支持魔法能力,被施魔法的剑(EnchantedeSword)只是比通用的剑(Sword)多了些伤害值。这里我们就可以应用『开』原则,通过结构体的组合来扩展Sword类型,覆盖Damage方法,如下:

type EnchantedSword struct {
       // Embed the Sword type
       Sword
}
// Damage returns the damage dealt by the enchanted sword. 
func (EnchantedSword) Damage() int {
    return 42 
}

对于Sword来说,name是不允许EnchantedSword修改的,所以是非导出的。EnchantedSword能做的只是扩展Sword(虽然代码里没有扩展什么字段,只有一个Sword),覆盖了Sword的Damage方法。这里就是对『开』和『闭』的一个实战中的典型应用。

开和闭都是对被引用的类型而言的,我放开我的类型名字(大写Sword),但我关闭我的字段被修改的可能(小写name),这样一来,扩展了我(Sword)的EnchantedSword可以将我作为它的一个字段,且可以创建EnchantedSword专属的属性字段,但是EnchantedSword不能修改我的属性字段。

Liskov substitution 李氏替换原则

简称LSP(老色批),陈述如下:

『If, for each object, O1 of type S there is an object O2 of type T such that for all
programs P defined in terms of T, the behavior of P is unchanged when O1 is substituted for O2, then S is a subtype of T.』

『type S的对象O1,type T的对象O2,如果当O1被O2替换后,程序P的行为没有变化,那么说明S是T的一个子类型』

go有个特点,就是它的interface是隐式的,每个interface里定义一堆等着其他对象去实现的方法签名。这样一来,当一个方法需要特定的interface类型作为参数的时候,go在编译时就可以判断出某个对象实例到底能不能作为参数传递给该方法。

Duck-typing的描述是:

『If it looks like a duck and it quacks like a duck, then it is a duck.』

『如果它看起来像鸭,且它叫唤的像鸭,那它就是鸭』

本质上,给定一个对象或接口,如果该对象实现了所有接口里定义的方法,那么它就可以作为参数替换该接口。前提是该接口是作为一个方法的参数,这样该实例就是实现了该接口的一个实例,可以替换该接口。

下面这段代码展示了李氏替换是如何操作的。一个Adder接口和一个简单的方法叫PrintSum,这个方法可以使用任何满足Adder接口签名的类型作为参数,去对两个数进行相加。

package main
import "fmt"
// Adder is implemented by objects that can add two integers together. 
type Adder interface {
    Add(int, int) int
}
func PrintSum(a, b int, adder Adder) { 
    fmt.Printf("%d + %d = %d", a, b, adder.Add(a, b))
}

Adder包里Int类型实现了Adder接口的Add方法。另还有一个Double类型的,它虽然实现了Add方法,但是它不算实现了Adder接口的Add方法,因为Adder接口的Add方法要求的参数类型是int。

package adder
// Int adds two integer values. 
type Int struct{}
// Add returns the sum a+b.
func (Int) Add(a, b int) int { return a + b }
// Double adds two double values. 
type Double struct{}
// Add returns the sum a+b.
func (Double) Add(a, b float64) float64 { return a + b }

下面的代码片段会说明编译时的接口替换是怎么工作的。我们可以给PrintSum传递Int的实例,但当传递Double的实例的时候,就会发生编译时错误:

package main

import "github.com/foo/adder"

func main() {
    PrintSum(1, 2, adder.Int{}) // prints: "1 + 2 = 3"
    // This line will trigger a compile-time error:
    // cannot use adder.Double literal (type adder.Double) as type Adder
    // in argument to PrintSum: adder.Double does not implement Adder // (wrong type for Add method)
    //      have Add(float64, float64) float64
    //      want Add(int, int) int
    PrintSum(1, 2, adder.Double{}) 
}
"panic: interface conversion: string is not io.Reader: missing method Read"

Interface segregation 接口隔离原则

接口隔离原则表述如下:

『主调方不该被迫去实现一些他们没有用到的接口。』

这条原则比较重要,因为它是应用其他原则的基础条件。

在上面的游戏例子中,我们用武器干嘛?当然是打怪。下面是Attack功能的签名:

// Attack deals damage to a monster using a sword. 
func Attack(m *Monster, s *Sword) {
    //... 
}

这个Attack功能要求2个参数,一个是怪物,一个是Sword。也就是说这个Attack功能是与这个类型的怪物,和这个类型的剑,是强耦合的。当我想用EnchantedSword的时候,又得写一个参数是EnchantedSword的类型的剑;又或者我想打另一种怪物的时候,也得为这种怪新创建一个Attack签名。

这里本质的原因是Attack签名的参数抽象的不够好,或者说压根就没有抽象,直接限制传递两个具体类型的实例。这里我们是不是可以抽象一下怪物和剑?

Attack攻击的是怪物,用的是剑。那什么样的算怪物,有什么特征的才算怪物,包括怪物的伤害和能力;同理,什么样的算剑,剑算武器,武器是有伤害的。所以我们是否可以实现两个接口,一个接口用来抽象怪物,一个接口用来抽象武器,然后将这2个接口作为参数,如此一来,Attack的签名不用变,只需要新建的怪物和武器能够实现接口里各自的方法就可以调用Attack实现功能了?的确。棒呆如下:

// DamageReceiver is implemented by objects that can receive weapon damage. 
type DamageReceiver interface {
    ApplyDamage(int)
}
// Damager is implemented by objects that can be used as weapons. 
type Damager interface {
    Damage(int)
}
// Attack deals weapon damage to target.
func Attack(target DamageReceiver, weapon Damager) {
    //... 
}

Dependency inversion 依赖反转

有点啰嗦,表述如下:

『High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.』

『高层模块不该关注底层模块的变化,他们都应该依赖抽象接口。抽象借口不该关注细节,细节应该关注抽象接口。』

依赖反转是对前几个原则的总结。万事万物,首先思考如何抽象!

总结一下SOLID:

单一职责,讲的是对象的职责,约束对象的能力圈。不要让一个对象做太多模糊不清、甚至一看就不属于它的职责的业务。

开闭原则,讲的是对象的属性,对象的哪些属性你想清楚是可以放开对外的,哪些是需要隐藏的。隐藏起来可以对对象起到很好的保护,对数据的安全

李氏替换,讲的是用接口作为参数,这样可以让『长得像鸭子和叫的像鸭子』的对象都可以作为参数传进来,相互替换,不用担心业务逻辑会跟着实例的不同而变更。说白了,还是说抽象。

接口隔离,什么接口?他在隔离什么?我理解的这个原则是通过把参数都interface化,对go来说interface就是抽象,那么把一个抽象的变量作为参数后,接口就不必再关注是谁再调了,只要满足interface里方法的实例都可以作为参数。这样就1、把接口与实例隔离,2、实例与实例间隔离。

依赖反转(倒置),一般来说上层依赖下层提供的服务,现在我们让依赖关系反转,并不是说真的让下层反过来依赖上层,但是下层如何去依赖上层?有一个办法就是把下层的服务抽象一下,作为参数,让上层自己去实现具体的实例,并将实例作为参数传给下层。如此一来,下层到底如何执行依赖的是上层给提供的实例,实现了依赖反转。

你可能感兴趣的:(go设计模式,golang)