Skip to content

Latest commit

 

History

History
1794 lines (1322 loc) · 61 KB

45 未雨绸缪!Go语言常见“坑”大汇.md

File metadata and controls

1794 lines (1322 loc) · 61 KB

45 未雨绸缪!Go语言常见“坑”大汇

未雨绸缪!Go语言常见“坑”大汇

“C 语言像一把雕刻刀,锋利,在技师手中非常有用。但和任何锋利的工具一样,C 语言也会伤到那些不能掌握它的人” - 安德鲁・凯尼格 (Andrew Koenig)

在上个世纪 80 年代中期,当时还在大名鼎鼎的贝尔实验室 (AT&T Bell Laboratories) 任职的 C 语言专家安德鲁・凯尼格 (Andrew Koenig) 发表了一篇名为 “C Traps and Pitfalls (C 语言的陷阱与缺陷)” 的论文。若干年后,他以这篇论文为基础,结合自己的工作经验,又出版了日后对 C 程序员影响甚大且极具价值的经典著作《C 语言陷阱与缺陷》。无论是论文还是书籍,作者凯尼格的出发点都不是要批判 C 语言,而是帮助 C 程序员绕过 C 语言编程过程中的陷阱和障碍

没有哪一种编程语言是完美和理想的,Go 语言也不例外。尽管 Go 语言很简单,但有过一定 Go 使用经验的开发人员或多或少都掉进过 Go 的 “陷阱”。之所以将陷阱二字加上双引号,是因为它们并非是真正的语言缺陷,而是一些在对 Go 语言规范、运行时、标准库以及工具链等了解不够全面、深入和透彻的前提下容易犯的错误或是因语言间的使用差异而导致的误用问题

Go 语言虽有 “陷阱”,但其数量和影响力与 C 相比还相差甚远,还没有严重到需要将其整理为一本小书出版的地步,因此在这里,我们仅用一个小节的篇幅来讲解。另外,与凯尼格编写《C 语言的陷阱与缺陷》的初衷类似,本小节的目的并非批判 Go 语言,而是重点介绍 Go 语言目前有哪些常见的 “坑害” 粗心 gopher 的 “陷阱” 以及如何绕过它们。熟知并牢记这些 “陷阱” 将有助于 Go 开发人员在工程实践中少走弯路。

注:本节提到的一些 “陷阱” 在之前的章节中可能也曾提及过,之所以在这里再次提出,一来是为了分类汇总方便查找,二来就是为了强调这些 “陷阱” 在我们的日常开发过程中会经常遇到,应予以高度重视。

1. 语法规范类

下面我们首先来看看与 Go 语言的语法规范相关的 “陷阱”(以下简称为 “坑”)。针对每个 “陷阱”,笔者都会对其作出评估,并使用两个指标:遇 “坑” 指数“坑” 害指数来描述其发生概率与危害程度。两个指数都采用 5 星制,星星数量越多,表示发生概率越高或危害程度越大。

1) 短变量声明相关的 “坑”

Go 语言提供了两种变量声明方式,一种是常规的变量声明形式,它可以应用在任何场合;另外一种则是短变量声明形式 (short variable declaration),它用来声明本地临时变量,仅适用于函数 / 方法内部或是 if、for 和 switch 的初始化语句中,不能用于包级变量的声明。短变量声明形式是 Go 提供的一种语法糖,用起来十分便利,但这个语法糖却总让 Gopher 陷入 “坑” 中。

短变量声明不总是会声明一个新变量

作为静态编译型语言的 Go,使用变量之前一定要先声明变量,采用常规变量声明形式和短变量声明形式均可以实现对新变量的声明:

var a int = 5
b, c := "hello", 3.1415
println(a, b, c) // 5 hello +3.141500e+000 

如果我们重复声明变量,Go 编译器会报错:

var a int = 5
b, c := "hello", 3.1415
var a int = 6        // 错误:在当前代码块(block)重复声明了变量a
b, c := "world", 1.1 // 错误::=左侧没有新变量 

但是下面代码却可以正常通过 Go 编译器检查:

var a int = 5
a, d := 55, "hello, go" // ok 

按照我们对声明语句的传统理解,上面第二行的多变量短声明语句重新声明了变量 a 和 d,我们用下面代码来确认一下:

// sources/go-trap/multi_variable_short_declaration.go
var a int = 5
println(a, &a) // 5 0xc00003c770
a, d := 55, "hello, go" 
println(&a) // 0xc00003c770
println(a, d) // 55 hello, go 

从输出结果来看,我们对声明语句的传统理解似乎 “失效” 了。多变量短声明语句 (multi-variable short declaration) 并未重新声明一个新变量 a,它只是给之前已经声明了的变量 a 做了重新赋值。

这是一个典型的由于认知偏差而形成的 “陷阱”。Go 规范针对此 “陷阱” 有明确的说明:在同一个代码块 (block) 中,使用多变量短声明语句重新声明已经声明过的变量时,短变量声明语句不会为该变量声明一个新变量,而只是会对其做重新赋值。

遇 “坑” 指数:★★★☆☆ “坑” 害指数:★★☆☆☆

短变量声明会导致 “难于发现” 的变量遮蔽 (variable shadowing)

对于 C 语言家族的语言来说,变量遮蔽并不是什么 “新鲜事” 了,即便没有使用短变量声明,变量遮蔽一样可能发生,比如下面示例:

// sources/go-trap/short_declaration_variable_shadowing_1.go 

var a int = 13

func main() {
	println(a, &a) // 13 0x10cb188
	var a int = 23 // 遮蔽了包级变量a
	println(a, &a) // 23 0xc00003e770

	if a == 23 {
		var a int = 33 // 遮蔽了main函数中声明的变量a
		println(a, &a) // 33 0xc00003e768
	}
} 

上面示例中发生了两次变量遮蔽,但这两次遮蔽相对容易被肉眼发现。我们再看下面这个示例:

// sources/go-trap/short_declaration_variable_shadowing_2.go 
func foo() (int, error) {
	return 11, nil
}

func bar() (int, error) {
	return 21, errors.New("error in bar")
}

func main() {
	var err error
	defer func() {
		if err != nil {
			println("error in defer:", err.Error())
		}
	}()

	a, err := foo()
	if err != nil {
		return
	}
	println("a=", a)

	if a == 11 {
		b, err := bar()
		if err != nil {
			return
		}
		println("b=", b)
	}
	println("no error occurs")
} 

对于上面这个示例,我们期待输出下面结果:

a= 11
error in defer: error in bar 

但实际运行后却发现只输出了:

a= 11 

我们使用 go vet 工具对该源码做一些静态检查。go vet (Go 1.14 版) 默认已经不再支持变量遮蔽的检查了,我们可以单独安装位于 Go 扩展项目中的 shadow 工具来实施检查:

$go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow
$go vet -vettool=$(which shadow) -strict short_declaration_variable_shadowing_2.go 
./short_declaration_variable_shadowing_2.go:28:6: declaration of "err" shadows declaration at line 14 

go vet 报告 b, err := bar() 这行声明中的 err (位于第 28 行) 遮蔽了 main 函数中声明的 err 变量 (位于第 14 行)!这样即便 bar 函数返回的 error 变量不为 nil,main 函数中声明的 err 变量的值依然为 nil。这样执行 defer 函数时,由于 err 变量值为 nil,因此没有输出错误信息。

这个示例中的变量遮蔽相较于第一个示例难于被肉眼发现,很多 gopher 看到 b, err := bar() 这行代码后会误以为 err 不会被重新声明为一个新变量,仅会做赋值操作,就像前面短声明变量的第一个 “坑” 中描述的那样。但实际上,由于不在同一个代码块 (block) 中,编译器没有在同一代码块里找到与 b, err := bar() 这行代码中 err 同名的变量,因此会声明一个新 err 变量,该 err 变量也就 “顺理成章” 地遮蔽了 main 函数代码块中的 err 变量。

