Effective Go

发布于 作者: Ethan

引言

本文为Effective Go的记录,关于编写过程中符合Go的规范,而不是“Java-like”或者什么什么别的“-like” Go。 很大程度上的区别是Go并没有规定自身的语言范式(OOP/Functional/...),包管理上也和别的语言有所区别,可以通过文章更好的了解这些区别来编写更Idiomatic的代码。

名称

格式化和注释没有什么好说的,Go提供了比较好的工具go fmt来统一格式化(其实Go作为官方来提供的工具是很好统一标准和方便开发的,例如测试/覆盖率报告等都有官方工具)。 这里着重看一下命名(a.k.a. 第一大难题):

包名称

应当简洁,单个单词,例如:

import "bytes"

也可以是从源目录来命名,如src/encoding/base64命名为:

import "encoding/base64"

包名称应是命名的一部分,不应与命名重复,比如bufio中的Reader不应命名为BufReader

一个包如果只有一个导出类型,大可以让实例化函数为New,比如ring.New,方法也是同理,比如once.Do()而不是once.DoOrWaitUntilDone(setup)

一个好的文档比一长串名称更有价值

Getter

对象可以有Getter,但是可以直接是字段,比如owner字段的Getter应当就是Owner(),Setter可以是SetOwner()

接口

单方法接口应是-er。 字符串转换应是String()而非ToString()

控制流

if/switch接收初始化语句来简化错误处理是很好的:

if err := file.Chmod(0664); err != nil {
    log.Print(err)
    return err
}

三种For:

// Like a C for
for init; condition; post { }

// Like a C while
for condition { }

// Like a C for(;;)
for { }

字符串应用range遍历来解析unicode码点:

for pos, char := range "日本\x80語" { // \x80 is an illegal UTF-8 encoding
    fmt.Printf("character %#U starts at byte position %d\n", char, pos)
}

Switch可以是表达式,如果不是表达式会自动通过值判断,多个值可以通过逗号相隔。 break会跳出switch,如需跳出外层循环可以定义label:

Loop:
    for n := 0; n < len(src); n += size {
        switch {
        case src[n] < sizeOne:
            if validateOnly {
                break
            }
            size = 1
            update(src[n])

        case src[n] < sizeTwo:
            if n+1 >= len(src) {
                err = errShortInput
                break Loop
            }
            if validateOnly {
                break
            }
            size = 2
            update(src[n] + src[n+1]<<shift)
        }
    }

type switch:

switch t := t.(type) {
default:
    fmt.Printf("unexpected type %T\n", t)     // %T prints whatever type t has
case bool:
    fmt.Printf("boolean %t\n", t)             // t has type bool
case int:
    fmt.Printf("integer %d\n", t)             // t has type int
case *bool:
    fmt.Printf("pointer to boolean %t\n", *t) // t has type *bool
case *int:
    fmt.Printf("pointer to integer %d\n", *t) // t has type *int
}

函数

多返回值/命名结果参数/defer,没什么好说的。

数据

new

内置函数,用于清零分配的内存并返回指针。 imply:可以直接使用无需进一步初始化

构造函数和组合字面量

以下四段代码完全相等:

// 1.
func NewFile(fd int, name string) *File {
    if fd < 0 {
        return nil
    }
    f := new(File)
    f.fd = fd
    f.name = name
    f.dirinfo = nil
    f.nepipe = 0
    return f
}

// 2.
func NewFile(fd int, name string) *File {
    if fd < 0 {
        return nil
    }
    f := File{fd, name, nil, 0}
    return &f
}

// 3.
//...
return &File{fd, name, nil, 0}

// 4.
//...
return &File{fd: fd, name: name}

以下几点注意事项:

  1. 组合字面量File{...}每次评估时会创建新实例。
  2. 局部变量地址返回后存储仍然存在(与逃逸分析有关)。
  3. 组合字面量字段按顺序排列,也可以显示标记。
  4. 组合字面量中无字段则会返回该类型的零值,所以new(File)等于&File{}

