第52章:Go技巧101



Go技巧101

索引:

如何强制一个代码包的使用者总是使用带字段名称的组合字面量来表示此代码包中的结构体类型的值?

代码包的开发者可以在一个结构体类型定义里放置一个非导出的零尺寸的字段,这样编译器将会禁止代码包的使用者使用含有一些字段但却不含有字段名字的组合字面量来创建此结构体类型的值。

例如:

  1. // foo.go
  2. package foo
  3. type Config struct {
  4. _ [0]int
  5. Name string
  6. Size int
  7. }
  1. // main.go
  2. package main
  3. import "foo"
  4. func main() {
  5. //_ = foo.Config{[0]int{}, "bar", 123} // 编译不通过
  6. _ = foo.Config{Name: "bar", Size: 123} // 编译没问题
  7. }

请尽量不要把零尺寸的非导出字段用做结构体的最后一个字段,因为这样做会有可能会增大结构体类型的尺寸(第51章)而导致一些内存浪费。

如何使一个结构体类型不可比较?

有时候,我们想要避免一个自定义的结构体类型被用做一个映射的键值类型,那么我们可以放置一个非导出的零尺寸的不可比较类型的字段在结构体类型中以使此结构体类型不可比较。 例如:

  1. package main
  2. type T struct {
  3. dummy [0]func()
  4. AnotherField int
  5. }
  6. var x map[T]int // 编译错误:非法的键值类型
  7. func main() {
  8. var a, b T
  9. _ = a == b // 编译错误:非法的比较
  10. }

不要使用其中涉及到的表达式之间会相互干涉的赋值语句。

目前(Go 1.22),在一些多值赋值中有一些表达式估值顺序是未指定的。 因此,如果一个多值赋值语句中涉及的表达式会相互干涉,或者不太容易确定是否会相互干涉,我们应该将此多值赋值语句分拆成多个单值赋值语句。

事实上,在一些写得很糟糕的代码中,单值赋值中的表达式求值顺序也有可能是有歧义的。 例如,下面的程序可能会打印[7 0 9][0 8 9]或者[7 8 9],依赖于具体编译器实现。

  1. package main
  2. import "fmt"
  3. var a = &[]int{1, 2, 3}
  4. var i int
  5. func f() int {
  6. i = 1
  7. a = &[]int{7, 8, 9}
  8. return 0
  9. }
  10. func main() {
  11. // 表达式"a"、"i"和"f()"的估值顺序未定义。
  12. (*a)[i] = f()
  13. fmt.Println(*a)
  14. }

换言之,一条赋值语句中的某个函数调用表达式的估值有可能会影响到其它非函数调用表达式的估值结果。 请阅读表达式估值顺序规则(第33章)以获取更多细节。

如何模拟一些其它语言中支持的for i in 0..N循环代码块?

我们可以通过遍历一个元素尺寸为零的数组或者一个空数组指针来模拟这样的循环。 例如:

  1. package main
  2. import "fmt"
  3. func main() {
  4. const N = 5
  5. for i := range [N]struct{}{} {
  6. fmt.Println(i)
  7. }
  8. for i := range [N][0]int{} {
  9. fmt.Println(i)
  10. }
  11. for i := range (*[N]int)(nil) {
  12. fmt.Println(i)
  13. }
  14. }

当我们废弃一个仍在使用的切片中的一些元素时,我们应该重置这些元素中的指针来避免暂时性的内存泄漏。

关于细节,请阅读如何删除切片元素(第18章)和因为未重置丢失的切片元素中的指针而造成的临时性内存泄露(第45章)。

一些标准包中的某些类型的值不期望被复制。

bytes.Buffer类型、strings.Builder类型以及在sync标准库包里的类型的值不推荐被复制。 (它们确实不应该被复制,尽管在某些特定情形下复制它们或许是没有问题的。)

strings.Builder的实现会在运行时刻探测到非法的strings.Builder值复制。 一旦这样的复制被发现,就会产生恐慌。例如:

  1. package main
  2. import "strings"
  3. func main() {
  4. var b strings.Builder
  5. b.WriteString("hello ")
  6. var b2 = b
  7. b2.WriteString("world!") // 一个恐慌将在这里产生
  8. }

复制标准库包sync中类型的值会被Go官方工具链提供的go vet命令检测到并被警告。

  1. // demo.go
  2. package demo
  3. import "sync"
  4. func f(m sync.Mutex) { // warning: f passes lock by value: sync.Mutex
  5. m.Lock()
  6. defer m.Unlock()
  7. // do something ...
  8. }
  1. $ go vet demo.go
  2. ./demo.go:5: f passes lock by value: sync.Mutex

复制bytes.Buffer的值不会在运行时被检查到,也不会被go vet命令所检测到。 千万要小心不要随意这样做。

我们可以利用memclr优化来重置数组或者切片中一段连续的元素。

关于细节,请阅读memclr优化(第18章)。

如何在不导入reflect标准库包的情况下检查一个值是否拥有某个方法。