修正这个问题的方法有很多,但最直接的方法就是去掉 if 代码块中的多变量短声明形式并提前单独声明变量 b:

// sources/go-trap/short_declaration_variable_shadowing_3.go 
func main() {
	... ...
	var b int
	if a == 11 {
		b, err = bar()
		if err != nil {
			return
		}
		println("b=", b)
	}
	println("no error occurs")
} 

这是一个在实际开发过程中经常发生的问题。在不同代码块层次上使用多变量短声明形式会带来 “难以发现” 的变量遮蔽问题,从而导致程序运行异常。通过 go vet+shadow 工具可以很快捷方便地发现这一问题。

遇 “坑” 指数:★★★★★ “坑” 害指数:★★★★☆

2) nil 相关的 “坑”

不是所有以 nil 作为零值的类型都是 “零值可用” 的

这句话读起来有些 “拗口”,我们可以将其分成两部分来理解:

  • 以 nil 为零值的类型

根据 Go 语言规范,诸如切片 (slice)、map、接口类型和指针类型的零值均为 nil。

  • 零值可用的类型

在第 11 条 “尽量定义零值可用的类型” 中,我们学习过什么是零值可用的类型,常见的包括:sync.Mutex 和 bytes.Buffer 等。Go 原生的切片类型只在特定使用方式下才可以被划到零值可用的范畴。

我们看到只有这两部分的 “交集” 中的类型才是零值可用的,这个集合中包含:特定使用方式下的切片类型、特定的自定义类型指针 (仅可以调用没有对自身进行指针解引用的方法) 以及特定使用方式下的接口类型,见下面示例:

// sources/go-trap/nil_type_1.go 
type foo struct {
	name string
	age  int
}

func (*foo) doSomethingWithoutChange() {
	fmt.Println("doSomethingWithoutChange")
}

type MyInterface interface {
	doSomethingWithoutChange()
}

func main() {
	// 切片仅在调用append操作时才是零值可用的
	var strs []string = nil
	strs = append(strs, "hello", "go")
	fmt.Printf("%q\n", strs)

	// 自定义类型的方法中没有对自身实例解引用的操作时,
	// 我们可以通过该类型的零值指针调用其方法
	var f *foo = nil
	f.doSomethingWithoutChange()

	// 给接口类型赋予显式转型后的nil(并非真正的零值)
	// 我们可以通过该接口调用没有解引用操作的方法
	var i MyInterface = (*foo)(nil)
	i.doSomethingWithoutChange()
} 

而其他以 nil 为类型零值的类型 (或未在特定使用方式下的上述类型) 则不是零值可用的:

// sources/go-trap/nil_type_2.go 
type foo struct {
	name string
	age  int
}

func (f *foo) setName(name string) {
	f.name = name
}

func (*foo) doSomethingWithoutChange() {
	fmt.Println("doSomethingWithoutChange")
}

type MyInterface interface {
	doSomethingWithoutChange()
}

func main() {
	var strs []string = nil
	strs[0] = "go" // panic

	var m map[string]int
	m["key1"] = 1 // panic

	var f *foo = nil
	f.setName("tony") // panic

	var i MyInterface = nil
	i.doSomethingWithoutChange() // panic
} 

遇 “坑” 指数:★★☆☆☆ “坑” 害指数:★★★☆☆

值为 “nil” 的接口类型变量并不总等于 nil

下面是 Go 语言中的一个令新手非常迷惑的例子:

// sources/go-trap/nil_interface_1.go 
type TxtReader struct{}

func (*TxtReader) Read(p []byte) (n int, err error) {
	// ... ...
	return 0, nil
}

func NewTxtReader(path string) io.Reader {
	var r *TxtReader
	if strings.Contains(path, ".txt") {
		r = new(TxtReader)
	}
	return r
}

func main() {
	i := NewTxtReader("/home/tony/test.png")
	if i == nil {
		println("fail to init txt reader")
		return
	}
	println("init txt reader ok")
} 

一般会认为上述程序执行后会输出 fail to init txt reader,因为传入的文件并非是一个后缀为.txt 的文件,函数 NewTxtReader 会将此时值为 nil 的变量 r 作为返回值直接返回。但执行上述程序得到的输出结果却是:

init txt reader ok 

难道值为 “nil” 的接口变量 i 与 nil 真的不相等?在第 26 条 “了解接口类型变量的内部表示” 一节中其实我们已经回答了这个问题,接口类型在运行时的表示分为两部分,一部分是类型信息,一部分是值信息。只有当接口类型变量的这两部分的值都为 nil 时,该变量才会与 nil 相等。为了便于理解,我们可以将上述例子简化为下面代码:

// sources/go-trap/nil_interface_2.go 
var r *TxtReader = nil
var i io.Reader = r
println(i == nil) // false
println(i) // (0x1089720,0x0) 

我们看到在接口类型变量 i 被赋值为值为 nil 的变量 r 后,变量 i 的类型信息部分并非为 nil (上面例子 println (i) 输出中的第一个值 0x1089720),这样 i 就与 nil 不等了。

这个 “坑” 在日常 Go 编码过程中会经常出现,并且一旦出现,很难排查,一旦漏到生产环境当中,其造成的危害后果也是很严重的。

遇 “坑” 指数:★★★★☆ “坑” 害指数:★★★★☆

3) for range 相关的 “坑”

注意:你得到的是序号值而不是元素值

下面是在 Python 语言中使用 for 循环迭代输出列表中的每个元素的代码:

fruits = ['banana', 'apple',  'mango']
for fruit in fruits:
   print fruit

执行输出banana
apple
mango 

如果你原先是 Python 程序员,十分享受上面语法给你带来的便捷,那么如果你转到 Go 语言门下,你可要小心了。如果你写出下面代码,那么其输出结果一定不是你想要得到的:

// sources/go-trap/for_range_1.go
func main() {
	fruits := []string{"banana", "apple", "mango"}
	for fruit := range fruits {
		println(fruit)
	}
} 

编译运行上述 Go 代码,你将看到输出如下结果:

0
1
2 

我们看到上述示例程序输出的是元素在切片中的序号 (从 0 开始),而不是真实的元素值,这是因为当使用 for range 针对切片、数组或字符串进行迭代操作,迭代变量是有两个的,第一个是元素在迭代集合中序号值 (从 0 开始),第二个值才是元素值,因此下面的代码才是你想要的:

// sources/go-trap/for_range_1.go
func main() {
	fruits := []string{"banana", "apple", "mango"}
	for _, fruit := range fruits {
		println(fruit)
	}
} 

遇 “坑” 指数:★★☆☆☆ “坑” 害指数:★★☆☆☆

针对 string 类型的 for range 迭代不是逐字节迭代

在 Python 中,下面的代码将会对字符串进行逐字节迭代:

#! /usr/bin/env python
# -*- coding: utf-8 -*-

for letter in 'Hi,中国':
    print '%r' % letter

运行上述代码的输出结果如下'H'
'i'
','
'\xe4'
'\xb8'
'\xad'
'\xe5'
'\x9b'
'\xbd' 

如果在 Go 中依旧沿用上述思维,我们会发现得到的结果并非我们预期的那样:

// sources/go-trap/for_range_2.go
func main() {
	for _, s := range "Hi,中国" {
		fmt.Printf("0x%X\n", s)
	}
} 

编译运行上述 Go 代码,你将看到如下输出结果:

0x48
0x69
0x2C
0x4E2D
0x56FD 

输出的结果 “似曾相识” 啊。没错!在第 52 条 “掌握字符集的原理和字符编码方案间的转换” 一节中,我们曾介绍过 0x4E2D 和 0x56FD 分别是 “中” 和 “国” 两个汉字的码点 (code point),在 Go 语言中每个 Unicode 字符码点对应的是一个 rune 类型的值,也就是说在 Go 中对字符串运用 for range 操作,每次返回的是一个码点 (rune),而不是一个字节

