开发Golang程序员

SOLID Go Design - Go语言面向对象设计

2016-09-08  本文已影响3754人  llitfk_DockOne

代码评审

为什么要代码评审?
如果代码评审是要捕捉糟糕的代码,那么你如何知道你审查的代码是好的还是糟糕的?

我在找一些客观的方式来谈论代码的好坏属性。

糟糕的代码

你可能会在代码审查中遇到以下这些糟糕的代码:

当你做代码审查的时候是否会很高兴看到这些词语?

当然不会。

好的设计

如果有一些描述优秀的设计属性的方式就更好了,不仅仅是糟糕的设计,是否能在客观条件下做?

SOLID - 面向对象设计

在2002年,Robert Martin的Agile Software Development, Principles, Patterns, and Practices 书中提到了五个可重用软件设计的原则 - "SOLID"(英文首字母缩略字):

这本书有点点过时,使用的语言也是十多年前的。但是,或许SOLID原则的某些方面可以给我们一个有关如何谈论一个精心设计的Go语言程序的线索。

1) Single Responsibility Principle - 单一功能原则

A class should have one, and only one, reason to change.
–Robert C Martin

现在Go语言显然没有classses - 相反,我们有更为强大的组合的概念 - 但是如果你可以看到过去class的使用,我认为这里有其价值。

为什么一段代码应该只有一个原因改变如此重要?当然,和你自己的代码要修改比较起来,发现自己代码所依赖的代码要修改会更令人头疼。而且,当你的代码不得不要修改的时候,它应该对直接的刺激有反应,而不应该是一个间接伤害的受害者。

所以,代码有单一功能原则从而有最少的原因来改变。

在你自己的项目中使用其他pakcage时要用import声明,它会在两个package之间建立一个源码级的耦合。

2) Open / Closed Principle - 开闭原则

Bertrand Meyer曾经写道:

Software entities should be open for extension, but closed for modification.
–Bertrand Meyer, Object-Oriented Software Construction

该建议如何应用到现在的编程语言上:

package main

type A struct {
        year int
}

func (a A) Greet() { fmt.Println("Hello GolangUK", a.year) }

type B struct {
        A
}

func (b B) Greet() { fmt.Println("Welcome to GolangUK", b.year) }

func main() {
        var a A
        a.year = 2016
        var b B
        b.year = 2016
        a.Greet() // Hello GolangUK 2016
        b.Greet() // Welcome to GolangUK 2016
}

typeA有一个year字段以及Greet方法。
typeB嵌入了A做为字段,从而,使B提供的Greet方法遮蔽了A的,调用时可以看到B的方法覆盖了A

但是嵌入不仅仅是对于方法,它还能提供嵌入type的字段访问。如你所见,由于AB都在同一个package内,B可以访问A的私有year字段就像B已经声明过。

因此 嵌入是一个强大的工具,它允许Go语言type对扩展是开放的。

package main

type Cat struct {
        Name string
}

func (c Cat) Legs() int { return 4 }

func (c Cat) PrintLegs() {
        fmt.Printf("I have %d legs\n", c.Legs())
}

type OctoCat struct {
        Cat
}

func (o OctoCat) Legs() int { return 5 }

func main() {
        var octo OctoCat
        fmt.Println(octo.Legs()) // 5
        octo.PrintLegs()         // I have 4 legs
}

在上边这个例子中,typeCatLegs方法来计算它有几条腿。我们嵌入Cat到一个新的typeOctoCat中,并声明Octocats有五条腿。然而,尽管OctoCat定义了自己有五条腿,但是PrintLegs方法被调用时会返回4。

这是因为PrintLegs在typeCat中定义。它会将Cat做为它的接收者,因此它会使用CatLegs方法。Cat并不了解已嵌入的type,因此它的嵌入方法不能被修改。

由此,我们可以说Go语言的types对扩展开放,但是对修改是关闭的。

事实上,Go语言接收者的方法仅仅是带有预先声明形式的参数的function的语法糖而已:

