Contents

Effective Go Thoughts

命名

某个名称包外是否可见,取决于其首个字符是否为大写字母。

Go中约定使用驼峰记法 MixedCaps 或 mixedCaps。

包名

包应当以小写的单个单词来命名,且不应使用下划线或驼峰记法,保持简洁易于理解。

包的导入者可以通过包名来引用其内容,因此包中可导出的名称 可以用包名来避免冲突。如bufio里的读取器叫Reader而非BufReader。因为用户使用时是bufio.Reader。这个显然清楚而简洁,不会与io.Reader发生冲突。同样用户创建ring.Ring的方法,可以叫做ring.New而不用ring.NewRing。因为Ring和它的包重名,也利用理解。

**长命名并不会使其更具可读性。一份有用的说明文档通常比额外的长名更有价值。**如once.Do(setup)就比once.DoOrWaitUntilDone(setup)清晰。

接口名

只包含一个方法的接口应当以该方法的名称加上-er后缀来命名。如 Reader、Writer、 Formatter、CloseNotifier等。

1
2
3
4
5
6
type Reader interface {
	Read(p []byte) (n int, err error)
}
type Writer interface {
	Write(p []byte) (n int, err error)
}

控制结构

if

if执行体里以 break、continue、goto 或 return 结束时,不必要的 else 会被省略。这是 代码为了防范一系列的错误条件,若控制流程继续,则说明排除了错误。由于错误后,直接return所以也就无需else了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
f, err := os.Open(name)
if err != nil {
	return err
}
d, err := f.Stat()
if err != nil {
	f.Close()
	return err
}
codeUsing(f, d)

重新声明与再次赋值

1
2
f, err := os.Open(name)
d, err := f.Stat()

发现两个语句都出现了err,第一条中err被声明,第二条中err只是被再次赋值。

在满足下列条件时,已被声明的变量 v 可出现在:= 声明中:

  • 本次声明与已声明的 v 处于同一作用域中(若 v 已在外层作用域中声明过,则此次声明会创建一个新的变量
  • 在初始化中与其类型相应的值才能赋予 v,且在此次声明中至少另有一个变量是新声明的

For

它统一了do和while,有三种形式。

1
2
3
4
5
6
7
8
// 如同C的for循环
for init; condition; post { }

// 如同C的while循环
for condition { }

// 如同C的for(;;)循环
for { }

若你想遍历数组、slice、字符串或者map,或从channel中读取消息,使用range实现循环。

1
2
3
for key, value := range oldMap {
	newMap[key] = value
}

Switch

case 语句会自上而下逐一进行求值直到匹配为止,但break可以使switch提前终止。有时候要打破循环,可以使用break到标签位置。fallthrough强制执行后面的case代码,如果所有case都有fallthrough,则default会被执行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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)
		}
	}

类型选择

switch 也可用于判断接口变量的动态类型。如 类型选择 通过圆括号中的关键字 type 使用类型断言语法。若 switch 在表达式中声明了一个变量,那么该变量的每个子句中都将有该变量对应的类型。代码

函数

可命名结果形参

Go函数的返回值或结果“形参”可被命名,并作为常规变量使用,就像传入的形参一样。 命名后,一旦该函数开始执行,它们就会被初始化为与其类型相应的零值; 若该函数执行了一条不带实参的 return 语句,则结果形参的当前值将被返回。

这种做法能让代码简单而清晰。如下面的io.ReadFull

1
2
3
4
5
6
7
8
9
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
}

defer

defer用于预设一个函数调用(即推迟执行函数)。通常用来释放资源,解锁互斥和关闭文件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Contents 将文件的内容作为字符串返回。
func Contents(filename string) (string, error) {
	f, err := os.Open(filename)
	if err != nil {
		return "", err
	}
	defer f.Close()  // f.Close 会在我们结束后运行。

	var result []byte
	buf := make([]byte, 100)
	for {
		n, err := f.Read(buf[0:])
		result = append(result, buf[0:n]...) // append 将在后面讨论。
		if err != nil {
			if err == io.EOF {
				break
			}
			return "", err  // 我们在这里返回后,f 就会被关闭。
		}
	}
	return string(result), nil // 我们在这里返回后,f 就会被关闭。
}