那么如果要进行逐字节迭代,我们应该怎么编写代码呢?我们需要将字符串转换为字节类型切片后再运用 for range 对字节类型切片做迭代:

// sources/go-trap/for_range_2.go

func main() {
	for _, b := range []byte("Hi,中国") {
		fmt.Printf("0x%X\n", b)
	}
} 

在第 15 条 “了解 string 实现原理与高效使用” 中我们还提到了 Go 编译器对上述代码中字符串到字节切片转换的优化处理,即 Go 编译器不会为 [] byte 做额外的内存分配,而是直接使用 string 的底层数据

遇 “坑” 指数:★★☆☆☆ “坑” 害指数:★★☆☆☆

对 map 类型内元素的迭代顺序是随机的

Go 语言原生的 “容器” 类型都支持 for range 迭代,比如上面提到的数组、切片。作为最常用的 “容器” 类型之一,map 类型同样支持 for range 迭代,但迭代的结果却是这样的:

// sources/go-trap/for_range_3.go
func main() {
	heros := map[int]string{
		1: "superman",
		2: "batman",
		3: "spiderman",
		4: "the flash",
	}
	for k, v := range heros {
		fmt.Println(k, v)
	}
} 

将上面这个示例运行三次:

$go run for_range_3.go
1 superman
2 batman
3 spiderman
4 the flash

$go run for_range_3.go
4 the flash
1 superman
2 batman
3 spiderman

$go run for_range_3.go
3 spiderman
4 the flash
1 superman
2 batman 

我们看到三次运行的结果各不相同,对 map 类型内元素进行迭代所得到的结果是随机无序的,这会让很多 Go 新手 “大跌眼镜”。不过 Go 的设计就是如此,要想有序对迭代 map 内的元素,我们需要额外的数据结构来支持,比如:使用一个切片来有序保存 map 内元素的 key 值:

// sources/go-trap/for_range_3.go

func main() {
	var indexes []int
	heros := map[int]string{
		1: "superman",
		2: "batman",
		3: "spiderman",
		4: "the flash",
	}
	for k, v := range heros {
		indexes = append(indexes, k)
	}

	sort.Ints(indexes)
	for _, idx := range indexes {
		fmt.Println(heros[idx])
	}
} 

遇 “坑” 指数:★★★★☆ “坑” 害指数:★★☆☆☆

在 “复制品” 上做迭代

下面是一个对切片进行迭代的例子:

// sources/go-trap/for_range_4.go
func main() {
	var a = []int{1, 2, 3, 4, 5}
	var r = make([]int, 0)

	fmt.Println("a = ", a)

	for i, v := range a {
		if i == 0 {
			a = append(a, 6, 7)
		}

		r = append(r, v)
	}

	fmt.Println("r = ", r)
	fmt.Println("a = ", a)
} 

在上面示例代码中,我们在迭代过程中动态地向切片 a 中添加了新元素 6 和 7,我们期望这鞋改变可以反映到新切片 r 上。但该示例程序的输出如下:

$go run for_range_4.go
a =  [1 2 3 4 5]
r =  [1 2 3 4 5]
a =  [1 2 3 4 5 6 7] 

我们看到对原切片 a 的动态扩容并未在 r 上得到体现。对 a 的迭代次数依旧是 5 次,也没有因 a 的扩容而变为 7 次。这是因为参与 range 表达式中的 a 实际上是原切片 a 的副本 (暂称为 a’),在该表达式初始化后,副本切片 a’内部表示中的 len 字段就是 5,并且在整个 for range 循环过程中并未改变,因此 for range 只会循环 5 次,也就只获取到原切片 a 所对应的底层数组的前 5 个元素。

更多关于该 “陷阱” 的描述和例子可以参见第 19 条 “了解 Go 语言控制语句惯用法及使用注意事项” 一节。

遇 “坑” 指数:★★★★☆ “坑” 害指数:★★★☆☆

迭代变量是 “重用” 的

for i, v := range xxx 这个语句中,i、v 都被称为迭代变量,迭代变量总是会参与到每次迭代的处理逻辑中,就像下面示例代码这样:

// sources/go-trap/for_range_5.go
func main() {
	var a = []int{1, 2, 3, 4, 5}
	var wg sync.WaitGroup

	for _, v := range a {
		wg.Add(1)
		go func() {
			time.Sleep(time.Second)
			fmt.Println(v)
			wg.Done()
		}()
	}
	wg.Wait()
} 

我们期望上面示例中每个 goroutine 输出切片 a 中的一个元素,但实际运行后却发现输出结果如下:

5
5
5
5
5 

之所以能写出上述示例中那样的代码,很可能是被 for range 表达式中的:= 迷惑了,认为每次迭代都会重新声明一个循环变量 v,但实际上这个循环变量 v 仅仅被声明了一次并在后续整个迭代过程中被 “重复” 使用:

for _, v := range a {

}

等价于

v := 0
for _, v = range a {

} 

这样上面示例的输出结果也就不那么令人意外了,新创建的 5 个 goroutine 在 Sleep 1 秒后所看到的是同一个变量 v,而此时变量 v 的值为 5,所以 5 个 goroutine 输出的 v 值也就都是 5。我们可以通过下面方法修正这个示例:

for _, v := range a {
	wg.Add(1)
	go func(v int) {
		time.Sleep(time.Second)
		fmt.Println(v)
		wg.Done()
	}(v)
} 

修改后的示例中每个 goroutine 输出的是每轮迭代时传入的循环变量 v 的副本,这个值不会随着迭代的进行而变化。

遇 “坑” 指数:★★★★☆ “坑” 害指数:★★★★☆

4) 切片相关的 “坑”

Go 切片相比数组更加高效和灵活,尽量使用切片替代数组也是 Go 语言的惯用法之一。Go 支持基于已有切片创建新切片 (reslicing) 的操作,新创建的切片与原切片共享底层存储,这个操作给 Go 开发者带来高灵活性和内存占用小等好处的同时,也十分容易让开发者掉入相关 “陷阱”。

小心对内存的过多占用

基于已有切片的 reslicing 而创建的新切片与原切片共享底层存储,这样如果原切片占用较大内存,新切片的存在又使得原切片内存无法得到释放,这样就会占用过多内存,如下面示例:

// sources/go-trap/slice_1.go
func allocSlice(min, high int) []int {
	var b = []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 99: 100}
	fmt.Printf("slice b: len(%d), cap(%d)\n",
		len(b), cap(b))

	return b[min:high]
}

func main() {
	b1 := allocSlice(3, 7)
	fmt.Printf("slice b1: len(%d), cap(%d), elements(%v)\n",
		len(b1), cap(b1), b1)
} 

在这个例子中,我们基于一个长度 (len) 和容量 (cap) 均为 100 的切片 b 创建一个长度仅为 4 的小切片 b1,这样通过 b1 我们仅仅能操纵 4 个整型值,但 b1 的存在却使得额外的 96 个整型数占用的空间无法得到及时释放。

我们可以通过内建函数 copy 为新切片建立独立的存储空间以避免与原切片共享底层存储,从而避免空间的浪费:

// sources/go-trap/slice_2.go

func allocSlice(min, high int) []int {
	var b = []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 99: 100}
	fmt.Printf("slice b: len(%d), cap(%d)\n",
		len(b), cap(b))
	nb := make([]int, high-min, high-min)
	copy(nb, b[min:high])
	return nb
} 

遇 “坑” 指数:★★★☆☆ “坑” 害指数:★★★☆☆

小心 “隐匿” 数据的暴露与切片数据 “篡改”

除了过多的内存占用,slice_1.go 这个示例还可能导致 “隐匿” 数据的暴露,我们将 slice_1.go 示例做一下改动:

// sources/go-trap/slice_3.go
func allocSlice(min, high int) []int {
	var b = []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
	fmt.Printf("slice b: len(%d), cap(%d), elements(%v)\n",
		len(b), cap(b), b)

	return b[min:high]
}