可以使用下面的例子中的方法。 (假设需要被检查的方法的描述是M(int) string。)

  1. package main
  2. import "fmt"
  3. type A int
  4. type B int
  5. func (b B) M(x int) string {
  6. return fmt.Sprint(b, ": ", x)
  7. }
  8. func check(v interface{}) bool {
  9. _, has := v.(interface{M(int) string})
  10. return has
  11. }
  12. func main() {
  13. var a A = 123
  14. var b B = 789
  15. fmt.Println(check(a)) // false
  16. fmt.Println(check(b)) // true
  17. }

如何高效且完美地克隆一个切片?

关于细节请阅读这篇wiki文章这篇wiki文章

在部分场景下我们应该使用三下标子切片形式。

假设一个包提供了一个func NewX(...Option) *X函数,并且这个函数的实现将输入选项与一些内部默认选项合并,那么下面的实现是不推荐的。

  1. func NewX(opts ...Option) *X {
  2. options := append(opts, defaultOpts...)
  3. // 使用合并后选项来创建一个X值并返回其指针。
  4. // ...
  5. }

上述实现不被推荐的原因是append函数调用可能会修改输入实参opts的底层潜在Option元素序列。 对大多数场景,这可能是没问题的。但是对某些特殊场景,这有可能会导致后续代码执行产生不期望的结果。

为了避免输入实参的底层Option元素序列被修改,我们应该使用下面的实现方法:

  1. func NewX(opts ...Option) *X {
  2. // 改用三下标子切片格式。
  3. opts = append(opts[:len(opts):len(opts)], defaultOpts...)
  4. // 使用合并后选项来创建一个X值并返回其指针。
  5. // ...
  6. }

另一方面,对于NewX函数的调用者来说,不应该依赖于此函数的具体实现,所以最好使用三下标子切片形式options[:len(options):cap(options)]来传递实参。

另外一个需要使用三下标子切片格式的场景在这篇wiki文章中被提及。

三下标子切片格式的一个缺点是它们有些冗长。 事实上,我曾经提了一个建议来让三下标格式看上起简洁得多。 但是此建议被否决了。

使用匿名函数来使部分延迟函数调用尽早执行。

关于细节,请阅读这篇文章(第29章)。

确保并表明一个自定义类型实现了指定的接口类型。

我们可以将一个自定义类型的一个值赋给指定接口类型的一个变量来确保此自定义类型实现了指定接口类型。 更重要的是,这样可以表明此自定义类型实现了指定接口类型。 使用自解释的代码编写文档比使用注释来编写文档要自然得多。

  1. package myreader
  2. import "io"
  3. type MyReader uint16
  4. func NewMyReader() *MyReader {
  5. var mr MyReader
  6. return &mr
  7. }
  8. func (mr *MyReader) Read(data []byte) (int, error) {
  9. switch len(data) {
  10. default:
  11. *mr = MyReader(data[0]) << 8 | MyReader(data[1])
  12. return 2, nil
  13. case 2:
  14. *mr = MyReader(data[0]) << 8 | MyReader(data[1])
  15. case 1:
  16. *mr = MyReader(data[0])
  17. case 0:
  18. }
  19. return len(data), io.EOF
  20. }
  21. // 下面三行中的任一行都可以保证类型*MyReader实现
  22. // 了接口io.Reader。
  23. var _ io.Reader = NewMyReader()
  24. var _ io.Reader = (*MyReader)(nil)
  25. func _() {_ = io.Reader(nil).(*MyReader)}

一些编译时刻断言技巧。

除了上一个技巧中提到过的编译时刻断言技巧,下面将要介绍更多编译时刻断言技巧。

下面是一些方法用来在编译时刻保证常量N不小于另一个常量M

  1. // 下面任一行均可保证N >= M
  2. func _(x []int) {_ = x[N-M]}
  3. func _(){_ = []int{N-M: 0}}
  4. func _([N-M]int){}
  5. var _ [N-M]int
  6. const _ uint = N-M
  7. type _ [N-M]int
  8. // 如果M和N都是正整数常量,则我们也可以使用下一行所示的方法。
  9. var _ uint = N/M - 1

另一个方法是借鉴@lukechampine的一个点子。 此点子利用了容器组合字面量中不能出现重复的常量键值(第18章)这一规则。

  1. var _ = map[bool]struct{}{false: struct{}{}, N>=M: struct{}{}}

此方法看上去有些冗长,但是它更加通用。它可以用来断言任何条件。 其实,它也可以不必很冗长,但需要多消耗一点(完全可以忽略的)内存,如下面所示:

  1. var _ = map[bool]int{false: 0, N>=M: 1}

类似地,下面是断言两个整数常量相等的方法:

  1. var _ [N-M]int; var _ [M-N]int
  2. type _ [N-M]int; type _ [M-N]int
  3. const _, _ uint = N-M, M-N
  4. func _([N-M]int, [M-N]int) {}
  5. var _ = map[bool]int{false: 0, M==N: 1}
  6. var _ = [1]int{M-N: 0} // 唯一被允许的元素索引下标为0
  7. var _ = [1]int{}[M-N] // 唯一被允许的元素索引下标为0
  8. var _ [N-M]int = [M-N]int{}