make

内置函数,返回slice/map/chan已初始化(而非清零)的值 因为三种类型在底层表示的是指向必须在使用前初始化的数据结构的引用。 示例:

var p *[]int = new([]int)       // allocates slice structure; *p == nil; rarely useful
var v  []int = make([]int, 100) // the slice v now refers to a new array of 100 ints

// Unnecessarily complex:
var p *[]int = new([]int)
*p = make([]int, 100, 100)

// Idiomatic:
v := make([]int, 100)

array

不要用

slice

Append 可以修改 slice 的元素,但切片本身(持有指针、长度和容量的运行时数据结构)按值传递,所以必须返回切片

二维slice

需定义

type Transform [3][3]float64  // A 3x3 array, really an array of arrays.
type LinesOfText [][]byte     // A slice of byte slices.

append

...用于展开

x := []int{1,2,3}
y := []int{4,5,6}
x = append(x, y...)
fmt.Println(x)

初始化

常量

使用iota枚举器创建

type ByteSize float64
const (
    _           = iota // ignore first value by assigning to blank identifier
    KB ByteSize = 1 << (10 * iota)
    MB
    GB
    TB
    PB
    EB
    ZB
    YB
)

p.s. iota特性 在常量组中,iota 的初始值为 0 每新增一行常量声明,iota 的值自动加 1 仅在常量组(const 块)中有效 每个常量组都会重新初始化 iota 为 0

init()

每个源文件可以定义自己的(多个)init函数。

调用顺序:

  1. 先初始化所有导入的包:当前包依赖的外部包,会先完成自身的初始化(变量 + init)。
  2. 再初始化当前包的变量:执行当前包内所有变量声明的初始化表达式(比如 var a = 1 + 2)。
  3. 最后执行 init 函数:所有变量初始化完成后,才会调用当前包的 init 函数(多个则按声明顺序执行)。

方法

值方法可以在指针和值上调用,但指针方法只能在指针上调用

“这条规则的产生是因为指针方法可以修改接收者;在值上调用它们会导致方法接收到值的副本,因此任何修改都会被丢弃。因此,语言不允许这种错误。不过有一个方便的例外。当值是可寻址的时,语言会自动插入地址运算符来处理在值上调用指针方法的常见情况。在我们的例子中,变量 b 是可寻址的,所以我们只需要用 b.Write 来调用它的 Write 方法。编译器会帮我们将其重写为 (&b).Write。”

interface

  1. Go 中几乎所有类型(结构体、整数、通道、函数等)都可定义方法,因此几乎都能实现接口。
  2. 若类型仅用于实现某接口、无其他导出方法,无需导出该类型,仅导出接口。
  3. 类型断言可以是直接也可以时安全的。

并发

不要通过共享内存来通信,而要通过通信来共享内存。

goroutine

函数字面量是闭包:实现确保函数所引用的变量在其活跃期间一直存在。

channel

无缓冲 -- 阻塞 有缓冲 -- 可以作为信号量

知名bug(1.22版本前):

func Serve(queue chan *Request) {
    // 这里的循环变量会被所有goroutine共享
    for req := range queue {
        sem <- 1
        go func() {
            process(req)
            <-sem
        }()
    }
}

上述代码也是管理goroutine资源的方式

Leaky Buffer

var freeList = make(chan *Buffer, 100)
var serverChan = make(chan *Buffer)

func client() {
    for {
        var b *Buffer
        // Grab a buffer if available; allocate if not.
        select {
        case b = <-freeList:
            // Got one; nothing more to do.
        default:
            // None free, so allocate a new one.
            b = new(Buffer)
        }
        load(b)              // Read next message from the net.
        serverChan <- b      // Send to server.
    }
}
func server() {
    for {
        b := <-serverChan    // Wait for work.
        process(b)
        // Reuse buffer if there's room.
        select {
        case freeList <- b:
            // Buffer on free list; nothing more to do.
        default:
            // Free list full, just carry on.
        }
    }
}