func main() {
	b1 := allocSlice(3, 7)
	fmt.Printf("slice b1: len(%d), cap(%d), elements(%v)\n",
		len(b1), cap(b1), b1)

	b2 := b1[:6]
	fmt.Printf("slice b2: len(%d), cap(%d), elements(%v)\n",
		len(b2), cap(b2), b2)
} 

在该示例中,通过 allocSlice 函数分配的切片 b1 又被做了一次 reslicing,由于 b1 的容量为 7,因此对齐进行 reslicing 时采用 b1[:6] 并不会出现 “越界” 问题。上述示例运行结果如下:

$go run slice_3.go
slice b: len(10), cap(10), elements([1 2 3 4 5 6 7 8 9 10])
slice b1: len(4), cap(7), elements([4 5 6 7])
slice b2: len(6), cap(7), elements([4 5 6 7 8 9]) 

示例的初衷显然是期望通过 reslicing 创建的 b2 是这样的:[4 5 6 7 0 0],但事与愿违,由于 b1、b2、b 三个切片共享底层存储,使得原先切片 b 对切片 b1 “隐匿” 的数据在切片 b2 中暴露了出来。

但切片 b2 对这种 “隐匿” 数据的存在可能毫不知情,这样当切片 b2 操作这两个位置的数据时,实际上会 “篡改” 掉原切片 b 本不想暴露给切片 b1 的那些数据。

我们依然可以通过内建函数 copy 为新切片建立独立的存储空间的方法来应对这个 “陷阱”,它避免了利用容量漏洞对新分配的切片进行 “扩张” 式的 reslicing 操作导致的 “隐匿” 数据暴露,看不到 “隐匿” 数据,自然也就无法实施 “篡改” 操作了:

// sources/go-trap/slice_4.go
func allocSlice(min, high int) []int {
	var b = []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
	fmt.Printf("slice b: len(%d), cap(%d), elements(%v)\n",
		len(b), cap(b), b)

	nb := make([]int, high-min, high-min)
	copy(nb, b[min:high])
	return nb
}

func main() {
	b1 := allocSlice(3, 7)
	fmt.Printf("slice b1: len(%d), cap(%d), elements(%v)\n",
		len(b1), cap(b1), b1)

	b2 := b1[:6]
	fmt.Printf("slice b2: len(%d), cap(%d), elements(%v)\n",
		len(b2), cap(b2), b2) // panic: runtime error: slice bounds out of range [:6] with capacity 4
} 

遇 “坑” 指数:★★★☆☆ “坑” 害指数:★★★☆☆

注意新切片与原切片底层存储的 “分家”

Go 中的切片支持 “自动扩容”,当扩容发生时,新切片与原切片底层存储便会出现 “分家” 现象。一旦发生 “分家”,后续对新切片的任何操作都不会影响到原切片了:

// sources/go-trap/slice_5.go

func main() {
	var b = []int{1, 2, 3, 4}
	fmt.Printf("slice b: len(%d), cap(%d), elements(%v)\n",
		len(b), cap(b), b)

	b1 := b[:2]
	fmt.Printf("slice b1: len(%d), cap(%d), elements(%v)\n",
		len(b1), cap(b1), b1)

	fmt.Println("\nappend 11 to b1:")
	b1 = append(b1, 11)
	fmt.Printf("slice b1: len(%d), cap(%d), elements(%v)\n",
		len(b1), cap(b1), b1)
	fmt.Printf("slice b: len(%d), cap(%d), elements(%v)\n",
		len(b), cap(b), b)
	fmt.Println("\nappend 22 to b1:")
	b1 = append(b1, 22)
	fmt.Printf("slice b1: len(%d), cap(%d), elements(%v)\n",
		len(b1), cap(b1), b1)
	fmt.Printf("slice b: len(%d), cap(%d), elements(%v)\n",
		len(b), cap(b), b)

	fmt.Println("\nappend 33 to b1:")
	b1 = append(b1, 33)
	fmt.Printf("slice b1: len(%d), cap(%d), elements(%v)\n",
		len(b1), cap(b1), b1)
	fmt.Printf("slice b: len(%d), cap(%d), elements(%v)\n",
		len(b), cap(b), b)

	b1[0] *= 100
	fmt.Println("\nb1[0] multiply 100:")
	fmt.Printf("slice b1: len(%d), cap(%d), elements(%v)\n",
		len(b1), cap(b1), b1)
	fmt.Printf("slice b: len(%d), cap(%d), elements(%v)\n",
		len(b), cap(b), b)
} 

运行该示例:

$go run slice_5.go
slice b: len(4), cap(4), elements([1 2 3 4])
slice b1: len(2), cap(4), elements([1 2])

append 11 to b1:
slice b1: len(3), cap(4), elements([1 2 11])
slice b: len(4), cap(4), elements([1 2 11 4])

append 22 to b1:
slice b1: len(4), cap(4), elements([1 2 11 22])
slice b: len(4), cap(4), elements([1 2 11 22])

append 33 to b1:
slice b1: len(5), cap(8), elements([1 2 11 22 33])
slice b: len(4), cap(4), elements([1 2 11 22])

b1[0] multiply 100:
slice b1: len(5), cap(8), elements([100 2 11 22 33])
slice b: len(4), cap(4), elements([1 2 11 22]) 

从示例输出结果我们看到:在将 22 附加 (append) 到切片 b1 后,切片 b1 的空间已满 (len(b1)==cap(b1))。之后当我们将 33 附加到切片 b1 的时候,切片 b1 与 b 发生了底层存储的 “分家”,Go 运行时为切片 b1 重新分配了一段内存 (容量为原容量的 2 倍) 并将数据拷贝到新内存块中,这次我们看到切片 b 并没有被改变,依旧保持了附加 22 后的状态。由于存储 “分家”,后续我们将 b1 [0] 乘以 100 的操作同样不会对切片 b 有任何影响。

遇 “坑” 指数:★★☆☆☆ “坑” 害指数:★★★☆☆

5) string 相关的 “坑”

无论是对 C 语言出身的 gopher,还是对 python 出身的 gopher,Go 的 string 类型都或多或少有一些让大家易入的小 “坑”,由于细小,这里把它们汇总到一起说明一下。

string 在 Go 语言中是原生类型,这与 C 语言使用使用字节数组 “模拟” 字符串类型不同,并且 Go 中的 string 类型没有结尾’\0’,其长度为 string 类型底层数组中的字节数量:

// sources/go-trap/string_1.go

s := "大家好"
fmt.Printf("字符串\"%s\"的长度为%d\n", s, len(s)) // 长度为9 

注意:字符串长度并不等于该字符串中的字符个数

// sources/go-trap/string_1.go
s := "大家好"
fmt.Printf("字符串\"%s\"中的字符个数%d\n", s, utf8.RuneCountInString(s)) // 字符个数为3 

string 类型支持下标操作符 [],我们可以通过下标操作符读取字符串中的每个字节:

// sources/go-trap/string_1.go
s1 := "hello"
fmt.Printf("s1[0] = %c\n", s1[0]) // s1[0] = h
fmt.Printf("s1[1] = %c\n", s1[1]) // s1[1] = e 

但如果你将下标操作符表达式作为左值,你将得到如下编译错误:

// sources/go-trap/string_1.go
s1 := "hello"
s[0] = 'j'

$go run string_1.go
./string_1.go:16:7: cannot assign to s[0] 

这是因为在 Go 中 string 类型是不可改变的,我们无法改变其中的数据内容。那些尝试将 string 转换为切片再修改的方案其实修改的都是切片自身,原始 string 的数据并未发生改变:

// sources/go-trap/string_1.go

b := []byte(s1)
b[0] = 'j'
fmt.Printf("字符串s1 = %s\n", s1) // 字符串s1 = hello
fmt.Printf("切片b = %q\n", b) // 切片b = "jello" 