defer的函数若包含实参,它会在defer执行的时候求值,而不是在调用时求值。这样就不用关心变量在函数执行时被改变。defer函数会按照(LIFO)后进先出的顺序执行。

1
2
3
4
for i := 0; i < 5; i++ {
	defer fmt.Printf("%d ", i)
}
// 4 3 2 1 0

defer是在return之前执行的,参考官方文档

1
2
3
4
5
6
7
// f returns 1
func f() (result int) {
	defer func() {
		result++
	}()
	return result
}

defer匿名函数与外部数据形成闭包时,闭包里的数据传入的是引用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func main() {
	for i := 0; i < 4; i++ {
		defer func () {
			fmt.Printf("%+v %p\n", i, &i)
		}()
	}
}

4 0xc42000e1f8
4 0xc42000e1f8
4 0xc42000e1f8
4 0xc42000e1f8

数据

new/make分配

new不会初始化内存,只会将内存置为零。也就是说,new(T)会为类型T分配一个已置零的内存空间,并返回它的地址,也是就*T的值。

复合字面,即File{fd, name, nil, 0}这种。而复合字面不包含字段时,它将创建该类型的零值。即:new(File) 和 &File{} 是等价的。

make用于创建slice/map/chan,并返回类型为T(而非*T)的 已初始化(而非零值)的值。造成这种差异的原因,这三种类型本质上为引用数据类型,它们内部还包含其它内容,所以在使用之前必须初始化。

理解:零值就是 该数据类型的初始值,如int默认为0,bool默认为false,而引用类型默认就是nil。
所以make返回 引用类型 初始化值,用于将其内部数据结构准备好将要使用的值。

下面例子阐明new和make的区别:

1
2
3
4
5
6
7
8
9
var p *[]int = new([]int)       // 分配切片结构;*p == nil;基本没用
var v  []int = make([]int, 100) // 切片 v 现在引用了一个具有 100 个 int 元素的新数组

// 没必要的复杂:
var p *[]int = new([]int)
*p = make([]int, 100, 100)

// 习惯用法:
v := make([]int, 100)

Arrays

数组作为切片的构件。它是值类型。

  • 数组是值。将一个数组赋予另一个数组会复制其所有元素。
  • 将某个数组传入某个函数,它将接收到该数组的一份副本而非指针。
  • 数组的大小是其类型的一部分。类型 [10]int 和 [20]int 是不同的。

针对第二点的例子:

1
2
3
4
5
6
7
8
9
func f(result [3]int) {
	result[0] = 2
}

func main() {
	a:=[3]int{0,0,0}
	f(a)
	fmt.Println(a) // [0 0 0]
}

Slices

切片对数据进行封装,提供更方便的接口。

切片保存了对底层数组的引用,将某个切片赋予给另一个切片,它会引用同一个数组。**因此,切片作为函数参数传入时候,函数可以修改其内部元素。**可以理解为切片传递了底层数组的指针。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func f(result []int) {
	result[0] = 2
}

func main() {
	a:=make([]int, 3)
	a[0]=1
	f(a)
	fmt.Println(a) // [2 0 0]
}

因此,Read函数可以接受一个切片实参,而非一个指针和一个计数。

Maps

map一般通过复合字面语法进行构建。通过key去获取某个val时,若key不存在,就会返回val类型对应的零值。一般我们通过ok来判断key是否存在。

1
2
3
4
5
6
7
8
9
func main() {
	a := map[string]int{
		"1":1,
		"2":2,
	}
	fmt.Println(a["3"]) // 0
}

v, ok := a["3"]

Print

这里注意一下覆写String方法是,在print时会无限递归的情况。

