第 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 这样的名字是在当前包中还是在导入的包中的顶级标识符。

https://golang.org/doc/faq

另外,如果你在使用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 拒绝编译带有未使用的变量或包导入,以短期的便利保证长期的构建速度和程序的清晰度。

https://golang.org/doc/faq

该规则的例外是全局变量和函数参数:

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 编译器会优化掉这些分配:

  1. 当把一个字符串和一个字节片相比较时:str == string(byteSlice)
  2. 当 []byte 被用于查找 map[string] 中的条目时:m[string(byteSlice)]
  3. 在字符串被转换为字节的 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 兼容性准则的保护。

https://golang.org/pkg/unsafe/

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 和字符集(没有借口!)》

做一个简短的回顾:

  1. Unicode 是 “一种用于不同语言和文字的国际编码标准,每个字母、数字或符号都被分配了一个独特的数值,适用于不同的平台和程序”。本质上,它是一个“码点”的大表。它包含了所有语言的大部分(但不是全部)字符。该表中,每个码位是一个索引,有时你可以看到用 U+ 符号指定,如 U+0041 表示字母 A。
  2. 通常,码位是指一个字符,例如汉字⻯(U+2EEF),但它也可以是一个几何形状或一个字符修饰符(例如德语 ä、ö 和 ü 等字母的音符)。出于某种原因,它甚至可以是一个便便图标(U+1F4A9)。
  3. UTF-8 是将 Unicode 大表中的元素编码成计算机可以处理的实际字节的方法之一(也是最常见的一种)。
  4. 当用 UTF-8 编码时,单个的 Unicode 代码点可能需要 1 到 4 个字节。
  5. 数字和拉丁字母(a-z,A-Z,0-9)的编码为 1 个字节。许多其他语言的字母在 UTF-8 编码中需要 1 个以上的字节。
  6. 如果你不知道第 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 循环遍历哈希, 而不是通过向元素赋值或进行删除来改变哈希,那么它们在不同步的情况下并发访问哈希就是安全的。

https://golang.org/doc/faq

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 可以大大减少锁争用。

https://github.com/golang/go/blob/master/src/sync/map.go

第 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 通常被用来简化执行各种清理动作的函数。

但是,有几点是需要特别注意的:

  1. 虽然递延函数在原函数返回时才会被执行,但其参数也会在调用 defer 时被使用
package main

import (
    "fmt"
)

func main() {
    s := "defer"
    defer fmt.Println(s)
    s = "original"
    fmt.Println(s)
}

输出:

original
defer
  1. 一旦原函数返回,递延函数就按后进先出的顺序执行
package main

import (
    "fmt"
)

func main() {
    defer fmt.Println("one")
    defer fmt.Println("two")
    defer fmt.Println("three")
}

输出:

three
two
one
  1. 递延函数可以访问和修改函数中已命名的参数
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
  1. 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 语句结束时。

  1. 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 中,你不能真正地继承结构体或接口(因为没有子类),但你可以把它们放在一起(嵌入),形成更复杂的结构体或接口。

嵌入与子类有一个重要的不同之处:当我们嵌入一个类型时,该类型的方法会成为外类型的方法;但是当它们被调用时,方法的接收者是内类型,而不是外类型。

https://golang.org/doc/effective_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 位架构),而不是该结构体的大小。 那么,这是否意味着将结构体作为指针传递会更好?经典回答——这要看情况。

分配一个结构体(或数组)的指针:

  1. 将其放在堆中,而不是像通常情况下放到栈中;
  2. 垃圾收集器来管理堆内存的分配。

如果你想复习一下栈与堆的关系,可以看看这个 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