string 类型的零值是 "",而不是 nil,因此判断一个 string 类型变量是否内容为空,可以将其与 "" 比较,或将其长度 (len(s)) 与 0 值比较,不要将 string 类型与 nil 比较:

// sources/go-trap/string_1.go

var s2 string
fmt.Println(s2 == "")     // true
fmt.Println(len(s2) == 0) // true
fmt.Println(s2 == nil)    // invalid operation: s2 == nil (mismatched types string and nil) 

遇 “坑” 指数:★★★★☆ “坑” 害指数:★☆☆☆☆

6) switch 语句相关的 “坑”

Go 中 switch 语句的执行流与 C 语言中 switch 语句有着很大的不同,这让很多 C 程序员转到 Go 之后频繁遭遇此 “坑”。以下面这个代码为例:

// sources/go-trap/switch.c

int main() {
         int a = 1;
          switch(a) {
          case 0:
                  printf("a = 0\n");
          case 1:
                  printf("a = 1\n");
          case 2:
                  printf("a = 2\n");
          default:
                  printf("a = N/A\n");
          }
} 

运行这段代码:

$gcc -o switch-demo switch.c
$./switch-demo 
a = 1
a = 2
a = N/A 

我们看到在 C 语言中如果没有在 case 中显式放置 break 语句,执行流就会从匹配到的 case 语句开始一直向下执行,于是我们就会在 C 语言中看到大量 break 充斥在 switch 语句中。在 Go 中,switch 的执行流则并不会从匹配到的 case 语句开始一直向下执行,而是执行完 case 语句块代码后跳出 switch 语句,除非你显式使用 fallthrough 强制向下一个 case 语句执行。所以下面的 Go 代码不会出现上面 C 语句的问题:

// sources/go-trap/switch_1.go
func main() {
	var a = 1
	switch a {
	case 0:
		println("a = 0")
	case 1:
		println("a = 1") // 输出a = 1
	case 2:
		println("a = 2")
	default:
		println("a = N/A")
	}
} 

事实上,这并不能算是 Go 语言的 “坑”,更可以理解为是 Go 语言修正了 C 语言中 switch/case 语句的 “缺陷”,只是由于 “习惯” 用法不同而导致的 “误用”。

遇 “坑” 指数:★★★★☆ “坑” 害指数:★★☆☆☆

7) goroutine 相关的 “坑”

goroutine 算是 Go 语言的一件 “大杀器”!“轻量级并发理念降低心智负担”、“随便写 (未经雕琢优化) 的十几行代码也能抗住 10 万 + 并发请求的神迹” 等 “噱头” 让 goroutine 赚足了 “眼球”。但 goroutine 也有自己的 “坑”,接下来我们就来看一看。

你无法得到 goroutine 退出状态

在使用线程作为最小并发调度单元的时代,我们可以通过 pthread_join 来阻塞等待某个线程退出并得到其退出状态:

// pthread_join函数原型
// 通过第二个参数可以获得线程的退出状态

int pthread_join(pthread_t thread, void **value_ptr); 

但当你使用 goroutine 来架构你的并发程序时,你会发现在没有外部结构支撑的情况下,Go 原生并不支持获取某个 goroutine 的退出状态,因为启动一个 goroutine 的标准代码是这样的:

go func() {
	... ...
}() 

而不是这样的:

ret := go func() {
	... ...
}() 

在传统的线程并发模型中,所谓获取线程退出返回值也是一种线程间通信;而在 Go 的并发模型中,这也算是 goroutine 间通信范畴。提到 goroutine 间通信,我们首先想到的就是使用 channel。没错,利用 channel,我们可以轻松获取 goroutine 的退出状态:

// sources/go-trap/goroutine_1.go
func main() {
	c := make(chan error, 1)

	go func() {
		//do something
		time.Sleep(time.Second * 2)
		c <- nil // or c <- errors.New("some error")
	}()

	err := <-c
	fmt.Printf("sub goroutine exit with error: %v\n", err)
} 

当然获取 goroutine 退出状态的手段可不仅这一种,更多关于 goroutine 并发模式和应用的例子可以再仔细读一下第 33 条 “掌握 Go 并发模型和常见并发模式”,你也许能受到更多启发。

遇 “坑” 指数:★★★★☆ “坑” 害指数:★★☆☆☆

程序随着 main goroutine 退出而退出,不等待其他 goroutine

Go 语言中存在一个 “残酷” 的事实,那就是程序退出与否全看 main goroutine 是否退出,一旦 main goroutine 退出了,这时即便有其他 goroutine 仍然在运行,程序进程也会毫无顾忌地退出,其他 goroutine 正在进行的处理工作就会戛然而止。比如下面这个示例:

// sources/go-trap/goroutine_2.go
func main() {
	println("main goroutine: start to work...")
	go func() {
		println("goroutine1: start to work...")
		time.Sleep(50 * time.Microsecond)
		println("goroutine1: work done!")
	}()

	go func() {
		println("goroutine2: start to work...")
		time.Sleep(30 * time.Microsecond)
		println("goroutine2: work done!")
	}()

	println("main goroutine: work done!")
} 

运行上面示例,我们可以得到下面输出结果 (注:你的运行结果可能与我的稍有不同,但极少会有 goroutine1 和 goroutine2 在 main goroutine 退出前已经处理完成的情况):

$go run goroutine_2.go
main goroutine: start to work...
main goroutine: work done! 

我们看到 main goroutine 丝毫没有顾及正在运行的 goroutine1 和 goroutine2 就退出了,这让 goroutine1 和 goroutine2 还未输出哪怕是一行日志就被无情杀掉了。

通常我们可以使用 sync.WaitGroup 来协调多个 goroutine,以上面代码为例,我们会在 main goroutine 中使用 sync.WaitGroup 来等待其他两个 goroutine 完成工作:

// sources/go-trap/goroutine_3.go
func main() {
	var wg sync.WaitGroup
	wg.Add(2)
	println("main goroutine: start to work...")
	go func() {
		println("goroutine1: start to work...")
		time.Sleep(50 * time.Microsecond)
		println("goroutine1: work done!")
		wg.Done()
	}()

	go func() {
		println("goroutine2: start to work...")
		time.Sleep(30 * time.Microsecond)
		println("goroutine2: work done!")
		wg.Done()
	}()

	wg.Wait()
	println("main goroutine: work done!")
} 

运行这段新程序,我们就会得到我们期望的结果:

$go run goroutine_3.go           
main goroutine: start to work...
goroutine2: start to work...
goroutine1: start to work...
goroutine2: work done!
goroutine1: work done!
main goroutine: work done! 

遇 “坑” 指数:★★★☆☆ “坑” 害指数:★★★★☆

任何一个 goroutine 出现 panic,如果没有及时捕获,那么整个程序都将退出

Go 语言规范在讲述 panic 时告诉了我们一个更为 “残酷” 的现实:如果某 goroutine 在某函数 / 方法 F 的调用时出现 panic,一个被称为 “panicking” 的过程将被激活。该过程先会调用函数 F 的 defer 函数 (如果有的话),然后依次向上,调用函数 F 的调用者的 defer 函数,直至该 goroutine 的顶层函数,即启动该 goroutine 时 (go T()) 的那个函数 T。如果函数 T 有 defer 函数,那么 defer 会被调用。在整个 paniking 过程的 defer 调用链中,如果没有使用 recover 捕获该 panic,那么 panicking 过程的最后一个环节将会发生:整个程序异常退出,并输出 panic 相关信息,无论发生 panic 的 goroutine 是否为 main goroutine。我们看下面示例:

// sources/go-trap/goroutine_4.go
func main() {
	var wg sync.WaitGroup
	wg.Add(2)
	println("main goroutine: start to work...")
	go func() {
		println("goroutine1: start to work...")
		time.Sleep(5 * time.Second)
		println("goroutine1: work done!")
		wg.Done()
	}()

	go func() {
		println("goroutine2: start to work...")
		time.Sleep(1 * time.Second)
		panic("division by zero")
		println("goroutine2: work done!")
		wg.Done()
	}()

	wg.Wait()
	println("main goroutine: work done!")
} 