1
2
3
4
5
6
7
8
9
type MyString string
func (m MyString) String() string {
	return fmt.Sprintf("MyString=%s", m)
}

func main() {
	var a MyString = "123"
	fmt.Println(a)
}

在Sprintf里隐式的会调用m的String方法,就造成了无限递归。 解决:

1
2
3
func (m MyString) String() string {
	return fmt.Sprintf("MyString=%s", string(m)) // 可以:注意转换
}

初始化

常量

Go中的常量就是不变量。枚举常量使用枚举器iota创建,它可以隐式的重复。

1
2
3
4
5
6
const (
	UpperCase = iota
	LowerCase
	TitleCase
	MaxCase
)

init函数

每个源文件都可以通过init来设置一些必要的状态。注意init函数可以声明多个,且按照声明的先后顺序执行。文件从import到声明到init的顺序,参考

方法

指针vs值

我们可以为任何已命名的类型(除了指针或接口)定义方法; 接收者可不必为结构体。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type ByteSlice []byte

func (p *ByteSlice) Write(data []byte) (n int, err error) {
}

type Writer interface {
	Write(p []byte) (n int, err error)
}

var b ByteSlice
fmt.Fprintf(&b, "This hour has %d days\n", 7)

可以看出Fprintf需要的是io.Writer类型,而我们只有*ByteSlice才实现了io.Writer,而ByteSlice没有。所以我们要传入&b。

不管你的method的receiver是指针类型还是非指针类型,都是可以通过指针/非指针类型进行调用的,编译器会帮你做类型转换。

指针方法的好处在于可以在方法里修改接收者,而值方法会导致方法 接收到的是 该值的副本(产生一次拷贝),任何修改都不会成功。

空白标识符

可被赋予或声明为任何类型的任何值,而其值会被无害地丢弃。在需要变量但不需要实际值的地方用作占位符。

有时候,写了一半的程序有未使用的包 和 变量,又想让它能编译。这是可以: 用空白标识符来引用未使用的包、将未使用的变量赋值给空白标识符。如下:

1
2
3
4
5
6
var _ = fmt.Printf 

file, e := os.Open("test.go")
	if e != nil {
	}
_ = file

为副作用而导入,如pprof导入只是使用其中的init。

1
import _ "net/http/pprof"

接口检查

一个类型无需显式地声明它实现了某个接口。取而代之,该类型只要实现了某个接口的方法, 其实就实现了该接口。大部分接口检测都会在编译时,例如,将一个 *os.File 传入一个预期的 io.Reader 函数将不会被编译, 除非 *os.File 实现了 io.Reader 接口。

而有些接口检测会在运行时,如encoding/json中的Marshaler接口。当JSON编码器接收到一个实现了该接口的值,那么该编码器就会调用该值的编组方法, 将其转换为JSON,而非进行标准的类型转换。

而编码器则使用类型断言来检测其属性。通常只需判断,就使用空白标识符来忽略类型断言的值。

1
2
3
if _, ok := val.(json.Marshaler); ok {
	fmt.Printf("value %v of type %T implements json.Marshaler\n", val, val)
}

确保一个类型 必须实现该接口,否则编译出错。

1
2
3
4
5
6
type Interface interface {
	Foo()
}
type Implementation struct{}
func (*Implementation) Foo() { fmt.Println("foo!") }
var _ Interface = (*Implementation)(nil)

若Interface接口修改,则最后一行是无法编译成功的。

内嵌

Go不提供子类化的概念,但可以通过将类型内嵌到结构体或接口中,它就能借鉴部分实现。

接口内嵌:io.ReadWriter就包含了Reader和Writer接口,从而包含了Read和Write方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// ReadWriter 接口结合了 Reader 和 Writer 接口。
type ReadWriter interface {
	Reader
	Writer
}
type Reader interface {
	Read(p []byte) (n int, err error)
}
type Writer interface {
	Write(p []byte) (n int, err error)
}

