Effective Go 查漏补缺

前几天把 Effective Go 这本小书读了一下,里边有些比较生疏或者实用的知识点,在此记录。

命名

包应当以小写的单个单词来命名,且不应使用下划线或驼峰记法。

另一个约定就是包名应为其源码目录的基本名称。在 src/pkg/encoding/base64 中的包应作为 “encoding/base64” 导入,其包名应为 base64, 而非 encoding_base64encodingBase64

我们的代码中给包起别名时也应该遵循这个规则,即:livedomain “gitlab.xxx.com/backend/xxx-live/proto”,不应该是 live_domain

长命名并不会使其更具可读性。一份有用的说明文档通常比额外的长名更有价值。

避免 Java 那样的长命名

若你有个名为 owner (小写,未导出)的字段,其获取器应当名为 Owner(大写,可导出)而非 GetOwner。大写字母即为可导出的这种规定为区分方法和字段提供了便利。 若要提供设置器方法,SetOwner 是个不错的选择。两个命名看起来都很合理:

1
2
3
4
owner := obj.Owner()
if owner != user {
obj.SetOwner(user)
}

Go 中的 Set 方法无需以 Set 开头,只需实现一个大写开头的方法就可以了。(不过大部分常见下,变量可以直接用导出的)

按照约定,只包含一个方法的接口应当以该方法的名称加上 er 后缀来命名,如 Reader、Writer、 Formatter、CloseNotifier 等。

自己实现接口时也尽量遵循这个规范。

Go 中约定使用驼峰记法 MixedCaps 或 mixedCaps。

即便是常量也不例外,即:不应该写为 LIVE_USER_TABLE 而应该是 LiveUserTable。

分号

若在新行前的最后一个标记为标识符(包括 int 和 float64 这类的单词)、数值或字符串常量之类的基本字面或以下标记之一,词法分析器会使用一条简单的规则来自动插入分号,因此因此源码中基本就不用分号了。

1
break continue fallthrough return ++ -- ) }

所以

1
2
if a == 1 && 
b == 2

可以编译通过

1
2
if a == 1 
&& b == 2

不能编译通过,因为词法分析器会自动在 if a == 1 后边插入分号。

通常Go程序只在诸如 for 循环子句这样的地方使用分号,以此来将初始化器、条件及增量元素分开。如果你在一行中写多个语句,也需要用分号隔开。

for i := 0; i <= 10; i++

无论如何,你都不应将一个控制结构(if、for、switch 或 select)的左大括号放在下一行。如果这样做,就会在大括号前面插入一个分号,这可能引起不需要的效果。 你应该这样写

1
2
3
if i < f() {
g()
}

控制结构

Go 不再使用 do 或 while 循环,只有一个更通用的 for;switch 要更灵活一点;if 和 switch 像 for 一样可接受可选的初始化语句; 此外,还有一个包含类型选择和多路通信复用器的新控制结构:select。

Go 的 for 循环类似于 C,但却不尽相同。它统一了 for 和 while,不再有 do-while 了。它有三种形式,但只有一种需要分号。

1
2
3
4
5
6
7
8
// Like a C for
for init; condition; post { }

// Like a C while
for condition { }

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

体现出 go 的简洁,不用费心的去考虑应该用 for 还是while 或者 do while。

由于 if 和 switch 可接受初始化语句, 因此用它们来设置局部变量十分常见。

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

switch 并不会自动下溯,但 case 可通过逗号分隔来列举相同的处理条件。

1
2
3
4
5
6
7
func shouldEscape(c byte) bool {
switch c {
case ' ', '?', '&', '=', '#', '+', '%':
return true
}
return false
}

不用担心因为漏写 break 而导致的bug,case 中支持多个判断条件也很实用。

尽管它们在 Go 中的用法和其它类 C 语言差不多,但 break 语句可以使 switch 提前终止。不仅是 switch, 有时候也必须打破层层的循环。在 Go 中,我们只需将标签放置到循环外,然后 “蹦” 到那里即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func main() {
m, n := 2,2
loop:
for i := 0; i < n; i++ {
for j:=0; j< m; j++ {
if j ==1 {
break loop
}
fmt.Println(i,j)
}
}
fmt.Println("done")
}
// output:
// 0 0
// done

这种用法很少使用,我之前甚至不知道有这种 label break 的用法,类似于其他语言中的 goto。

switch 也可用于判断接口变量的动态类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var t interface{}
t = functionOfSomeType()
switch t := t.(type) {
default:
fmt.Printf("unexpected type %T", t) // %T 输出 t 是什么类型
case bool:
fmt.Printf("boolean %t\n", t) // t 是 bool 类型
case int:
fmt.Printf("integer %d\n", t) // t 是 int 类型
case *bool:
fmt.Printf("pointer to boolean %t\n", *t) // t 是 *bool 类型
case *int:
fmt.Printf("pointer to integer %d\n", *t) // t 是 *int 类型
}

