初学Golang易犯的错误

Golang是Google推出的面向软件工程的,拥有GC的,高并发的语言。

初学Go的时候很可能犯一些错误,犯错误并不怕,可怕的是一直犯同一个错误。 只要犯的错误足够多,那么就会成为大师了。

Master Yoda

不使用Go工具

初学Go的时候,不知道Go有那么多好工具,并且可以和编辑器很好的结合,一概都不使用。 把Go当作了脚本语言,直接开一个文本编辑器来处理,写作效率很低下。

其实Go有许多的工具,都可以和编辑器结合起来使用,使其成为强大的IDE,大大提高写Go的效率。

常见的工具有:

  • 代码补全工具 gocode
  • 代码格式化工具 gofmt
  • 代码跳转,查找引用等 guru
  • 代码自动补import等 goimports
  • 静态检查 golint
  • 本地查看文档 godoc
  • etc.

其实只要VSCode安装Go插件后,就可以一键下载并使用这些工具了。

没接受Interfaces

Go的Interface与其他语言(比如Java,C#)的Interface是有差别的。 Go的Interface是非侵入式的,Go的Interface只是一个合约。

从其他语言,尤其是OO过来的程序员,常常带着原先语言的口音来写Go。 经常定义一个Struct然后定义各种Method,但是从来不定义一个Interface。 又或者会有把Interface当作虚基类来定义的。

Go的Interface是Go中非常强大的一个功能,它给Go带来了很好的扩展性,并且很灵活。 Go的Interface是由方法定义的,那么Interface应当仅仅是一组行为,且同名的行为应当一致。

比如如下代码:

func (c *Content) saveAs(path string) {
    b := new(bytes.Buffer)
    b.Write(c.Content)
    c.save(b.Bytes(), path)
}

func (c *Content) save(by []byte, path string) {
    writeToDisk(path, bytes.NewReader(by))
}

这段代码本身没有什么问题,但是不够高效,且不宜扩展。我们在saveAs中申请了一个Buffer, 然后又将这个Buffer转换成了byte数组,传给了下游方法savesave拿到这个byte数组后, 又创建了一个Reader来最终执行写文件操作。

其实,Buffer是有Reader方法的,而且这个方法刚好与io.Reader Interface所一致, 因此,将代码改为如下写法,更高效,也更加Gopher。

func (c *Content) saveAs(path string) {
    b := new(bytes.Buffer)
    b.Write(c.Content)
    c.save(b, path)
}

func (c *Content) save(r io.Reader, path string) {
    writeToDisk(path, r)
}

Interface在Go中对各个struct进行了隔离,在实现的时候,大家可以完全遵循依赖倒置的原则,只需要针对接口编程。

定义一个过于宽泛的Interface

Go中的Interface以及struct其实都是可以很容易的组合在一起的,没有必要定义一个特别宽泛的Interface, 而是应该定义尽可能小的Interface,在需要的地方对其进行组合。

比如 sort.Sort所接收的参数就是一组最小的行为集合,有获取长度,比较以及交换,缺一不可。

type Interface interface {
    // Len is the number of elements in the collection.
    Len() int
    // Less reports whether the element with
    // index i should sort before the element with index j.
    Less(i, j int) bool
    // Swap swaps the elements with indexes i and j.
    Swap(i, j int)
}

但是,假设我们需要创建一个文件系统,那么需要定义一个File的接口,我们会怎么定义呢?

type File interface {
    Open()
    Close()
    Read(...)
    ReadAt(...)
    Write(...)
    WriteAt(...)
    Seek(...)
}

假如我们这样定义,语法上当然没问题了,但是在使用的时候,当在一个方法的参数传递这个File接口,如下 那么问题就来了,要使用这个方法的话,每个使用者都必须实现那么一组方法,不管那些方法是否需要。听起来就不那么好。

func (fs *FileSystem) ReadFile(f File) {
    ...
}

那么,什么是相对好一些的做法呢?我们定义或复用接口,并在方法上用且仅用必须的那组接口。

type File interface {
    Open()
    Close()
    io.Read(...)
    io.ReadAt(...)
    io.Write(...)
    io.WriteAt(...)
    io.Seek(...)
}

func (fs *FileSystem) ReadFile(r io.Reader) {
    ...
}

到处请求空Interface

Go的任意的struct都可以是空Interface,于是为了方便,那么到处传递这种空Interface,直到用的时候再转换回之前的类型使用

type Value interface{}

func (n *Node) Insert(v Value) {
    original := v.(OriginalType)
}

这样有问题吗?大部分的时候没问题,但是这个接口其实是能传递进来任意东西的,那么当传入的不是一个OriginalType的时候, 在方法内部做类型转换的时候就会发生panic

然而,如果使用正确的Interface,则可以在编译期就发现这样的问题。

方法实现不一致

其实不仅仅是在写Go,在写任意代码的时候,同样名字的函数应该有相同的参数以及相同的行为。 在Go中,其实应该更加严格的遵守这条规范,因为Go语言本身不支持重载,多态等。 Go是面向软件工程的,设计初衷之一就是想减少软件编码引入的错误, 如果函数名相同,而参数或者行为不一致,那么又会导致通过编码引入错误。

func (l *Logger) Info(format string, interface...) { ... }
func (c *ConsoleLogger) Info(format string, log string, print bool) { ... }

试看这样的代码,单独看都没有问题,但是大家都是logger,同名的Info却有着不一样的参数以及行为。 那么如果有一天,需要用Logger来替换ConsoleLogger的时候,编译运行都不会崩溃, 但是,真正运行起来的时候,则可能有意想不到或者完全未知的错误出现。

不使用 io.Reader 以及 io.Writer

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

这两个都是Interface,于是同不喜欢使用Interface一样,很多刚开始写Go的人对这两个Interface不闻不问。 经常在各个地方传递着字符串,[]byte等等。

这两个方法在Go中其实是很常见的,除了习以为常的读写文件以外,读写内存,读写管道,读写Buffer等等的struct, 也都实现了Reader以及Writer方法,且都与io.Reader以及io.Writer一致。

使用这两个接口,可以使你所写的Go程序更加的灵活且易于扩展。

方法 VS 函数

OO界的同学眼里只有Method方法,而PO界的同学眼里只有Function函数。 但是Go里两个都有。

那么来自不同国度的同学到了Go之后,就对何时使用Method何时使用Function有些混淆了。

Function只是一个函数,且是时不变的,也就是说,在给定输入的时候,输出也是一定的,function不应当依赖于系统的状态。 Method是依赖于一个对象的,Method会使用该对象内部的一些状态,是这个对象的一些行为。

使用 值 VS 指针

Go语言是有指针的,来自与C/C++的同学再熟悉不过了,而来自于Java的同学则会经常把指针当作引用。

在定义type的方法的时候,接收器可以指定为值也可以指定为指针,但是什么时候该用哪种接收器呢? 在给函数传递参数的时候,也可以定义参数为指针或者值。而且,不管如何定义,在使用这些方法或函数的时候,书写的代码并无差别。

或许会有以下声音:

  • 只使用指针,因为指针的性能更好
  • 只使用,我们需要对象的拷贝

在Go语言中,使用指针或者值,通常情况下,不应当是以性能作为参考,而应当是以共享对象作为参考。 当这个对象需要被共享访问的时候,或者需要改变其内部状态的时候,那么就用指针,否则就用值。

把Error当作String处理

Error在Go语言中,仅仅是一个Interface,且这个interface中仅有一个返回字符串的方法。

type error interface {
    Error() string
}

于是,很多同学在使用error的时候,会不自觉的把error当作string来处理。比如:

func foo() error {
    return errors.New("Some Error")
}

func main() {
    err := foo()
    if err != nil && err.Error() == "Some Error" {
        ...
    }
}

这并不是一个很好的写法,error的描述很有可能会改变,应该把error先定义好,然后判断对象会更好。

var SomeError = errors.New("Some Error")

func foo() error {
    return SomeError
}

func main() {
    err := foo()
    if err != nil && err.Error() == SomeError {
        ...
    }
}

另外,要对Error进行客制化,也不需要每次通过fmt.Errorf来搞定。

我们完全可以对Error进行扩展

type Error struct {
    ErrCode int
    Message string
}

// Error returns a human readable representation of the error.
func (e Error) Error() string {
    return fmt.Sprintf("%d : $s", e.ErrCode, e.Message)
}

这样,不仅仅判断的时候可以通过ErrCode非常容易的比较,而且还能得到一个很有好的error字符串, 且这个Error一样可以通过Go原生的error接口返回。

并发安全问题

Go语言是高并发的,我们在定义一个新的数据结构的时候需要考虑并发情况。

然而真实情况是这样吗?

并发的安全,是有代价的,这个代价就是性能。其实,作为底层数据结构,考虑并发其实是多余的, Go语言自己的map都没有考虑并发安全的问题,并发安全的问题一直是由使用者来保证的。

使用者可以通过使用mutex来对访问的对象加锁,抑或通过channel来传递。

其实在Go语言中,不提倡通过共享内存来交流,更提倡通过channel来交换数据。

Don’t communicate by sharing memory, share memory by communicating.