effective go 简读
摘录自《高效的 Go 编程 Effective Go 2020》| Go 技术论坛 , 由 chrome插件简悦 · 收集合辑整理
引言
Go 是一门全新的语言。尽管它从既有的语言中借鉴了许多理念,但其与众不同的特性, 使得使用 Go 编程在本质上就不同于其它语言。
换句话说,要想将 Go 程序写得好,就必须理解其特性和风格。了解命名、格式化、 程序结构等既定规则
格式化
格式化问题总是充满了争议,但却始终没有形成统一的定论。
若所有人都遵循相同的编码风格,在这类问题上浪费的时间将会更少。 问题就在于如何实现这种设想,而无需冗长的语言风格规范
在 Go 中我们另辟蹊径,让机器来处理大部分的格式化问题。
gofmt程序(也可用go fmt,它以包为处理对象而非源文件)将 Go 程序按照标准风格缩进、 对齐,保留注释并在需要时重新格式化。
我们使用制表符(tab)缩进,
gofmt默认也使用它。在你认为确实有必要时再使用空格。
Go 对行的长度没有限制,别担心打孔纸不够长。如果一行实在太长,也可进行折行并插入适当的 tab 缩进。
比起 C 和 Java,Go 所需的括号更少:控制结构(
if、for和switch)在语法上并不需要圆括号。
代码注释
Go 语言支持 C 风格的块注释
/* */和 C++ 风格的行注释//。 行注释更为常用,而块注释则主要用作包的注释,当然也可在禁用一大段代码时使用。
注释无需进行额外的格式化,如用星号来突出等。生成的输出甚至可能无法以等宽字体显示, 因此不要依赖于空格对齐,
godoc会像gofmt那样处理好这一切。 注释是不会被解析的纯文本
fmt包的注释就用了这种不错的效果go doc fmt
godoc既是一个程序,又是一个 Web 服务器,它对 Go 的源码进行处理,并提取包中的文档内容。 出现在顶级声明之前,且与该声明之间没有空行的注释,将与该声明一起被提取出来,作为该条目的说明文档。参考 ftm.print包
文档注释最好是完整的句子,这样它才能适应各种自动化的展示。 第一句应当以被声明的东西开头,并且是单句的摘要。
godoc -http=:6060
1
2
3 // Compile 用于解析正则表达式并返回,如果成
// 功,则 Regexp 对象就可用于匹配所针对的文本。
func Compile(str string) (*Regexp, error) {
命名规则
正如命名在其它语言中的地位,它在 Go 中同样重要
当一个包被导入后,包名就会成了内容的访问器。在以下代码
之后,被导入的包就能通过
bytes.Buffer来引用了
按照惯例, 包应当以小写的单个单词来命名,且不应使用下划线或驼峰记法
Go 并不对获取器(getter)和设置器(setter)提供自动支持。 你应当自己提供获取器和设置器,通常很值得这样做,但若要将
Get放到获取器的名字中,既不符合习惯,也没有必要。若你有个名为owner(小写,未导出)的字段,其获取器应当名为Owner(大写,可导出)而非GetOwner。大写字母即为可导出的这种规定为区分方法和字段提供了便利。 若要提供设置器方法,SetOwner是个不错的选择。两个命名看起来都很合理:
接口命名#
按照约定,只包含一个方法的接口应当以该方法的名称加上 - er 后缀来命名,如
Reader、Writer、Formatter、CloseNotifier等。
驼峰 命名#
最后,Go 中的约定是使用
MixedCaps或mixedCaps而不是下划线来编写多个单词组成的命名。
分号
和 C 一样,Go 的正式语法使用分号来结束语句,和 C 不同的是,这些分号并不在源码中出现。 取而代之,词法分析器会使用一条简单的规则来自动插入分号,因此源码中基本就不用分号了。
通常 Go 程序只在诸如
for循环子句这样的地方使用分号, 以此来将初始化器、条件及增量元素分开。如果你在一行中写多个语句,也需要用分号隔开。
警告:无论如何,你都不应将一个控制结构(
if、for、switch或select)的左大括号放在下一行。
Go 中的结构控制与 C 有许多相似之处,但其不同之处才是独到之处。 Go 不再使用
do或while循环,只有一个更通用的for;switch要更灵活一点;if和switch像for一样可接受可选的初始化语句; 此外,还有一个包含类型选择和多路通信复用器的新控制结构:select。 其语法也有些许不同:没有圆括号,而其主体必须始终使用大括号括住。
1
2
3
4 if err := file.Chmod(0664); err != nil {
log.Print(err)
return err
}
err在第一条语句中被声明,但在第二条语句中只是被再次赋值罢了。也就是说,调用f.Stat使用的是前面已经声明的err,它只是被重新赋值了而已。
若你想遍历数组、切片、字符串或者映射,或从信道中读取消息,
range子句能够帮你轻松实现循环。
1
2
3
4 sum := 0
for i := 0; i < 10; i++ {
sum += i
}在满足下列条件时,已被声明的变量
v可出现在:=声明中:
- 本次声明与已声明的
v处于同一作用域中(若v已在外层作用域中声明过,则此次声明会创建一个新的变量 §),- 在初始化中与其类型相应的值才能赋予
v,且- 在此次声明中至少另有一个变量是新声明的。
1
2
3 for key, value := range oldMap {
newMap[key] = value
}Go 没有逗号操作符,而
++和--为语句而非表达式。 因此,若你想要在for中使用多个变量,应采用平行赋值的方式 (因为它会拒绝++和--).
Go 的
switch比 C 的更通用。其表达式无需为常量或整数,case语句会自上而下逐一进行求值直到匹配为止。若switch后面没有表达式,它将匹配true,因此,我们可以将if-else-if-else链写成一个switch,这也更符合 Go 的风格。
1
2
3
4
5
6
7
8
9
10
11 func unhex(c byte) byte {
switch {
case '0' <= c && c <= '9':
return c - '0'
case 'a' <= c && c <= 'f':
return c - 'a' + 10
case 'A' <= c && c <= 'F':
return c - 'A' + 10
}
return 0
}
switch并不会自动下溯,但case可通过逗号分隔来列举相同的处理条件。
1
2
3
4
5
6
7 func shouldEscape(c byte) bool {
switch c {
case ' ', '?', '&', '=', '#', '+', '%':
return true
}
return false
}
switch也可用于判断接口变量的动态类型。如 类型选择 通过圆括号中的关键字type使用类型断言语法。若switch在表达式中声明了一个变量,那么该变量的每个子句中都将有该变量对应的类型。
1
2
3
4
5
6
7
8
9
10
11
12
13
14 var t interface{}
t = functionOfSomeType()
switch t := t.(type) {
default:
fmt.Printf("unexpected type %T\n", t) // %T 打印任何类型的 t
case bool:
fmt.Printf("boolean %t\n", t) // t 是 bool 类型
case int:
fmt.Printf("integer %d\n", t) // t 是 int 类型
case *bool:
fmt.Printf("pointer to boolean %t\n", *t) // t 是 *bool 类型
case *int:
fmt.Printf("pointer to integer %d\n", *t) // t 是 *int 类型
}
函数
Go 与众不同的特性之一就是函数和方法可返回多个值。这种形式可以改善 C 中一些笨拙的习惯: 将错误值返回(例如用
-1表示EOF)和修改通过地址传入的实参。
Go 函数的返回值或结果 “形参” 可被命名,并作为常规变量使用,就像传入的形参一样。 命名后,一旦该函数开始执行,它们就会被初始化为与其类型相应的零值; 若该函数执行了一条不带实参的
return语句,则结果形参的当前值将被返回。
Go 的
defer语句用于预设一个函数调用(即推迟执行函数), 该函数会在执行defer的函数返回之前立即执行。它显得非比寻常, 但却是处理一些事情的有效方式,例如无论以何种路径返回,都必须释放资源的函数。 典型的例子就是解锁互斥和关闭文件。
被推迟函数的实参在
defer执行时才会被求值
数据
Go 提供了两种分配原语,即内建函数
new和make。 它们所做的事情不同,所应用的类型也不同。它们可能会引起混淆,但规则却很简单
new(T)会为类型为T的新项分配已置零的内存空间, 并返回它的地址
1
2 p := new(SyncedBuffer) // type *SyncedBuffer
var v SyncedBuffer // type SyncedBuffer有时零值还不够好,这时就需要一个初始化构造函数,如来自
os包中的这段代码所示
1
2
3
4
5
6
7
8
9
10
11 func NewFile(fd int, name string) *File {
if fd < 0 {
return nil
}
f := new(File)
f.fd = fd
f.name = name
f.dirinfo = nil
f.nepipe = 0
return f
}
1
2
3
4
5
6
7 func NewFile(fd int, name string) *File {
if fd < 0 {
return nil
}
f := File{fd, name, nil, 0}
return &f
}返回一个局部变量的地址完全没有问题,这点与 C 不同。该局部变量对应的数据 在函数返回后依然有效
表达式
new(File)和&File{}是等价的
回到内存分配上来。内建函数
make(T,args)的目的不同于new(T)。它只用于创建切片、映射和信道,并返回类型为T(而非*T)的一个已初始化 (而非置零)的值
对于切片、映射和信道,
make用于初始化其内部的数据结构并准备好将要使用的值
请记住,
make只适用于映射、切片和信道且不返回指针。若要获得明确的指针, 请使用new分配内存。
在 Go 中,
- 数组是值。将一个数组赋予另一个数组会复制其所有元素。
- 特别地,若将某个数组传入某个函数,它将接收到该数组的一份副本而非指针。
- 数组的大小是其类型的一部分。类型
[10]int和[20]int是不同的。数组为值的属性很有用,但代价高昂;若你想要 C 那样的行为和效率,你可以传递一个指向该数组的指针。
切片通过对数组进行封装,为数据序列提供了更通用、强大而方便的接口。 除了矩阵变换这类需要明确维度的情况外,Go 中的大部分数组编程都是通过切片来完成的。
切片保存了对底层数组的引用,若你将某个切片赋予另一个切片,它们会引用同一个数组。 若某个函数将一个切片作为参数传入,则它对该切片元素的修改对调用者而言同样可见, 这可以理解为传递了底层数组的指针。
我们必须返回切片,因为尽管
Append可修改slice的元素,但切片自身(其运行时数据结构包含指针、长度和容量)是通过值传递的。
映射是方便而强大的内建数据结构,它可以关联不同类型的值。其键可以是任何相等性操作符支持的类型, 如整数、浮点数、复数、字符串、指针、接口(只要其动态类型支持相等性判断)、结构以及数组。
映射可使用一般的复合字面语法进行构建,其键 - 值对使用冒号分隔,因此可在初始化时很容易地构建它们。
1
2
3
4
5
6
7 var timeZone = map[string]int{
"UTC": 0*60*60,
"EST": -5*60*60,
"CST": -6*60*60,
"MST": -7*60*60,
"PST": -8*60*60,
}若试图通过映射中不存在的键来取值,就会返回与该映射中项的类型对应的零值。
需要区分某项是不存在还是其值为零值。如对于一个值本应为零的
"UTC"条目,也可能是由于不存在该项而得到零值。你可以使用多重赋值的形式来分辨这种情况。
1
2
3 var seconds int
var ok bool
seconds, ok = timeZone[tz]若仅需判断映射中是否存在某项而不关心实际的值,可使用空白标识符 (
_)来代替该值的一般变量
1 _, present := timeZone[tz]要删除映射中的某项,可使用内建函数
delete,它以映射及要被删除的键为实参。 即便对应的键不在该映射中,此操作也是安全的。
映射中的键可能按任意顺序输出。当打印结构体时,改进的格式
%+v会为结构体的每个字段添上字段名,而另一种格式%#v将完全按照 Go 的语法打印值。
1
2
3
4 &{7 -2.35 abc def}
&{a:7 b:-2.35 c:abc def}
&main.T{a:7, b:-2.35, c:"abc\tdef"}
map[string]int{"CST":-21600, "EST":-18000, "MST":-25200, "PST":-28800, "UTC":0}若你想控制自定义类型的默认格式,只需为该类型定义一个具有
String() string签名的方法。对于我们简单的类型T,可进行如下操作。
1
2
3
4 func (t *T) String() string {
return fmt.Sprintf("%d/%g/%q", t.a, t.b, t.c)
}
fmt.Printf("%v\n", t)我们的
String方法也可调用Sprintf, 因为打印例程可以完全重入并按这种方式封装。不过有一个重要的细节你需要知道: 请勿通过调用Sprintf来构造String方法,因为它会无限递归你的的String方法。如果Sprintf调用试图将接收器直接打印为字符串,而该字符串又将再次调用该方法,则会发生这种情况。这是一个常见的错误,如本例所示。
1
2
3
4
5 type MyString string
func (m MyString) String() string {
return fmt.Sprintf("MyString=%s", m) // 错误:会无限递归
}要解决这个问题也很简单:将该实参转换为基本的字符串类型,它没有这个方法。
1
2
3
4 type MyString string
func (m MyString) String() string {
return fmt.Sprintf("MyString=%s", string(m)) // 可以:注意转换
}
1
2
3
4 type MyString string
func (m MyString) String() string {
return fmt.Sprintf("MyString=%s", string(m)) // 可以:注意转换
}
1
2
3
4
5
6
7
8
9 func Min(a ...int) int {
min := int(^uint(0) >> 1) // 最大的 int
for _, i := range a {
if i < min {
min = i
}
}
return min
}如果我们要像
Append那样将一个切片追加到另一个切片中呢? 很简单:在调用的地方使用...,就像我们在上面调用Output那样。以下代码片段的输出与上一个相同。
1
2
3
4 x := []int{1,2,3}
y := []int{4,5,6}
x = append(x, y...)
fmt.Println(x)
初始化
尽管从表面上看,Go 的初始化过程与 C 或 C++ 差别并不算太大,但它确实更为强大。 在初始化过程中,不仅可以构建复杂的结构,还能正确处理不同包对象间的初始化顺序。
Go 中的常量就是不变量。它们在编译时创建,即便它们可能是函数中定义的局部变量。 常量只能是数字、字符(符文)、字符串或布尔值。由于编译时的限制, 定义它们的表达式必须也是可被编译器求值的常量表达式。例如
1<<3就是一个常量表达式,而math.Sin(math.Pi/4)则不是,因为对math.Sin的函数调用在运行时才会发生。可被编译器求值
1
2
3
4
5 var (
home = os.Getenv("HOME")
user = os.Getenv("USER")
gopath = os.Getenv("GOPATH")
)每个源文件都可以通过定义自己的无参数
init函数来设置一些必要的状态。 (其实每个文件都可以拥有多个init函数。)而它的结束就意味着初始化结束: 只有该包中的所有变量声明都通过它们的初始化器求值后init才会被调用, 而包中的变量只有在所有已导入的包都被初始化后才会被求值。
init函数还常被用在程序真正开始执行前,检验或校正程序的状态。
1
2
3
4
5
6
7
8
9
10
11
12
13 func init() {
if user == "" {
log.Fatal("$USER not set")
}
if home == "" {
home = "/home/" + user
}
if gopath == "" {
gopath = home + "/go"
}
// gopath 可通过命令行中的 --gopath 标记覆盖掉。
flag.StringVar(&gopath, "gopath", gopath, "override default GOPATH")
}
方法
正如
ByteSize那样,我们可以为任何已命名的类型(除了指针或接口)定义方法; 接收者可不必为结构体。
以指针或值为接收者的区别在于:值方法可通过指针和值调用, 而指针方法只能通过指针来调用。
之所以会有这条规则是因为指针方法可以修改接收者;通过值调用它们会导致方法接收到该值的副本, 因此任何修改都将被丢弃,因此该语言不允许这种错误。
接口与其它类型
Go 中的接口为指定对象的行为提供了一种方法:如果某样东西可以完成这个, 那么它就可以用在这里
是类型转换的一种形式:它接受一个接口,在选择 (switch)中根据其判断选择对应的情况(case), 并在某种意义上将其转换为该种类型
1
2
3
4
5
6
7
8
9
10
11 type Stringer interface {
String() string
}
var value interface{} // Value 由调用者提供
switch str := value.(type) {
case string:
return str
case Stringer:
return str.String()
}
1
2
3
4
5
6 str, ok := value.(string)
if ok {
fmt.Printf("string value is: %q\n", str)
} else {
fmt.Printf("value is not a string\n")
}
空白标识符
我们在
for-range循环和映射中提过几次空白标识符。 空白标识符可被赋予或声明为任何类型的任何值,而其值会被无害地丢弃。它有点像 Unix 中的/dev/null文件:它表示只写的值,在需要变量但不需要实际值的地方用作占位符。 我们在前面已经见过它的用法了。
若某次赋值需要匹配多个左值,但其中某个变量不会被程序使用, 那么用空白标识符来代替该变量可避免创建无用的变量,并能清楚地表明该值将被丢弃。 例如,当调用某个函数时,它会返回一个值和一个错误,但只有错误很重要, 那么可使用空白标识符来丢弃无关的值。
1
2
3 if _, err := os.Stat(path); os.IsNotExist(err) {
fmt.Printf("%s does not exist\n", path)
}你偶尔会看见为忽略错误而丢弃错误值的代码,这是种糟糕的实践。请务必检查错误返回, 它们会提供错误的理由。
1
2
3
4
5 // 很糟糕的代码!若路径不存在,它就会崩溃。
fi, _ := os.Stat(path)
if fi.IsDir() {
fmt.Printf("%s is a directory\n", path)
}若导入某个包或声明某个变量而不使用它就会产生错误。未使用的包会让程序膨胀并拖慢编译速度, 而已初始化但未使用的变量不仅会浪费计算能力,还有可能暗藏着更大的 Bug。 然而在程序开发过程中,经常会产生未使用的导入和变量。虽然以后会用到它们, 但为了完成编译又不得不删除它们才行,这很让人烦恼。空白标识符就能提供一个工作空间。
要让编译器停止关于未使用导入的包,需要空白标识符来引用已导入包中的符号。 同样,将未使用的变量
fd赋予空白标识符也能关闭未使用变量错误。 该程序的以下版本可以编译。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 package main
import (
"fmt"
"io"
"log"
"os"
)
var _ = fmt.Printf // 用于调试,结束时删除。
var _ io.Reader // 用于调试,结束时删除。
func main() {
fd, err := os.Open("test.go")
if err != nil {
log.Fatal(err)
}
// TODO: use fd.
_ = fd
}
1 import _ "net/http/pprof"为辅助作用导入
若只需要判断某个类型是否是实现了某个接口,而不需要实际使用接口本身 (可能是错误检查部分),就使用空白标识符来忽略类型断言的值:
1
2
3 if _, ok := val.(json.Marshaler); ok {
fmt.Printf("value %v of type %T implements json.Marshaler\n", val, val)
}
内嵌
Go 并不提供典型的,类型驱动的子类化概念,但通过将类型内嵌到结构体或接口中, 它就能 “借鉴” 部分实现。
1
2
3
4
5 // ReadWriter 接口结合了 Reader 接口 和 Writer 接口
type ReadWriter interface {
Reader
Writer
}
ReadWriter能够做任何Reader和Writer可以做到的事情,它是内嵌接口的联合体 (它们必须是不相交的方法集)。只有接口能被嵌入到接口中。
当内嵌一个类型时,该类型的方法会成为外部类型的方法, 但当它们被调用时,该方法的接收者是内部类型,而非外部的。在我们的例子中,当
bufio.ReadWriter的Read方法被调用时, 它与之前写的转发方法具有同样的效果;接收者是ReadWriter的reader字段,而非ReadWriter本身。type Bank struct { sync.RWMutex saving map[string]int } func NewBank() *Bank { b := Bank{ saving: make(map[string]int), } return &b } //存 func (b *Bank) Deposit(name string, amount int) { // 可以直接调用自身的Lock方法,但真实接收者是 b.RWMutex ,体现了内嵌的便利性 b.Lock() defer b.Unlock() remain, ok := b.saving[name] if !ok { b.saving[name] = amount } else { b.saving[name] = remain + amount } } //取 func (b *Bank) Withdraw(name string, amount int) int { b.Lock() defer b.Unlock() remain, ok := b.saving[name] if !ok { return 0 } if remain < amount { amount = remain } b.saving[name] = remain - amount return amount } //查 func (b *Bank) Query(name string) int { b.RLock() defer b.RUnlock() amount, ok := b.saving[name] if !ok { return 0 } return amount }
并发
并发编程是个很大的论题。但限于篇幅,这里仅讨论一些 Go 特有的东西。
在并发编程中,为实现对共享变量的正确访问需要精确的控制,这在多数环境下都很困难。
不要通过共享内存来通信,而应通过通信来共享内存。
我们称之为 Go 协程是因为现有的术语 — 线程、协程、进程等等 — 无法准确传达它的含义。 Go 协程具有简单的模型:它是与其它 Go 协程并发运行在同一地址空间的函数。它是轻量级的, 所有消耗几乎就只有栈空间的分配。而且栈最开始是非常小的,所以它们很廉价, 仅在需要时才会随着堆空间的分配(和释放)而变化。
Go 协程的设计隐藏了线程创建和管理的诸多复杂性。
在函数或方法前添加
go关键字能够在新的 Go 协程中调用它。当调用完成后, 该 Go 协程也会安静地退出。
信道与映射一样,也需要通过
make来分配内存。其结果值充当了对底层数据结构的引用。 若提供了一个可选的整数形参,它就会为该信道设置缓冲区大小。默认值是零,表示不带缓冲的或同步的信道。
1
2
3 ci := make(chan int) // 整数无缓冲信道
cj := make(chan int, 0) // 整数无缓冲信道
cs := make(chan *os.File, 100) // 指向文件的指针的缓冲信道若信道是不带缓冲的,那么在接收者收到值前, 发送者会一直阻塞;若信道是带缓冲的,则发送者仅在值被复制到缓冲区前阻塞; 若缓冲区已满,发送者会一直等待直到某个接收者取出一个值为止。
带缓冲的信道可被用作信号量,例如限制吞吐量。在此例中,进入的请求会被传递给
handle,它向信道内发送一个值,处理请求后将值从信道中取回,以便让该 “信号量” 准备迎接下一次请求。信道缓冲区的容量决定了同时调用process的数量上限,因此我们在初始化时首先要填充至它的容量上限。
1
2
3
4
5
6
7
8
9
10
11
12
13
14 var sem = make(chan int, MaxOutstanding)
func handle(r *Request) {
sem <- 1 // 等待活动队列清空。
process(r) // 可能需要很长时间。
<-sem // 完成;使下一个请求可以运行。
}
func Serve(queue chan *Request) {
for {
req := <-queue
go handle(req) // 无需等待 handle 结束。
}
}Bug 出现在 Go 的
for循环中,该循环变量在每次迭代时会被重用,因此req变量会在所有的 Go 协程间共享,这不是我们想要的。我们需要确保req对于每个 Go 协程来说都是唯一的。有一种方法能够做到,就是将req的值作为实参传入到该 Go 协程的闭包中:
1
2
3
4
5
6
7
8
9 func Serve(queue chan *Request) {
for req := range queue {
sem <- 1
go func(req *Request) {
process(req)
<-sem
}(req)
}
}回到编写服务器的一般问题上来。另一种管理资源的好方法就是启动固定数量的
handleGo 协程,一起从请求信道中读取数据。Go 协程的数量限制了同时调用process的数量。Serve同样会接收一个通知退出的信道, 在启动所有 Go 协程后,它将阻塞并暂停从信道中接收消息。
1
2
3
4
5
6
7
8
9
10
11
12
13 func handle(queue chan *Request) {
for r := range queue {
process(r)
}
}
func Serve(clientRequests chan *Request, quit chan bool) {
// 启动处理程序
for i := 0; i < MaxOutstanding; i++ {
go handle(clientRequests)
}
<-quit // 等待通知退出。
}这些设计的另一个应用是在多 CPU 核心上实现并行计算。如果计算过程能够被分为几块 可独立执行的过程,它就可以在每块计算结束时向信道发送信号,从而实现并行处理。
我们在循环中启动了独立的处理块,每个 CPU 将执行一个处理。 它们有可能以乱序的形式完成并结束,但这没有关系; 我们只需在所有 Go 协程开始后接收,并统计信道中的完成信号即可。
1
2
3
4
5
6
7
8
9
10
11
12
13 const numCPU = 4 // CPU 核心数
func (v Vector) DoAll(u Vector) {
c := make(chan int, numCPU) // 缓冲区是可选的,但明显用上更好
for i := 0; i < numCPU; i++ {
go v.DoSome(i*len(v)/numCPU, (i+1)*len(v)/numCPU, u, c)
}
// 排空信道。
for i := 0; i < numCPU; i++ {
<-c // 等待任务完成
}
// 一切完成
}
1 var numCPU = runtime.NumCPU()
1 var numCPU = runtime.GOMAXPROCS(0)
错误
库函数很多时候必须将错误信息返回给函数的调用者。如前所述,Go 允许函数可以有多个返回值的特性,使得函数的调用者在得到正常返回值的同时,可以获取到更为详细的错误信息
按照约定,错误的类型通常为
error,这是一个内置的简单接口。
1
2
3 type error interface {
Error() string
}若调用者关心错误的完整细节,可使用类型选择或者类型断言来查看特定错误,并抽取其细节。比如
PathErrors,它你可能会想检查内部的Err字段来判断这是否是一个可以被恢复的错误。
1
2
3
4
5
6
7
8
9
10
11 for try := 0; try < 2; try++ {
file, err = os.Create(filename)
if err == nil {
return
}
if e, ok := err.(*os.PathError); ok && e.Err == syscall.ENOSPC {
deleteTempFiles() // 恢复一些空间。
continue
}
return
}
1
2
3
4
5
6
7 var user = os.Getenv("USER")
func init() {
if user == "" {
panic("no value for $USER")
}
}当
panic被调用后(包括不明确的运行时错误,例如切片越界访问或类型断言失败), 程序将立刻终止当前函数的执行,并开始回溯 Go 协程的栈,运行任何被推迟的函数。 若回溯到达 Go 协程栈的顶端,程序就会终止。不过我们可以用内建的recover函数来重新或来取回 Go 协程的控制权限并使其恢复正常执行。调用
recover将停止回溯过程,并返回传入panic的实参。 由于在回溯时只有被推迟函数中的代码在运行,因此recover只能在被推迟的函数中才有效。