结构体内嵌:bufio.ReadWriter就包含了bufio.Reader和bufio.Writer,它就包含了两者的方法。同时还满足io.Reader、io.Writer、io.ReadWriter三个接口。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
type ReadWriter struct {
	*Reader
	*Writer
}
type Reader struct {
	buf          []byte
	rd           io.Reader // reader provided by the client
	r, w         int       // buf read and write positions
	err          error
	lastByte     int
	lastRuneSize int
}
type Writer struct {
	err error
	buf []byte
	n   int
	wr  io.Writer
}

可以看出这个结构体内嵌的方便,它比在结构体内声明变量,然后显示的去调用变量的方法方便很多。如下就是显示的调用:

1
2
3
4
5
6
7
type ReadWriter struct {
	reader *Reader
	writer *Writer
}
func (rw *ReadWriter) Read(p []byte) (n int, err error) {
	return rw.reader.Read(p)
}

注意:

当内嵌一个类型时,该类型的方法会成为外部类型的方法, 但当它们被调用时,该方法的接收者是内部类型,而非外部的。在我们的例子中,当 bufio.ReadWriter 的 Read 方法被调用时,它与上面显示声明变量并调用的方法 具有同样的效果;接收者是 ReadWriter 的 reader 字段,而非 ReadWriter 本身。

实例:

1
2
3
4
type Job struct {
	Command string
	*log.Logger
}

这里的Job就具备了Logger的方法,如job.Log("xxx")

而Logger 是 Job 结构体的常规字段,所以我们通过一般方式初始化。

1
2
3
4
5
func NewJob(command string, logger *log.Logger) *Job {
	return &Job{command, logger}
}

job := &Job{command, log.New(os.Stderr, "Job: ", log.Ldate)}

**注意:内嵌类型是作为外部结构体的常规字段存在,所以在初始化后,外部结构体是可以访问的内嵌结体里的公开属性的。**如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type Logger struct {
	T int
}
type Job struct {
	Command string
	*Logger
}

func main() {
	job := Job{"command", &Logger{1}}
	fmt.Println(job.T) // 1
}

并发

不要通过共享内存来通信,而应通过通信来共享内存。

Channels

  • 接收方会一直阻塞直到有数据到来
  • 如果channel是无缓冲的,发送方会一直阻塞直到接收方将数据取出
  • 如果channel带有缓冲区,发送方会一直阻塞直到数据被拷贝到缓冲区
  • 如果缓冲区已满,则发送方只能在接收方取走数据后才能从阻塞状态恢复

带缓冲的信道可被用作信号量,例如限制吞吐量。缓冲区的容量决定了处理数量上限。

Channels of channels

这种可以理解成一个横的管道,其中的每个元素都是一个竖着的管道。这种特性通常被用来实现安全、并行的多路分解。

代码 这里面的workerPool就是一个带缓冲的chan chan job。即其中每一个worker都包含一个chan job用来读取任务。

并行化

这里就要谈到Go的并发:关于goroutine的管理调度执行称为go的并发。 而并行是指在不同物理CPU上,执行不同的代码。

简单说,并行是可以同时做很多事情。并发是同时管理很多事情,它同一时刻只能执行一件事。参考

错误

error是一个内建的接口

1
2
3
type error interface {
	Error() string
}

我们可以实现这个接口来自定义 一些错误信息。

panic

有时错误无法恢复,不能让程序进行。为此,提供了内建panic函数,它会产生一个运行时错误并终止程序。它一般接收字符串,并在程序终止时打印。

recover

当panic后,程序立刻终止当前函数的执行,并开始回溯goroutine的栈,运行defer函数。当回溯到goroutine的顶端,程序就将终止。但是,我们可以通过recover函数来获取goroutine控制权并恢复正常。

调用recover将停止回溯过程,由于在回溯时只有defer函数中的代码在运行,因此 recover 只能在被推迟的函数中才有效。


Reference

Effective Debugging Techniques

attach effective debugging video by Andrii Soldatenko

Go proverbs

go-proverbs