Effective Go 精简版

Table of Contents

花了一段时间看完了官方的 Effective Go 文档,今天又把 中文翻译 的过了一遍。

对于 Effective 系列的书,我还是停留在五六年前看过的 Effective C++More Effective C++Effective STL,其内容深层次的剖 析了 C++ 语言中的众多核心特性(指针、内存分配、面向对象、泛型编程等),看完之后让人茅塞顿开。

然而带着对 Go 语言的疑惑(指针、内存模型、并发等)来看 Effective Go,并没有得到我想要的东西,对于深层次的东西都是一带而过 的,有些失望,可能是 Go 语言本身并没有 C++ 那么复杂,也可能是我预期太高了一些。

总体来看 Effective Go 比较基础,可作为 Go 入门之后的第二本书(而 Effective C++ 没有一定的 C++ 基础不建议阅读,也看不懂), 更像是一个如何更好些 Go 语言的语言规范。

作为一个 C/C++ 出身的程序员,这篇文章主要整理了在写 Go 程序过程中可能会迷惑的、出问题地方(基于 Effective Go)。


1. 引言

Go 是一门全新的语言,尽管它借鉴了很多已有语言的许多理念,但它有自己的语言特性。虽然你也可以用写 C++ 或者 Java 的方式来写 Go 语言,但程序可能不能令人满意。

所以想要把 Go 程序写好了,需要了解它的特性、风格,即以 Go 的方式来思考 Go,才能写出更好的 Go 程序。

2. 格式化

Go 提供了 gofmt 工具来按照标准来格式化代码,尝试解决同一种语言编码风格混乱的问题,所有人都遵循相同的风格,也不用在编码风 格上再浪费时间。

3. 注释

Go 语言支持 C 风格的注释 /**/// ,块注释一般用于给包做注释。

包注释 一般放于包子句 package xxx 的前面,包含多个包文件的包,包注释放于任意一个文件中即可。

在包中,顶级声明前面的注释称作该声明的*文档注释*,程序中,每个导出的名称(首字母大写)都应该有文档注释。第一句应当以被声 明的东西开头(以便查找文档),并且是一个完整的句子作为摘要。

同样,Go 提供了 godoc 用来提取工程中的文档。

4. 命名

4.1. 包命名

包的名字应该简洁明了,易于理解:

  • 使用小写的单个单词来命名,不使用下划线或驼峰记法
  • 不需要保证在所有源码中保持唯一(导入时需要使用包的全路径),即便是同一个源文件中出现了两个相同的包名,也可以使用别名的方式解决冲突
  • 使用包的内容,一般通过包名来引用(可避免命名冲突)

4.2. Getter、Setter 命名

Go 不在语言层面提供 getter 和 setter 的支持,你可以自己做封装。通常 getter 直接用变量名命名(首字母大写), 而不是 GetXxx ,setter 使用 SetXxx

4.3. 接口命名

如果只有一个方法的接口,应该在该方法的名称加上 -er 后缀来命名,比如 Reader、Writer。

4.4. 驼峰命名

Go 使用驼峰的方式来命名,MinxedCaps 或者 mixedCaps。

5. 分号

