Go語言 Go技巧

2023-02-16 17:42 更新

如何強制一個代碼包的使用者總是使用帶字段名稱的組合字面量來表示此代碼包中的結構體類型的值?

代碼包的開發(fā)者可以在一個結構體類型定義里放置一個非導出的零尺寸的字段,這樣編譯器將會禁止代碼包的使用者使用含有一些字段但卻不含有字段名字的組合字面量來創(chuàng)建此結構體類型的值。

例如:

// foo.go
package foo

type Config struct {
	_    [0]int
	Name string
	Size int
}
// main.go
package main

import "foo"

func main() {
	//_ = foo.Config{[0]int{}, "bar", 123} // 編譯不通過
	_ = foo.Config{Name: "bar", Size: 123} // 編譯沒問題
}

請盡量不要把零尺寸的非導出字段用做結構體的最后一個字段,因為這樣做會有可能會增大結構體類型的尺寸而導致一些內存浪費。

如何使一個結構體類型不可比較?

有時候,我們想要避免一個自定義的結構體類型被用做一個映射的鍵值類型,那么我們可以放置一個非導出的零尺寸的不可比較類型的字段在結構體類型中以使此結構體類型不可比較。 例如:

package main

type T struct {
	dummy        [0]func()
	AnotherField int
}

var x map[T]int // 編譯錯誤:非法的鍵值類型

func main() {
	var a, b T
	_ = a == b // 編譯錯誤:非法的比較
}

不要使用其中涉及到的表達式之間會相互干涉的賦值語句。

目前(Go 1.19),在一些多值賦值中有一些表達式估值順序是未指定的。 因此,如果一個多值賦值語句中涉及的表達式會相互干涉,或者不太容易確定是否會相互干涉,我們應該將此多值賦值語句分拆成多個單值賦值語句。

事實上,在一些寫得很糟糕的代碼中,單值賦值中的表達式求值順序也有可能是有歧義的。 例如,下面的程序可能會打印[7 0 9]、[0 8 9]或者[7 8 9],依賴于具體編譯器實現。

package main

import "fmt"

var a = &[]int{1, 2, 3}
var i int
func f() int {
	i = 1
	a = &[]int{7, 8, 9}
	return 0
}

func main() {
	// 表達式"a"、"i"和"f()"的估值順序未定義。
	(*a)[i] = f()
	fmt.Println(*a)
}

如何模擬一些其它語言中支持的for i in 0..N循環(huán)代碼塊?

我們可以通過遍歷一個元素尺寸為零的數組或者一個空數組指針來模擬這樣的循環(huán)。 例如:

package main

import "fmt"

func main() {
	const N = 5

	for i := range [N]struct{}{} {
		fmt.Println(i)
	}
	for i := range [N][0]int{} {
		fmt.Println(i)
	}
	for i := range (*[N]int)(nil) {
		fmt.Println(i)
	}
}

當我們廢棄一個仍在使用的切片中的一些元素時,我們應該重置這些元素中的指針來避免暫時性的內存泄漏。

關于細節(jié),請閱讀如何刪除切片元素因為未重置丟失的切片元素中的指針而造成的臨時性內存泄露。

一些標準包中的某些類型的值不期望被復制。

bytes.Buffer類型、strings.Builder類型以及在sync標準庫包里的類型的值不推薦被復制。 (它們確實不應該被復制,盡管在某些特定情形下復制它們或許是沒有問題的。)

strings.Builder的實現會在運行時刻探測到非法的strings.Builder值復制。 一旦這樣的復制被發(fā)現,就會產生恐慌。例如:

package main

import "strings"

func main() {
	var b strings.Builder
	b.WriteString("hello ")
	var b2 = b
	b2.WriteString("world!") // 一個恐慌將在這里產生
}

復制標準庫包sync中類型的值會被Go官方工具鏈提供的go vet命令檢測到并被警告。

// demo.go
package demo

import "sync"

func f(m sync.Mutex) { // warning: f passes lock by value: sync.Mutex
	m.Lock()
	defer m.Unlock()
	// do something ...
}
$ go vet demo.go
./demo.go:5: f passes lock by value: sync.Mutex