func (c Cat) PrintLegs() {
        fmt.Printf("I have %d legs\n", c.Legs())
}

func PrintLegs(c Cat) {
        fmt.Printf("I have %d legs\n", c.Legs())
}

第一个function的接收者就是你传进去的参数,而且由于Go语言不知道重载,所以说OctoCats并不能替换普通的Cats,这就引出了接下来一个原则:

3) Liskov Substitution Principle - 里氏替换原则

该原则由Barbara Liskov提出,大致上,它规定了两种类型如果调用者不能区分出他们行为的不同,那么他们是可替代的。

基于class的编程语言,里氏替换原则通常被解释为一个抽象基类的各种具体子类的规范。但是Go语言没有class或者inheritance(继承),因此就不能以抽象类的层次结构实现替换。

type Reader interface {
        // Read reads up to len(buf) bytes into buf.
        Read(buf []byte) (n int, err error)
}

我最喜爱的Go语言interface - io.Reader

interfaceio.Reader非常简单,Read读取数据到提供的buffer,并返回调用者读取数据的bytes的数量以及读取期间的任何错误。它看起来简单但是很强大。

因为io.Reader可以处理任何能转换为bytes流的数据,我们可以在任何事情上构建readers:string常量、byte数组、标准输入、网络数据流、gzip后的tar文件以及通过ssh远程执行的命令的标准输出。

所有这些实现对于另外一个都是可替换的,因为他们都履行了相同的简单合同。

因此,里氏替换原则在Go语言的应用,可以用 Jim Weirich 的格言来总结:

Require no more, promise no less.
–Jim Weirich

接下来就到了"SOLID"第四个原则。

4) Interface Segregation Principle - 接口隔离原则

Clients should not be forced to depend on methods they do not use.

–Robert C. Martin

在Go语言中,接口隔离原则的应用是指一个方法来完成其工作的孤立行为的过程。举个“栗子”,编写方法来保存一个文档结构到磁盘的任务。

// Save writes the contents of doc to the file f.
func Save(f *os.File, doc *Document) error

我可以这样定义这个Save方法,使用*os.File做为保存Document的文件。但是这样做会有一些问题。

Save方法排除了保存数据到网络位置的选项。假如过后要加入网络储存的需求,那么该方法就需要修改也就意味着要影响到所有使用该方法的调用者。

因为Save直接地操作磁盘上的文件,测试起来很不方便。要验证其操作,测试不得不在文件被写入后读取其内容。另外测试必须确保f被写入一个临时的位置而且过后还要删除。

*os.File还包含了许多跟Save无关的方法,像读取路径以及检查路径是否是软连接。如果Save方法只使用*os.File相关的部分将会非常有用。

我们如何做呢:

// Save writes the contents of doc to the supplied ReadWriterCloser.
func Save(rwc io.ReadWriteCloser, doc *Document) error

使用io.ReadWriteCloser来应用接口隔离原则,这样就重新定义了Save方法使用一个interface来描述更为通用的类型。

随着修改,任何实现了io.ReadWriteCloser接口的type都可以代替之前的*os.File。这使得 Save不仅扩展了它的应用范围同时也给Save的调用者说明了type*os.File哪些方法是操作相关的。

做为Save的作者,我没有了在*os.File上调用无关的方法选项了,因为他们都被隐藏于io.ReadWriteCloser接口。我们可以进一步地应用接口隔离原则。

首先,Save方法不太可能会保持单一功能原则,因为它要读取的文件内容应该是另外一段代码的责任。(译注:待更新)因此我们可以缩小接口范围,只传入writingclosing

// Save writes the contents of doc to the supplied WriteCloser.
func Save(wc io.WriteCloser, doc *Document) error

其次,通过向Save提供一种机制来关闭它的数据流,会导致另外一个问题:wc会在什么情况下关闭。Save可能会无条件的调用Close或在成功的情况下调用Close