Go 语言和 C 一样,已分号作为语句的结尾,但与 C 不同的是,分号并不会在源码中出现,Go 的词法分析器会根据规则自动插入分号。 也因为这样代码块 {} 中的前一个 { 放在上一行的结尾(如果放在下一行,Go 会在上一行自动加上分号),还有多值分行初始化时, 最后一行同样要添加一个逗号。

6. 控制结构

  • Go 没有 while 循环,只有通用的 for 循环(可以满足 while 的需求)
  • if、switch、for 支持一个可选的初始化语句,常见的用法: if _, err := func(); err != nil
  • Go 没有逗号操作符,所以可以使用平行复制的方式 i, j = j, i
  • ++-- 是语句 而非 表达式

6.1. Switch

Go 中的 switch 比 C 更通用(而且解决 C 的很多潜在问题):

  • switch 表达式无需常量或者整数
  • case 语句会逐一进行求值直到匹配为止(C 并不是这样,C 是找到一个匹配的入口,如果没遇到 break 则会一直往下执行,不管下面的 case 条件是否满足)
  • if-else-if-else 可以使用 switch 替换
  • Go 的 break 可以指定一个可选的 Label(类似 C 中的 goto 语句,但是 C 中的 goto 可能会导致资源泄露,所以一般不用)
  • switch 配合类型断言语法,可做类型选择

7. 函数

  • Go 支持多值返回,通常第二个值表示错误码
  • 返回值可添加命名形参,在函数开始执行时初始化为零值,有命名形参时,返回时 return 空即可
  • defer 语法可以让函数延迟到代码块结束时调用,一般用来释放资源:
    • 被延迟调用的函数参数是立即计算值,而不是调用时
    • Deferred 函数式后入先出的执行顺序(LIFO)

8. 数据

Go 有两种内置的内存分配原语: newmake

  • new 跟其它编程语言不同的地方在于它申请的内存不会被 初始化 ,它只是全部设置为 零值 返回的是 T* 译者:文档有点歧义,实际上 Go 的 new 是会初始化内存的,只不过初始化成了对应类型的零值,这里表达的应该是类似 C++ 中的 new 会自动调用构造函数。
  • make(T, args)new(T) 有不同的设计目标,make 只用来创建 slices、maps 和 channels,并且返回的是 初始化 的(非零值) 的类型 T(而不是*T),原因是这三种类型在后台实现时必须进行初始化。 比如:切片实际包含三个字段:指向数据的指针、长度和容量,在初始化这些数据之前 slice 是 nil 。对于 slices、maps 和 channels,make 会初始化其内部结构数据。

注意 make 只能用于 slices、maps 和 channels 并且返回的是对象,而不是指针。

8.1. 数组

Go 的数组与 C 不同点:

  • 数组是值,赋值时会把所有的数据拷贝一份
  • 如果把数组传递给函数,函数拿到的是数组的 copy ,而不是指针
  • 数组的大小是类型的一部分。 [10]int[20]int 是不同的

如果你想要和 C 一样传递数组给函数,你需要使用数组的指针,但通常使用 slice 来代替 array。

8.2. 切片

请查看我之前写的 理解 Go 的 Array 和 slice

8.3. Map

  • 将 map 传递给函数,在函数内部修改 map 会修改调用方的 map
  • 访问一个 key 不存在的值,会返回 value 类型的零值(所以 set 可以使用 value 类型为 bool 来实现,不存在时值为 false); 当需要程序上判断一个值是否存在时,可以通过访问时返回的第二个值来判断,如下:

    func offset(tz string) int {
        if seconds, ok := timeZone[tz]; ok {
            return seconds
        }
        log.Println("unknown time zone:", tz)
        return 0
    }
    
  • 删除 map 中的某个 key 使用内置的 delete 函数,即便 key 已经已经被删掉了,再执行一次也是安全的

8.4. 初始化

  • 常量:在 Go 中常量仅仅指的是不变的值。他们由编译期间创建,可以为数字、字符、字符串或者布尔类型,因为编译期间的限制, 表达式必须是常量表达式,由编译器来计算值(函数调用是运行时)
  • init 函数:每个源文件都可以定义自己的 init 函数,而且可以有多个。 init 函数即不接受参数也不返回任何值,而且不能被 主动调用,在包导入时会自动调用执行,在 main 函数之前执行。 init 最常见的用法是用来完成初始化表达式未能完成的初始化工作,还可用作状态检查与修复、注册、只被执行一次的运算等

9. 方法

9.1. 指针 vs 值

对于指针和值接收器调用原则为:值接收器关联的函数可以被指针和值调用,而指针方法只能被指针调用。语言为了避免这种错误,就添 加了一个例外,当值是有地址的时候,出现值调用指针方法的时候,语言会自动插入地址运算。

换句话说,都可以相互调用,只不过区别在于是否修改调用方的值。

10. 接口

  • Go 中的接口和实现不像其它语言一样,没有显式的关联关系,它只是定义了一个规范,谁有它的 行为 ,谁就是它
  • 如果一个类型仅仅实现了一个接口,并且除此之外没有其它需要导出的方法,那这个类型也不需要导出(在这种情况下构造函数返回一 个接口值,而非类型),这其实是一种很好的抽象(关注行为,而非数据)。

11. 空白标识符

  • 空白标识符可被赋予或声明为任何类型的任何值,而其值会被无害地丢弃,类似 Unix 中的 /dev/null 文件,只写不读
  • 导入包时,别名设置为空白标识符,可解决需要使用导入包的 init,但本文件又不需要包内容的情况(Go 不允许导入不使用的包)

12. 内嵌

Go 语言不提供类型子类这样的东西,但是它提供了在接口或者结构体中内嵌的接口或结构体的方法。

内嵌的接口和结构体可以接口/结构体名称直接访问,如果想要直接访问时字段名为类型名。比如:

type Job struct {
    Command string
    *log.Logger
}

访问 log.Logger 的成员可以直接通过 Job 的对象来访问,当然也可以用显示调用的方式来访问: job.Logger.Logf, 访问忽略包名(log)即可。

内嵌引来的问题是名称冲突,解决规则很简单:

  • 首先,上层的字段会覆盖到深层的字段
  • 其次,如果在相同级别上出现了两个名字相同的字段(这样通常是错误的),但如果内部不使用的情况下,不会出问题

笔者:尽可能用组合代替继承。

13. 并发

13.1. Goroutines

  • goroutines 是并发运行在同一地址空间的函数,比线程更轻量级(消耗只有栈空间的分配)
  • 在多线程操作系统上实现多路复用,如果一个线程阻塞(比如等待 I/O),就会在其它线程上运行(隐藏了线程创建和管理的复杂性, 本质上底层是线程调度的)

13.2. 管道(Channels)

管道和 maps 类似,使用 make 分配,返回一个底层数据结构的引用。可以提供一个可选的整型参数,用来设置管道的大小。默认值是 0,作为无缓冲或者同步管道。

14. 错误

14.1. Panic

通常情况下,出错的时候应该向调用者返回一个 error ,比如 Read 方法会返回字节数量和 error。但是有时候会遇到错误无法恢复, 程序无法正常运行的情况。

Go 提供了内建函数 panic 用来创建一个运行时的错误将停止程序的运行,它有一个任意类型的参数(经常是个 string 在程序快挂的 时候输出),一般用来表示处理逻辑上不可能发生的事情,比如无限循环竟然退出了:

// 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 。如果程序出现了问题,应该尽可能的自愈然后继续运行。

14.2. Recover

panic 调用时,包含隐式的运行时错误,比如越界访问,断言失败等,它会立即停止运行然后展开 goroutine 的堆栈,接下来运行 defer 函数。但是可以通过 recover 重新获得 goroutine 的控制并恢复正常运行。代码只能放在 defer 函数中(只有 defer 函数在 这个时候才能正常运行)。

recover 只会关闭当前的 goroutine(干净的退出),而不会影响其它正在执行的 goroutines。

First created: 2018-08-03 14:11:00
Last updated: 2022-12-11 Sun 12:49
Power by Emacs 27.1 (Org mode 9.4.4)