第 0 章 介绍
当前译本仍不稳定,如翻译有问题请及时联系 jacob953@csu.edu.cn 。
译者序
决定翻译《Darker Corners of Go》那日,与 Rytis 先生结束初步交流时,已经凌晨 5 点了,想来更多是一种冲动。自从开始学习 Go 以来,越来越感到 Go 语言核心理论的简单美。 在学习 Go 之初,我也硬着头皮去仔细阅读过《Effective Go》。但对于一个初学者来说,很难领会“圣经”中所表达的核心理念。
于是,我试着去阅读《Darker Corners of Go》,精确、易懂、简短,以类型的方式对陷阱进行分类。尽管它不是很典型的入门类型书籍,但它依然让我直观地感受到了 Go 语言的特性,还帮助我更快地锁定查阅的范围。 所以,我打算干脆将它用更加地道的方式翻译出来,直接分享给大家。一方面,可以锻炼自己的英文水平,面向更加开阔的技术世界;另一方面,可以对 Go 有更加深入的了解,还可以为 Go 的中文资源做贡献。
这篇文章的篇幅很短,所以翻译得比较轻松,以不至于萌生放弃的念头。翻译工作仍在继续,我会逐步发布新的章节,在此面向中文社区的 Gopher 征求意见,希望给读者带来更好的阅读体验。 这本书是根据《Darker Corners of Go》的电子版翻译过来的,与网上的电子版有一定的差异,有些内容是电子版没有的,或许后续会更新上去。
实际上,在多年前,Kyle Quest 发布过一篇 “50 Shades of Go: Traps, Gotchas, and Common Mistakes for New Golang Devs”,当然,这篇文章也有中文译本。 那篇文章以难度高低的方式对陷阱进行分类,而《Go 鲜为人知的角落》以类型的方式对陷阱进行分类。对于一个经验不足的 Gopher 来说,我认为后者或许更易被接受和学习。
由于本人也是 Gopher 新手,翻译过程中难免会有一些错误和问题,还希望各位高手批评指正。如果您觉得翻译或内容中有任何错误和问题,请您及时反馈给我,以便及时地进行修正:jacob953@csu.edu.cn
最后,我要感谢 Rytis Bieliunas 先生,是他给予了我极大的信任,让我以独译的角色翻译这本书,并时刻关注译本的进度,同时也要感段桂华女士,在翻译筹备过程中,是她在背后给予了我很多支持。
我希望本书能对那些想了解、学习和研究 Go 的人们有所帮助!
作者序
这是什么?
在我刚开始学习 Go 的时候,读过一些相关的介绍书籍和语言规范,并且在此之前,我已经熟悉过其他几种编程语言。 尽管如此,在学习了这些书籍之后,我认为我对 Go 的了解真的不够多,很难进行实际的工作。 我觉得我对 Go 的工作方式了解得还不够深入,可能需要掉进很多坑里,才能对使用 Go 有所把握。
事实证明,我的判断是对的。
虽然 简单
、诗意
、简洁
是 Go 语言的核心理论,但当你深入使用 Go 时,你会发现,它的许多创造性方法会使你掉进坑里。
目前为止,在使用 Go 制作应用程序的这几年时间中,我踩过数不胜数的坑,于是,我打算将这些经验总结到一起,为 Go 的新手们编写一份指南。
我的目标是将 Go 中的各种可能让新开发者意料之外的知识点都收集到一起,这也许还能对一些不寻常的功能有所启发。 希望这样可以为读者节省大量的查询和调试时间,并尽可能避免一些代价昂贵的漏洞。
在阅读这本书之前,我认为你应该至少已经知道 Go 的语法,才能确保这本书对你是有用的。如果你已经有一定 Go 的编程经验,或已经熟悉其他编程语言,并且希望学习 Go,那就最好不过了。
如果你发现书籍中有撰写错误的地方,或者我总结的例子中没有包括令你最意外的 Go 的用法,请联系我:rytbiel@gmail.com。
非常感谢
Vytautas Shaltenis
和
Jon Forrest
,在他们的帮助下,这本书变得更加完整。
第 1 章 代码格式化
当前译本仍不稳定,如翻译有问题请及时联系 jacob953@csu.edu.cn 。
gofmt
Go 大部分代码的格式化都是由 gofmt 工具强制执行的。gofmt 可以对源文件进行自动修改,例如,对导入的声明进行排序和对代码应用缩进等。 这是自切片面包以来最好的东西,因为它让开发人员不必再去争论这些无关紧要的事情。例如,使用制表符进行缩进,使用空格进行对齐,对代码风格的争论便到此为止。
你完全可以不使用 gofmt 工具,但如果你真的使用它,你就不能把它配置成一种特定的格式化风格。该工具完全没有提供任何代码格式化的选项,而这正是重点所在—— 提供一种统一的、“足够好”的格式化风格。这可能没有人喜欢的风格,但 Go 的开发者最终决定 统一胜于完美。
统一风格和自动格式化代码有很多好处,包括但不限于:
- 在代码审查中,你不需要花时间在格式化问题上;
- 你不需要再与强迫症的同事去争论,开头的大括号应该放在哪里,或者应该用什么来做缩进。你的所有热情和精力都可以更有意义的地方。
- 代码更容易写:你不需要担心代码的格式化细节。
- 代码更容易读:你不需要在去适应不熟悉的代码风格。
大多数流行的 IDE 都有 Go 的插件,会在保存源文件时自动运行 gofmt。
如果必要,你可以使用诸如 goformat 此类第三方工具自定义 Go 的代码风格。
长行代码
gofmt 不会尝试分解长行代码,但你可以利用有诸如 golines 等第三方工具可以做到这一点。(一行代码不建议超过 200 个字母)
正括号
在 Go 中,正括号必须放在行尾。有趣的是,这并不是由gofmt强制执行的,而是Go词法分析器实现方式的副作用。 不管有没有 gofmt,正括号都不能放在新的一行上:
package main
// 缺少函数体
func main()
// 语法错误:不期望分号或换行在 { 前。
{
}
// 没毛病!
func main() {
}
多行声明中的逗号
在 Go 中,初始化 Slice, Array, Map 或结构体时,要在新的一行之前使用逗号。许多语言都允许使用尾部逗号,一些代码风格指南也鼓励这样使用。 但在 Go 中,它们是强制性的。这样,就可以在不修改无关代码行的情况下,重新排列行或添加新行。这也意味着在代码审查的差异中,可以减少干扰。
// 这些都是可以的
a := []int{1, 2}
b := []int{1, 2,}
c := []int{
1,
2}
d := []int{
1,
2,
}
// 语法错误,没有尾部逗号
e := []int{
1,
// 语法错误:不期望换行,期望是 , 或 }
2
}
结构体也是如此:
type s struct {
One int
Two int
}
f := s{
One: 1,
// 语法错误:不期望换行,期望是 , 或 }
Two: 2
}
第 2 章 包导入
当前译本仍不稳定,如翻译有问题请及时联系 jacob953@csu.edu.cn 。
未使用的导入包
带有未使用导入包的 Go 程序是无法进行编译的。这是该语言的特点,因为导入包会降低编译器的速度。 在大型程序中,导入未使用的包会对编译时间产生重大影响。
为了在开发过程中使编译器正常运行,你可以以下方式导入未使用的包:
package main
import (
"fmt"
"math"
)
// 参照导入未使用的包
var _ = math.Round
func main() {
fmt.Println("Hello")
}
goimports
更好的解决方案是使用 goimports 工具,它可以删除未引用的导入包。更棒的是,它试图自动找到并添加缺失的包:
package main
import "math"
// 导入而未使用:"math"
func main() {
fmt.Println("Hello") // 未定义:fmt
}
运行 goimports:
./goimports main.go
package main
import "fmt"
func main() {
fmt.Println("Hello")
}
大多数流行的 IDE 的 Go 插件在保存源文件时,会自动运行 goimports。
下划线导入
以下划线的方式导入包只是为了包的副作用。以这种方式导入包,程序会创建包级变量并运行包的
init 函数
:
package package1
func package1Function() int {
fmt.Println("Package 1 side-effect")
return 1
}
var globalVariable = package1Function()
func init() {
fmt.Println("Package 1 init side effect")
}
在 package2 中:
package package2
import _ package1
这将打印出信息并初始化 globalVariable:
Package 1 side-effect
Package 1 init side effect
多次导入一个包(e.g. 在 main 包及其引用的其他包中),只运行一次 init 函数。
下划线导入在 Go 运行时库中使用。例如,导入 net/http/pprof 会调用它的 init 函数,以暴露可以提供调试信息的 HTTP 端点:
import _ "net/http/pprof"
点导入
点导入允许在没有限定词的情况下,访问导入的包中的标识符:
package main
import (
"fmt"
. "math"
)
func main() {
fmt.Println(Sin(3)) // 引用 math.Sin
}
关于点导入是否应该从语言中删除,有一个公开的辩论。Go 团队不建议在除了在测试包之外的地方使用它们。 这使得程序更难阅读,因为不清楚像 Quux 这样的名字是在当前包中还是在导入的包中的顶级标识符。
另外,如果你在使用go-lint工具,那么,在测试文件之外使用点导入时,它会显示一个警告,而且你无法轻易关闭它。
Go 团队推荐的一种使用情况是在测试中,由于循环依赖,不能成为被测包的一部分。依赖关系。
// foo_test 包测试 foo 包
package foo_test
import (
"bar/testutil" // 也导入了 "foo"
. "foo"
)
这个测试文件不是 foo 包的一部分,因为它引用了 bar/testutil,而 bar/testutil 又引用了 foo。这将产生一个循环的依赖关系。
在这种情况下,首先要考虑的是,也许有一个更好的方法来结构这些包,以避免循环依赖性。 将 bar/testutil 使用的包从 foo 移到 foo 和 bar/testutil 都可以导入的第三个包中,可能有意义,也可能没有意义,这样就能在 foo 包中正常地编写测试。
如果重构没有意义,并且测试以点导入的方式被引用到独立包中,foo_test 包至少可以假装是 foo 包的一部分。但要注意的是,它不能访问 foo 包中未导出的类型和函数。
可以说,在特定领域的语言中,点导入有一个很好的用例。 例如,Goa 框架将其用于配置。如果没有点导入,它看起来就不是很好:
package design
import . "goa.design/goa/v3/dsl"
// API 描述了 API 服务器的全局属性。
var _ = API("calc", func() {
Title("Calculator Service")
Description("HTTP service for adding numbers, a goa teaser")
Server("calc", func() {
Host("localhost", func() { URI("http://localhost:8088") })
})
})
第 3 章 变量
当前译本仍不稳定,如翻译有问题请及时联系 jacob953@csu.edu.cn 。
未使用的变量
含有未使用变量的 Go 程序无法编译:
存在未使用的变量,表明可能存在一个错误[…],Go 拒绝编译带有未使用的变量或包导入,以短期的便利保证长期的构建速度和程序的清晰度。
该规则的例外是全局变量和函数参数:
package main
var unusedGlobal int // 合法的
func f1(unusedArg int) { // 未使用的函数声明也是可以的
// 错误: 定义但未使用
a, b := 1,2
// 这里使用了 b ,但 a 只是分配给了,不算是 "使用"
a = b
}
短变量声明
短变量声明只在函数中起作用:
package main
v1 := 1 // 错误: 在函数体外不能有声明语句
var v2 = 2 // 合法的
func main() {
v3 := 3 // 合法的
fmt.Println(v3)
}
在设置结构体字段值时,它们也不起作用:
package main
type myStruct struct {
Field int
}
func main() {
var s myStruct
// 错误: 非名称的 s.Field在 := 的左侧。
s.Field, newVar := 1, 2
var newVar int
s.Field, newVar = 1, 2 // 这实际上是合法的
}
变量遮盖
令人遗憾的是,Go 中允许使用变量遮盖。这是你需要经常注意的事情,因为它可能导致难以发现的问题。 发生这种情况往往是图方便,在至少有一个变量是新的情况下,Go 允许使用短变量声明:
package main
import "fmt"
func main() {
v1 := 1
// 在这里 v1 实际上没有被重新声明,只是被设置了一个新的值
v1, v2 := 2, 3
fmt.Println(v1, v2) // 打印 2, 3
}
然而,如果该声明是在另一个代码块内,它将声明一个新的变量,有可能导致严重的错误:
package main
import "fmt"
func main() {
v1 := 1
if v1 == 1 {
v1, v2 := 2, 3
fmt.Println(v1, v2) // 打印 2, 3
}
fmt.Println(v1) // 打印 1 !
}
对于一个更现实的例子,我们假设你有一个返回错误的函数:
package main
import (
"errors"
"fmt"
)
func func1() error {
return nil
}
func errFunc1() (int, error) {
return 1, errors.New("important error")
}
func returnsErr() error {
err := func1()
if err == nil {
v1, err := errFunc1()
if err != nil {
fmt.Println(v1, err) // 打印: 1 important error
}
}
return err // 返回 nil!
}
func main() {
fmt.Println(returnsErr()) // 打印 nil
}
解决这个问题的办法很多,其中之一是不要在嵌套的代码块中使用短变量声明:
func returnsErr() error {
err := func1()
var v1 int
if err == nil {
v1, err = errFunc1()
if err != nil {
fmt.Println(v1, err) // 打印: 1 important error
}
}
return err // 返回 "important error"
}
或者,相对于上面的例子,更好的做法是提前退出:
func returnsErr() error {
err := func1()
if err != nil {
return err
}
v1, err := errFunc1()
if err != nil {
fmt.Println(v1, err) // 打印: 1 important error
return err
}
return nil
}
也有一些工具可以帮助避免这个问题。在 go vet 工具中曾有实验性的变量遮盖检测,但它被删除了。 你可以输入如下命令来安装和运行该工具:
go get -u golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow
go vet -vettool=$(which shadow)
打印:
.\main.go:20:7: declaration of "err" shadows declaration at line 17
第 4 章 运算符
当前译本仍不稳定,如翻译有问题请及时联系 jacob953@csu.edu.cn 。
运算符优先级
Go的运算符优先级与其他语言不同:
优先级 运算符
5 *, /, %, <<, >>, &, &^
4 +, -, |, ^
3 ==, !=, <, <=, >, >=
2 &&
1 ||
与 C 语言进行比较:
优先级 运算符
10 *, /, %
9 +, -
8 <<, >>
7 <, <=, >, >=
6 ==, !=
5 &
4 ^
3 |
2 &&
1 ||
对于同一表达式,可能会产生不同结果:
In Go: 1 << 1 + 1 // (1<<1)+1 = 3
In C: 1 << 1 + 1 // 1<<(1+1) = 4
自增与自减
Go 与许多其他语言不同,没有前缀自增或自减运算符:
var i int
++i // 语法错误: 不期望 ++, 期望 }
--i // 语法错误: 不期望 --, 期望 }
虽然 Go 有这些运算符的后缀版本,但不允许在表达式中使用:
slice := []int{1,2,3}
i := 1
slice[i++] = 0 // 语法错误: 不期望 ++, 期望:
三目运算符
Go 不支持三目运算符:
result := a ? b : c
在 Go 中这是不存在的,放弃吧。你必须使用 if-else 来代替三目运算符。 Go 语言的设计者认为这个运算符往往会导致难看的代码,最好不要有它。
按位非
在 Go 中,XOR 运算符 ^ 被用作一元 NOT 运算符,而不是像许多其他语言使用 ~ 符号。
In Go: ^1 // -2
In C: ~1 // -2
二元 XOR 运算符仍被用作 XOR 运算符(异或)使用。
3^1 // 2
第 5 章 常量
当前译本仍不稳定,如翻译有问题请及时联系 jacob953@csu.edu.cn 。
iota
在 Go 中,iota 是起始常量编号。 它并不像人们想象的那样意味着 “从零开始”。它是当前 const 块中一个常数的索引:
const (
myconst = "c"
myconst2 = "c2"
two = iota // 2
)
使用两次 iota 并不能重置编号:
const (
zero = iota // 0
one // 1
two = iota // 2
)
第 6 章 切片 & 数组
当前译本仍不稳定,如翻译有问题请及时联系 jacob953@csu.edu.cn 。
切片和数组
在 Go 中,切片和数组有类似的目的。它们的声明方式也几乎相同:
package main
import "fmt"
func main() {
slice := []int{1, 2, 3}
array := [3]int{1, 2, 3}
// 让编译器来计算数组的长度
// 这将是一个等同于 [3]int
array2 := [...]int{1, 2, 3}
fmt.Println(slice, array, array2)
}
输出:
[1 2 3] [1 2 3] [1 2 3]
切片就像上层附带有用功能的数组。在实现过程中,他们在内部使用指向数组的指针。 但是,切片是如此的方便,以至于我们很少在 Go 中直接使用数组。
数组
数组是固定长度内存的同类型序列。不同长度的数组被认为是不同的不兼容类型。 与 C 语言不同,Go 在创建数组时,数组元素被初始化为零值,因此,不需要明确地这样做。 同样,与 C 语言不同,Go 的数组是一种值类型。它不是一个指向内存块中第一个元素的指针。 如果把一个数组传入一个函数,整个数组将被复制。当然,仍然可以传递一个指向数组的指针来避免它被复制。
切片
切片是数组段的描述符。它是一个非常有用的数据结构,但可能有点不寻常。 有几种方法可以让你在使用它的时候踩坑,但如果你知道切片的内部工作原理,就可以避免踩这些坑。 下面是 Go 源代码中关于切片的实际定义:
type slice struct {
array unsafe.Pointer
len int
cap int
}
切片的定义十分有趣。切片本身是一个值类型,但是它用一个指针引用它所使用的数组。 与数组不同的是,如果你把一个切片传递给一个函数,你会得到一个数组指针、长度和容量属性的拷贝(上图中的第一个块),但数组本身的数据不会被复制。 两个切片的副本都会指向同一个数组。当你“切割”一个分片时,也会发生同样的事情。进行切割时,会创建一个新的切片,它仍然指向同一个数组:
package main
import "fmt"
func f1(s []int) {
// 进行切割时,会产生一个新的切片
// 但不复制数组数据
s = s[2:4]
// 修改子切片
// 也改变了主函数中切片的数组
for i := range s {
s[i] += 10
}
fmt.Println("f1", s, len(s), cap(s))
}
func main() {
s := []int{1, 2, 3, 4, 5}
// 将一个切片作为参数传递
// 复制切片的属性(指针、长度和容量)
// 但该副本共享相同的数组
f1(s)
fmt.Println("main", s, len(s), cap(s))
}
输出:
f1 [13 14] 2 3
main [1 2 13 14 5] 5 5
如果你不知道切片是什么,你可能会认为它是一个值类型,并对 f1 “破坏”了主函数中切片的数据而感到惊讶。
获得一个带有数据的切片副本
为了得到一个带有数据的切片副本,你需要做一些工作。你可以手动复制元素到一个新的切片,或者使用复制或追加函数:
package main
import "fmt"
func f1(s []int) {
s = s[2:4]
s2 := make([]int, len(s))
copy(s2, s)
// 或者如果你喜欢一个更简洁,但效率较低的版本:
// s2 := append([]int{}, s[2:4]...)
for i := range s2 {
s2[i] += 10
}
fmt.Println("f1", s2, len(s2), cap(s2))
}
func main() {
s := []int{1, 2, 3, 4, 5}
f1(s)
fmt.Println("main", s, len(s), cap(s))
}
输出:
f1 [13 14] 2 3
main [1 2 3 4 5] 5 5
用 append 扩容切片
切片的所有副本都共享同一个数组,因此,如果对切片的捣乱,会对指针、长度和容量产生影响。除非他们不共享。 切片最有用的特性是它可以管理数组的扩容。当它需要扩容到超过现有数组的容量时,需要分配一个全新的数组。 如果你希望两份切片副本共享数组数据,这也可能是一个坑:
package main
import "fmt"
func main() {
// 做一个长度为 3、容量为 4 的切片
s := make([]int, 3, 4)
// 初始化为 1,2,3
s[0] = 1
s[1] = 2
s[2] = 3
// 数组的容量是 4
// 在初始数组中增加一个适合的数字
s2 := append(s, 4)
// 修改数组中的元素
// s 和 s2 仍然共享同一个数组
for i := range s2 {
s2[i] += 10
}
fmt.Println(s, len(s), cap(s)) // [11 12 13] 3 4
fmt.Println(s2, len(s2), cap(s2)) // [11 12 13 14] 4 4
// 这种扩容会使数组的容量增加,超过它的容量
// 必须为 s3 分配新的数组
s3 := append(s2, 5)
// 修改数组中的元素以查看结果
for i := range s3 {
s3[i] += 10
}
fmt.Println(s, len(s), cap(s)) // 依然是旧的数组 [11 12 13] 3 4
fmt.Println(s2, len(s2), cap(s2)) // 旧数组 [11 12 13 14] 4 4
// 数组在最后一次扩容时被复制 [21 22 23 24 15] 5 8
fmt.Println(s3, len(s3), cap(s3))
}
nil 切片
不需要检查切片是否为 nil,也不必将其初始化。因为,诸如 len、cap 和 append 等函数在一个 nil 切片上是可以正常工作:
package main
import "fmt"
func main() {
var s []int // nil 数组
fmt.Println(s, len(s), cap(s)) // [] 0 0
s = append(s, 1)
fmt.Println(s, len(s), cap(s)) // [1] 1 1
}
空切片与 nil 切片不是一回事:
package main
import "fmt"
func main() {
var s []int // 这是一个 nil 切片
s2 := []int{} // 这是一个空切片
// 在这里看起来是一回事:
fmt.Println(s, len(s), cap(s)) // [] 0 0
fmt.Println(s2, len(s2), cap(s2)) // [] 0 0
// 但 s2 实际上被分配到了某个地方
fmt.Printf("%p %p", s, s2) // 0x0 0x65ca90
}
如果你非常关心性能、内存使用等问题,初始化空分片可能不如使用 nil 切片来得理想。
make 的陷阱
你可以使用 make 创建一个新切片,参数是切片的初始化类型、长度和容量。其中,容量参数是可选的:
func make([]T, len, cap) []T
这样做似乎有点太容易了:
package main
import (
"fmt"
)
func main() {
s := make([]int, 3)
s = append(s, 1)
s = append(s, 2)
s = append(s, 3)
fmt.Println(s)
}
输出:
[0 0 0 1 2 3]
“不,这绝不会发生在我身上。我知道,对切片的第二个论据是长度,而不是容量…”我仿佛听到你这样说。
未使用的切片数组数据
因为切割数组时,会创建一个新切片,但它们共享底层数组,所以有可能在内存中保留更多的数据,而这可能正是你想要或期望的。这里有一个愚蠢的例子:
package main
import (
"bytes"
"fmt"
"io/ioutil"
"os"
)
func getExecutableFormat() []byte {
// 将我们自己的可执行文件读入内存
bytes, err := ioutil.ReadFile(os.Args[0])
if err != nil {
panic(err)
}
return bytes[:4]
}
func main() {
format := getExecutableFormat()
if bytes.HasPrefix(format, []byte("ELF")) {
fmt.Println("linux executable")
} else if bytes.HasPrefix(format, []byte("MZ")) {
fmt.Println("windows executable")
}
}
在上面的代码中,只要那个格式变量在范围内,且没被垃圾回收,那么整个可执行文件(可能是几兆字节的数据)就会被保留在内存中。 为了解决这个问题,应该复制实际需要的字节。
多维切片
在 Go 中,目前还没有这样的东西。也许有一天会有,但目前为止, 你要么需要通过自己计算元素索引来手动将单维切片用作多维切片, 要么使用 “锯齿状 “切片(锯齿状切片是切片的切片):
package main
import "fmt"
func main() {
x := 2
y := 3
s := make([][]int, y)
for i := range s {
s[i] = make([]int, x)
}
fmt.Println(s)
}
输出:
[[0 0] [0 0] [0 0]]
第 7 章 字符串 & 字节数组
当前译本仍不稳定,如翻译有问题请及时联系 jacob953@csu.edu.cn 。
Go 的字符串
在 Go 中,字符串的定义是这样的:
type StringHeader struct {
Data uintptr
Len int
}
字符串本身是值类型,包含一个指向字节数组的指针和一个固定的长度。与 C 语言不同,Go 字符串中的零字节并不标志着字符串的结束。 字符串里面可以包含任何数据。通常情况下,这些数据被编码为 UTF-8 字符串,但它不一定是这样。
字符串不能为 nil
在 Go 中,字符串永远不会为 nil。字符串的默认值是一个空字符串,而不是 nil:
package main
import "fmt"
func main() {
var s string
fmt.Println(s == "") // 为真
s = nil // 错误: 不能在赋值中使用 nil 作为字符串类型
}
字符串是不可变的(某种程度上)
Go 并不希望你修改字符串:
package main
func main() {
str := "darkercorners"
str[0] = 'D' // 错误: 不能分配给 str[0]
}
不可变数据更易于推理,因此产生的问题更少。但缺点是,每次你想从一个字符串中添加或删除某些内容时,都必须分配一个全新的字符串。 如果你真的希望做些更改,可以通过 unsafe 包来修改字符串,但如果你真打算采用这种方式,可能就聪明过头了。
最常见情况是,当许多字符串需要加在一起时,你可能要担心分配的问题。 有一个 strings.Builder 类型用于解决这个问题,它在添加字符串时是批量分配内存,而不是每次都分配内存。
package main
import (
"strconv"
"strings"
"testing"
)
func BenchmarkString(b *testing.B) {
var str string
for i := 0; i < b.N; i++ {
str += strconv.Itoa(i)
}
}
func BenchmarkStringBuilder(b *testing.B) {
var str strings.Builder
for i := 0; i < b.N; i++ {
str.WriteString(strconv.Itoa(i))
}
}
输出:
BenchmarkString-8 401053 147346 ns/op 1108686 B/op 2 allocs/op
BenchmarkStringBuilder-8 29307392 44.9 ns/op 52 B/op 0 allocs/op
在这个例子中,使用 strings.Builder 比简单的添加字符串(每次分配新的内存)要快3000倍。
在某些情况下,Go 编译器会优化掉这些分配:
- 当把一个字符串和一个字节片相比较时:str == string(byteSlice)
- 当 []byte 被用于查找 map[string] 中的条目时:m[string(byteSlice)]
- 在字符串被转换为字节的 range 子句中:for i, v := range []byte(str) {…}
Go 编译器的新版本可能会增加更多的优化,所以如果性能很重要,最好使用基准测试和分析器。
字符串 vs 字节切片
修改字符串的一种方法是首先将其转换为字节切片,然后再转换回字符串。 如下面的例子所示,将一个字符串转换为字节切片,然后再复制整个字符串和字节片。 原字符串并没有改变:
package main
import (
"fmt"
)
func main() {
str := "darkercorners"
bytes := []byte(str)
bytes[0] = 'D'
str2 := string(bytes)
bytes[6] = 'C'
// 打印: darkercorners Darkercorners DarkerCorners
fmt.Println(str, str2, string(bytes))
}
使用 unsafe 包,有可能(但显然是不安全的)直接修改字符串而不分配内存。
导入 unsafe 包带来的结果可能是不可移植的,并且不受 Go 1 兼容性准则的保护。
package main
import (
"fmt"
"unsafe"
)
func main() {
buf := []byte("darkercorners")
buf[0] = 'D'
// 分配一个字符串,指向与 buf 字节切片相同的数据
str := *(*string)(unsafe.Pointer(&buf))
// 修改字节切片
// 现在它指向的是与字符串相同的内存。
// 这里也对 str 进行了修改
buf[6] = 'C'
// DarkerCorners DarkerCorners
fmt.Println(str, string(buf))
}
UTF-8 的那些事儿
Unicode 和 UTF-8 是个棘手的问题。要了解 Unicode 和 UTF-8 的一般工作原理,你可能想阅读 Joel Spolsky 的博客
《每个软件开发人员绝对必须知道的 Unicode 和字符集(没有借口!)》
。
做一个简短的回顾:
- Unicode 是 “一种用于不同语言和文字的国际编码标准,每个字母、数字或符号都被分配了一个独特的数值,适用于不同的平台和程序”。本质上,它是一个“码点”的大表。它包含了所有语言的大部分(但不是全部)字符。该表中,每个码位是一个索引,有时你可以看到用 U+ 符号指定,如 U+0041 表示字母 A。
- 通常,码位是指一个字符,例如汉字⻯(U+2EEF),但它也可以是一个几何形状或一个字符修饰符(例如德语 ä、ö 和 ü 等字母的音符)。出于某种原因,它甚至可以是一个便便图标(U+1F4A9)。
- UTF-8 是将 Unicode 大表中的元素编码成计算机可以处理的实际字节的方法之一(也是最常见的一种)。
- 当用 UTF-8 编码时,单个的 Unicode 代码点可能需要 1 到 4 个字节。
- 数字和拉丁字母(a-z,A-Z,0-9)的编码为 1 个字节。许多其他语言的字母在 UTF-8 编码中需要 1 个以上的字节。
- 如果你不知道第 5 条,一旦有人用其他语言使用你的 Go 程序,你的程序可能会崩溃。当然,除非你仔细阅读了本章的其他内容。
Go 中的字符串编码
Go 中的字符串是一个字节数组。任何字节,字符串本身不在意如何编码,也不必采用 UTF-8 编码。尽管有些库函数甚至是一种语言特性(for-range 循环,下文将介绍)假设它采用 UTF-8 编码。
认为 Go 字符串都是 UTF-8 的情况并不少见。但字符串的字面量给这种混乱带来了很大的影响。虽然字符串本身没有任何特定的编码,但 Go 编译器总是将源代码解释为 UTF-8。
当字符串的字面量被定义后,编辑器会把它同其他的代码一样,保存为 UTF-8 编码的 Unicode 字符串。这就是 Go 解析后会被编译到程序中的内容。无论是编译器还是 Go 的字符串处理代码,都与字符串最终被编码为 UTF-8 无关,这只是文本编辑器将字符串写入磁盘的方式。
package main
import (
"fmt"
)
func main() {
// 一个含有 Unicode 字符的字符串字面量
s := "English 한국어"
// 打印出预期的 Unicode 字符串: English 한국어
fmt.Println(s)
}
为了证明这一点,你可以这样定义一个非UTF-8的字符串:
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "\xe2\x28\xa1"
fmt.Println(utf8.ValidString(s)) // false
fmt.Println(s) // �(�
}
rune 类型
在Go中,Unicode 码点用 “rune” 类型表示,它是一个 32 位的整数。
字符串长度
对字符串调用 len 函数,会返回字符串中的字节数,而不是字符数。
获取字符数可能是相当复杂的。计算字符串中的 rune 可能足够好,也可能不够好:
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "한국어" // 3 个韩文字符,用 9 个字节编码
byteLen := len(s)
runeLen := utf8.RuneCountInString(s)
runeLen2 := len([]rune(s)) // 做同 RuneCountInString 一样的事情
fmt.Println(byteLen, runeLen, runeLen2) // 打印 9 3 3
}
不幸的是,有些 Unicode 字符跨越了多个码点,因此也有多个 rune。
正如
Unicode Standard
中所解释的那样,需要做一些可怕的事情,才能计算出 Unicode 字符串中人类所感知的字符数。
Go 库并没有真正提供一个简单的方法来做到这一点。这里提供了一种解决方法:
package main
import (
"fmt"
"unicode/utf8"
"golang.org/x/text/unicode/norm"
)
func normlen(s string) int {
var ia norm.Iter
ia.InitString(norm.NFKD, s)
nc := 0
for !ia.Done() {
nc = nc + 1
ia.Next()
}
return nc
}
func main() {
str := "é́́" // 一个特别奇怪的字符串
fmt.Printf(
"%d bytes, %d runes, %d actual character",
len(str),
utf8.RuneCountInString(str),
normlen(str))
}
输出:
7 bytes, 4 runes, 1 actual character
字符串索引操作符 vs for-range
简而言之,对于字符串索引操作符,返回该字符串的字节数组中索引的字节。 对于 for-range,在一个字符串中对 rune 进行迭代,将字符串解释为 UTF-8 编码的文本:
package main
import (
"fmt"
)
func main() {
s := "touché"
// 打印每个字节
// touché
for i := 0; i < len(s); i++ {
fmt.Print(string(s[i]))
}
fmt.Println()
// 打印每个 rune
// touché
for _, r := range s {
fmt.Print(string(r))
}
fmt.Println()
// 将一个字符串转换为 rune 切片,以便通过索引访问
// touché
r := []rune(s)
for i := 0; i < len(r); i++ {
fmt.Print(string(r[i]))
}
}
第 8 章 哈希
当前译本仍不稳定,如翻译有问题请及时联系 jacob953@csu.edu.cn 。
哈希的迭代顺序是随机的(实则不然)
技术上来说,哈希的迭代顺序是“未定义的”。在 Go 中,哈希的内部会使用一个哈希表,所以迭代通常是按照元素在该表中的顺序进行的。 但这个顺序是不可靠的,当新的元素被添加到哈希中时,这个顺序会随着哈希表的增长而改变。 在 Go 的早期时候,这对那些没有阅读说明,并且依赖按一定顺序迭代哈希的程序员来说,是一个巨坑。 为了帮助程序员尽早,而不是在生产中发现这些问题,Go 的开发者将哈希的迭代变得随机:
package main
import "fmt"
func main() {
// 添加顺序元素
// 使哈希看起来像是按顺序迭代的
m := map[int]int{0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5}
for i := 0; i < 5; i++ {
for i := range m {
fmt.Print(i, " ")
}
fmt.Println()
}
// 添加更多元素
// 使哈希的哈希表增长并重新排序元素
m[6] = 6
m[7] = 7
m[8] = 8
for i := 0; i < 5; i++ {
for i := range m {
fmt.Print(i, " ")
}
fmt.Println()
}
}
输出:
3 4 5 0 1 2
5 0 1 2 3 4
0 1 2 3 4 5
1 2 3 4 5 0
0 1 2 3 4 5
0 1 3 6 7 2 4 5 8
1 3 6 7 0 4 5 8 2
2 4 5 8 0 1 3 6 7
0 1 3 6 7 2 4 5 8
0 1 3 6 7 2 4 5 8
在上面的例子中,当哈希被初始化时,元素 1 到 5 被按顺序添加到哈希表中。
前五行打印的数字都是按顺序写的 0 到 5。在 Go 中,这只是随机从某个元素开始迭代。
向哈希中添加更多的元素会使哈希表增长,从而重新排列整个哈希表的顺序。
打印最后 5 行时,就不再有任何明显的顺序。如果必要,你可以在
Go 的 Map 源代码
中找到所有的信息。
检查哈希的键是否存在
访问哈希中不存在的元素时,会返回哈希值类型的零值。如果是一个整型的哈希,它将返回 0,对于引用类型,它将返回 nil。 为了检查元素是否存在于哈希中,有时一个零值就足够了。 例如,如果是一个值类型为指向结构体的指针的哈希,那么在访问哈希时,如果得到一个 nil 值,这意味着你寻找的元素不在哈希中。 但是,如果是一个值类型为布尔值的哈希,因为默认零值为“false”,所以它不足以判断元素的值为“false”,还是元素根本不存在于哈希中。 因此,访问哈希元素时,会返回一个可选的第二参数,以显示该元素是否真的在哈希中:
package main
import "fmt"
func main() {
m := map[int]bool{1: false, 2: true, 3: true}
// 打印为 false 时
// 不清楚该元素的值是 false,或者哈希中不存在此元素
// 因为返回的默认零值为 false
fmt.Println(m[1])
val, exists := m[1]
fmt.Println(val, exists) // 打印 false true
}
哈希是指针
虽然切片类型是一个结构体(值类型),它有一个指向数组的指针,但哈希本身就是一个指针。 切片的零值完全是可用的。你可以使用 append 函数来添加元素,也可以获得切片的长度。 而哈希则不同,尽管 Go 的开发者希望让哈希的零值完全可用,但没找到有效的途径来实现这一点。 在 Go 中,map 关键字是 *runtime.hmap 类型的别名。它的零值是 nil,nil 哈希可以被读取,但不能被写入:
package main
import "fmt"
func main() {
var m map[int]int // 一个 nil 哈希
// 读取 nil 哈希的长度是可以的,打印 0
fmt.Println(len(m))
// 读取 nil 哈希也是确定的,打印 0(哈希的值类型的默认值)
fmt.Println(m[10])
m[10] = 1 // 警告: 分配内存给 nil 哈希中的元素
}
nil 哈希可以被读取,因为哈希的元素是通过像这样的函数(来自 runtime/map.go)来访问的:
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer
这个函数检查哈希是否为 nil,如果是,则返回哈希值类型的零值。注意,它不能创建一个哈希。如果要创建完全可用的哈希,必须使用 make:
package main
import "fmt"
func main() {
m := make(map[int]int)
m[10] = 11 // 现在就都没问题了
fmt.Println(m[10]) // 打印 11
}
由于哈希是指针,把它传递给函数时,就会传递指向同一个 map 数据结构的指针:
package main
import "fmt"
func f1(m map[int]int) {
m[5] = 123
}
func main() {
m := make(map[int]int)
f1(m)
fmt.Println(m[5]) // 打印 123
}
当哈希的指针被传递给函数时,指针的值会被复制(Go 通过值传递一切,包括指针)。在函数中创建一个新的哈希会改变指针副本的值,所以这是不可行的:
package main
import "fmt"
func f1(m map[int]int) {
m = make(map[int]int)
m[5] = 123
}
func main() {
var m map[int]int
f1(m)
fmt.Println(m[5]) // 打印 0
fmt.Println(m == nil) // true
}
struct{} 类型
在 Go 中,没有集合这个数据结构(类似于有键但无值的哈希,如 C++ 的 std::set 或 C# 的 HashSet)。 使用哈希来代替是很容易的。一个小技巧是使用 struct{} 类型作为哈希值类型:
package main
import (
"fmt"
)
func main() {
m := make(map[int]struct{})
m[123] = struct{}{}
_, keyexists := m[123]
fmt.Println(keyexists) // true
}
通常,这里会使用布尔值,但如果使用 struct{} 值类型的哈希会节省一点内存。struct{} 类型实际上就是零字节大小:
package main
import (
"fmt"
"unsafe"
)
func main() {
fmt.Println(unsafe.Sizeof(false)) // 1
fmt.Println(unsafe.Sizeof(struct{}{})) // 0
}
哈希的容量
哈希是一个相当复杂的数据结构。虽然在创建它时,可以指定它的初始容量,但之后就不可能得到它的容量了(至少不能用 cap 函数):
package main
import (
"fmt"
)
func main() {
m := make(map[int]bool, 5) // 初始化容量为 5
fmt.Println(len(m)) // len 函数是可以的
fmt.Println(cap(m)) // 对于 cap 函数来说,m (map[int]bool 类型) 是非法参数
}
哈希值是不可寻址的
在 Go 中,哈希是以哈希表的形式实现的,而哈希表需要在哈希增长或缩小时移动其元素。由于这个原因,Go 不允许获取哈希元素的地址:
package main
import "fmt"
type item struct {
value string
}
func main() {
m := map[int]item{1: {"one"}}
fmt.Println(m[1].value) // 读取结构值是可以的
addr := &m[1] // 错误: 无法读取 m[1] 的地址
// 错误: 在哈希中,不能赋值给结构体字段 m[1].value
m[1].value = "two"
}
有一个
允许赋值给一个结构体字段的提议
(m[1].value = “two”),因为在这种情况下,只通过赋值,值字段的指针不会被保留。
但由于 “subtle corner cases”,目前还没有具体计划关于此提议何时或是否会实施。
有一种变通方法,但整个结构体需要重新被分配到哈希中:
package main
type item struct {
value string
}
func main() {
m := map[int]item{1: {"one"}}
tmp := m[1]
tmp.value = "two"
m[1] = tmp
}
另外,指向结构体的指针映射也可以成功。在这种情况下,m[1] 的值是一个 *item 类型。 Go 不需要获取指向映射值的指针,因为该值本身已经是指针了。 哈希表会在内存中移动指针,但是如果你复制一个 m[1] 的值,它将一直指向同一个元素,所以这也是一样的:
package main
import "fmt"
type item struct {
value string
}
func main() {
m := map[int]*item{1: {"one"}}
// Go 在这里不需要访问 m[1] 的地址。
// 因为它已经是指针
m[1].value = "two"
fmt.Println(m[1].value) // two
addr := &m[1] // 同样的错误: 不能访问 m[1] 的地址
}
值得注意的是,切片和数组不存在这个问题:
package main
import "fmt"
func main() {
slice := []string{"one"}
saddr := &slice[0]
*saddr = "two"
fmt.Println(slice) // [two]
}
数据竞争
在 Go 中,普通哈希对于并发访问并不安全。哈希经常被用来在 goroutine 之间共享数据, 但对哈希的访问必须通过 sync.Mutex、sync.RWMutex,其他内存锁进行同步,或者与 Go 的通道协调以防止并发访问,但以下情况除外:
只有当更新发生时,哈希访问才是不安全的。只要所有的 goroutine 只是在阅读查找哈希中的元素,包括使用 for-range 循环遍历哈希, 而不是通过向元素赋值或进行删除来改变哈希,那么它们在不同步的情况下并发访问哈希就是安全的。
package main
import (
"math/rand"
"time"
)
func readWrite(m map[int]int) {
// 对哈希做一些随机的读和写
for i := 0; i < 100; i++ {
k := rand.Int()
m[k] = m[k] + 1
}
}
func main() {
m := make(map[int]int)
// 启动 goroutine 来同时读写哈希
for i := 0; i < 10; i++ {
go readWrite(m)
}
time.Sleep(time.Second)
}
输出:
致命错误: 同时进行的哈希读取和写入
致命错误: 同时进行的哈希写入
...
在这种情况下,可以使用 Mutex 同步访问哈希。下面的代码将按预期工作:
package main
import (
"math/rand"
"sync"
"time"
)
var mu sync.Mutex
func readWrite(m map[int]int) {
mu.Lock()
// defer unlock mutex 将解锁 mutex
// 即使这个 goroutine 会警告
defer mu.Unlock()
for i := 0; i < 100; i++ {
k := rand.Int()
m[k] = m[k] + 1
}
}
func main() {
m := make(map[int]int)
for i := 0; i < 10; i++ {
go readWrite(m)
}
time.Sleep(time.Second)
}
sync.Map
在 sync 包中,哈希有一个专门版本,对于多个 goroutine 的并发使用是安全的。 然而 Go 文档建议在大多数情况下使用带有锁或者协调的普通哈希。 因为 sync.Map 不是类型安全的,它类似于 map[interface{}]interface{}。如 sync.Map 文档所说:
哈希类型针对两种常见的使用情况进行了优化: (1)当一个给定键的条目只被写入一次,但被多次读取,就像在只会增长的缓存中, 或者(2)当多个 goroutine 读取、写入和覆盖不相干的键集的条目时。 在这两种情况下,与 Go 中的普通哈希搭配单独的 Mutex 或 RWMutex 相比,使用 sync.map 可以大大减少锁争用。
第 9 章 循环
当前译本仍不稳定,如翻译有问题请及时联系 jacob953@csu.edu.cn 。
range 迭代器会返回两个值
由于 for-range 的工作方式与其他语言不尽相同,对于 Go 的初学者来说,这很可能是一个坑。 for-range 会返回一个或两个变量,第一个是迭代索引(如果是迭代哈希,则是哈希键),第二个是值。如果只使用一个变量——那它就是索引:
package main
import "fmt"
func main() {
slice := []string{"one", "two", "three"}
for v := range slice {
fmt.Println(v) // 0, 1, 2
}
for _, v := range slice {
fmt.Println(v) // one two three
}
}
for 循环会重复使用迭代器变量
在循环中,每次迭代都会重复使用同一个迭代器变量。如果你读取它的地址,那每次都是同一个地址。 这意味着在每次迭代中,迭代器变量的值都会被复制到同一个内存位置。这使得循环更有效率,但也是 Go 最常见的陷阱之一。 下面是 Go wiki 中的一个例子:
package main
import "fmt"
func main() {
var out []*int
for i := 0; i < 3; i++ {
out = append(out, &i)
}
fmt.Println("Values:", *out[0], *out[1], *out[2])
fmt.Println("Addresses:", out[0], out[1], out[2])
}
输出:
Values: 3 3 3
Addresses: 0xc0000120e0 0xc0000120e0 0xc0000120e0
惊讶吧,不过有一个解决方案,是在循环中声明一个新的变量。在代码块中声明的变量不会被重复使用,即使在循环中也是如此:
package main
import "fmt"
func main() {
var out []*int
for i := 0; i < 3; i++ {
i := i // 将 i 复制到一个新的变量中
out = append(out, &i)
}
fmt.Println("Values:", *out[0], *out[1], *out[2])
fmt.Println("Addresses:", out[0], out[1], out[2])
}
现在,它就可以像预期的那样运行:
Values: 0 1 2
Addresses: 0xc0000120e0 0xc0000120e8 0xc0000120f0
如果是 for-range 子句,则会重复使用索引变量和值变量。
这与在一个循环中启动 goroutine 的情况类似:
package main
import (
"fmt"
"time"
)
func main() {
for i := 0; i < 3; i++ {
go func() {
fmt.Print(i)
}()
}
time.Sleep(time.Second)
}
输出:
333
这些 goroutine 是在这个循环中创建的,但它们需要一点时间来开始运行。 由于它们捕获的是单个 i 变量,因此,Println 会打印 goroutine 执行时的任何值。
在这种情况下,可以像之前的例子那样,在代码块内创建一个新的变量,或者把迭代器变量作为参数传递给 goroutine:
package main
import (
"fmt"
"time"
)
func main() {
for i := 0; i < 3; i++ {
go func(i int) {
fmt.Print(i)
}(i)
}
time.Sleep(time.Second)
}
输出:
012
这里,goroutine 的参数 i 是一个新变量,它作为创建 goroutine 时的一部分,从迭代器变量中复制过来的。
如果不启动 goroutine,而是在循环中调用一个简单的函数,代码就会像预期的那样工作:
for i := 0; i < 3; i++ {
func() {
fmt.Print(i)
}()
}
输出:
012
变量 i 像以前一样被重复使用。然而,直到函数执行完毕,并不是每个函数调用都会让循环继续。在那段时间里,变量 i 会有预期值。
这就变得有点棘手了。请看这个例子,在结构体上调用方法:
package main
import (
"fmt"
"time"
)
type myStruct struct {
v int
}
func (s *myStruct) myMethod() {
// 打印 myStruct 的值和它的地址
fmt.Printf("%v, %p\n", s.v, s)
}
func main() {
byValue := []myStruct{{1}, {2}, {3}}
byReference := []*myStruct{{1}, {2}, {3}}
fmt.Println("By value")
for _, i := range byValue {
go i.myMethod()
}
time.Sleep(time.Millisecond * 100)
fmt.Println("By reference")
for _, i := range byReference {
go i.myMethod()
}
time.Sleep(time.Millisecond * 100)
}
输出:
By value
3, 0xc000012120
3, 0xc000012120
3, 0xc000012120
By reference
1, 0xc0000120e0
3, 0xc0000120f0
2, 0xc0000120e8
再次惊讶吧!当 myStruct 采用引用类型时,它的运行起来就像一开始就没有陷阱一样! 这与 goroutine 的创建方式有关,在 goroutine 被创建时,goroutine 的参数会被评估。 方法接收器(myMethod 的 myStruct)实际上是一个参数。
当通过值类型调用时:由于 myMethod 的参数 s 是一个指针,i 的地址被作为参数传给 goroutine。 正如我们所知,迭代器变量是重复使用的,所以每次都是同一个地址。 当迭代器运行时,它将复制一个新的 myStruct 值到 i 变量的同一地址。打印的值是在 goroutine 执行时 i 变量的值。
当通过引用类型调用时:参数已经是一个指针,所以在创建 goroutine 时,它的值被推到新 goroutine 的堆栈中。这恰好是我们想要的地址,这样预期值就被打印出来了。
带标签的 break 和 continue
在 Go 中,也许还有一些不太为人所知的特点,比如能够给 for、switch 和 select 语句打上标签,并在这些标签上使用 break 和 continue。 下面是如何跳出外循环:
loopi:
for x := 0; x < 3; x++ {
for y := 0; y < 3; y++ {
fmt.Printf(x, y)
break loopi
}
}
输出:
0 0
continue 也可以以类似的方式使用:
loopi:
for x := 0; x < 3; x++ {
for y := 0; y < 3; y++ {
fmt.Printf(x, y)
continue loopi
}
}
输出:
0 0
1 0
2 0
标签也可以与 switch 和 select 语句一起使用。在这里,没有标签的 break 只会跳出 select 语句,进入 for 循环:
package main
import (
"fmt"
"time"
)
func main() {
loop:
for {
select {
case <-time.After(time.Second):
fmt.Println("timeout reached")
break loop
}
}
fmt.Println("the end")
}
输出:
timeout reached
the end
如前所述,switch 和 select 语句也可以被标记,所以我们可以把上面的例子转过来:
package main
import (
"fmt"
"time"
)
func main() {
myswitch:
switch {
case true:
for {
fmt.Println("switch")
break myswitch // 在这种情况下,就不必执行 "continue" 了
}
}
fmt.Println("the end")
}
在前面的例子中,我们很容易将 “label 语句” 与 goto 所使用的标签混淆。实际上,你可以为 break/continue 和 goto 使用同一个标签,但行为会有所不同。 在下面的代码中,break 会跳出一个有标签的循环,而 goto 会将执行转移到标签的位置(并在下面代码中,导致无限循环):
package main
import (
"fmt"
"time"
)
func main() {
loop:
switch {
case true:
for {
fmt.Println("switch")
break loop // 跳出 “label 语句”
}
}
fmt.Println("not the end")
goto loop // 跳入带有 “loop” 标签的语句
}
输出:
switch
not the end
switch
not the end
...
第 10 章 switch & select 语句
当前译本仍不稳定,如翻译有问题请及时联系 jacob953@csu.edu.cn 。
case 语句默认为断句
与 C 语言的 case 语句不同,Go 中的 case 语句默认为中断。要使 case 语句通过,请使用 fallthrough 关键字:
package main
import (
"fmt"
"time"
)
func main() {
// 这样无法运行,如果是 Saturday 情况,那什么都不会被打印
switch time.Now().Weekday() {
case 6: // 这种情况下,不会做任何事情,并跳出 switch
case 7:
fmt.Println("weekend")
}
switch time.Now().Weekday() {
case 1:
break // 这个 break 没有任何作用,因为无论如何都会跳出
case 2:
fmt.Println("weekend")
}
// fallthrough 关键字将使 Saturday 同样也打印 weekend
switch time.Now().Weekday() {
case 6:
fallthrough
case 7:
fmt.Println("weekend")
}
// case 也可以有多个值
switch time.Now().Weekday() {
case 6, 7:
fmt.Println("weekend")
}
// 条件性中断仍然是可用的
switch time.Now().Weekday() {
case 6, 7:
day := time.Now().Format("01-02")
if day == "12-25" || day == "12-26" {
fmt.Println("Christmas weekend")
break // 不会打印 "weekend"
}
// 一个正常的 weekend
fmt.Println("weekend")
}
}
带标签的断点
正如之前在循环的章节中提到的,switch 和 select 也可以利用带标记的 break 来跳出外循环,而不是 switch 或 select 语句本身:
package main
import (
"fmt"
"strings"
)
func main() {
s := "The quick brown Waldo fox jumps over the lazy dog"
findWaldoLoop:
for _, w := range strings.Split(s, " ") {
switch w {
case "Waldo":
fmt.Println("found Waldo!")
break findWaldoLoop
default:
fmt.Println(w, "is not Waldo")
}
}
}
输出:
The is not Waldo
quick is not Waldo
brown is not Waldo
found Waldo!
第 11 章 函数
当前译本仍不稳定,如翻译有问题请及时联系 jacob953@csu.edu.cn 。
defer 语句
defer 似乎没有大坑,但有一些细微差别是值得一提的。
关于这个问题,有一篇来自 Andrew Gerrand 的
好文章
:
defer 语句会将其包涵的递延函数推到一个调用列表上, 保存的调用列表将在原函数返回后被执行。 因此,defer 通常被用来简化执行各种清理动作的函数。
但是,有几点是需要特别注意的:
- 虽然递延函数在原函数返回时才会被执行,但其参数也会在调用 defer 时被使用:
package main
import (
"fmt"
)
func main() {
s := "defer"
defer fmt.Println(s)
s = "original"
fmt.Println(s)
}
输出:
original
defer
- 一旦原函数返回,递延函数就按后进先出的顺序执行:
package main
import (
"fmt"
)
func main() {
defer fmt.Println("one")
defer fmt.Println("two")
defer fmt.Println("three")
}
输出:
three
two
one
- 递延函数可以访问和修改函数中已命名的参数:
package main
import (
"fmt"
"time"
)
func timeNow() (t string) {
defer func() {
t = "Current time is: " + t
}()
return time.Now().Format(time.Stamp)
}
func main() {
fmt.Println(timeNow())
}
输出:
Current time is: Feb 13 13:36:44
- defer 对代码块不起作用,只对整个函数起作用
与变量声明不同,defer 语句不属于代码块的范围:
package main
import (
"fmt"
)
func main() {
for i := 0; i < 9; i++ {
if i%3 == 0 {
defer func(i int) {
fmt.Println("defer", i)
}(i)
}
}
fmt.Println("exiting main")
}
输出:
exiting main
defer 6
defer 3
defer 0
在这个例子中,当 i 为 0、3 和 6 时,递延函数将被添加到调用列表中。但它只有在主函数退出时才会被调用,而不是在 if 语句结束时。
- recover() 函数只在递延函数中起作用,在原函数中不会做任何事情
在 Go 中,如果你想找一个与 try-catch 语句相当的语句,可以肯定这是没有的。想要捕获 panic 的内容,需要在递延函数中使用 recover():
package main
import (
"fmt"
)
func panickyFunc() {
panic("panic!")
}
func main() {
defer func() {
r := recover()
if r != nil {
fmt.Println("recovered", r)
}
}()
panickyFunc()
fmt.Println("this will never be printed")
}
输出:
recovered panic!
第 12 章 协程
当前译本仍不稳定,如翻译有问题请及时联系 jacob953@csu.edu.cn 。
什么是 goroutine
通常情况下,goroutine 可以被认为是轻量级线程。goroutine 的启动非常快速,因为仅需要使用 2kb 内存就可以初始化堆栈(当然,也可以改动)。 goroutine 由 Go 的运行时系统管理(而不是操作系统),因此,在上下文切换的代价很低。 goroutine 就是为并发而生的,即使在多个硬件的线程上运行,它们也可以并行。
并发是指同时处理很多事情;并行是指同时做很多事情。
Rob Pike
goroutine 的效率是非常之高的,如果与 channel 相结合,它们很可能是 Go 的最佳功能。 尽管 goroutine 在 Go 中是无处不在的,但也存在一个极端但很好的例子。 当一个服务器管理大量并发的 websocket 连接时,goroutine 需要分别被单独管理,但更多可能是处于闲置状态的(不占用很多 CPU 或内存)。 为每个连接都创建一个线程,一旦连接数变得数以千计就会出现问题,然而,在使用 goroutine 时,产生数十万的连接也是可能的。
关于 goroutine 是如何工作的,可以
在这里
找到更详细的帖子。
运行 goroutine 并不能阻止程序退出
在 Go 中,当主函数退出时,程序也就退出了。此时,任何在后台运行的 goroutine 都会安静地停止。 下面的程序将退出,且不打印任何东西:
package main
import (
"fmt"
"time"
)
func goroutine1() {
time.Sleep(time.Second)
fmt.Println("goroutine1")
}
func goroutine2() {
time.Sleep(time.Second)
fmt.Println("goroutine2")
}
func main() {
go goroutine1()
go goroutine2()
}
为了确保这些 goroutine 可以完成,需要添加一些同步,例如使用通道或 sync.WaitGroup:
package main
import (
"fmt"
"sync"
"time"
)
func goroutine1(wg *sync.WaitGroup) {
time.Sleep(time.Second)
fmt.Println("goroutine1")
wg.Done()
}
func goroutine2(wg *sync.WaitGroup) {
time.Sleep(time.Second)
fmt.Println("goroutine2")
wg.Done()
}
func main() {
wg := &sync.WaitGroup{}
wg.Add(2)
go goroutine1(wg)
go goroutine2(wg)
wg.Wait()
}
输出:(该结果可能会颠倒)
goroutine2
goroutine1
goroutine 报警会使整个程序崩溃
在 goroutine 内部发生的报警时,必须用 defer 和 recover() 来处理。否则,整个应用程序将崩溃:
package main
import (
"fmt"
"time"
)
func goroutine1() {
panic("something went wrong")
}
func main() {
go goroutine1()
time.Sleep(time.Second)
fmt.Println("will never get here")
}
panic: something went wrong
goroutine 18 [running]:
main.goroutine1()
c:/projects/test/main.go:9 +0x45
created by main.main
c:/projects/test/main.go:13 +0x45
第 13 章 接口
当前译本仍不稳定,如翻译有问题请及时联系 jacob953@csu.edu.cn 。
检查接口变量是否为 nil
在 Go 中,接口当然是最常见的坑之一。但与其他语言不同,Go 的接口不只是一个指向内存位置的指针。
一个接口类型的结构有:
- 静态类型(接口本身的类型)
- 动态类型
- 值
如果一个变量为接口类型,当它的动态类型和值都为 nil 时,那它就等于 nil。
package main
import (
"fmt"
)
type ISayHi interface {
Say()
}
type SayHi struct{}
func (s *SayHi) Say() {
fmt.Println("Hi!")
}
func main() {
// 这里,变量 "sayer" 只具有静态类型的 ISayHi。
// 动态类型和值都是 nil
var sayer ISayHi
// 果然,Sayer 等于 nil
fmt.Println(sayer == nil) // true
// 一个具体类型,但值为 nil 的变量
var sayerImplementation *SayHi
// 接口变量的动态类型现在是 SayHi
// 接口指向的实际值仍然为 nil
sayer = sayerImplementation
// sayer 不再等于 nil,因为它的动态类型已经被设置
// 即使它所指向的值为 nil
// 这里并不是大多数人所期望的那样
fmt.Println(sayer == nil) // false
}
接口值被设置为一个 nil 结构体时,不能用来做任何事情,那么为什么它不等于nil呢? 与其他语言相比,这便是 Go 的另一个不同之处。在 C# 中,对一个 nil 类调用方法会抛出一个异常,但在 Go 中,无论怎样,这都是允许的。 因此,当接口设置了动态类型时,即使值为 nil,有时也可以使用。所以,你可以说接口并不是真的是 “nil”:
package main
import (
"fmt"
)
type ISayHi interface {
Say()
}
type SayHi struct{}
func (s *SayHi) Say() {
// 这个函数并没有访问 s
// 即使 s 为 nil,这也会正常执行
fmt.Println("Hi!")
}
func main() {
var sayer ISayHi
var sayerImplementation *SayHi
sayer = sayerImplementation
// 在 sayer 接口中,SayHi 的值为 nil
// 在 Go 中,可以对一个 nil 结构体调用方法。
// 这行可以正常运行,因为 Say 函数并没有访问s
sayer.Say()
}
尽管这可能很奇怪,但没有简单的方法可以用来检查一个接口指向的值是否为 nil。 关于这个问题,有一个长期的讨论,但似乎并没有什么进展。所以在近期,你可以做这些事情:
弊端最少的选择 #1:永远不要给接口分配值为 nil 的具体类型
如果你从不给接口变量分配值为 nil 的具体类型(除非是那些被设计为与 nil 接收器一起工作的类型),那么简单的 “==nil” 判断将总是有效。 例如,永远不要这样做:
func MyFunc() ISayHi {
var result *SayHi
if time.Now().Weekday() == time.Sunday {
result = &SayHi{}
}
// 如果不是 Sunday,则返回一个不等于 nil 的接口
// 但其具体类型的值为 nil
// (MyFunc() == nil 将是 false)
return result
}
而应该返回一个实际的 nil:
func MyBetterFunc() ISayHi {
if time.Now().Weekday() != time.Sunday {
// 如果不是 Sunday
// MyBetterFunc() == nil 将是 true
return nil
}
return &SayHi{}
}
即使这并不理想,但它可能是现有的最好的解决方案,因为那时每个人都必须意识到它的存在,然后,并在代码审计等方面进行监控。 在某种程度上,这是在做计算机可以完成的工作。
特殊情况下的选择 #2:反射
如果必要,你可以通过反射来检查一个接口的底层值是否为 nil。 这可能不是一个好主意,如果你的代码总是调用这些函数,程序会变得很慢:
func IsInterfaceNil(i interface{}) bool {
if i == nil {
return false
}
rvalue := reflect.ValueOf(i)
return rvalue.Kind() == reflect.Ptr && rvalue.IsNil()
}
检查 Kind() 的值是否为指针是必要的,因为如果类型为 nil(如简单的 int),IsNil 会报警。
请不要做这个选择 #3:在你的结构接口中添加 IsNil
这样做,你可以在不使用反射的情况下检查一个接口是否为 nil:
type ISayHi interface {
Say()
IsNil() bool
}
type SayHi struct{}
func (s *SayHi) Say() {
fmt.Println("Hi!")
}
func (s *SayHi) IsNil() bool {
return s == nil
}
也许应该考虑 #1 和 #4:断言具体类型
如果你知道接口值应该是什么类型,你可以通过类型转换或类型断言,这样,先得到一个具体类型的值,再来检查它是否为nil:
func main() {
v := MyFunc()
fmt.Println(v.(*SayHi) == nil)
}
如果你真的知道自己在做什么,这也许是好的,但在很多情况下,这就违背了使用接口的初衷。 考虑一下,当 ISayHi 的新实现被添加进来时会发生什么。你是否需要记得去寻找这段代码,并为新结构体添加另一个检查?你会对每个新的实现都这样做吗? 如果这段代码是在处理一个很少发生的事件,且没有对新添加的实现进行检查,而是在代码进入生产后很久才注意到的,那该怎么办?
接口是隐性满足的
与许多其他语言不同,你不需要明确说明一个结构实现了一个接口。编译器可以自己解决这个问题。这有很大的意义,而且在实践中非常方便:
package main
import (
"fmt"
)
// 一个接口
type ISayHi interface {
Say()
}
// 这个结构体实现了 ISayHi,即使不知道存在 ISayHi
type SayHi struct{}
func (s *SayHi) Say() {
fmt.Println("Hi!")
}
func main() {
var sayer ISayHi // sayer 是一个 interface
sayer = &SayHi{} // SayHi 隐式地实现了 ISayHi
sayer.Say()
}
有时,让编译器检查一个结构是否实现了一个接口是很有用的:
// 在编译时验证 *SayHi 是否实现了 ISayHi
var _ ISayHi = (*SayHi)(nil)
对错误的类型进行类型断言
类型断言有单变量和双变量版本。当类型不是被断言的类型时,单变量版本会报警:
func main() {
var sayer ISayHi
sayer = &SayHi{}
// t 是一个类型为 *SayHi2 的零值(本例中为 nil)
// ok 将会是 false
t, ok := sayer.(*SayHi2)
if ok {
t.Say()
}
// 警告: 接口转换:
// main.ISayHi 是 *main.SayHi,而不是 *main.SayHi2
t2 := sayer.(*SayHi2)
t2.Say()
}
第 14 章 继承
当前译本仍不稳定,如翻译有问题请及时联系 jacob953@csu.edu.cn 。
重定义类型 vs 嵌入类型
Go 的类型系统可能更加实用。它不像 C++ 或 Java 那样是面向对象的。在 Go 中,你不能真正地继承结构体或接口(因为没有子类),但你可以把它们放在一起(嵌入),形成更复杂的结构体或接口。
嵌入与子类有一个重要的不同之处:当我们嵌入一个类型时,该类型的方法会成为外类型的方法;但是当它们被调用时,方法的接收者是内类型,而不是外类型。
除了嵌入类型,Go 还允许重新定义一个类型。 重定义会继承一个类型的字段,但没有继承其方法:
package main
type t1 struct {
f1 string
}
func (t *t1) t1method() {
}
// 嵌入类型
type t2 struct {
t1
}
// 重定义类型
type t3 t1
func main() {
var mt1 t1
var mt2 t2
var mt3 t3
// 字段在所有情况下都会被继承
_ = mt1.f1
_ = mt2.f1
_ = mt3.f1
// 正常运行
mt1.t1method()
mt2.t1method()
// mt3.t1method 未定义(t3 类型没有字段或者 t1method 方法)
mt3.t1method()
}
第 15 章 平等性
当前译本仍不稳定,如翻译有问题请及时联系 jacob953@csu.edu.cn 。
Go 的平等性
在 Go 中,有很多不同的方法来比较平等性,但没有一个是完美的。
== 和 != 操作符
在 Go 中,== 运算符是最简单、最有效的比较方法,但它只对某些类型有效。 最值得注意的是,它对切片或哈希不起作用。如果采用这种方式,切片和哈希只能与 nil 进行比较。
你可以使用 == 比较基本类型,如 int 和 string,还有数组和结构体中的元素本身也可以使用 == 进行比较:
package main
import "fmt"
type compareStruct1 struct {
A int
B string
C [3]int
}
func main() {
s1 := compareStruct1{}
s2 := compareStruct1{}
fmt.Println(s1 == s2) // 正常运行,打印 true
}
在结构体中,一旦添加了一个不能使用 == 比较的属性,就需要用另一种方式来比较:
package main
import "fmt"
type compareStruct2 struct {
A int
B string
C []int // 将 C 的类型从数组改为切片
}
func main() {
s1 := compareStruct2{}
s2 := compareStruct2{}
// 无效操作: s1 == s2
// (包含 []int 的结构体不能被比较)
fmt.Println(s1 == s2)
}
编写专门的比较代码
如果性能很重要,而且需要比较稍微复杂的类型,最好的选择可能是手写比较代码:
type compareStruct struct {
A int
B string
C []int
}
func (s *compareStruct) Equals(s2 *compareStruct) bool {
if s.A != s2.A || s.B != s2.B || len(s.C) != len(s2.C) {
return false
}
for i := 0; i < len(s.C); i++ {
if s.C[i] != s2.C[i] {
return false
}
}
return true
}
像上面代码中的比较函数可以自动生成,但在写这篇文章时,我还不知道有什么工具可以做到这一点。
reflect.DeepEqual
在 Go 中,DeepEqual 是最通用的比较方法,它可以处理大部分平等性比较。但这里有一个问题:
var (
c1 = compareStruct{
A: 1,
B: "hello",
C: []int{1, 2, 3},
}
c2 = compareStruct{
A: 1,
B: "hello",
C: []int{1, 2, 3},
}
)
func BenchmarkManual(b *testing.B) {
for i := 0; i < b.N; i++ {
c1.Equals(&c2)
}
}
func BenchmarkDeepEqual(b *testing.B) {
for i := 0; i < b.N; i++ {
reflect.DeepEqual(c1, c2)
}
}
输出:
BenchmarkManual-8 217182776 5.51 ns/op 0 B/op 0 allocs/op
BenchmarkDeepEqual-8 2175002 559 ns/op 144 B/op 8 allocs/op
在该结构体的比较例子中,DeepEqual 比手动编写的代码来要慢100倍。
请注意,DeepEqual 会比较结构体中未导出的(小写的)字段。 另外,两个不同的类型永远不会被认为是深度相等的,即使它们是具有相同字段和值的不同结构体。
不可比较性
有些存在是不能被比较的,甚至被认为是与自己不相等的,例如具有 NaN 值的浮点变量或 func 类型。 例如,如果你在一个结构体中拥有这样的字段,那么如果使用 DeepEqual 进行比较,该结构体将不等于其自身:
func TestF(t *testing.T) {
x := math.NaN
fmt.Println(reflect.DeepEqual(x, x)) // false
fmt.Println(reflect.DeepEqual(TestF, TestF)) // false
}
bytes.Equal
bytes.Equal 是专门为字节切片设计的一种比较方法。它比简单地用 for 循环比较两个切片要快得多。
值得注意的是,bytes.Equal 函数认为空切片和 nil 切片是相等的,而 reflect.DeepEqual 则相反。
第 16 章 内存管理
当前译本仍不稳定,如翻译有问题请及时联系 jacob953@csu.edu.cn 。
结构体应该按值传递还是按引用传递?
在 Go 中,函数的参数总是按值传递。当一个结构体(或数组)类型的变量被传递到函数中时,整个结构体会被复制。 如果结构体的指针被传递,那么这个指针会被复制,但它所指向的结构体不会被复制。拷贝的是 8 个字节内存(对于 64 位架构),而不是该结构体的大小。 那么,这是否意味着将结构体作为指针传递会更好?经典回答——这要看情况。
分配一个结构体(或数组)的指针:
- 将其放在堆中,而不是像通常情况下放到栈中;
- 垃圾收集器来管理堆内存的分配。
如果你想复习一下栈与堆的关系,可以看看这个
stackoverflow 帖子
。就本章而言,了解这些就足够了:堆栈——快,堆——慢。
这意味着如果分配结构体比传递它们更频繁,那么在栈中复制它们会更快:
package test
import (
"testing"
)
type myStruct struct {
a, b, c int64
d, e, f string
g, h, i float64
}
func byValue() myStruct {
return myStruct{
a: 1, b: 1, c: 1,
d: "foo", e: "bar", f: "baz",
g: 1.0, h: 1.0, i: 1.0,
}
}
func byReference() *myStruct {
return &myStruct{
a: 1, b: 1, c: 1,
d: "foo", e: "bar", f: "baz",
g: 1.0, h: 1.0, i: 1.0,
}
}
func BenchmarkByValue(b *testing.B) {
var s myStruct
for i := 0; i < b.N; i++ {
// 拷贝整个结构体
// 但要通过栈内存来实现
s = byValue()
}
_ = s
}
func BenchmarkByReference(b *testing.B) {
var s *myStruct
for i := 0; i < b.N; i++ {
// 在堆上为结构体分配内存
// 并只返回它的一个指针
s = byReference()
}
_ = s
}
输出:
BenchmarkByValue-8 476965734 2.499 ns/op 0 B/op 0 allocs/op
BenchmarkByReference-8 24860521 45.86 ns/op 96 B/op 1 allocs/op
在这个初级案例中,按值传递(不涉及堆或垃圾收集器)的速度是按引用传递的 18 倍。
为了说明这个观点,让我们做一个相反的初级案例,分配一次结构体,只把它传递给函数:
var s = myStruct{
a: 1, b: 1, c: 1,
d: "foo", e: "bar", f: "baz",
g: 1.0, h: 1.0, i: 1.0,
}
func byValue() myStruct {
return s
}
func byReference() *myStruct {
return &s
}
输出:
BenchmarkByValue-8 471494428 2.509 ns/op 0 B/op 0 allocs/op
BenchmarkByReference-8 1000000000 0.2484 ns/op 0 B/op 0 allocs/op
当变量只被传来传去,但不被分配时——通过引用会快很多。
想要了解更多细节,请查看这篇 Vincent Blanchon 的
经典文章
。
虽然这一章是关于哪个更快,但在许多应用中,代码的清晰度和一致性将比性能更重要,当然,这又是一个单独的讨论。 总之,不要认为复制变量会很慢,如果性能很重要的话,请使用优秀的 Go 分析工具。
给 C 语言开发者的说明
在 Go 中,对内存管理的要求更为严格。指针运算是不允许的,也不可能有悬空的指针。 但像这样的事情是完全可以的:
func byReference() *myStruct {
return &myStruct{
a: 1, b: 1, c: 1,
d: "foo", e: "bar", f: "baz",
g: 1.0, h: 1.0, i: 1.0,
}
}
Go 的编译器很智能,会将该结构体移至堆中。
第 17 章 日志
当前译本仍不稳定,如翻译有问题请及时联系 jacob953@csu.edu.cn 。
log.Fatal 和 log.Panic
在 Go 中,使用日志包进行日志记录时,log.Fatal 和log.Panic 这两个函数有一个陷阱在等着你。 与你期望的日志函数不同,这些函数不仅仅是用不同的日志级别记录一条消息,它们还终止了整个应用程序。 log.Fatal 干净地退出应用程序,log.Panic 调用运行时警告。下面是 Go 日志包中的实际函数:
// Fatal 相当于在 Print() 之后调用 os.Exit(1)
func Fatal(v ...interface{}) {
std.Output(2, fmt.Sprint(v...))
os.Exit(1)
}
// Panic 相当于在 Print() 之后调用 panic()
func Panic(v ...interface{}) {
s := fmt.Sprint(v...)
std.Output(2, s)
panic(s)
}
第 18 章 时间
当前译本仍不稳定,如翻译有问题请及时联系 jacob953@csu.edu.cn 。
使用 time.LoadLocation 从文件中读取数据
在 Go 中,这是我个人而言最喜欢的一个坑。在时区之间转换,首先要需要加载位置信息。事实证明,每次调用 time.LoadLocation 都会从一个文件中读取数据。在格式化大量 CSV 报告的每一行时,这不是最好的做法:
package main
import (
"testing"
"time"
)
func BenchmarkLocation(b *testing.B) {
for n := 0; n < b.N; n++ {
loc, _ := time.LoadLocation("Asia/Kolkata")
time.Now().In(loc)
}
}
func BenchmarkLocation2(b *testing.B) {
loc, _ := time.LoadLocation("Asia/Kolkata")
for n := 0; n < b.N; n++ {
time.Now().In(loc)
}
}
输出:
BenchmarkLocation-8 16810 76179 ns/op 58192 B/op 14 allocs/op
BenchmarkLocation2-8 188887110 6.97 ns/op 0 B/op 0 allocs/op