如果它想要在写入document之后再写入额外的数据时会引起Save的调用者一个问题。

type NopCloser struct {
        io.Writer
}

// Close has no effect on the underlying writer.
func (c *NopCloser) Close() error { return nil }

一个原始解决方案回事定义一个新的type,在其内嵌入io.Writer以及重写Close方法来阻止Save方法关闭底层数据流。

但是这样可能会违反里氏替换原则,如果NopCloser并没有关闭任何东西。

// Save writes the contents of doc to the supplied Writer.
func Save(w io.Writer, doc *Document) error

一个更好的解决办法是重新定义Save只传入io.Writer,剥离它的所有责任除了写入数据到数据流。

通过对Save方法应用接口隔离原则,同时得到了最具体以及最通用的需求函数。我们现在可以使用Save方法来保存数据到任何实现了io.Writer的地方。

A great rule of thumb for Go is accept interfaces, return structs.
–Jack Lindamood

5) Dependency Inversion Principle - 依赖反转原则

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.
–Robert C. Martin

对于Go语言来讲,依赖反转意味着什么呢:

如果你应用以上所有的原则,代码已经被分解成离散的有明确责任和目的的package,你的代码应该描述了它的依赖interface以及这些interface应该只描述他们需要的功能行为。换句话说就是他们不会再过多的改变。

因此,我认为Martin所讲的在Go语言的应用是context,即你import graph(译注:后文用“导入图”代替)的结构。

在Go语言中,你的导入图必须是非循环。不遵守此非循环的需求会导致编译错误,但是更为严重的是它代表了一系列的设计错误。

所有条件都相同的情况下精心设计的导入图应该是广泛的以及相对平坦的,而不是又高又窄。如果你有一个package的函数在没有其他package的情况下就无法操作,也许这就表明了代码没有考虑pakcage的边界。

依赖反转原则鼓励你尽可能地像导入图一样在mainpackage或者最高层级的处理程序内对具体细节负责,让低层级代码来处理抽象的接口。

“SOLID” Go语言设计

回顾一下,当应用到Go语言设计中,每个“SOLID”原则都是强有力的声明,但是加在一起他们有一个中心主题。

如果总结这个演讲(译注:该篇文章取自Dave大神在Golang UK Conference 2016的演讲文字内容,文章结尾处有YouTube链接(需要翻墙))它可能会是:

interfaces let you apply the SOLID principles to Go programs

因为interface描绘了他们的pakcage的规定,而不是如何规定的。换个说法就是“解耦”,这确实是我们的目标,因为解耦的软件修改起来更容易。

就像Sandi Metz提到的:

Design is the art of arranging code that needs to work today, and to be easy to change forever.
–Sandi Metz

因为如果Go语言想要成为公司长期投资的编程语言,Go程序的维护,更容易的变更将是他们决定的关键因素。

结尾

最后,问个问题这个世界上有多少个Go语言程序员,我的回答是:

By 2020, there will be 500,000 Go developers.
-me

五十万Go语言程序员会做什么?显然,他们会写好多Go代码。实话实说,并不是所有的都是好的代码,一些可能会很糟糕。

...

Go语言程序员应当讨论更多的是设计而非框架。我们应当不惜一切代价地关注重用而非性能。

我想要看到是今天的人们谈论关于如何使用编程语言,无论是设计解决方案还是解决实际问题的选择和局限性。

我想要听到的是人们谈论如何通过精心设计、解耦、重用以及适应变化的方式来设计Go语言程序。

...还有一点

我们需要告诉世界优秀的软件该如何编写。告诉他们使用Go语言如何编写优秀的、可组合的及易于变化的软件。

...

感谢!

相关博文:

  1. Inspecting errors
  2. Should methods be declared on T or *T
  3. Ice cream makers and data races
  4. Stupid Go declaration tricks

原文视频:Golang UK Conference 2016 - Dave Cheney - SOLID Go Design

原文链接:SOLID Go Design


上一篇 下一篇

猜你喜欢

热点阅读