最后一行的灵感同样来自于Luke Champine的一条tweet。

下面是一些用来断言一个常量字符串是不是一个空串的方法。

  1. type _ [len(aStringConstant)-1]int
  2. var _ = map[bool]int{false: 0, aStringConstant != "": 1}
  3. var _ = aStringConstant[:1]
  4. var _ = aStringConstant[0]
  5. const _ = 1/len(aStringConstant)

最后一行借鉴自Jan Mercl的一个点子

有时候,为了避免包级变量消耗太多的内存,我们可以把断言代码放在一个名为空标识符的函数体中。 例如:

  1. func _() {
  2. var _ = map[bool]int{false: 0, N>=M: 1}
  3. var _ [N-M]int
  4. }

如何声明一个最大的int和uint常量?

  1. const MaxUint = ^uint(0)
  2. const MaxInt = int(^uint(0) >> 1)

如何在编译时刻决定系统原生字的尺寸?

这个技巧和Go无关。

  1. const Is64bitArch = ^uint(0) >> 63 == 1
  2. const Is32bitArch = ^uint(0) >> 63 == 0
  3. const WordBits = 32 << (^uint(0) >> 63) // 64或32

如何保证64位原子函数调用中操作的64位整数的地址在32位架构上总是64位对齐的?

关于细节,请阅读关于Go值的内存布局(第44章)一文。

尽量避免将大尺寸的值包裹在接口值中。

当一个非接口值被赋值给一个接口值时,此非接口值的一个副本将被包裹到此接口值中。 副本复制的开销和非接口值的尺寸成正比。尺寸越大,复制开销越大。 所以请尽量避免将大尺寸的值包裹到接口值中。

在下面的例子中,后两个打印调用的成本要比前两个低得多。

  1. package main
  2. import "fmt"
  3. func main() {
  4. var a [1000]int
  5. // 这两行的开销相对较大,因为数组a中的元素都将被复制。
  6. fmt.Println(a)
  7. fmt.Printf("Type of a: %T\n", a)
  8. // 这两行的开销较小,数组a中的元素没有被复制。
  9. fmt.Printf("%v\n", a[:])
  10. fmt.Println("Type of a:", fmt.Sprintf("%T", &a)[1:])
  11. }

关于不同种类的类型的尺寸,请阅读值复制成本(第34章)一文。

利用BCE(边界检查消除)进行性能优化。

请阅读此文(第35章)来获知什么是边界检查消除(BCE)以及目前的标准编译器对BCE的支持程度。

下面是一个利用了BCE进行性能优化的例子:

  1. package main
  2. import (
  3. "strings"
  4. "testing"
  5. )
  6. func NumSameBytes_1(x, y string) int {
  7. if len(x) > len(y) {
  8. x, y = y, x
  9. }
  10. for i := 0; i < len(x); i++ {
  11. if x[i] != y[i] {
  12. return i
  13. }
  14. }
  15. return len(x)
  16. }
  17. func NumSameBytes_2(x, y string) int {
  18. if len(x) > len(y) {
  19. x, y = y, x
  20. }
  21. if len(x) <= len(y) { // 虽然代码多了,但是效率提高了
  22. for i := 0; i < len(x); i++ {
  23. if x[i] != y[i] { // 边界检查被消除了
  24. return i
  25. }
  26. }
  27. }
  28. return len(x)
  29. }
  30. var x = strings.Repeat("hello", 100) + " world!"
  31. var y = strings.Repeat("hello", 99) + " world!"
  32. func BenchmarkNumSameBytes_1(b *testing.B) {
  33. for i := 0; i < b.N; i++ {
  34. _ = NumSameBytes_1(x, y)
  35. }
  36. }
  37. func BenchmarkNumSameBytes_2(b *testing.B) {
  38. for i := 0; i < b.N; i++ {
  39. _ = NumSameBytes_2(x, y)
  40. }
  41. }

从下面所示的基准测试结果来看,函数NumSameBytes_2比函数NumSameBytes_1效率更高。

  1. BenchmarkNumSameBytes_1-4 10000000 669 ns/op
  2. BenchmarkNumSameBytes_2-4 20000000 450 ns/op

请注意:标准编译器(gc)的每个新的主版本都会有很多小的改进。 上例中所示的优化从gc 1.11开始才有效。 未来的gc版本可能会变得更加智能,以使函数NumSameBytes_2中使用技巧变得不再必要。 事实上,从gc 1.11开始,如果xy是两个切片,即使上例中使用小技巧没有被使用,y[i]中的边界检查也已经被消除了。


本书由老貘历时三年写成。目前本书仍在不断改进和增容中。你的赞赏是本书和Go101.org网站不断增容和维护的动力。

(请搜索关注微信公众号“Go 101”或者访问github.com/golang101/golang101获取本书最新版)