运行这个示例,我们将得到如下结果:

$go run goroutine_4.go
main goroutine: start to work...
goroutine2: start to work...
goroutine1: start to work...
panic: division by zero
... ... 

我们看到 goroutine2 的 panic 导致 goroutine1 和 main goroutine 尚未完成处理就因进程退出而停止了。这个 “坑” 带来的危害是极大的,你能想象出一个服务端守护程序的进程跑着跑着就 “消失了” 的情况出现吗!同时,由于 goroutine 的轻量特质,开发者可以在任何代码中随意启动一个 goroutine,因此你无法保证你的程序依赖的第三方包中是否启动了存在 “panic” 可能性的 goroutine,这就好比一颗定时炸弹,随时可能引爆。

那么如何避免呢?没有好办法,只能采用防御型代码,即在每个 goroutine 的启动函数中加上对 panic 的捕获逻辑,下面是对上面示例的改造:

// sources/go-trap/goroutine_5.go
func safeRun(g func()) {
	defer func() {
		if e := recover(); e != nil {
			fmt.Println("caught a panic:", e)
		}
	}()

	g()
}

func main() {
	var wg sync.WaitGroup
	wg.Add(2)
	println("main goroutine: start to work...")
	go safeRun(func() {
		defer wg.Done()
		println("goroutine1: start to work...")
		time.Sleep(5 * time.Second)
		println("goroutine1: work done!")
	})

	go safeRun(func() {
		defer wg.Done()
		println("goroutine2: start to work...")
		time.Sleep(1 * time.Second)
		panic("division by zero")
		println("goroutine2: work done!")
	})

	wg.Wait()
	println("main goroutine: work done!")
} 

运行该示例:

$go run goroutine_5.go
main goroutine: start to work...
goroutine2: start to work...
goroutine1: start to work...
caught a panic: division by zero
goroutine1: work done!
main goroutine: work done! 

我们看到 goroutine2 抛出的 panic 被 safeRun 函数捕获,这样 paniking 过程终止,main goroutine 和 goroutine1 才能得以 “善终”。

不过有些时候,panic 的确是由自己的代码 bug 导致的,及时退出程序所产生的影响可能比继续 “带病” 运行更小,而另一种适合大规模并行处理的高可靠性编程语言 Erlang 就崇尚 “任其崩溃” 的设计哲学,因此面对是否要捕获 panic 的情况,我们也不能 “一刀切”,也要视具体情况而定。

遇 “坑” 指数:★★★★★ “坑” 害指数:★★★★★

8) channel 相关的 “坑”

牢记处于 “特殊状态” 下的 channel 的行为特征

日常进行 Go 开发时,我们一般面对的都是有效状态 (已初始化,尚未关闭) 下的 channel 实例,但 channel 还有另外两种 “特殊状态”:

  • 零值 channel (nil channel)
  • 已关闭的 channel (closed channel)

Go 新手面对这两种 “特殊状态” 下的 channel 极易掉入 “坑” 中,为了避免掉坑,建议牢记这两种状态下的 channel 行为特征,如下表:

操作 closed channel nil channel
从 channel 接收 (xx := <-c) channel 中元素类型的零值 阻塞
向 channel 发送 (c <- xx) panic 阻塞

通过下面的示例我们可以更直观看到两种 “特殊状态” channel 的行为特征:

// sources/go-trap/channel_1.go
func main() {
	var nilChan chan int
	nilChan <- 5   // 阻塞
	n := <-nilChan // 阻塞
	fmt.Println(n)

	var closedChan = make(chan int)
	close(closedChan)
	m := <-closedChan
	fmt.Println(m)  // int类型的零值:0
	closedChan <- 5 // panic: send on closed channel
} 

更多关于 channel 的例子,可再仔细阅读一下第 34 条 “了解 channel 的妙用”。

遇 “坑” 指数:★★★☆☆ “坑” 害指数:★★★☆☆

9) 方法 (method) 相关的 “坑”

  • 使用值类型 receiver 的方法无法改变类型实例的状态

Go 语言的方法很独特,除了参数和返回值,它还拥有一个代表着类型实例的 receiver。receiver 有两类:值类型 receiver 和指针类型 receiver。而采用值类型 receiver 的方法无法改变类型实例的状态,我们来看下面示例:

// sources/go-trap/mehtod_1.go
type foo struct {
	name string
	age  int
}

func (f foo) setNameByValueReceiver(name string) {
	f.name = name
}

func (p *foo) setNameByPointerReceiver(name string) {
	p.name = name
}

func main() {
	f := foo{
		name: "tony",
		age:  20,
	}
	fmt.Println(f) // {tony 20}

	f.setNameByValueReceiver("alex")
	fmt.Println(f) // {tony 20}
	f.setNameByPointerReceiver("alex")
	fmt.Println(f) // {alex 20}
} 

之所以采用值类型 receiver 的方法无法改变类型实例的状态,那是因为方法本质上就是一个以 receiver 作为第一个参数的函数,我们知道通过传值方式 (by value) 传递的参数,即便在函数内部被改变,其改变也不会影响到外部的实参。更多关于方法本质的内容可再仔细阅读第 23 条 “理解方法本质以正确选择 receiver 类型” 一节。

遇 “坑” 指数:★★☆☆☆ “坑” 害指数:★★☆☆☆

值类型实例可以调用采用指针类型 receiver 的方法,指针类型实例亦可以调用采用值类型 receiver 的方法

Go 语言在方法调用时引入了 “语法糖” 以支持值类型实例调用采用指针类型 receiver 的方法,同时支持指针类型实例调用采用值类型 receiver 的方法。Go 在后台会做对应的转换:

// sources/go-trap/mehtod_2.go
type foo struct{}

func (foo) methodWithValueReceiver() {
	println("methodWithValueReceiver invoke ok")
}

func (*foo) methodWithPointerReceiver() {
	println("methodWithPointerReceiver invoke ok")
}

func main() {
	f := foo{}
	pf := &f

	f.methodWithPointerReceiver() // 值类型实例调用采用指针类型receiver的方法 ok
	pf.methodWithValueReceiver()  // 指针类型实例调用采用值类型receiver的方法 ok
} 

在上面示例中,Go 编译器会将 f.methodWithPointerReceiver() 自动转换为 (&f).methodWithPointerReceiver(),同理也会将 pf.methodWithValueReceiver 自动转换为 (*pf).methodWithValueReceiver

不过这个 “语法糖” 的影响范围也就局限在类型实例调用方法这个范畴。当我们将类型实例赋值给某接口类型变量时,只有真正实现了该接口类型的实例类型才能赋值成功:

// sources/go-trap/mehtod_2.go
type fooer interface {
	methodWithPointerReceiver()
}

func main() {
	f := foo{}
	pf := &f

	// var i fooer = f  // 错误:f并未实现methodWithPointerReceiver
	var i fooer = pf // ok
	i.methodWithPointerReceiver()
} 

foo 值类型并未实现 fooer 接口的 methodWithPointerReceiver 方法,因此无法被赋值给 fooer 类型变量。关于类型方法集合与接口实现的内容,可再仔细阅读第 24 条 “方法集合决定接口实现” 一节。

遇 “坑” 指数:★★★★☆ “坑” 害指数:★☆☆☆☆

10) break 语句相关的 “坑”

一般 break 语句都是用来跳出某个 for 循环的,但在 Go 中,如果 for 循环与 switch 或 select 联合使用时,我们就很可能掉入 break 的 “坑” 中,见下面示例:

// sources/go-trap/break_1.go
func breakWithForSwitch(b bool) {
	for {
		time.Sleep(1 * time.Second)
		fmt.Println("enter for-switch loop!")
		switch b {
		case true:
			break
		case false:
			fmt.Println("go on for-switch loop!")
		}
	}
	fmt.Println("exit breakWithForSwitch")
}