我们的工具库中也有这样的用法,比如将一个 interface{} 类型转为 int64类型,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func Int64(num interface{}, defaultValue ...int64) int64 {

var rsp int64
var err error

switch t := num.(type) {
case string:
rsp, err = strconv.ParseInt(t, 10, 64)
case int:
rsp = int64(t)
case int8:
rsp = int64(t)
case int16:
rsp = int64(t)
case int32:
rsp = int64(t)
case int64:
rsp = t
default:
}
if err != nil {
if len(defaultValue) > 0 {
return defaultValue[0]
}
}
return rsp
}

函数

Go 与众不同的特性之一就是函数和方法可返回多个值。这种形式可以改善 C 中一些笨拙的习惯: 将错误值返回(例如用 -1 表示 EOF)和修改通过地址传入的实参。

Java 中由于也不支持多返回值,也经常将引用传入一个方法,方法执行完后根据传入引用中的数据进行后续处理,这种方法通常被称为有副作用的方法。

Go 函数的返回值或结果 “形参” 可被命名,并作为常规变量使用,就像传入的形参一样。 命名后,一旦该函数开始执行,它们就会被初始化为与其类型相应的零值; 若该函数执行了一条不带实参的 return 语句,则结果形参的当前值将被返回。

此名称不是强制性的,但它们能使代码更加简短清晰:它们就是文档。若我们命名了 nextInt 的结果,那么它返回的 int 就值如其意了。

避免在函数签名上命名返回值变量,除非无法从上下中判断返回值的含义用作文档用途,或者希望在 defer 中改变变量值

Go 的 defer 语句用于预设一个函数调用(即推迟执行函数),该函数会在执行 defer 的函数返回之前立即执行。它显得非比寻常, 但却是处理一些事情的有效方式,例如无论以何种路径返回,都必须释放资源的函数。 典型的例子就是解锁互斥和关闭文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Contents returns the file's contents as a string.
func Contents(filename string) (string, error) {
f, err := os.Open(filename)
if err != nil {
return "", err
}
defer f.Close() // f.Close will run when we're finished.

var result []byte
buf := make([]byte, 100)
for {
n, err := f.Read(buf[0:])
result = append(result, buf[0:n]...) // append is discussed later.
if err != nil {
if err == io.EOF {
break
}
return "", err // f will be closed if we return here.
}
}
return string(result), nil // f will be closed if we return here.
}

类似于 Java 中的 finally。

推迟诸如 Close 之类的函数调用有两点好处:

  • 第一, 它能确保你不会忘记关闭文件。如果你以后又为该函数添加了新的返回路径时, 这种情况往往就会发生。
  • 第二,它意味着 “关闭” 离 “打开” 很近, 这总比将它放在函数结尾处要清晰明了。

被推迟的函数按照后进先出(LIFO)的顺序执行,我们可以充分利用这个特点,即被推迟函数的实参在 defer 执行时才会被求值。 跟踪例程可针对反跟踪例程设置实参。以下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func trace(s string) string {
fmt.Println("entering:", s)
return s
}

func un(s string) {
fmt.Println("leaving:", s)
}

func a() {
defer un(trace("a"))
fmt.Println("in a")
}

func b() {
defer un(trace("b"))
fmt.Println("in b")
a()
}

func main() {
b()
}

输出:

1
2
3
4
5
6
entering: b
in b
entering: a
in a
leaving: a
leaving: b

数据

new 是个用来分配内存的内建函数, 但与其它语言中的同名函数不同,它不会初始化内存,只会将内存置零。 也就是说,new(T) 会为类型为 T 的新项分配已置零的内存空间, 并返回它的地址,也就是一个类型为 *T 的值。用 Go 的术语来说,它返回一个指针, 该指针指向新分配的,类型为 T 的零值。

表达式 new(File)&File{} 是等价的。

开发时更常用到的是 &File{} 这种形式,因为可以同时对成员进行初始化。

复合字面的字段必须按顺序全部列出。但如果以 字段: 值 对的形式明确地标出元素,初始化字段时就可以按任何顺序出现,未给出的字段值将赋予零值。

内建函数 make(T, args) 的目的不同于 new(T)。它只用于创建切片、映射和信道,并返回类型为 T(而非 *T)的一个已初始化 (而非置零)的值。 出现这种用差异的原因在于,这三种类型本质上为引用数据类型,它们在使用前必须初始化。

make 只适用于映射、切片和信道且不返回指针。若要获得明确的指针, 请使用 new 分配内存。

这就是 slice, map, channel 需要使用 make 进行初始化的原因。

映射可使用一般的复合字面语法进行构建,其键-值对使用冒号分隔,因此可在初始化时很容易地构建它们。