復制bytes.Buffer的值不會在運行時被檢查到,也不會被go vet命令所檢測到。 千萬要小心不要隨意這樣做。

我們可以利用memclr優(yōu)化來重置數組或者切片中一段連續(xù)的元素。

關于細節(jié),請閱讀memclr優(yōu)化。

如何在不導入reflect標準庫包的情況下檢查一個值是否擁有某個方法。

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

package main

import "fmt"

type A int
type B int
func (b B) M(x int) string {
	return fmt.Sprint(b, ": ", x)
}

func check(v interface{}) bool {
	_, has := v.(interface{M(int) string})
	return has
}

func main() {
	var a A = 123
	var b B = 789
	fmt.Println(check(a)) // false
	fmt.Println(check(b)) // true
}

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

關于細節(jié)請閱讀這篇wiki文章這篇wiki文章。

在部分場景下我們應該使用三下標子切片形式。

假設一個包提供了一個func NewX(...Option) *X函數,并且這個函數的實現將輸入選項與一些內部默認選項合并,那么下面的實現是不推薦的。

func NewX(opts ...Option) *X {
	options := append(opts, defaultOpts...)
	// 使用合并后選項來創(chuàng)建一個X值并返回其指針。
	// ...
}

上述實現不被推薦的原因是append函數調用可能會修改輸入實參opts的底層潛在Option元素序列。 對大多數場景,這可能是沒問題的。但是對某些特殊場景,這有可能會導致后續(xù)代碼執(zhí)行產生不期望的結果。

為了避免輸入實參的底層Option元素序列被修改,我們應該使用下面的實現方法:

func NewX(opts ...Option) *X {
	// 改用三下標子切片格式。
	opts = append(opts[:len(opts):len(opts)], defaultOpts...)
	// 使用合并后選項來創(chuàng)建一個X值并返回其指針。
	// ...
}

另一方面,對于NewX函數的調用者來說,不應該依賴于此函數的具體實現,所以最好使用三下標子切片形式options[:len(options):cap(options)]來傳遞實參。

另外一個需要使用三下標子切片格式的場景在這篇wiki文章中被提及。

三下標子切片格式的一個缺點是它們有些冗長。 事實上,我曾經提了一個建議來讓三下標格式看上起簡潔得多。 但是此建議被否決了。

使用匿名函數來使部分延遲函數調用盡早執(zhí)行。

關于細節(jié),請閱讀這篇文章。

確保并表明一個自定義類型實現了指定的接口類型。

我們可以將一個自定義類型的一個值賦給指定接口類型的一個變量來確保此自定義類型實現了指定接口類型。 更重要的是,這樣可以表明此自定義類型實現了指定接口類型。 使用自解釋的代碼編寫文檔比使用注釋來編寫文檔要自然得多。

package myreader

import "io"

type MyReader uint16

func NewMyReader() *MyReader {
	var mr MyReader
	return &mr
}

func (mr *MyReader) Read(data []byte) (int, error) {
	switch len(data) {
	default:
		*mr = MyReader(data[0]) << 8 | MyReader(data[1])
		return 2, nil
	case 2:
		*mr = MyReader(data[0]) << 8 | MyReader(data[1])
	case 1:
		*mr = MyReader(data[0])
	case 0:
	}
	return len(data), io.EOF
}

// 下面三行中的任一行都可以保證類型*MyReader實現
// 了接口io.Reader。
var _ io.Reader = NewMyReader()
var _ io.Reader = (*MyReader)(nil)
func _() {_ = io.Reader(nil).(*MyReader)}

一些編譯時刻斷言技巧。

除了上一個技巧中提到過的編譯時刻斷言技巧,下面將要介紹更多編譯時刻斷言技巧。

下面是一些方法用來在編譯時刻保證常量N不小于另一個常量M

// 下面任一行均可保證N >= M
func _(x []int) {_ = x[N-M]}
func _(){_ = []int{N-M: 0}}
func _([N-M]int){}
var _ [N-M]int
const _ uint = N-M
type _ [N-M]int

// 如果M和N都是正整數常量,則我們也可以使用下一行所示的方法。
var _ uint = N/M - 1

另一個方法是借鑒@lukechampine的一個點子。 此點子利用了容器組合字面量中不能出現重復的常量鍵值這一規(guī)則。

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