func breakWithForSelect(c <-chan int) {
	for {
		time.Sleep(1 * time.Second)
		fmt.Println("enter for-select loop!")
		select {
		case <-c:
			break
		default:
			fmt.Println("go on for-select loop!")
		}
	}
	fmt.Println("exit breakWithForSelect")
}

func main() {
	go func() {
		breakWithForSwitch(true)
	}()

	c := make(chan int, 1)
	c <- 11
	breakWithForSelect(c)
} 

运行该示例:

$go run break_1.go
enter for-select loop!
enter for-switch loop!
enter for-switch loop!
enter for-select loop!
go on for-select loop!
enter for-switch loop!
... ... 

我们看到无论是 switch 内的 break,还是 select 内的 break,都没有跳出各自最外层的 for 循环,而仅仅是跳出了 switch 或 select 代码块,但这就是 Go 语言 break 语句的原生语义不接标签的 break 语句会跳出最内层的 switch、select 或 for 代码块

如果要跳出最外层的循环,我们需要为该循环定义一个标签 (label),并让 break 跳到这个标签处,改造后的代码如下 (仅以 for switch 为例):

// sources/go-trap/break_2.go
func breakWithForSwitch(b bool) {
outerloop:
	for {
		time.Sleep(1 * time.Second)
		fmt.Println("enter for-switch loop!")
		switch b {
		case true:
			break outerloop
		case false:
			fmt.Println("go on for-switch loop!")
		}
	}
	fmt.Println("exit breakWithForSwitch")
} 

运行 break_2.go 这个改造后的例子,我们能看到输出与我们的预期一致:

$go run break_2.go
enter for-switch loop!
exit breakWithForSwitch
enter for for-select loop!
exit breakWithForSelect 

遇 “坑” 指数:★★★★☆ “坑” 害指数:★★★☆☆

2. 标准库类

Go 标准库的完成度很高,整体稳定,更关键的是标准库的核心 API 得到了 Go1 兼容性承诺的保证,这让 Go 标准库颇受广大 Gopher 的喜爱。但这不代表标准库就没有 “坑”,这里我们就挑选了标准库中的几个最常用的包,一起来看看使用这些包时究竟会遇到哪些 “坑”。

注:标准库也在演化,在这里 (Go 1.14) 别视为 “坑” 的用法或行为,在 Go 后续版本中可能会有改善。

time 包相关的 “坑”

Go 标准库中的 time 包提供了时间、日期、定时器等日常开发中最常用的时间相关的工具,但很多 Gopher 在初次使用 time 包时都会遇到下面这个示例中的问题:

// sources/go-trap/time_1.go

func main() {
        fmt.Println(time.Now().Format("%Y-%m-%d %H:%M:%S"))
} 

运行该示例,我们将看到如下输出:

$go run time_1.go 
%Y-%m-%d %H:%M:%S 

大多数出身于 C 家族语言的 Gopher 都会惊讶于上述示例的输出,因为采用 “字符化” 的占位符 (如:%Y%m等) 拼接出时间的目标输出格式布局(如上面例子中的:%Y-%m-%d %H:%M:%S)几乎是时间格式化输出的标准方案。但如果你在 Go 中这么使用,那你就掉入 “坑” 里了。在第 53 条 “掌握 time 包使用的正确姿势” 中我们曾详细地介绍了 Go 语言采用的 “参考时间 (reference time)” 方案。使用 “参考时间” 构造出来的 “时间格式串” 与最终输出串是 “一模一样” 的,这就省去了程序员再次在大脑中对格式串进行解析的过程:

// sources/go-trap/time_1.go

func main() {
	fmt.Println(time.Now().Format("2006年01月02日 15时04分05秒")) // 输出:2020年06月18日 12时27分32秒
} 

遇 “坑” 指数:★★★★★ “坑” 害指数:★★★☆☆

encoding/json 包相关的 “坑”

Go 语言在 Web 服务开发以及 API 领域获得开发者的广泛青睐,这也使得 encoding/json 包成为 Go 标准库中被使用最为频繁的包之一,也正因为如此,json 包中的很多 “坑” 也被暴露了出来,我们逐一来看一下。

未导出的结构体字段不会被编码到 json 文本中

使用 json 包将结构体类型编码为 json 文本十分简单,我们通过为结构体字段添加标签 (tag) 的方式来指示其在 json 文本中的名字:

// sources/go-trap/json_1.go
type person struct {
	Name   string `json:"name"`
	Age    int    `json:"age"`
	Gender string `json:"gender"`
	id     string `json:"id"`
}

func main() {
	p := person{
		Name:   "tony",
		Age:    20,
		Gender: "male",
		id:     "xxx-xxx-xxx-xxx",
	}

	b, err := json.Marshal(p)
	if err != nil {
		fmt.Println("json marshal error:", err)
		return
	}

	fmt.Printf("%s\n", string(b))
} 

上面示例输出结果如下:

$go run json_1.go
{"name":"tony","age":20,"gender":"male"} 

我们看到在最终输出的 json 文本中并没有字段 id 的身影!这是因为 json 包默认仅对结构体中的导出字段 (字段名头母大写) 进行编码,非导出字段并不会被编码。解码时亦是如此:

// sources/go-trap/json_1.go

s := `{"name":"tony","age":20,"gender":"male", "id":"xxx-xxx-xxx-xxx"}`
var p1 person
err = json.Unmarshal([]byte(s), &p1)
if err != nil {
	fmt.Println("json unmarshal error:", err)
	return
}
fmt.Printf("%#v\n", p1) //main.person{Name:"tony", Age:20, Gender:"male", id:""} 

不光 json,在 Go 标准库的 encoding 目录下的各类编解码包,比如 xml、gob 等也都遵循相同的规则。

遇 “坑” 指数:★★★★☆ “坑” 害指数:★★☆☆☆

注意:nil 切片和空切片被编码为不同文本

日常开发过程中,我们很容易混淆 nil 切片和空切片。因此,在具体了解这个 “坑” 之前,我们需要明确什么是 nil 切片和空切片。nil 切片 (nil slice) 就是指尚未初始化的切片,Go 运行时尚未为其分配存储空间;而空切片 (empty slice) 则是已经初始化了的切片,Go 运行时也为其分配了存储空间,但该切片的长度为 0 (len (empty slice) = 0):

// sources/go-trap/json_2.go

var nilSlice []int
var emptySlice = make([]int, 0, 5)

println(nilSlice == nil)   // true
println(emptySlice == nil) // false

println(nilSlice, len(nilSlice), cap(nilSlice)) // [0/0]0x0 0 0
println(emptySlice, len(emptySlice), cap(emptySlice)) // [0/5]0xc00001e150 0 5 

json 包在编码时会区别对待这两种切片:

// sources/go-trap/json_2.go

m := map[string][]int{
        "nilSlice":   nilSlice,
        "emptySlice": emptySlice,
}

b, _ := json.Marshal(m)
println(string(b)) 

输出上面 json 编码后的文本为:

{"emptySlice":[],"nilSlice":null} 

我们看到空切片被编码为 [],而 nil 切片则被编码为 null

遇 “坑” 指数:★★★★☆ “坑” 害指数:★★☆☆☆

注意:字节切片被编码为 base64 编码的字符串

一般情况下,字符串与字节切片的区别在于前者存储的合法的 unicode 字符的 utf8 编码,而字节切片中可以存储任意字节序列。因此,json 包在编码时会区别对待这两种类型数据:

func main() {
	m := map[string]interface{}{
		"byteSlice": []byte("hello, go"),
		"string":    "hello, go",
	}

	b, _ := json.Marshal(m)
	fmt.Println(string(b)) // {"byteSlice":"aGVsbG8sIGdv","string":"hello, go"}
} 

我们看到字节切片被编码为一个 base64 编码的文本,我们可以用下面命令将其还原:

$echo "aGVsbG8sIGdv" | base64 -D
hello, go 