1
2
3
4
5
6
7
var timeZone = map[string]int{
"UTC": 0*60*60,
"EST": -5*60*60,
"CST": -6*60*60,
"MST": -7*60*60,
"PST": -8*60*60,
}

map 可以在初始化时同时赋值,很方便。

集合可实现成一个值类型为 bool 的映射。将该映射中的项置为 true 可将该值放入集合中,此后通过简单的索引操作即可判断是否存在。

1
2
3
4
5
6
7
8
9
attended := map[string]bool{
"Ann": true,
"Joe": true,
...
}

if attended[person] { // will be false if person is not in the map
fmt.Println(person, "was at the meeting")
}

Go 中没有 Set,可以用这种方法代替,有些人习惯将 map 的 value 值声明为 interface 类型,我个人不是很喜欢,bool 更方便使用一些。

在使用 map 时,有时你需要区分某项是不存在还是其值为零值。如对于一个值本应为零的 “UTC” 条目,也可能是由于不存在该项而得到零值。你可以使用多重赋值的形式来分辨这种情况。

1
2
3
var seconds int
var ok bool
seconds, ok = timeZone[tz]

在下面的例子中,若 tz 存在, seconds 就会被赋予适当的值,且 ok 会被置为 true; 若不存在,seconds 则会被置为零,而 ok 会被置为 false。

1
2
3
4
5
6
7
func offset(tz string) int {
if seconds, ok := timeZone[tz]; ok {
return seconds
}
log.Println("unknown time zone:", tz)
return 0
}

若仅需判断映射中是否存在某项而不关心实际的值,可使用 空白标识符 (_)来代替该值的一般变量。

1
_, present := timeZone[tz]

要删除映射中的某项,可使用内建函数 delete,它以映射及要被删除的键为实参。 即便对应的键不在该映射中,此操作也是安全的。

1
delete(timeZone, "PDT")  // Now on Standard Time

当打印结构体时,改进的格式 %+v 会为结构体的每个字段添上字段名,而另一种格式 %#v 将完全按照 Go 的语法打印值。

初始化

常量只能是数字、字符(符文)、字符串或布尔值。由于编译时的限制, 定义它们的表达式必须也是可被编译器求值的常量表达式。例如 1<<3 就是一个常量表达式,而 math.Sin(math.Pi/4) 则不是,因为对 math.Sin 的函数调用在运行时才会发生。

在 Go 中,枚举常量使用枚举器 iota 创建。由于 iota 可为表达式的一部分,而表达式可以被隐式地重复,这样也就更容易构建复杂的值的集合了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
)

方法

以指针或值为接收者的区别在于:值方法可通过指针和值调用, 而指针方法只能通过指针来调用。

之所以会有这条规则是因为指针方法可以修改接收者;通过值调用它们会导致方法接收到该值的副本, 因此任何修改都将被丢弃,因此该语言不允许这种错误。不过有个方便的例外:若该值是可寻址的, 那么该语言就会自动插入取址操作符来对付一般的通过值调用的指针方法。在我们的例子中,变量 b 是可寻址的,因此我们只需通过 b.Write 来调用它的 Write 方法,编译器会将它重写为 (&b).Write。

通常我们会将方法写为指针接收者,这种情况下,即便是用值调用这个方法,编辑器会自动帮我们改为指针调用。

并发

并发是用可独立执行的组件构造程序的方法,而并行则是为了效率在多 CPU 上平行地进行计算。

并发是两个队列交替使用一台咖啡机,并行是两个队列同时使用两台咖啡机

错误

若调用者关心错误的完整细节,可使用类型选择或者类型断言来查看特定错误,并抽取其细节。

1
2
3
4
5
6
7
8
9
10
11
for try := 0; try < 2; try++ {
file, err = os.Create(filename)
if err == nil {
return
}
if e, ok := err.(*os.PathError); ok && e.Err == syscall.ENOSPC {
deleteTempFiles() // Recover some space.
continue
}
return
}

panic 被调用后(包括不明确的运行时错误,例如切片检索越界或类型断言失败),程序将立刻终止当前函数的执行,并开始回溯 Go 程的栈,运行任何被推迟的函数。 若回溯到达 Go 程栈的顶端,程序就会终止。不过我们可以用内建的 recover 函数来重新或来取回 Go 程的控制权限并使其恢复正常执行。

调用 recover 将停止回溯过程,并返回传入 panic 的实参。 由于在回溯时只有被推迟函数中的代码在运行,因此 recover 只能在被推迟的函数中才有效。

recover 的一个应用就是在服务器中终止失败的 Go 程而无需杀死其它正在执行的 Go 程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func server(workChan <-chan *Work) {
for work := range workChan {
go safelyDo(work)
}
}

func safelyDo(work *Work) {
defer func() {
if err := recover(); err != nil {
log.Println("work failed:", err)
}
}()
do(work)
}