本文译自Effective Go,是一份对如何编写清晰,符合语言规范的Go语言技巧的介绍。
简介
Go(Golang)是一种新的编程语言。虽然它借鉴了现有语言的思想,但它仍有不同寻常的特性,使得Go程序的高效性与相关语编写的程序有所不同。如果将C++或Java程序直接转换为Go,其结果不太可能令人满意。因为Java程序是用Java编写的,而不是Go;另一方面,以Go的角度思考问题可能会产生一个成功但完全不同的程序。也就是说,要写好Go,了解它的语言特性和语法非常重要。此外,了解Go编程中的即有约定也很重要,如:命令、格式、程序结构等,这样你编写的程序也很容易让其它Go程序员理解。
本文档对如何编写清晰,符合语言规范的Go语言给出了一些建议。在此之前,你应该首先阅读语言规范、Go 教程(Tour of Go)和怎样编写Go代码(How to Write Go Code)。
示例
Go源码包不仅做为核心库,还可以作为语言示例使用。此外,很多包中都自包含了可以直接在golang.org网站上直接运行的示例,例如这个(可以点击“Example”打开)。如果你对如何处理问题或对某些实现有疑问,库中的文档、代码和示例可以提供答案、解决思路和相关资料。
格式化
格式化问题是最具争议性但最不重要的问题。人们可以适应不同的格式样式,但最好不要这样,如果每个人都遵循相同的风格,那么花在不必要问题上的时间就会减少。
在Go语言中,采取了一种不同的方式,让机器来处理大多数格式问题。gofmt程序(也可以用go fmt,其会在程序包级别而不是源文件级别执行)读入Go程序,然后以标准样式的缩进和垂直对齐源码,保留并在需要时重新格式化注释。如果想知道如何处理新布局的情况,请运行gofmt;如果结果看起来不正确,那么应该重新组织你的程序(或提交有关gofmt的错误),而不是绕过问题。
例如,没必要在排列结构体的字段注释上花时间。Goftm会为你做这一点。给定一个声明:
type T struct {
name string // name of the object
value int // its value
}
Goftm会排列成:
type T struct {
name string // name of the object
value int // its value
}
标准包中的所有Go代码,都已使用gofmt格式化。
但一些格式细节仍然会保留。简列如下:
- 缩进
使用制表符(
tab)进行缩进,默认情况下gofmt会输出它们。仅在你需要时时才使用空格。 - 行长
Go没有行长度限制,如果感觉一行太长,可以折成几行,并额外使用一个
tab进行缩进 - 括号
Go相比C和Java,所需要的括号更少:控制结构(
if、for、switch)的语法中没有括号。此外,运算符的优先级层次更短、更清晰。所以:x<<8 + y<<16
的含义已由空格表明,这点不同于其它语言。
注释
Go提供了C风格的块注释/* */和C++风格的行注释//。行注释更常用;块注释通常用于程序包的注释,但也会用在表达式或用来注释掉一大块代码。
程序(也是Web服务器),godoc用于处理Go源码文件,以提取出关于包内容的文档。出现在顶层声明之前,并且中间没有换行的注释,会随声明一同被抽取,做为该项目的解释性文本。这些注释的性质和风格决定了godoc所生成文档的质量。
每个程序包都应该有一个包注释,一个位于package子句之前的块注释。对于有多个文件的包,包注释只需要出现其中任何一个包文件中即可。包注释应该包括包的介绍,并提供整个包相关的信息。包注释会首先出现在godoc页面上,并应设置以下详细文档。
/*
Package regexp implements a simple library for regular expressions.
The syntax of the regular expressions accepted is:
regexp:
concatenation { '|' concatenation }
concatenation:
{ closure }
closure:
term [ '*' | '+' | '?' ]
term:
'^'
'$'
'.'
character
'[' [ '^' ] character-ranges ']'
'(' regexp ')'
*/
package regexp
如果程序包比较简单,则包注释也可以非常简短。
// Package path implements utility routines for // manipulating slash-separated filename paths.
评论不需要额外的格式,如:星号横幅。所生成的输出甚至可能不会以固定宽度的字体显示,因此不要依赖于空格对齐 - godoc,像gofmt就会处理这些事情。注释应该是未解释的纯文本,所以HTML及其它注释(如:_this_)将会逐字重现,不应使用。对于缩进文本,godoc确实会做调整,以按照固定宽度的来显示,这适用于程序段。fmtpackage的包注释使用了这种方式以获取良好的显示效果。
根据上下文,godoc可能不会重新格式化注释,因此应确保它们看起来比较直观:使用正确的拼写、标点符号和语句结构、折叠长行等等。
在程序包中,任何在顶级声明之前的注释,都会充当该声明的文档注释。程序中的每个导出(大写的)名称,都应该有一个文档注释。
文档注释最适合作为完整的句子,这会允许各种自动化展示。第一条注释应该是一个句子摘要,并以所声明的名称为开头。
// Compile parses a regular expression and returns, if successful,
// a Regexp that can be used to match against text.
func Compile(str string) (*Regexp, error) {
如果每个文档注释都以它所描述项的名称为开头,则可以使用go工具的doc子命令并通过grep运行输出。假高你不记得名称“Compile”,但是正在寻找正则表达式的解析函数,所以你运行了命令:
$ go doc -all regexp | grep -i parse
如果包中的所有文档注释都以"This function..."开头,grep就无法帮你找到名称。但是,因为程序包以名称开始每个文档注释,你会看到类似这样的内容,它会让你想起你正在寻找的单词。
$ godoc regexp | grep parse
Compile parses a regular expression and returns, if successful, a Regexp
parsed. It simplifies safe initialization of global variables holding
cannot be parsed. It simplifies safe initialization of global variables
$
Go的声明语法允许对声明进行分组。单个文档注释可以引入一组相关的常量或变量。自提出整个声明以来,这样的评论往往是敷衍的。由于所展现的是整个声明,这样的注释通常非常简单。
// Error codes returned by failures to parse an expression.
var (
ErrInternal = errors.New("regexp: internal error")
ErrUnmatchedLpar = errors.New("regexp: unmatched '('")
ErrUnmatchedRpar = errors.New("regexp: unmatched ')'")
...
)
分组还可以指示各项之间的关系,例如一组受互斥锁保护的变量。
var (
countLock sync.Mutex
inputCount uint32
outputCount uint32
errorCount uint32
)
命名
与任何其他语言一样,命名在Go中非常重要。它们甚至还有语义的效果:名称在包外的可见性取决于其第一个字符是否为大写。因此,花一点时间讨论Go程序中的命名约定是值得的。
程序包命名
当一个程序包被导入,包名便可以用来访问其内容。如:
import "bytes"
导入之后,所导入的包就可以bytes.Buffer。如果每个使用该包的人都可以使用相同的名称来引用其内容,这就意味着包名应该是友好的:简短、简洁、惯用。依照惯例,程序包应该以小写、一个单词命名;不要使用下划线或者混合大小写。简而言之,因为每个使用你的包的人都会输入这个名字。并且不要担心与先前的有冲突。包名称只是导入的缺省名称;它不需要在所有源代码中都是唯一的,并且在极少数冲突的情况下,导入包可以选择一个在本地使用的其他名称。在任何情况下,冲突都很少见,因为导入中的文件名确定了所要使用的包。
另一个约定是,包名基于其源码目录基础名;在src/encoding/base64中的包被导入为"encoding/base64",但其名称为base64,而不是encoding_base64或encodingBase64。
导入者会使用包名来引用其内容,因此包中的导出名称可以使用实际值,以避免冲突。(不要使用import.表示法,这会简化必须在它们正在测试的包之外运行的测试,其它情况应该避免)例如,bufio包中的缓存读取类型称为Reader,而不是BufReader,因为用户看到的是bufio.Reader这一清晰、简明的名称。此外,由于被导入的实体始终其名称进行寻址,因此bufio.Reader不会与io.Reader冲突。类似的,创建ring.Ring新实例的函数(Go中定义的构造函数)通常称为NewRing,但由于Ring是包导出的唯一类型,并且其包名为ring,所以它只叫做New。这样,程序包的用户将看到ring.New,使用程序包结构会帮助你选择更友好的名称。
另一外简单示例是once.Do;once.Do(setup)更易读,写成once.DoOrWaitUntilDone(setup)并不会有所改善。长名称不会自动使事物更具可读性,有用的文档注释通常比超长名称更有价值。
Getter
Go没有提供对getter和setter的自动支持。你可以自己提供getter和setter,这没有什么不妥,但在getter方法名上加上Get是不符合语言习惯的、也没有必要的。如果你有一个owner(小写,未导出),则getter方法名应该是Owner(大写,导出),而不是GetOwner。使用大写名称进行导出提供了将字段与方法区分开的钩子。如果需要,setter方法可以叫做SetOwner。这两个名称在实践中都很好读:
owner := obj.Owner()
if owner != user {
obj.SetOwner(user)
}
接口命名
按照约定,单个方法的接口由方法名加上er后缀来命名,或者以类似的修改来构造出代理名词,如:Reader、Writer、Formatter、CloseNotifier等。
有很多这样的名称,保持他们不变能够很有成效的体现其功能。Read、Write、Close、Flush、String等具有规范的签名和含义。为避免混淆,请不要将它们用做你的方法名,除非其有相同的签名和含义。反过来讲,如果你的类型实现了一个和众所周知的类型有相同含义的方法,那么就使用相同的名字和签名;例如,为你的字符串转换方法起名为String,而不是ToString。
混合大小写
最后,Go中的约定是使用MixedCaps或mixedCaps的形式,而不是下划线来编写多单词名称的。
分号
与C一样,Go的规范语法也是使用分号来结束语名。但与C不同,这些分号并不出现在源码文件中。词法分析器会使用一个简单的规划,在扫描时自动插入分号,因此输入文本中大多没有分号。
规则是这样的。如果换行符之前的最后一个标记是一个标识符(包括int和float64之类的单词);一个基本文字,例如数字或字符串常量;或者如下中一个符号:
break continue fallthrough return ++ -- ) }
则词法分析器总会在符号之后插入一个分号。可以概括为“如果换行出现在可以结束语句的标记之后,则插入分号”。
在右括号之前分号也可以省略掉,所以像下面这样的语句,就不需要分号:
go func() { for { dst <- <-src } }()
惯用的Go程序仅在诸如for循环子句之类的地方使用分号,以分隔初始化、条件和延续元素。分号也用于在一行中分隔多个语句,这写是编写代码应该使用的方式。
分号插入规则的导致的一个结果是,你不能将控制结构的左括号(if、for、switch或select)放在下一行。如果这样做,则会在大括号之前插入分号,这可能会导致不良的影响。应该这样写:
if i < f() {
g()
}
而不是这样:
if i < f() // wrong!
{ // wrong!
g()
}
控制结构
Go的控制结构与C的控制结构有关,但有一些重要的区别。没有do或while循环,只有一个比较广义的for;switch;if和switch接受类似for那样的可选初始化语句;break和continue语句采用可选标签来标识要中断或继续的内容;存在新的控制结构,包括type switch和通讯多路复用器,select。语句也略有不同:没有括号、并且控制结构主体必须始终以大括号分隔。
If
在Go中,简单的if像下面这样:
if x > 0 {
return y
}
强制括号鼓励在多行上编写简单的if语句。不管怎样,这是一个好的编码风格,特别是当正文包含一个控制语句,如return或break时。
由于if和switch接受初始化语句,所以会看到用于设置局部变量的语句。
if err := file.Chmod(0664); err != nil {
log.Print(err)
return err
}
在Go库中,你会发现当if语句没有流入下一个语句时(即,控制结构以break、continue、goto或return结束),代码会省略不必要的else。
f, err := os.Open(name)
if err != nil {
return err
}
d, err := f.Stat()
if err != nil {
f.Close()
return err
}
codeUsing(f, d)
这是一种常见情况的示例,其中代码必须防止一系列错误条件。如果成功情况的控制流在页面上运行,则代码读取良好,从而消除出现的错误情况。由于错误情况往往会在return语句中结束,因此代码不需要else语句。
f, err := os.Open(name)
if err != nil {
return err
}
d, err := f.Stat()
if err != nil {
f.Close()
return err
}
codeUsing(f, d)
重新声明与赋值
另外,上一节中最后一个示例演示了:=简单声明形式的工作原理。该声明调用os.Open进行读取:
f, err := os.Open(name)
该语句声明了两个变量,f和err。之后,又调用f.Stat进行读取:
d, err := f.Stat()
这看起来像是d和err。但需要注意,两个语句中都有err。这种重复是合法的:err是在第一条语句被声明的;在第二条语句中仅仅是重新分配。对调用f.Stat并使用上面声明的已有err变量,仅会给它一个新值。
在:=声明中,变量v即使已经声明过,也可以重复出现,前提是:
- 该声明与
v在同一作用域内(如果v已在外部作用域中声明,声明将创建一个新变量) - 初始化中相应的值是可以分配给
v的 - 并且,声明中至少有一个其它变量被重新声明
这种不寻常的属性是纯粹为了实用。例如,这样很容易在一个if-else链中使用单个err值。你会经常看到这种用法。
值得注意的是,在Go中,函数参数和返回值的作用与函数体是相同的,虽然在词法上它们是在包裹大括号之外出现的。
For
Go的for循环类似,但又不等同于C。它统一了for和while,但没有do-while。for有三种形式,但只有一种有分号:
// 类似于 C 中的 for
for init; condition; post { }
// 类似于 C 中的
for condition { }
// 类似于 C 中的 for(;;)
for { }
短声明可以很容易的在循环中声明索引变量。
sum := 0
for i := 0; i < 10; i++ {
sum += i
}
如果你是在循环遍历数组、切片(slice)、字符串或映射(map)、或从通道(channel)读取,则可以使用range子句管理循环。
for key, value := range oldMap {
newMap[key] = value
}
如果你只需要range的第一项(key或index),则可以丢弃第二项:
for key := range m {
if key.expired() {
delete(m, key)
}
}
如果你只需要range的第二项(value),则可以使用空白标识符(一个下划线)来丢弃第一项:
sum := 0
for _, value := range array {
sum += value
}
空白标识符还有很多用途,会在后续章节中介绍。
对于字符串,range会为你做更多的工作,它会通过解析UTF-8可折分出单个Unicode编码点。错误的编码会消耗一个字节,并产生一个替换字符U+FFFD。(名称(与关联的内置类型)符号是单个Unicode编码点的Go术语。详细信息,请参阅语言规范。)
以下循环:
for pos, char := range "日本\x80語" { // \x80 is an illegal UTF-8 encoding
fmt.Printf("character %#U starts at byte position %d\n", char, pos)
}
会输出:
character U+65E5 '日' starts at byte position 0 character U+672C '本' starts at byte position 3 character U+FFFD '�' starts at byte position 6 character U+8A9E '語' starts at byte position 7
最后,Go中没有逗号操作符,并且++和--是语句不是表达式。所以,如果你要在for中运行多个变量,就需要使用并行赋值(虽然这样会阻碍使用++和--):
// Reverse a
for i, j := 0, len(a)-1; i < j; i, j = i+1, j-1 {
a[i], a[j] = a[j], a[i]
}
Switch
Go的switch比C更加通用。其中的表达式不必是常数甚至整数,case是按照从上到下的顺序进行求值并匹配。如果switch没有表达式,则将其置为true。因此,按使用习惯,可以将if-else-if-else链做为switch。
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子句进入另一个,但可以使用逗号分隔case列表:
func shouldEscape(c byte) bool {
switch c {
case ' ', '?', '&', '=', '#', '+', '%':
return true
}
return false
}
虽然和其它类C语言一样,可以用break语句来提前止switch,但在Go中并不常见。但有时候,需要中断包含它的循环,而不是switch。在Go中,可以通过在循环上放置一个标签,然后通过breaking到该标签来实现。下例演示了这两种用法:
Loop:
for n := 0; n < len(src); n += size {
switch {
case src[n] < sizeOne:
if validateOnly {
break
}
size = 1
update(src[n])
case src[n] < sizeTwo:
if n+1 >= len(src) {
err = errShortInput
break Loop
}
if validateOnly {
break
}
size = 2
update(src[n] + src[n+1] << shift)
}
}
当然, continue也可以接受一个标签,但只能用于循环。
作为在本节的结束,有一个使用两个switch语句的字节切片的比较例程:
// Compare returns an integer comparing the two byte slices,
// lexicographically.
// The result will be 0 if a == b, -1 if a < b, and +1 if a > b
func Compare(a, b []byte) int {
for i := 0; i < len(a) && i < len(b); i++ {
switch {
case a[i] > b[i]:
return 1
case a[i] < b[i]:
return -1
}
}
switch {
case len(a) > len(b):
return 1
case len(a) < len(b):
return -1
}
return 0
}
Type switch
switch也可用于获取接口变量的动态类型。这种类型 switch使用在括号内带有type关键字的类型断言语法。如果switch在表达式中声明了一个变量,则该变量会在每个子句中有相应的类型。在这种情况下,比较符合语言习惯是在每个case重用同一个名称,实际上是在每个case中声明一个具有相同名称但不同类型的新变量。
var t interface{}
t = functionOfSomeType()
switch t := t.(type) {
default:
fmt.Printf("unexpected type %T", t) // %T prints whatever type t has
case bool:
fmt.Printf("boolean %t\n", t) // t has type bool
case int:
fmt.Printf("integer %d\n", t) // t has type int
case *bool:
fmt.Printf("pointer to boolean %t\n", *t) // t has type *bool
case *int:
fmt.Printf("pointer to integer %d\n", *t) // t has type *int
}
函数
多个返回值
Go的不同寻常功能之一是,函数和方法可以返回多个值。这种形式可以用于改进C中的几个拙略的语言风格:直接返回一个错误,例如:-1表示EOF,并修改地址传递参数。
在C中,写入错误是由一个负计数和一个易变位置的错误代码来表示的。在Go中,Write可以返回一个计数和一个错误:“是的,你写了一些字节但不是全部,因为你的设备已经被填满了”。以下是os包中文件Write方法的签名:
func (file *File) Write(b []byte) (n int, err error)
并且正如文档所述,当n != len(b)时,其会返回一个写入的字节数及一个非零的error。这是一种常见的风格;更多的例子可以参阅错误处理章节。
类似的方法消除了将指针传递给返回值,以模拟引用参数。以下是一个简单有用的函数,用于从字节切片中的获取数据,并返回数字和下一位置。
func nextInt(b []byte, i int) (int, int) {
for ; i < len(b) && !isDigit(b[i]); i++ {
}
x := 0
for ; i < len(b) && isDigit(b[i]); i++ {
x = x*10 + int(b[i]) - '0'
}
return x, i
}
你可以像下面这样,用它来扫描输入切片b中的数字:
for i := 0; i < len(b); {
x, i = nextInt(b, i)
fmt.Println(x)
}
命名结果参数
Go函数的返回或结果“参数”,可以指定名称并作为普通变量使用,就像传入参数一样。命名时,它们会在函数开始时被初始化为其类型的零值;如果函数执行了但没有return语句,则结果参数的当前值将用做返回值。
名称不是强制性的,但它们可以使代码更加简短清晰:它们也是文档。如果我们将nextInt的结果进行命名,则要返回的int是哪一个就很明显了。
func nextInt(b []byte, pos int) (value, nextPos int) {
由于命名结果已被初始化,并与未处理的return相关联,所以其简单又清晰。以下是一个io.ReadFull的版本,其很好的使用了这些特性:
func ReadFull(r Reader, buf []byte) (n int, err error) {
for len(buf) > 0 && err == nil {
var nr int
nr, err = r.Read(buf)
n += nr
buf = buf[nr:]
}
return
}
延时
Go的defer用于调度函数的调用(延时函数),会使其在defer函数返回之前执行。这是一种不常见但有效的方法,用于处理函数无论通过哪条件执行路径返回,资源都必须被释放的情况。比较典型的示例是,处理互斥锁或关闭文件:
// Contents returns the file's contents as a string.
func Contents(filename string) (string, error) {
f, err := os.Open(filename)
if err != nil {
return "", err
}
defer f.Close() // f.Close will run when we're finished.
var result []byte
buf := make([]byte, 100)
for {
n, err := f.Read(buf[0:])
result = append(result, buf[0:n]...) // append is discussed later.
if err != nil {
if err == io.EOF {
break
}
return "", err // f will be closed if we return here.
}
}
return string(result), nil // f will be closed if we return here.
}
延迟诸如Close之类的函数调用,有两个好处。首先,它会保证你永远不会忘记关闭文件,如果之后你修改函数并添加新的返回路径,会很容易犯这个错误;其次,关闭紧邻着打开,这比将其放在函数末尾要清晰的多。
延迟函数的参数(如果函数是方法,包括接收者)在被defer执行时取值,而不是在被调用执行时。这样,除不必担心变量会随函数执行而发生改变外,还意味着单个被延时执行的调用点可以延期多个函数执行。下面是一个简单的示例:
for i := 0; i < 5; i++ {
defer fmt.Printf("%d ", i)
}
延时函数会以LIFO顺序执行,所以当函数返回时,这段代码会打印4 3 2 1 0。一个更合理的示例,以下是一个跟踪程序中函数执行的简单方法。我们可以编写几个简单的跟踪程序,如下所示:
func trace(s string) { fmt.Println("entering:", s) }
func untrace(s string) { fmt.Println("leaving:", s) }
// Use them like this:
func a() {
trace("a")
defer untrace("a")
// do something....
}
通过利用defer执行时计算延时函数参数这一特性,可以做得更好。trace程序可以为untrace程序设置参数。以下示例中:
func trace(s string) string {
fmt.Println("entering:", s)
return s
}
func un(s string) {
fmt.Println("leaving:", s)
}
func a() {
defer un(trace("a"))
fmt.Println("in a")
}
func b() {
defer un(trace("b"))
fmt.Println("in b")
a()
}
func main() {
b()
}
会打印:
entering: b in b entering: a in a leaving: a leaving: b
对于习惯于其他语言中块级资源管理的程序员来说,defer可能看起来很奇怪,但其最有趣和最强大的应用恰恰来自于它不是基于块,而是基于函数这一事实。其后,我们将会在panic和recover章节中看到它的另一个可能的例子。
数据
使用new进行分配
Go中有两个分配原语,内置函数new和make。它们所做的事情不同,并且适用于不同的类型。这可能会令人混淆,但规则很简单。我们来谈谈一下new:它是一个内置函数,可以分配内存,但与其它语言不同,它不会初始化内存,只会将内存置零。也就是说,new(T)会为类型为T的新项目分配归零存储并返回其地址,类型为*T的值。在Go术语中,它返回一个指向新分配的类型为T的零值的指针。
由于new所返回的内存会被置零,因此在设计数据结构时,可以使用每种类型的零值,而无需进一步初始化。这意味着,数据结构用户可以使用new创建该结构,并可以正常工作。例如,bytes.Buffer的文档中说胆:“Buffer的零值是一个可以使用的空缓冲区”;同样,sync.Mutex也没有显式构造函数或Init方法;相反,sync.Mutex的零值被定义为未锁定的互斥锁。
“零值可用”这一属性是可传递的。考虑这种类型声明:
type SyncedBuffer struct {
lock sync.Mutex
buffer bytes.Buffer
}
SyncedBuffer类型的值也可以在分配或仅声明后立即使用。在下面代码段中,p和v都可以正常工作而无需进一步安排。
p := new(SyncedBuffer) // type *SyncedBuffer var v SyncedBuffer // type SyncedBuffer
构造函数和复合字面量
有时,零值并不够好,并且需要初始化构造函数。如下派生自os包的示例:
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
}
有很多这样的模板,我们可以使用“复合字面量”(composite literal)来进行,复合字面量是一个表达式,每次取值时都会创建一个新实例。
func NewFile(fd int, name string) *File {
if fd < 0 {
return nil
}
f := File{fd, name, nil, 0}
return &f
}
请注意,与C不同,返回局部变量的地址是完全可以的;与函数关联的存储在函数返回后依然存在。实际上,获取复合字面量的地址在每次取值时都会分配一个新实例,因此我们可以将这两行结合起来。
return &File{fd, name, nil, 0}
复合字面量的字段按顺序排列,并且必须全部存在。但是,通过将元素明确标记为字字段:值(field:value)对,初始化设定项可以按任何顺序出现,缺少的元素为各自的零值。因此,可以写成:
return &File{fd: fd, name: name}
作为一种极端情况,如果复合字面量中不包含任何字段,则会为该类似创建零值。表达式new(File)和&File{}是等效的。
还可以为数组(Array)、切片(Slice)和映射(map)创建复合字面量,并且字段标签可以使用适当的索引或映射键。以下示例中,不管Enone、Eio或Einval的值是什么,只要它们不同,初始化就可以生效。
a := [...]string {Enone: "no error", Eio: "Eio", Einval: "invalid argument"}
s := []string {Enone: "no error", Eio: "Eio", Einval: "invalid argument"}
m := map[int]string{Enone: "no error", Eio: "Eio", Einval: "invalid argument"}
使用make进行分配
回到分配。内置函数make(T,args)与new(T)的用途不同。其用于创建切片(slice)、映射(map)和通道(channel),并且返回一个已初始化的(非零值),类型为T的值(而非*T)。之所以有抽不同,是因为这三种类型所对应的底层表示,要示在使用前必须初始化的数据结构的引用。例如,切片是一个三项描述符,包含指向数据的指针(在数组内)、长度和容量,并且在初始化这些项之前,切片是nil。对于切slice、map和channel,make会初始化内部数据结构,并准备好可用的值。如:
make([]int, 10, 100)
以上会创建一个容量为100个整型值的数组,然后创建一个长度为10且容量为100的切片结构,指向数组的前10个元素。(创建slice时,容量可以省略掉,更多信息参见slice章节。)相应的,new([]int)返回一个指向新分配的,被置零的slice结构体的指针,即指向nil切片值的指针。
以下示例展示了new和make之间的差异:
var p *[]int = new([]int) // allocates slice structure; *p == nil; rarely useful var v []int = make([]int, 100) // the slice v now refers to a new array of 100 ints // Unnecessarily complex: var p *[]int = new([]int) *p = make([]int, 100, 100) // Idiomatic: v := make([]int, 100)
请记住,make仅适用于映射、切片和通道,并且不会返回指针。要获取一个显式指针,应使用new分配或显式的使用一个变量的地址。
数组
在进行精细化的内存管理时,数组非常有用,有时候可以帮助避免分配。不过从根本上讲,它是切片的基本构件,这是下一节的主题,做为铺垫,这里介绍一下数组。
在Go和C中,数组工作方式有几个重要差异。在Go中:
- 数组是值类似。将一个数组赋值给另一个数组会复制所有元素。
- 特别是,将数组传递给函时,它将接收数组的副本,而不是指向它的指针。
- 数组的大小是其类型的一部分。类型
[10]int和[20]int是不同的。
数组是值类型的属性可能很有用,但也会有一些代价;如果你希望实现类似C的行为和效率,可以传递一个数组的指针:
func Sum(a *[3]float64) (sum float64) {
for _, v := range *a {
sum += v
}
return
}
array := [...]float64{7.0, 8.5, 9.1}
x := Sum(&array) // Note the explicit address-of operator
不过,这种风格并不符合Go的语言习惯。相反的,应该使用切片。
切片
切片(Sclice)对数组进行包装,以提供一个对数据序列更通用、强大且方便的接口。除了有显式维度的项目(如转换矩阵)之外,Go中大多数的数组编程都是通过切片完成的,而不是简单的数组。
切片保存对底层数组的引用,如果将一个切片赋值给另一个切片,则两者会引用相同的数组。如果函数接受一个片参数,那么其对切片的修改对调用者是可见的,类似于传递了一个底层数组的指针。因此,Read函数可以接受一个切片参数,而不是一个指针和计数;切片内的长度己设置了要读取数据量的上限。以下是os包中File类型的Read方法的签名:
func (file *File) Read(buf []byte) (n int, err error)
该方法返回读取的字节数和错误值(如果有)。如果要读入更大缓冲区buf的前32个字节,可对缓冲区进行切片(这里是动词)。
n, err := f.Read(buf[0:32])
这种切片很常见且有效。实际上,如果暂不考虑效率,以下代码段也可以读取缓冲区的前32个字节
var n int
var err error
for i := 0; i < 32; i++ {
nbytes, e := f.Read(buf[i:i+1]) // Read one byte.
n += nbytes
if nbytes == 0 || e != nil {
err = e
break
}
}
只要切片仍然适合底层数组的限制,就可以改变切片的长度;直接将其赋值给切片自已即可。切片的容量可以通过内置函数cap访问,其会返回切片可以使用的最大长度。
nil用于切片时len和cap是合法的,并会返回0
func Append(slice, data[]byte) []byte {
l := len(slice)
if l + len(data) > cap(slice) { // reallocate
// Allocate double what's needed, for future growth.
newSlice := make([]byte, (l+len(data))*2)
// The copy function is predeclared and works for any slice type.
copy(newSlice, slice)
slice = newSlice
}
slice = slice[0:l+len(data)]
for i, c := range data {
slice[l+i] = c
}
return slice
}
其后,我们必须返回切片,因为虽然Append可以修改slice的元素,便切片本身(包含指针,长度和容量的运行时数据结构)是按值传递的。
为切片添加元素的想法非常有用,可以通过内置的append函数实现。要了解该函数的设置,我们还需要更多的信息,所以我们还会在后面讨论。
二维切片
Go的数组和切片是一维的。要创建二维数组或切片的等效项,需要定义一个数组数组或切片,如下所示:
type Transform [3][3]float64 // A 3x3 array, really an array of arrays. type LinesOfText [][]byte // A slice of byte slices.
因为切片是可变长度的,所以可以使每个内部切片具有不同的长度。这是一种常见情况,如我们在LinesOfText的示例中:每一行都有一个独立的长度。
text := LinesOfText{
[]byte("Now is the time"),
[]byte("for all good gophers"),
[]byte("to bring some fun to the party."),
}
有时需要分配二维切片,如用于处理扫描像素行时。有两种方法可以实现:一种是独立分配每个切片;另一种是分配单个数组,并将其用于各个切片。使用哪种方式取决于你的应用。如果切片可能会增长或缩小,则应单独分配它们,以避免覆盖下一行;如果不是,则使用单个分配构造对象可能更有效。以下是两种实现概要,以供参考。首先,是一次一行:
// Allocate the top-level slice.
picture := make([][]uint8, YSize) // One row per unit of y.
// Loop over the rows, allocating the slice for each row.
for i := range picture {
picture[i] = make([]uint8, XSize)
}
然后是一次分配,并用于多行:
// Allocate the top-level slice, the same as before.
picture := make([][]uint8, YSize) // One row per unit of y.
// Allocate one large slice to hold all the pixels.
pixels := make([]uint8, XSize*YSize) // Has type []uint8 even though picture is [][]uint8.
// Loop over the rows, slicing each row from the front of the remaining pixels slice.
for i := range picture {
picture[i], pixels = pixels[:XSize], pixels[XSize:]
}
Maps
Map(映射)是一种方便且功能强大的内置数据结构,它会将一种类型值(key)(键)与另一种类型(element或value)(元素或值)的值相关联。key可以是任务定义了相等运算符的类型,如:整数、浮点数和复数、字符串、指针、接口(只要动态类型支持相等)、结构和数组。切片不能做为映射的key,因为它没有定义相等性。与切片类似,映射保持了对底层数据结构的引用。果将映射传递给函数,则其对映射内容的修改,对调用者是可见的。
映射通常可以用复合文字语法,冒号分隔的key-value来构建,因此在初始化期间可以很容易的构建射。
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,
}
获取和设置映射上的值与切片和数组类似,只是索引不需要是一个整数。
offset := timeZone["EST"]
尝试使用映射中不存在的键获取值时,将会获取映中元素类型的零值。例如,如果映射中包含整数,查找不存在的键时将会返回0。如下所示,可以将值类型为bool的映射来实现一个集合,将映射项设置为true以将值放入集合中,然后通过简单的索引来对其进行测试:
attended := map[string]bool{
"Ann": true,
"Joe": true,
...
}
if attended[person] { // will be false if person is not in the map
fmt.Println(person, "was at the meeting")
}
有时你需要区分没有的项与零值。是否有UTC条目或是0,或者其不存在于映射中。这时,可以通过多赋值的形式区分:
var seconds int var ok bool seconds, ok = timeZone[tz]
这种用法被形象的称为"comma ok"(逗号确认)。在这个示例中,如果存在tz,则会将seconds设置为适当的值,并将ok设置为true;如果没有,seconds将会设置为零值,ok将为false。以下是一个函数示例,其会进行一个友好的错误报告:
func offset(tz string) int {
if seconds, ok := timeZone[tz]; ok {
return seconds
}
log.Println("unknown time zone:", tz)
return 0
}
如果只测试是否在映射中存在,而不关心实际的值,可以将通常使用变量的地方换成空白标识符(_)。
_, present := timeZone[tz]
要删除映射的项,可以使用delete内置函数,其参数是映射和要删除的key。即使映射上已经没有该key,这样做也是安全的。
delete(timeZone, "PDT") // Now on Standard Time
打印输出
Go中的格式化打印使用了类似于C的printf系列的样式,但更加丰富和通用。这些函数位于fmt包中,并具有大写名称:fmt.Printf、fmt.Fprintf、fmt.Sprintf等。字符串函数(Sprintf等)会返回一个字符串而不是填充提供的缓冲区。
你不需要提供格式字符串。对于Printf、Fprintf、Sprintf都有另一个对应的函数,如Print和Println。这些函数不使用格式字符串,而是为每个参数生成一个缺省格式。Println版本的还会参数之间插入空格,并添加一个换行符;而Print版本仅在两个操作数都是字符串时才添加空格。以下示例中,每行都会产生相同的输出:
fmt.Printf("Hello %d\n", 23)
fmt.Fprint(os.Stdout, "Hello ", 23, "\n")
fmt.Println("Hello", 23)
fmt.Println(fmt.Sprint("Hello ", 23))
格式化打印函数fmt.Fprint等,接受的第一个参数为任何实现了io.Writer接口的对象;变量os.Stdout和os.Stderr是常见实例。
接下来就和C不一样了。首先,像%d等数字格式不带正负号标记或大小;相反的,打印程序使用参数的类型来决定这些属性。
var x uint64 = 1<<64 - 1
fmt.Printf("%d %x; %d %x\n", x, x, int64(x), int64(x))
输出:
18446744073709551615 ffffffffffffffff; -1 -1
如果只想要默认转换,如十进制整数,则可能使用通用格式%v(表示"value");这正式Print和Println生成的结果。而且核格式可以打印任何值,甚至是数组、切片、桔构体和映射。以下是上一节中自定义时区映射的打印语名:
fmt.Printf("%v\n", timeZone) // or just fmt.Println(timeZone)
其输出为:
map[CST:-21600 EST:-18000 MST:-25200 PST:-28800 UTC:0]
对于映射,会按键的字典顺序对输出进行排序。
打印结构时,带修饰的格式%+v会使用结构体的字段名称对其注释。对于任何值,%#v格式会按照完整的Go语法进行打印。
type T struct {
a int
b float64
c string
}
t := &T{ 7, -2.35, "abc\tdef" }
fmt.Printf("%v\n", t)
fmt.Printf("%+v\n", t)
fmt.Printf("%#v\n", t)
fmt.Printf("%#v\n", timeZone))
输出为:
&{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或[]byte类型的值时,引用字符串格式也可通过%q获得。(%q格式也适用于整型和符文,它会产生一个带单引号的符文产量)此外,%x适用于字符串、字节数组和字节切片及整数,会生成长十六进制字符串,并且如果格式带有空格(%x),其会在字节之间插入空格。
另一个比较方便的格式是%T,其会打印出值的类型:
fmt.Printf("%T\n", timeZone)
会输出:
map[string] int
如果你想控制自定义类型的默认格式,只需要为其定义一个签名为String() string的方法。如,我们有一个简单类型T,其可能是这样的:
func (t *T) String() string {
return fmt.Sprintf("%d/%g/%q", t.a, t.b, t.c)
}
fmt.Printf("%v\n", t)
会按以下格式打印:
7/-2.35/"abc\tdef"
(如果你需要打印T类型的值,以及指向T的指针,那么String的接收者必须为值类型;本示例使用了指针,因为对于结构类型而言,它更有效且更惯用。请参见下面章节,以了解更多信息:指针与指针接收者。)
我们的String方法能够调用Sprintf,因为打印例程是完全可重入的,并且可以通过这种方式包装。关于此方法,有一个重要的细节要理解:不要通过无限制的递归调用String方法的方式调用Sprintf,来构造String方法。如果Sprintf调用尝试将接收者直接打印为字符串,而字符串又会再次调用该方法,则可能会发生这种情况。如本例所示,这是一个常见且易犯的错误。
type MyString string
func (m MyString) String() string {
return fmt.Sprintf("MyString=%s", m) // Error: will recur forever.
}
这也很容易解决:将参数转换为没有方法的基本字符串类型。
type MyString string
func (m MyString) String() string {
return fmt.Sprintf("MyString=%s", string(m)) // OK: note conversion.
}
在初始化章节,我们将看到另一种避免递归的方式。
另一种打印技术是,将一个打印程序(routine)直接传给另一个这样的程序。Printf的签名使用...interface{}类型作为其最后一个参数,以指定可以在格式之后显示任意数量的参数(任意类型)。
func Printf(format string, v ...interface{}) (n int, err error) {
在函数Printf中,v的作用类似于[]interface{}类型的变量,但如果将其传递给另一个可变参数的函数,则其作用类似于常规的参数列表。这是一个我们对上面用到的函数log.Println的实现。它将其参数直接传给fmt.Sprintln来做实际的格式化。
// Println prints to the standard logger in the manner of fmt.Println.
func Println(v ...interface{}) {
std.Output(2, fmt.Sprintln(v...)) // Output takes parameters (int, string)
}
我们在嵌套调用中的v后面写了...,以告诉编译器将v视为参数列表;否则它将会仅将v作为单个切片参数传递。
除了我们这里介绍的之外,有很多打印相关技术。更多相关内容,请参见godoc文档中fmt包的介绍。
顺便说一句,...是特定类型,例如,...int用于min函数中选择最小整数列表:
func Min(a ...int) int {
min := int(^uint(0) >> 1) // largest int
for _, i := range a {
if i < min {
min = i
}
}
return min
}
内置函数append
现在,我们来了解下append内置函数的设计。append的签名与上面的自定义Append函数不同。类似如下:
func append(slice []T, elements ...T) []T
其中,T是任何指定类型的占位符。在Go,你实际上不能编写一个由调用者确定类型T的函数。这就是内置append的原因:它需要编译器的支持。
append的作用是将元素追加到切片的末尾,并返回结果。需要返回结果,是因为和我们手写的Append一样,底层数组可能会更改。一个简单的示例:
x := []int{1,2,3}
x = append(x, 4, 5, 6)
fmt.Println(x)
会打印[1 2 3 4 5 6]。所以,其工作方式类似于Printf,收集任意数量的参数。
但是,如果我们想像Append那样,怎么给切片添加一个切片?很简单:在调用处使用...,就像上面调用Output一样。以下代码段会和上面相同的输出:
x := []int{1,2,3}
y := []int{4,5,6}
x = append(x, y...)
fmt.Println(x)
如果没有...,会因为类型错误而无法编译;y不是int类型。
初始化
尽管从表面来看,Go的初始化与C或C++中的初始化没有太大区别,但其功能更强大。可以在初始化过程中构建复杂的结构,并且能够正确处理初始化对象之间(甚至不同包之间)的顺序问题。
常量
Go中的常量仅仅是-常量。它们是在编译时创建的,即使在函数被定义在函数局部也是如此,并且只能是数字、字符(符文)、字符串或布尔值。由于编译时的限制,定义它们的表达式必须是编译时可求值的表达式。例如:1<<3是一个常量表达式,而math.Sin(math.Pi/4不是,因为math.Sin在运行时才会发生函数调用。
在Go中,使用iota枚举器创建枚举常量。因为iota可以是表达式的一部分,并且表达式可以隐式重复,因此可以轻松构建复杂的值集。
type ByteSize float64
const (
_ = iota // 通过分配给空白标识符来忽略第一个值
KB ByteSize = 1 << (10 * iota)
MB
GB
TB
PB
EB
ZB
YB
)
可以将如String之类的方法,附加到任何用户定义的类型上,这种的能力使得任意值都可以自动格式化打印其自身。虽然你会看到它常用于结构,但该技术对于标量类型(如:ByteSize之类的浮点类型)也很有用。
func (b ByteSize) String() string {
switch {
case b >= YB:
return fmt.Sprintf("%.2fYB", b/YB)
case b >= ZB:
return fmt.Sprintf("%.2fZB", b/ZB)
case b >= EB:
return fmt.Sprintf("%.2fEB", b/EB)
case b >= PB:
return fmt.Sprintf("%.2fPB", b/PB)
case b >= TB:
return fmt.Sprintf("%.2fTB", b/TB)
case b >= GB:
return fmt.Sprintf("%.2fGB", b/GB)
case b >= MB:
return fmt.Sprintf("%.2fMB", b/MB)
case b >= KB:
return fmt.Sprintf("%.2fKB", b/KB)
}
return fmt.Sprintf("%.2fB", b)
}
上面的YB表达式会打印出1.00YB,而ByteSize(1e13)会打印出9.09TB。
在这里,使用Sprintf来实现ByteSize的String方法是安全的(避免无穷递归),这并不是因为做了转换,而是因为使用%f调用了Sprintf,它不是字符串格式:Sprintf仅在需要字符串时才会调用String方法 ,而%f想要一个浮点值。
变量
变量可以像常量一样被初始化,但是初始值可以是在运行时计算的通用表达式。
var (
home = os.Getenv("HOME")
user = os.Getenv("USER")
gopath = os.Getenv("GOPATH")
)
init函数
最后,每个源文件都可以定义自己的无参初始化函数(niladic init),以设置其需的状态。(实际上,每个文件可以有多个init函数。)最后意味着:init是在程序包中的所有变量声明都被初始化后,并且在所有导入的程序包中变量初始化后才被调用。
除了不能通过声明表示的初始化外,init函数的常见用法是,在实际执行开始之前验证或修复程序状态的正确性。
func init() {
if user == "" {
log.Fatal("$USER not set")
}
if home == "" {
home = "/home/" + user
}
if gopath == "" {
gopath = home + "/go"
}
// gopath may be overridden by --gopath flag on command line.
flag.StringVar(&gopath, "gopath", gopath, "override default GOPATH")
}
方法
指针与值
正如我们在ByteSize看到的,任何命名类型都可以定义方法(指针和接口除外);接收者(receiver)不必为一个结构体。
在上面关于切片的讨论中,我们写了一个Append函数。我们还可以将其定义成切片方法。为此,我们需要首先声明一个用于绑定方法的命名类型,然后将方法的接口者做为该类型的值。
type ByteSlice []byte
func (slice ByteSlice) Append(data []byte) []byte {
// Body exactly the same as the Append function defined above.
}
这仍需要方法返回更新后的切片。我们可以通过重新定义方法,将一个ByteSlice指针做为接收者,以消除这种笨拙方式。这样,方法就可以改写调用者切片。
func (p *ByteSlice) Append(data []byte) {
slice := *p
// Body as above, without the return.
*p = slice
}
实际上我们可以做的更好。如果我们将方法改写成像标准的Write,像这样:
func (p *ByteSlice) Write(data []byte) (n int, err error) {
slice := *p
// Again as above.
*p = slice
return len(data), nil
}
然后*ByteSlice类型就会标准的io.Writer,这样会很方便。例如,我们可以打印成:
var b ByteSlice
fmt.Fprintf(&b, "This hour has %d days\n", 7)
我们传递ByteSlice的地址,是因为只有*ByteSlice满足io.Writer。关于指针与接收者值的规则是:可以在指针和值上调用值方法,但只能在指针上调用指针方法。
之所以这样,是因为指针方法可以修改接收者,在值上调用它们会导致方法接收该值的副本。但有一个方便的例外,当该值可寻址时,语言将通过自动插入地址运算符来处理在值上调指针方法的常见情况。在我们的示例中,变量b是可寻址的,因为我们仅可使用b.Write调用其Write方法,编译器会将其重写为(&b).Write。
顺便提一下,在字节切片上使用Write的思想,对于bytes.Buffer的实现至关重要的。
接口与其它类型
接口
Go中的接口提供了一种指定对象行为的方式:如果可以做到这一点,那么就可以在这里使用它。我们已经看过几个简单的例子;自定义打印可以通过String方法实现,而Fprintf可以使用Write方法生成任何内容的输出。只有一个或两个方法的接口在Go代码中很常见,并且通常使用从该方法派生的名称,如:实现Write的io.Writer。
一个类型可以实现多个接口。例如,如果一个集合实现了sort.Interface,其中包含Len()、Less(i, j int) bool和Swap(i, j int),那么就可以通过程序包中的sort来对其排序,同时它还可以有一个自定义格式化器。以下示例中的Sequence同时符合这些条件:
type Sequence []int
// Methods required by sort.Interface.
func (s Sequence) Len() int {
return len(s)
}
func (s Sequence) Less(i, j int) bool {
return s[i] < s[j]
}
func (s Sequence) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
// Copy returns a copy of the Sequence.
func (s Sequence) Copy() Sequence {
copy := make(Sequence, 0, len(s))
return append(copy, s...)
}
// Method for printing - sorts the elements before printing.
func (s Sequence) String() string {
s = s.Copy() // Make a copy; don't overwrite argument.
sort.Sort(s)
str := "["
for i, elem := range s { // Loop is O(N²); will fix that in next example.
if i > 0 {
str += " "
}
str += fmt.Sprint(elem)
}
return str + "]"
}
转换
Sequence的String方法重复了Sprint对切片的工作。(它的复杂度为O(N²),很差。)如果在调用Sprint之前将Sequence转换为普通的[]int,则可以共享所有工作(并加快速度)。
func (s Sequence) String() string {
s = s.Copy()
sort.Sort(s)
return fmt.Sprint([]int(s))
}
此方法是转换技术的另一个示例,其会从String方法安全地调用Sprintf。因为如果忽略类型名称,这两种类型(Sequence和[]int)是相同的,所以在它们之前进行转换是合法的。该转换不会创建新值,只不过暂时使现有值有一个新类型。(还有其它一些合法的转换会创建新什,例如:从整数到浮点的转换)
在Go程序中,习惯用法是对表达式的类型进行转换,以访问不同的方法集。例如,我们可以使用已有的类型sort.IntSlice来对整个示例进行简化:
type Sequence []int
// Method for printing - sorts the elements before printing
func (s Sequence) String() string {
s = s.Copy()
sort.IntSlice(s).Sort()
return fmt.Sprint([]int(s))
}
现在,我们不再用Sequence实现多个接口(排序和打印),而是利用了将数据项转换为多种类型(Sequence、sort.IntSlice和[]int)的能力,每种类型都可以完成某些工作。 在实际中,这种情况不常见,但却很有效。
接口转换与类型断言
类型选择(Type switch)是一种转换形式:它接受一个接口,并且对于switch中的每种case,并在某种意义上都将其转换为该种类型。以下代码中的fmt.Printf通过类型switch将值转换为字符串的简化版本。如果已经是字符串,则我们需要接口保留实际的字符串值,如果它有String方法,则我们需要调用该方法的结果。
type Stringer interface {
String() string
}
var value interface{} // Value provided by caller.
switch str := value.(type) {
case string:
return str
case Stringer:
return str.String()
}
第一种情况会找到具体的价值;第二种将接口转换为另一个接口。以这种方式混合类型就没有问题了。
如果我们只关心一种类型该怎么办?如果我们知道值包含一个字符串,只想将它取出来该怎么做?只有一个case的类型switch也是可以的,还可以使用类型断言。类型断言接受一个接口值,并从中提取显式指定的类型的值。其语法借鉴了类型switch子句,只不过使用了显式类型,而不是type关键字:
value.(typeName)
结果是静态类型typeName的新值。该类型必须是接口所持有的具体类型,或者是可以将值转换为的第二种接口类型。为了提取已知的值中的字符串,可以这样写:
str := value.(string)
但是,如果该值实际不包含字符串,那么程序会因运行时错误而崩溃。为了避免这种情况,请使用“comma, ok”惯用法来安全地测试该值是否为字符串:
str, ok := value.(string)
if ok {
fmt.Printf("string value is: %q\n", str)
} else {
fmt.Printf("value is not a string\n")
}
如果类型断言失败,str依然会存在,并且类型为字符串,不过其为零值,一个空字符串。
这里有一个if-else语句的实例,其效果等价于本章开始的类型switch示例。
if str, ok := value.(string); ok {
return str
} else if str, ok := value.(Stringer); ok {
return str.String()
}
概述
如果类型仅用于实现接口,并且除了该接口外不会导出其它方法,则就不需要导出类型本身。仅导出接口,即可清楚地知道该值除了接口中描述的内容外,并没有其他行为。它还避免了需要在通用方法的每个实例上进行重复的文档介绍。
在这种情况下,构造函数应返回接口值,而不是实现类型。例如,在hash库中,crc32.NewIEEE和adler32.New都返回接口类型hash.Hash32。在Go程序中,将CRC-32算法替换为Adler-32,只需更修改构造函数调用即可;其余代码都不受影响。
类似的方式,可以将不同crypto包中的流密码算法,与它们链接在一起的块密码分开。crypto/cipher程序包中的Block接口指定了块密码的行为,即对单个数据块进行加密。然后,类似于bufio包,实现该接口的密码包可用于构造以Stream接口表示的流密码,而无需了解加密块的详细信息。
crypto/cipher接口如下所示:
type Block interface {
BlockSize() int
Encrypt(dst, src []byte)
Decrypt(dst, src []byte)
}
type Stream interface {
XORKeyStream(dst, src []byte)
}
这是计数器模式(CTR)流的定义,它将块密码转换为流密码。请注意,分组密码的详细信息已被抽象掉:
// NewCTR returns a Stream that encrypts/decrypts using the given Block in // counter mode. The length of iv must be the same as the Block's block size. func NewCTR(block Block, iv []byte) Stream
NewCTR不仅适用于一种特定的加密算法和数据源,而且适用于Block接口和任何Stream。因为它们返回接口值,所以用其他加密模式替换CTR加密是一个本地化的修改。构造函数调用必须修改,但是由于上下文代码必须仅将结果视为Stream,因此不会注意到差异。
接口与方法
由于几乎所有内容都可以附加方法,因此几乎所有内容都可以满足接口。一个示例,http包中定义了Handler接口。任何实现Handler的对象都可以处理HTTP请求。
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
ResponseWriter本身是一个接口,提供对将响应返回给客户端所需的方法的访问。这些方法包括标准的Write方法,因此可以在可使用io.Writer的任何地方使用http.ResponseWrite。请求(Request)是一个结构,其中包含来自客户端的请求的解析表示。
为简单起见,我们会忽略POST,并假设HTTP请求总是GET;这种简化不会影响处理程序的设置方式。以下一个简单但完整的handler实现,用于计算访问页面的次数。
// Simple counter server.
type Counter struct {
n int
}
func (ctr *Counter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
ctr.n++
fmt.Fprintf(w, "counter = %d\n", ctr.n)
}
(另外,应注意Fprintf如何打印到http.ResponseWriter的。)作为参考,以下是将这种服务添加到URL树上的节点的方法。
import "net/http"
...
ctr := new(Counter)
http.Handle("/counter", ctr)
为什么Counter做为一个结构体?只需要一个整数就可以了。(接收者必须是一个指针,这样增量才能对调用者可见。)
// Simpler counter server.
type Counter int
func (ctr *Counter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
*ctr++
fmt.Fprintf(w, "counter = %d\n", *ctr)
}
如果你的程序有一些内部状态,需要通知已访问页面怎么办?可以将一个频道(channel)绑定到网页。
// A channel that sends a notification on each visit.
// (Probably want the channel to be buffered.)
type Chan chan *http.Request
func (ch Chan) ServeHTTP(w http.ResponseWriter, req *http.Request) {
ch <- req
fmt.Fprint(w, "notification sent")
}
最后,假设我们要在/args上显示调用二进制服务器时使用的参数。可以很容易的编写一个函数以打印参数。
func ArgServer() {
fmt.Println(os.Args)
}
我们怎么将它转为HTTP服务呢?可以使用将ArgServer创建为某种类型的方法,该方法的值我们会忽略,但是有一种更简洁的方法。由于我们可以为除指针和接口之外的任何类型定义方法,因此我们可以为函数编写方法。http程序包中包含以下代码:
// The HandlerFunc type is an adapter to allow the use of
// ordinary functions as HTTP handlers. If f is a function
// with the appropriate signature, HandlerFunc(f) is a
// Handler object that calls f.
type HandlerFunc func(ResponseWriter, *Request)
// ServeHTTP calls f(c, req).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, req *Request) {
f(w, req)
}
HandlerFunc是一个类型,它有一个方法ServeHTTP,所以该类型的值可以提供HTTP请求服务。看一下该方法的实现:接收者是一个函数f,并且该方法调用f。这看起来可能很奇怪,但是与接收者是channel,并在在该channel上发送方法没有什么不同。
为了使ArgServer成为HTTP服务器,我们首先将其签名修改正确。
// Argument server.
func ArgServer(w http.ResponseWriter, req *http.Request) {
fmt.Fprintln(w, os.Args)
}
现在ArgServer和HandlerFunc有相同的签名,因此可以将其转换为那个类型以访问其方法,就像我们将Sequence转换为IntSlice以访问IntSlice.Sort一样。代码实现很简洁:
http.Handle("/args", http.HandlerFunc(ArgServer))
当有人访问页面/args时,在该页面上安装的处理程序就有值ArgServer和类型HandlerFunc。HTTP服务器将使用ArgServer作为接收者,来调用该类型的方法ServeHTTP,该方法会通过在HandlerFunc.ServeHTTP内部的调用f(w, req)来调用ArgServer。 然后将显示参数。
在本节中,我们通过结构、整数、channel和函数创建了HTTP服务器,这都是因为接口只是方法集,可以(几乎)定义任何类型。
空白标识符
在for range循环和映射(maps)的章节,我们已经多次提及“空白标识符”。空白标识符可以赋值给任何变量,或声明为任意类型,并且可以无害地丢弃该值。这有点像向Unix的/dev/null文件写入数据:它表示需要有值,但与实际值无关的占位符。它的用途我们已经看到的用途还要多。
空白标识符在多赋值语句中使用
在for rangee循环中,使用空白标识符是一种特殊情况:多重分配。
在赋值在左侧有多个值,但程序不会使用其中一个值,那么就可以用空白标识符占位,以避免创建虚拟变量,并明确说明:该值将被丢弃。
例如,当调用一个函数其会返回一个值和一个错误,我们仅关心错误时,就可以用空白标识符占位,以丢弃不相关的值。
if _, err := os.Stat(path); os.IsNotExist(err) {
fmt.Printf("%s does not exist\n", path)
}
有时,你会发现一些代码用空白标识符对错误占位,以忽略错误信息,这种做法并不好。好的实现是,总应该检查返回的错误值,因为它会告诉我们错误发生的原因。
// Bad! This code will crash if path does not exist.
fi, _ := os.Stat(path)
if fi.IsDir() {
fmt.Printf("%s is a directory\n", path)
}
未使用的导入和变量
导入包或声明变量而不使用它,会引起编译错误。因为,导入未使用的包会使程序变得臃肿,也w会降低编译效率;而己初始化但未使用的变量,会浪费计算量,甚至引用严重的Bug。但程序开发中,经常会出现未使用的导入和变量,为了使编译器不报错我们需要将其删除,而在需要时又要再添加,这会比较麻烦,这时可以使用空白标识符。
下面这个编写中的程序中有两个未使用的导入(fmt和io)和一个未使用的变量(fd),因此它不会编译通过。但我们希望它现在就能通过编译:
package main
import (
"fmt"
"io"
"log"
"os"
)
func main() {
fd, err := os.Open("test.go")
if err != nil {
log.Fatal(err)
}
// TODO: use fd.
}
为了使编译器不因为未使用导入包而报错,我们可以用空白标识符来引用一个被导入包中的符号。同样,也可以将未使用的变量fd赋值给一个空白标识符,以使编译错误静默。这个版本的程序就可以编译通过了。
package main
import (
"fmt"
"io"
"log"
"os"
)
var _ = fmt.Printf // For debugging; delete when done.
var _ io.Reader // For debugging; delete when done.
func main() {
fd, err := os.Open("test.go")
if err != nil {
log.Fatal(err)
}
// TODO: use fd.
_ = fd
}
按照惯例,临时取消未使用导入错误的全局声明语句必须紧随导入语句块之后,并需要注释,以使其易于查找,以提醒以后进行清理。
有副作用的导入
最终应该使用或删除上一个示例中未使用的导入,例如fmt或io:空白分配是一种临时措施,表示正在进行的工作。但是有时导入软件包仅为了一些副作用,而无需任何显式使用。例如,net/http/pprof包会在其导入期间调用init函数,该函数会注册HTTP处理程序以提供调试信息。这个包也有导出的API,但是大多数客户端只需要注册处理程序,以通过其访问Web数据。如果出于副作用的需要而导入软件包,请将软件包重命名为空白标识符:
import _ "net/http/pprof"
这种导入形式可以清楚地知道该软件包是出于其副作用而被导入的,因为该软件包没有其他可能的用途:在此文件中,它没有名称。(如果导入包不是匿名的,而在程序中我们又没有使用该名称,编译器将会报错。)
接口检查
正如我们在上面关于接口的讨论中所看到的,类型不必明确表示它所实现的接口。相反,类型要实现方法,仅需实现接口的方法即可。实际上,大多数接口转换都是静态的,会在编译时进行检查。例如,将*os.File类型传给接受io.Reader参数的函数将不会编译,除非*os.File实现了io.Reader接口。
但是,有些接口检查是在运行时发生。encoding/json包中有一个实例,它定义了Marshaler接口。当JSON编码器收到实现该接口的值时,编码器将调用该值的marshaling方法将其转换为JSON,而不是执行标准转换。编译器会利用<类型断言机制在运行时进行类型检查:
m, ok := val.(json.Marshaler)
如果只想知道某个类型是否实现了一个接口,而不实际使用该接口本身(可能作为错误检查的一部分),则可以使用空白标识符忽略类型声明的值:
if _, ok := val.(json.Marshaler); ok {
fmt.Printf("value %v of type %T implements json.Marshaler\n", val, val)
}
在某些情况下,我们需要确保在包的内部某类型必须实现了某个接口。如果某个类型(例如json.RawMessage)需要自定义JSON表示形式,则应实现json.Marshaler,但是没有静态转换会导致编译器自动对此进行验证。如果类型不完全满足该接口,则JSON编码器仍将起作用,但将不使用自定义实现。为了确保实现正确,可以在包中利用空白标识符做一个全局声明:
var _ json.Marshaler = (*RawMessage)(nil)
在此声明中,赋值导致了从*RawMessage到Marshaler的类型转换,这要求*RawMessage实现了Marshaler,并将在编译时检查该属性。如果json.Marshaler接口发生更改,则此程序包将不再编译,我们知道需要对其进行更新。
在这个结构中出现的空白标识符表示该声明仅存在于类型检查中,而不用于创建变量。但是,不要对满足接口的所有类型都执行此操作。按照惯例,只有在代码没有静态转换时才使用此类声明,这种情况很少见。
内嵌(Embedding)
Go没有提供典型的类型驱动的派生类概念,但确可以通过内嵌其它结构或接口代码的方式来实现类似的功能。
接口嵌入非常简单。前面我们已经提到了io.Reader和io.Writer接口,这是他们的定义。
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
io包还提供了其他几个接口,这些接口指定可以实现多种此类方法的对象。例如,io.ReadWriter它同时包含了Read和Write两个接口。我们可以通过显式列出Read和Write来定义io.ReadWriter,但是内嵌这两种接口以形成新的接口更容易,也使代码显得更加简洁、直观:
// ReadWriter is the interface that combines the Reader and Writer interfaces.
type ReadWriter interface {
Reader
Writer
}
也就是说:ReadWriter类型可以执行Read和Write的功能,它是这些内嵌接口的合并集(这些内嵌接口必须是一组不相干的方法)。只有接口可以嵌入接口中。
类似的想法也可以用于结构,但会稍微复杂一些。在bufio包中有两种结构类型:bufio.Reader和bufio.Writer,他们都实与io包中类似的接口。而且bufio还有一个缓冲reader/writer类型,可以通过将reader和writer内嵌到一个结构体中来实现:在结构体中,只列出了两种类型,而未提供字段名。
// ReadWriter stores pointers to a Reader and a Writer.
// It implements io.ReadWriter.
type ReadWriter struct {
*Reader // *bufio.Reader
*Writer // *bufio.Writer
}
嵌入的元素是指向结构的指针,当然,必须先将其初始化为指向有效的结构,然后才能使用它们。 ReadWriter结构可以写成:
type ReadWriter struct {
reader *Reader
writer *Writer
}
为了使各字段对应的方法能满足io的接口规范,我们还需要提供如下的方法:
func (rw *ReadWriter) Read(p []byte) (n int, err error) {
return rw.reader.Read(p)
}
通过直接内嵌结构,可以避免复杂的记录。所有内嵌类型的方法可以不受约束的使用,也就是说bufio.ReadWriter不仅有bufio.Reader和bufio.Writer方法,而且还满足io.Reader、io.Writer和io.ReadWriter三个接口。
“内嵌”与“派生类”之间有一个重要的区别。当我们嵌入一个类型时,该类型所有方法都会成为外部类型的方法,但当调用它们时,该方法的接收者是内部类型,而不是外部类型。在我们的示例中,调用bufio.ReadWriter的Read方法时,其效果与调用我们刚刚实现的Read方法相同。只不过前者是ReadWriter的reader字段,而非ReadWriter本身。
内嵌还有一种更简单的方式,以下示例展示了如何将内嵌字段和一个普通的命名字段同时放在一个结构体定义中:
type Job struct {
Command string
*log.Logger
}
Job类型现在有了 Print、Printf、Println及*log.Logger所有其它方法。当然,我们可以给Logger指定一个字段名,但是没有必要这样做。现在,初始化后,我们就可以在Job调用日志记录功能了:
job.Log("starting now...")
Logger是Job结构的一个常规字段,因此我们可以在Job的构造函数中按通常方式对其进行初始化,如下所示:
func NewJob(command string, logger *log.Logger) *Job {
return &Job{command, logger}
}
或写成以下形式:
job := &Job{command, log.New(os.Stderr, "Job: ", log.Ldate)}
如果我们需要直接引用一个内嵌字段,那么该字段的类型名称(忽略包限定符)将用作字段名称,就像ReadWriter结构的Read方法中一样。在这里,如果我们可以用job.Logger来访问Job类型变量job的*log.Logger字段。当需要修改Logger的方法时,这种引用方式会很有用。
func (job *Job) Printf(format string, args ...interface{}) {
job.Logger.Printf("%q: %s", job.Command, fmt.Sprintf(format, args...))
}
内嵌类型会引起命名冲突的问题,但是解决冲突也很简单。首先,一个名为<X的字段或方法,可以将其他任何项隐藏在该类型的更深层嵌套中。如果log.Logger中也包含一个称为Command的字段或方法,那么可以用Job的Command字段对其进行访问封装。
另外,如果相同的名称出现在同层嵌套中,通常是错误的。如果Job结构包含另一个称为Logger的字段或方法,则内嵌log.Logger是错误的。但是,如果这个名称没有在类型定义之外地方使用,则可以。这一限定为从外部内嵌的类型更改提供了一些保护;如果添加的字段与另一个子类型中的另一个字段发生冲突(如果这两个字段都不曾使用过),则没有问题。
并发
以通信实现共享
并发编程是一个很大的话量,这里仅讨论一些Go语言特有的亮点。
在并发编程中,为实现变量共享要处理很多细节,这使得并发编程变的很复杂。Go鼓励开发者采用一种不同的方法,在这种方法中共享变量在Channel(通道)上相互传递(实际并没有真正在不同的执行线程间共享数据)的方式解决变量共享的问题。在任何时间,只有一个Goroutine可以访问该变量的值。按这一设计,数据争用
实现对共享变量的正确访问所需的微妙之处使得在许多环境中进行并行编程变得很困难。 Go鼓励采用一种不同的方法,在这种方法中,共享值在通道上传递,实际上,决不由单独的执行线程主动共享。在任何给定时间,只有一个goroutine可以访问该值。因此,在设计就避免了数据争用。为了鼓励这种思维方式,我们将其简化为一个口号:
不要以共享内存进行通信;而是以通信的方式共享内存。
这种方式可应用的更广。例如,“引用计数”最好的使用方式是通过在一个共享歌数周围加一互斥锁实现,但做为一种更高级的方法,可以使用Channel来控制共享权限,以使程序变得更加容易和正确。
考虑一种分析上述模型的方法,我们只是在处理传统的单线程程序。该程序仅运行在一个CPU上,无而考虑同步原语;现在运行另一个这样的实例,它也不需要同步。然后让这两个实例进行通信。如果通信也是同步原语,那么它会是系统中仅有的同步原语。例如,Unix系统的管道(Pipeline)就非常适合此模型。尽管Go语言的并发模型源自Hoare的CPS模型(Communicating Sequential Processes - 通信顺序进程),但它也可以看做Unix管道的类型安全的泛化。
Goroutine
之所以称之为goroutine,是因为现有术语(线程、协程、进程等)都不足以准确描述其含义。Goroutine有一个简单的模型:它是一个并发的、与其他goroutine在同一地址空间中执行的函数。Goroutine非常轻量级,其资源开销不比分配堆栈空间大多少。并且其初始堆栈很小,在后续执行中,会根据需要分配(和释放)额外的堆栈空间。
Goroutine被多路复用到多个OS线程上,因此,如果一个因某种原因阻塞(例如在等待I/O时),其他会继续运行。它的设计隐藏了线程创建和管理的很多复杂性细节。
在一个函数或方法调用前加上go关键字前缀,就可以创建一个新的goroutine,并在其中执行该调用。调用完成后,goroutine会静默退出。(其效果类似于在Unix shell中&在后台运行命令。)
go list.Sort() // run list.Sort concurrently; don't wait for it.
函数字面量也可以在goroutine中方便的调用。
func Announce(message string, delay time.Duration) {
go func() {
time.Sleep(delay)
fmt.Println(message)
}() // Note the parentheses - must call the function.
}
在Go中,函数字面量的形式是闭包:其实现确保函数所引用的变量只要函数调用处于活动状态就可以保留。
以上示例并不太实用,因为这些函数执行无法发出完成信号的方式。为此,我们还需要Channel。
Channel
类似于映射(Map),Channel(通道)也是由make分配的,其返回值结果值是一个对底层数据结构的引用。创建时,可以提供一个整型的可选参数,如果提供将设置channel的缓冲区大小。缺省值为0,表示无缓冲区,也称为“同步channel”。
ci := make(chan int) // unbuffered channel of integers cj := make(chan int, 0) // unbuffered channel of integers cs := make(chan *os.File, 100) // buffered channel of pointers to Files
无缓冲Channel将通信(值的交换)与同步相结合,以确保两个计算(goroutines)处于已知状态。
对于channel的使用,有很多不错用法。我们将通过以下示例开始介绍。在上节中,我们使用了一个后台排序操作,通过channel可是让操作发起操作的goroutine等待排序完成:
c := make(chan int) // Allocate a channel.
// Start the sort in a goroutine; when it completes, signal on the channel.
go func() {
list.Sort()
c <- 1 // Send a signal; value does not matter.
}()
doSomethingForAWhile()
<-c // Wait for sort to finish; discard sent value.
接收方会保持阻塞,直到有数据要接收为止。如果通道是无缓存的,则发送方将阻塞,直到接收方收到该值为止。如果通道有缓冲区,则发送方刚阻塞,直到将值复制到缓冲区为止;如果缓冲区已满,则意味着发送方只能等接收方取走数据后才能从阻塞状态恢复。
可以像使用信号量一样使用缓冲区通道,用于限制吞吐量等。在以下示例中,传入的请求被传入到处理函数(handle),该函数将值发送到通道以处理请求,然后从通道接收一个值,以为下一个使用者准备“信号量”。通道缓冲区的容量限制了同时进行处理的调用量。
var sem = make(chan int, MaxOutstanding)
func handle(r *Request) {
sem <- 1 // Wait for active queue to drain.
process(r) // May take a long time.
<-sem // Done; enable next request to run.
}
func Serve(queue chan *Request) {
for {
req := <-queue
go handle(req) // Don't wait for handle to finish.
}
}
一旦MaxOutstanding处理程序执行流程,任何其他处理程序都将阻止尝试发送到已填充的通道缓冲区,直到现有处理程序之一完成并从缓冲区接收消息为止。
但是,这种设计有一个问题:Serve为每个传入的请求创建一个新的goroutine,即使它们中只有MaxOutstanding可以随时运行。这样的话,如果请求太快,程序可能会无限制的消耗资源。我们可以通过修改服务来控制goroutine的创建,以解决该缺陷。这是一个显而易见的解决方案,但是请注意,它有一个Bug,我们将在随后修复:
func Serve(queue chan *Request) {
for req := range queue {
sem <- 1
go func() {
process(req) // Buggy; see explanation below.
<-sem
}()
}
}
一个Bug是在Go的for循环中,循环变量在每次迭代中都会重复使用,因此req变量会在所有goroutine中共享。那不是我们想要的。我们需要确保每个goroutine的req都是唯一的。一种方法是将req的值作为参数传递给goroutine中的闭包:
func Serve(queue chan *Request) {
for req := range queue {
sem <- 1
go func(req *Request) {
process(req)
<-sem
}(req)
}
}
将此版本与先前版本比较,就可以了解在声明和运行闭包方面的差异。另一个解决方案,创建一个具有相同名称的新变量,如下例所示:
func Serve(queue chan *Request) {
for req := range queue {
req := req // Create new instance of req for the goroutine.
sem <- 1
go func() {
process(req)
<-sem
}()
}
}
这样写看起来可能很怪:
req := req
但这在Go中是合法且惯用的。你将获得一个有相同名称变量的新版本,可以为每个goroutine创建一个唯一的、本地变量的拷贝。
回到编写服务器的通用问题上来,另一个有效资源管理方法是启动固定数据的 handle goroutine,每个goroutine都从channel中读取数据。goroutine的数量限制了同时进行的process的调用量。Serve函数还接受一个额外的函数,用于接收退出通道; 启动goroutines后,Server 自身将阻塞并等待该channel上的结束信号。
func handle(queue chan *Request) {
for r := range queue {
process(r)
}
}
func Serve(clientRequests chan *Request, quit chan bool) {
// Start handlers
for i := 0; i < MaxOutstanding; i++ {
go handle(clientRequests)
}
<-quit // Wait to be told to exit.
}
Channel的Channel
Go的最重要的属性之一是Channel是一个first-class类型,其可以像其它first-class类型变量一样进行分配、传递。该属性的一个常用方法是用来实现安全的并行多路分解处理。
在上节示例中,handle 是一个理想化的请求程序,但我们并没有定义处理的类型。如果该类型包括用于回复的channel,则每个客户端可以提供自己的响应方式。这是一个简单的Request类型定义:
type Request struct {
args []int
f func([]int) int
resultChan chan int
}
客户端提供了一个函数及其参数,以及请求对象内部的channel用于接收回答消息。
func sum(a []int) (s int) {
for _, v := range a {
s += v
}
return
}
request := &Request{[]int{3, 4, 5}, sum, make(chan int)}
// Send request
clientRequests <- request
// Wait for response.
fmt.Printf("answer: %d\n", <-request.resultChan)
在服务端,只有 handle 函数需要修改:
func handle(queue chan *Request) {
for req := range queue {
req.resultChan <- req.f(req.args)
}
}
显然,上述代码还有很大优化空间,以提高其可用性,但是此代码已经可以做为一个对于对速度要求不高、并行、非阻塞的RPC系统的框架。
并行
上面想法的另一个应用场景是多核CPU之间的并行化计算。如果可以将计算分解为可独立执行的独立部分,则可以并行化计算,并在每个部分完成时向channel发送信号。
假设我们要对一个向量项执行一个耗时的操作,并且操作值都是独立的,如下面这个理想化的示例。
type Vector []float64
// Apply the operation to v[i], v[i+1] ... up to v[n-1].
func (v Vector) DoSome(i, n int, u Vector, c chan int) {
for ; i < n; i++ {
v[i] += u.Op(v[i])
}
c <- 1 // signal that this piece is done
}
我们为每个CPU加载一个循环独立部分。他们可以按任何顺序完成,但这无关紧要。 在启动所有goroutine之后,从channel中读取完成信号的计数即可。
const numCPU = 4 // number of CPU cores
func (v Vector) DoAll(u Vector) {
c := make(chan int, numCPU) // Buffering optional but sensible.
for i := 0; i < numCPU; i++ {
go v.DoSome(i*len(v)/numCPU, (i+1)*len(v)/numCPU, u, c)
}
// Drain the channel.
for i := 0; i < numCPU; i++ {
<-c // wait for one task to complete
}
// All done.
}
可以通过运行获取值,而不是为numCPU创建一个常量值。函数runtime.NumCPU会返回机器中CPU内核的数量,因此我们可以编写:
var numCPU = runtime.NumCPU()
还有一个函数runtime.GOMAXPROCS,它会报告(或设置)用户指定的Go程序可以同时运行的内核数。其的默认值是runtime.NumCPU的值,但可以通过类似shell命名的方式设置环境变量或使用正数调用该函数来覆盖它(用零调用为查询)。因此,如果我们想满足用户的资源请求,我们应该:
var numCPU = runtime.GOMAXPROCS(0)
注意不要混淆了“并发”和“并行”的概念:“并发”是将程序构造为独立执行的组件;而“并行”是将计算分配到多个CPU上,以提高效率。尽管Go的并发特性可以使一些问题易于并行计算,但是Go是一种并发语言,而不是并行语言,并且并非所有并行化问题都适合Go的模型。有关区别的讨论,请参见此博客文章中的相关讨论。
一个“Leaky Buffer”示例
并发编程工具甚至可以使非并发思想更易于表达。以下是从RPC包中抽象出来的示例。客户端goroutine循环从某个来源(可能是网络)接收数据,为了避免分配和释放缓冲区,它会保留一个空闲列表,并使用一个缓冲的通道来表示它;如果通道为空,则会分配一个新的缓冲区。一旦消息缓冲区准备就绪,它将被发送到 serverChan 上的服务器。
var freeList = make(chan *Buffer, 100)
var serverChan = make(chan *Buffer)
func client() {
for {
var b *Buffer
// Grab a buffer if available; allocate if not.
select {
case b = <-freeList:
// Got one; nothing more to do.
default:
// None free, so allocate a new one.
b = new(Buffer)
}
load(b) // Read next message from the net.
serverChan <- b // Send to server.
}
}
服务器循环从客户端接收每个消息,对其进行处理,然后将缓冲区返回到空闲列表。
func server() {
for {
b := <-serverChan // Wait for work.
process(b)
// Reuse buffer if there's room.
select {
case freeList <- b:
// Buffer on free list; nothing more to do.
default:
// Free list full, just carry on.
}
}
}
客户端会尝试从freeList检索缓冲区;如果没有可用的,它将分配一个新的。服务端会将Buffer对象 b 放回空闲列表 freeList,如果列表已满,则丢弃 b,并由垃圾收集器定期回收内存。(select 语句中的 default 子句在没有满足其它 case 时就会执行,这意味着selects永不阻塞。)此实现仅依靠缓冲的channel和垃圾收集机制,仅几行代码就完成了Leaky Bucket Free List。
Errors
向调用者返回错误提示是库程程必须提供的功能之一。如前所述,Go的多值返回特性使返回正常值以及详细的错误描述变得容易。例如,正如我们将看到的,os.Open不仅会在失败时返回nil指针,还会返回一个描述错误原因的错误值。
按照惯例,错误具有error类型,这是一个简单的内置接口。
type error interface {
Error() string
}
库开发者可以自由地实现此接口以提供更丰富的模型,从而不仅可以看到错误,而且可以提供一些上下文。如前所述,除了通常的*os.File返回值外,os.Open还返回错误值。如果文件成功打开,则错误将为nil,但是如果出现问题,它将包含os.PathError:
// PathError records an error and the operation and
// file path that caused it.
type PathError struct {
Op string // "open", "unlink", etc.
Path string // The associated file.
Err error // Returned by the system call.
}
func (e *PathError) Error() string {
return e.Op + " " + e.Path + ": " + e.Err.Error()
}
PathError的Error会生成类型如下字符串:
open /etc/passwx: no such file or directory
在这个错误中包括:有问题的文件名、操作以及它触发的操作系统错误。即使在导致错误的调用远未打印的情况下也有用,其也比普通的“no such file or directory”提供的有效信息更多。
在可行的情况下,错误描述字符串应标识其来源,例如通过使用前缀来命名产生错误的操作或程序包。 例如,在image包中,由于未知格式导致的解码错误的字符串表示形式是:“image: unknown format”。
对于需要错误详细信息的调用者,可以使用类型切换或类型断言来查看具体错误并提取详细信息。对于 PathErrors,这些信息可能包括在内部的Err字段中,其可以用来进行故障恢复。
for try := 0; try < 2; try++ {
file, err = os.Create(filename)
if err == nil {
return
}
if e, ok := err.(*os.PathError); ok && e.Err == syscall.ENOSPC {
deleteTempFiles() // Recover some space.
continue
}
return
}
上面示例,第二个if语句是另一个类型断言。如果失败,则ok为false,e为nil。 如果成功,则ok为true,这意味着错误的类型为>*os.PathError,e类型也是,从而可以获取该错误的更多信息。
严重故障(Panic)
通常,向调用者报告错误的方法是将错误做为额外的变量返回。Read方法就是一个很好的示例:它会返回一个字节数和一个错误。但有时如果错误无法恢复程序将无法进行,该怎么处理呢?
为此,Go提供了一个内置的panic方法,用来创建一个运行时错误并停止程序运行。该函数可以接受一个任意类型的参数,通常是使用字符串,以便在程序出意外时打印错误。但panic不适合指示一些不可达的状态,例如退出无限循环。
// A toy implementation of cube root using Newton's method.
func CubeRoot(x float64) float64 {
z := x/3 // Arbitrary initial value
for i := 0; i < 1e6; i++ {
prevz := z
z -= (z*z*z-x) / (3*z*z)
if veryClose(z, prevz) {
return z
}
}
// A million iterations has not converged; something is wrong.
panic(fmt.Sprintf("CubeRoot(%g) did not converge", x))
}
以上公为示例,但是实际的库函数中,应避免使用panic。如果错误可以掩盖或是绕过,最好还是让程序继续运行而不是中目整个程序。还过还是有一些例外:比如该库确实无法完成初始化设置,那么panic退出是可以理解的。
var user = os.Getenv("USER")
func init() {
if user == "" {
panic("no value for $USER")
}
}
恢复(Recover)
调用panic时,包括隐式的运行时错误(例如,对切片索引越界或类型声明断言错误),它将立即停止函数执行,并展开当前goroutine的堆栈,并在此过程中运行所有defer函数。如果展开达到了goroutine堆栈的顶部,程序将终止。但是,可以使用内置函数restore恢复对goroutine的控制并恢复正常执行。
调用restore会停止展开操作,并返回传递给panic的参数。因为展开时运行的唯一代码是在延迟函数中,所以恢复仅在延迟函数中有用。
以下示例中,调用recover会关闭Server中失败oroutine,而不会杀死其他正在执行的goroutine。
func server(workChan <-chan *Work) {
for work := range workChan {
go safelyDo(work)
}
}
func safelyDo(work *Work) {
defer func() {
if err := recover(); err != nil {
log.Println("work failed:", err)
}
}()
do(work)
}
在这个示例中,如果do(work)发生panic,则其结果将被记录,goroutine将优雅地退出而不会打扰其他程序。不需要在defer的闭包中有任何操作,仅调用recover即可。
仅在defer函数中直接调用recover,否则总是返回nil,所以延时defer函数可以调用本身使用了panic的库,并且可以进行恢复而不会失败。例如,safetyDo中的defer函数可能会在高用restore之前调用一个日志记录函数,并且该日志记录代码将不受panic状态的影响。
使用错误恢复模式后,do函数及其调用代码可以通过调用panic,很优雅的从错误状态恢复。利用该特性,我们可以简化复杂软件中的错误处理。如下所示,是一个regexp包的简化版本,它通过使用本地Error类型调用panic来解析错误。以下是Error的定义、error处理方法及Compile函数:
// Error is the type of a parse error; it satisfies the error interface.
type Error string
func (e Error) Error() string {
return string(e)
}
// error is a method of *Regexp that reports parsing errors by
// panicking with an Error.
func (regexp *Regexp) error(err string) {
panic(Error(err))
}
// Compile returns a parsed representation of the regular expression.
func Compile(str string) (regexp *Regexp, err error) {
regexp = new(Regexp)
// doParse will panic if there is a parse error.
defer func() {
if e := recover(); e != nil {
regexp = nil // Clear return value.
err = e.(Error) // Will re-panic if not a parse error.
}
}()
return regexp.doParse(str), nil
}
如果doParse出现panic,则错误代码块会将返回值设置为nil-因为延迟函数可以修改命名的返回值。然后,会对错误类型进行断言,以检查该问题是否为Error类型。 如果不是,则类型声明将失败,从而导致运行时错误,该错误将继续展开堆栈,而没有任何中断。此检查意味着,如果发生意外情况(例如索引越界),即使我们正在使用panic和recover来处理解析错误,代码也终将失败。
有了错误处理,error方法(因为它是绑定到类型的方法,与内置error类型同名,这很好、很自然也不会有任何问题),可以很容易地报告解析错误,而不必担心展开错误和手动解析堆栈:
if pos == 0 {
re.error("'*' illegal at start of expression")
}
尽管此模式很有用,但应仅在包中使用。Parse会将内部的panic转换为error值,不会向用户暴露panic。这是一个良好的编码规范。
顺便说一句,如果发生实际错误,惯用re-panic(重新触发panic)法会改变panic值。但是,原始错误和新错误信息都将显示在崩溃报告中,因此问题的触发点仍然可见。所以,这种简单的re-panic方法通常就足够了(毕竟是程序崩溃)。但是,如果只想显示原始值,则可以编写更多代码来过滤错误并使用原始错误re-panic。
一个Web服务器
让我们以一个完整的Go程序结束:一个Web服务器。实际上,这是一类Web 重定向服务。Google的chart.apis.google.com上提供了一项服务,该服务会将数据自动格式化为图表和图形。但是没有友好交互界面,你需要将数据做为查询参数放在URL中。这里,程序为提供了一个更友好的接口:可以传入一小段文本,然后该服务会调用接口以产生二维码。可以用可以用手机的摄像头捕获,并解析为URL,从而省去了在手机的输入URL的麻烦。
完整代码示例如下:
package main
import (
"flag"
"html/template"
"log"
"net/http"
)
var addr = flag.String("addr", ":1718", "http service address") // Q=17, R=18
var templ = template.Must(template.New("qr").Parse(templateStr))
func main() {
flag.Parse()
http.Handle("/", http.HandlerFunc(QR))
err := http.ListenAndServe(*addr, nil)
if err != nil {
log.Fatal("ListenAndServe:", err)
}
}
func QR(w http.ResponseWriter, req *http.Request) {
templ.Execute(w, req.FormValue("s"))
}
const templateStr = `
<html>
<head>
<title>QR Link Generator</title>
</head>
<body>
{{if .}}
<img src="http://chart.apis.google.com/chart?chs=300x300&cht=qr&choe=UTF-8&chl={{.}}" />
<br>
{{.}}
<br>
<br>
{{end}}
<form action="/" name=f method="GET"><input maxLength=1024 size=70
name=s value="" title="Text to QR Encode"><input type=submit
value="Show QR" name=qr>
</form>
</body>
</html>
`
它的main部分应该易于理解。flac包用来设置服务器的默认HTTP端口。模板变量templ是有意思的地方。它构建了一个HTML模板,该模板将由服务器处理以显示页面。
main函数会通过上面介绍的机制来解析flag,并将QR函数绑定到服务器根路径。然后调用http.ListenAndServe来启动服务器。服务器运行中,会处理阻塞状态。
QR函数用来接收单数据的请求,并以s参数对表单值对执行模板。
模板包html/template功能非常强大,上面程序仅使用其基本功能。从本质上讲,它通过替换传入给templ.Execute方法的参数(本例中为表单值),并重新生成HTML文本。在模板文本中(templateStr),用双大括号包裹表示需要执行模板替换动作,从{{if.}}到{{end}}那段代码,只有当数据项不为空(也就是.)时才执行。也就是说,当字符串为空时,该模板部分将被忽略。
代码段{{.}}表示要在网页上显示的,提供给模板的数据(查询字符串)。HTML模板会自动提供合适的转意,以使文本可以安全的显示。
模板字符串的其余部分只是页面加载时显示的HTML。更多详细信息,请参阅:模板包文档。
就这样,你通过很少的代码及一些数据驱动的HTML文本,就实现了一个很有用的Web服务器。Go的强大之处正在于,用很少的代码完成很多事情。