笔者觉得这个 “坑” 也有其合理性,毕竟字节切片可以存储任意字节序列,可能会包含控制字符、字符 \0 以及不合法 Unicode 字符等无法显示或导致乱码的内容。如果确认你的字节切片中存储的仅是合法 Unicode 字符的 utf8 编码字节,又不想将其编码为 base64 输出,那么可以先将其转换为 string 类型后再用 json 进行编码处理。

遇 “坑” 指数:★★★☆☆ “坑” 害指数:★★★☆☆

当 json 文本中的整型数值被解码为 interface {} 类型时,其底层真实类型为 float64

对于 json 文本中的整型值,多数 gopher 会认为应该被解码到一个整型字段或变量中,就像下面这样:

// sources/go-trap/json_4.go
type foo struct {
	Name string
	Age  int
}

func fixedJsonUnmarshal() {
	s := `{"age": 23, "name": "tony"}`
	var f foo
	_ = json.Unmarshal([]byte(s), &f)
	fmt.Printf("%#v\n", f) // main.foo{Name:"tony", Age:23}
} 

但很多时候 json 文本中的字段不确定,我们常用 map[string]interface{} 来存储 json 解码后的数据,这样 json 字段值就会被存储在一个 interface {} 变量中,我们通过类型断言来获取其中存储的整型值,改造后的例子如下:

// sources/go-trap/json_4.go

func flexibleJsonUnmarshal() {
	s := `{"age": 23, "name": "tony"}`
	m := map[string]interface{}{}
	_ = json.Unmarshal([]byte(s), &m)
	age := m["age"].(int) // panic: interface conversion: interface {} is float64, not int
	fmt.Println("age =", age)
} 

我们看到运行这个示例后出现了 panic!panic 内容很清楚:interface {} 的底层类型是 float64,而不是 int

怎么填这个小 “坑” 呢?json 提供了 Number 类型来存储 json 文本中的各类数值类型,并可以转换为整型 (int64)、浮点型 (float64) 以及字符串,结合 json.Decoder,我们来修正一下上面示例的问题:

// sources/go-trap/json_4.go
func flexibleJsonUnmarshalImproved() {
        s := `{"age": 23, "name": "tony"}`
        m := map[string]interface{}{}

        d := json.NewDecoder(strings.NewReader(s))
        d.UseNumber()

        _ = d.Decode(&m)
        age, _ := m["age"].(json.Number).Int64()
        fmt.Println("age =", age) // age = 23
} 

这次我们成功将 json 文本中的整型数值解码到一个整型类型中了!

遇 “坑” 指数:★★★☆☆ “坑” 害指数:★★★☆☆

net/http 包相关的 “坑”

如果说标准库是 Go “自带电池” 设计哲学的体现之一,那么 net/http 包可以看作是这块 “电池” 的 “聚能环”,让标准库可以持续展现魅力。http 包也是整个标准库使用频度最高的包 (可能没有之一),正确使用 http 包,规避 http 包的一些小 “坑” 是保证程序正确性和健壮性的前提。

别忘记关闭 Response.Body

通过 http 包我们可以很容易实现一个 http 客户端,比如:

// sources/go-trap/http_1.go

func main() {
	resp, err := http.Get("https://tip.golang.org")
	if err != nil {
		fmt.Println(err)
		return
	}

	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(string(body))
} 

我们看到这个示例通过 http.Get 获取某个网站的页面内容,然后读取应答 Body 字段中的数据并输出到命令行控制台上。但仅仅这么做还不行,因为 http 包需要你配合完成一项任务:务必关闭 resp.Body

// sources/go-trap/http_1.go
resp, err := http.Get("https://tip.golang.org")
if err != nil {
  fmt.Println(err)
  return
}
defer resp.Body.Close() 

目前 http 包的实现逻辑是只有当应答的 Body 中的内容被全部读取完毕且调用了 Body.Close(),默认的 http 客户端才会重用带有 keep-alive 标志的 http 连接,否则每次 http 客户端发起一次请求都会单独向服务端建立一条新的 TCP 连接,这样做的消耗要比重用连接大得多。

注:仅在做为客户端时,我们才需要手动关闭 Response.Body;如果作为服务端,http 包会自动处理 Request.Body。

遇 “坑” 指数:★★★★★ “坑” 害指数:★★★☆☆

及时关闭 http 连接

如果一个 http 客户端与一个 http 服务端之间要持续通信,那么向服务端建立一条带有 keep-alive 标志的 http 长连接并重用该连接收发数据是十分必要的,也是最有效率的。但是如果我们的业务逻辑是向不同服务端快速建立连接并在完成一次数据收发后就放弃该连接,那么我们需要及时关闭 http 连接以及时释放该 http 连接占用的资源。

但 Go 标准库 http 客户端的默认实现并不会及时关闭已经用完的 http 连接 (仅当服务端主动关闭或要求关闭时才会关闭),这样一旦连接建立过多又得不到及时释放,就很可能会出现端口资源或文件描述符资源耗尽的异常。

及时释放 http 连接的方法有两种,第一种是将 http.Request 中的字段 Close 设置为 true:

// sources/go-trap/http_2.go

var sites = []string{
	"https://tip.golang.org",
	"https://www.oracle.com/java",
	"https://python.org",
}

func main() {
	var wg sync.WaitGroup
	wg.Add(len(sites))

	for _, site := range sites {
		site := site
		go func() {
			defer wg.Done()
			req, err := http.NewRequest("GET", site, nil)
			if err != nil {
				fmt.Println(err)
				return
			}
			req.Close = true 

			resp, err := http.DefaultClient.Do(req)
			if err != nil {
				fmt.Println(err)
				return
			}
			defer resp.Body.Close()

			body, err := ioutil.ReadAll(resp.Body)
			if err != nil {
				fmt.Println(err)
				return
			}

			fmt.Printf("get response from %s, resp length = %d\n", site, len(body))
		}()
	}
	wg.Wait()
} 

该示例并没有直接使用 http.Get 函数,而是自行构造了 http.Request,并将其 Close 字段设置为 true,然后通过 http 包的 DefaultClient 将请求发送出去,当收到并读取完应答后,http 包就会及时关闭该连接。下面是示例的运行结果:

$go run http_2.go
get response from https://www.oracle.com/java, resp length = 65458
get response from https://python.org, resp length = 49111
get response from https://tip.golang.org, resp length = 10599 

第二种方法则是通过创建一个 http.Client 新实例来实现的 (不使用 DefaultClient):

// sources/go-trap/http_3.go

func main() {
	var wg sync.WaitGroup
	wg.Add(len(sites))

	tr := &http.Transport{
		DisableKeepAlives: true,
	}
	cli := &http.Client{
		Transport: tr,
	}

	for _, site := range sites {
		site := site
		go func() {
			defer wg.Done()

			resp, err := cli.Get(site)
			if err != nil {
				fmt.Println(err)
				return
			}
			defer resp.Body.Close()

			body, err := ioutil.ReadAll(resp.Body)
			if err != nil {
				fmt.Println(err)
				return
			}

			fmt.Printf("get response from %s, resp length = %d\n", site, len(body))
		}()
	}
	wg.Wait()
} 

我们看到在该方案中,新创建的 Client 实例的字段 Transport 的 DisableKeepAlives 属性值为 true,即设置了与服务端不保持长连接,这样使用该 Client 实例与服务端收发数据后会及时关闭两者之间的 http 连接。

遇 “坑” 指数:★★★☆☆ “坑” 害指数:★★★★☆

3. 小结

Go 语言是云计算时代的 C 语言,它同样像一把雕刻刀,锋利无比,在熟练的 Gopher 技师手里它非常强大。但 Go 语言也会伤到那些对它理解不够还不能掌握它的人。因此,熟知牢记上述列出的 Go 的常见 “陷阱” 将帮助这些人免受伤害或尽可能将伤害降到最低,直到他们成为可以熟练掌控 Go 语言的技师。