此方法看上去有些冗長,但是它更加通用。它可以用來斷言任何條件。 其實,它也可以不必很冗長,但需要多消耗一點(完全可以忽略的)內存,如下面所示:

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

類似地,下面是斷言兩個整數常量相等的方法:

var _ [N-M]int; var _ [M-N]int
type _ [N-M]int; type _ [M-N]int
const _, _ uint = N-M, M-N
func _([N-M]int, [M-N]int) {}

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

var _ = [1]int{M-N: 0} // 唯一被允許的元素索引下標為0
var _ = [1]int{}[M-N]  // 唯一被允許的元素索引下標為0

var _ [N-M]int = [M-N]int{}

最后一行的靈感同樣來自于Luke Champine的一條tweet。

下面是一些用來斷言一個常量字符串是不是一個空串的方法。

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

最后一行借鑒自Jan Mercl的一個點子。

有時候,為了避免包級變量消耗太多的內存,我們可以把斷言代碼放在一個名為空標識符的函數體中。 例如:

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

如何聲明一個最大的int和uint常量?

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

如何在編譯時刻決定系統(tǒng)原生字的尺寸?

這個技巧和Go無關。

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

如何保證64位原子函數調用中操作的64位整數的地址在32位架構上總是64位對齊的?

關于細節(jié),請閱讀關于Go值的內存布局一文。

盡量避免將大尺寸的值包裹在接口值中。

當一個非接口值被賦值給一個接口值時,此非接口值的一個副本將被包裹到此接口值中。 副本復制的開銷和非接口值的尺寸成正比。尺寸越大,復制開銷越大。 所以請盡量避免將大尺寸的值包裹到接口值中。

在下面的例子中,后兩個打印調用的成本要比前兩個低得多。

package main

import "fmt"

func main() {
	var a [1000]int

	// 這兩行的開銷相對較大,因為數組a中的元素都將被復制。
	fmt.Println(a)
	fmt.Printf("Type of a: %T\n", a)

	// 這兩行的開銷較小,數組a中的元素沒有被復制。
	fmt.Printf("%v\n", a[:])
	fmt.Println("Type of a:", fmt.Sprintf("%T", &a)[1:])
}

關于不同種類的類型的尺寸,請閱讀值復制成本一文。

利用BCE(邊界檢查消除)進行性能優(yōu)化。

請閱讀此文來獲知什么是邊界檢查消除(BCE)以及目前的標準編譯器對BCE的支持程度。

下面是一個利用了BCE進行性能優(yōu)化的例子:

package main

import (
	"strings"
	"testing"
)

func NumSameBytes_1(x, y string) int {
	if len(x) > len(y) {
		x, y = y, x
	}
	for i := 0; i < len(x); i++ {
		if x[i] != y[i] {
			return i
		}
	}
	return len(x)
}

func NumSameBytes_2(x, y string) int {
	if len(x) > len(y) {
		x, y = y, x
	}
	if len(x) <= len(y) { // 雖然代碼多了,但是效率提高了
		for i := 0; i < len(x); i++ {
			if x[i] != y[i] { // 邊界檢查被消除了
				return i
			}
		}
	}
	return len(x)
}

var x = strings.Repeat("hello", 100) + " world!"
var y = strings.Repeat("hello", 99) + " world!"

func BenchmarkNumSameBytes_1(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = NumSameBytes_1(x, y)
	}
}

func BenchmarkNumSameBytes_2(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = NumSameBytes_2(x, y)
	}
}

從下面所示的基準測試結果來看,函數NumSameBytes_2比函數NumSameBytes_1效率更高。

BenchmarkNumSameBytes_1-4   	10000000	       669 ns/op
BenchmarkNumSameBytes_2-4   	20000000	       450 ns/op

請注意:標準編譯器(gc)的每個新的主版本都會有很多小的改進。 上例中所示的優(yōu)化從gc 1.11開始才有效。 未來的gc版本可能會變得更加智能,以使函數NumSameBytes_2中使用技巧變得不再必要。 事實上,從gc 1.11開始,如果xy是兩個切片,即使上例中使用小技巧沒有被使用,y[i]中的邊界檢查也已經被消除了。


以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號