在本篇文章中,我會對 Go 語言編程模式的一些基本技術和要點,這樣可以讓你更容易掌握 Go 語言編程。其中,主要包括,數組切片的一些小坑,還有接口編程,以及時間和程序運行性能相關的話題。
本文是全系列中第 1 / 9 篇:Go 編程模式[1]
Go 編程模式:切片,接口,時間和性能
Go 編程模式:錯誤處理
[2]
Go 編程模式:Functional Options
[3]
Go 編程模式:委托和反轉控制
[4]
Go 編程模式:Map-Reduce
[5]
Go 編程模式:Go Generation
[6]
Go 編程模式:修飾器
[7]
Go 編程模式:Pipeline
[8]
Go 編程模式:k8s Visitor 模式
[9]
1. Slice
首先,我們先來討論一下 Slice,中文翻譯叫“切片”,這個東西在 Go 語言中不是數組,而是一個結構體,其定義如下:
type?slice?struct?{
array?unsafe.Pointer?//指向存放數據的數組指針
len???int????????????//長度有多大
cap???int????????????//容量有多大
}
用圖示來看,一個空的 slice 的表現如下:
熟悉 C/C++的同學一定會知道,在結構體里用數組指針的問題——數據會發生共享!下面我們來看一下 slice 的一些操作
foo?=?make([]int,?5)
foo[3]?=?42
foo[4]?=?100
bar??:=?foo[1:4]
bar[1]?=?99
對于上面這段代碼。
首先先創建一個 foo 的 slice,其中的長度和容量都是 5
然后開始對 foo 所指向的數組中的索引為 3 和 4 的元素進行賦值
然后,對 foo 做切片后賦值給 bar,再修改 bar[1]
通過上圖我們可以看到,因為 foo 和 bar 的內存是共享的,所以,foo 和 bar 的對數組內容的修改都會影響到對方。
接下來,我們再來看一個數據操作 append() 的示例
a?:=?make([]int,?32)
b?:=?a[1:16]
a?=?append(a,?1)
a[2]?=?42
上面這段代碼中,把 a[1:16] 的切片賦給到了 b ,此時,a 和 b 的內存空間是共享的,然后,對 a做了一個 append()的操作,這個操作會讓 a 重新分享內存,導致 a 和 b 不再共享,如下圖所示:
從上圖我們可以看以看到 append()操作讓 a 的容量變成了 64,而長度是 33。這里,需要重點注意一下——append()這個函數在 cap 不夠用的時候就會重新分配內存以擴大容量,而如果夠用的時候不不會重新分享內存!
我們再看來看一個例子:
func?main()?{
path?:=?[]byte("AAAA/BBBBBBBBB")
sepIndex?:=?bytes.IndexByte(path,'/’)
dir1?:=?path[:sepIndex]
dir2?:=?path[sepIndex+1:]
fmt.Println("dir1?=>",string(dir1))?//prints:?dir1?=>?AAAA
fmt.Println("dir2?=>",string(dir2))?//prints:?dir2?=>?BBBBBBBBB
dir1?=?append(dir1,"suffix"...)
fmt.Println("dir1?=>",string(dir1))?//prints:?dir1?=>?AAAAsuffix
fmt.Println("dir2?=>",string(dir2))?//prints:?dir2?=>?uffixBBBB
}
上面這個例子中,dir1 和 dir2 共享內存,雖然 dir1 有一個 append() 操作,但是因為 cap 足夠,于是數據擴展到了dir2 的空間。下面是相關的圖示(注意上圖中 dir1 和 dir2 結構體中的 cap 和 len 的變化)
如果要解決這個問題,我們只需要修改一行代碼。
dir1?:=?path[:sepIndex]
修改為
dir1?:=?path[:sepIndex:sepIndex]
新的代碼使用了 Full Slice Expression,其最后一個參數叫“Limited Capacity”,于是,后續的 append() 操作將會導致重新分配內存。
2. 深度比較
當我們復雜一個對象時,這個對象可以是內建數據類型,數組,結構體,map……我們在復制結構體的時候,當我們需要比較兩個結構體中的數據是否相同時,我們需要使用深度比較,而不是只是簡單地做淺度比較。這里需要使用到反射 reflect.DeepEqual() ,下面是幾個示例
import?(
"fmt"
"reflect"
)
func?main()?{
v1?:=?data{}
v2?:=?data{}
fmt.Println("v1?==?v2:",reflect.DeepEqual(v1,v2))
//prints:?v1?==?v2:?true
m1?:=?map[string]string{"one":?"a","two":?"b"}
m2?:=?map[string]string{"two":?"b",?"one":?"a"}
fmt.Println("m1?==?m2:",reflect.DeepEqual(m1,?m2))
//prints:?m1?==?m2:?true
s1?:=?[]int{1,?2,?3}
s2?:=?[]int{1,?2,?3}
fmt.Println("s1?==?s2:",reflect.DeepEqual(s1,?s2))
//prints:?s1?==?s2:?true
}
3. 接口編程
下面,我們來看段代碼,其中是兩個方法,它們都是要輸出一個結構體,其中一個使用一個函數,另一個使用一個“成員函數”。
func?PrintPerson(p?*Person)?{
fmt.Printf("Name=%s,?Sexual=%s,?Age=%d\n",
p.Name,?p.Sexual,?p.Age)
}
func?(p?*Person)?Print()?{
fmt.Printf("Name=%s,?Sexual=%s,?Age=%d\n",
p.Name,?p.Sexual,?p.Age)
}
func?main()?{
var?p?=?Person{
Name:?"Hao?Chen",
Sexual:?"Male",
Age:?44,
}
PrintPerson(&p)
p.Print()
}
你更喜歡哪種方式呢?在 Go 語言中,使用“成員函數”的方式叫“Receiver”,這種方式是一種封裝,因為 PrintPerson()本來就是和 Person強耦合的,所以,理應放在一起。更重要的是,這種方式可以進行接口編程,對于接口編程來說,也就是一種抽象,主要是用在“多態”,這個技術,在《Go 語言簡介(上):接口與多態[10]》中已經講過。在這里,我想講另一個 Go 語言接口的編程模式。
首先,我們來看一下,有下面這段代碼:
type?Country?struct?{
Name?string
}
type?City?struct?{
Name?string
}
type?Printable?interface?{
PrintStr()
}
func?(c?Country)?PrintStr()?{
fmt.Println(c.Name)
}
func?(c?City)?PrintStr()?{
fmt.Println(c.Name)
}
c1?:=?Country?{"China"}
c2?:=?City?{"Beijing"}
c1.PrintStr()
c2.PrintStr()
其中,我們可以看到,其使用了一個 Printable 的接口,而 Country 和 City 都實現了接口方法 PrintStr() 而把自己輸出。然而,這些代碼都是一樣的。能不能省掉呢?
我們可以使用“結構體嵌入”的方式來完成這個事,如下的代碼所示:
type?WithName?struct?{
Name?string
}
type?Country?struct?{
WithName
}
type?City?struct?{
WithName
}
type?Printable?interface?{
PrintStr()
}
func?(w?WithName)?PrintStr()?{
fmt.Println(w.Name)
}
c1?:=?Country?{WithName{?"China"}}
c2?:=?City?{?WithName{"Beijing"}}
c1.PrintStr()
c2.PrintStr()
引入一個叫 WithName的結構體,然而,所帶來的問題就是,在初始化的時候,變得有點亂。那么,我們有沒有更好的方法?下面是另外一個解。
type?Country?struct?{
Name?string
}
type?City?struct?{
Name?string
}
type?Stringable?interface?{
ToString()?string
}
func?(c?Country)?ToString()?string?{
return?"Country?=?"?+?c.Name
}
func?(c?City)?ToString()?string{
return?"City?=?"?+?c.Name
}
func?PrintStr(p?Stringable)?{
fmt.Println(p.ToString())
}
d1?:=?Country?{"USA"}
d2?:=?City{"Los?Angeles"}
PrintStr(d1)
PrintStr(d2)
上面這段代碼,我們可以看到——**我們使用了一個叫Stringable 的接口,我們用這個接口把“業務類型” Country 和 City 和“控制邏輯” Print() 給解耦了。**于是,只要實現了Stringable 接口,都可以傳給 PrintStr() 來使用。
這種編程模式在 Go 的標準庫有很多的示例,最著名的就是 io.Read 和 ioutil.ReadAll 的玩法,其中 io.Read 是一個接口,你需要實現他的一個 Read(p []byte) (n int, err error) 接口方法,只要滿足這個規模,就可以被 ioutil.ReadAll這個方法所使用。這就是面向對象編程方法的黃金法則——“Program to an interface not an implementation”
4. 接口完整性檢查
另外,我們可以看到,Go 語言的編程器并沒有嚴格檢查一個對象是否實現了某接口所有的接口方法,如下面這個示例:
type?Shape?interface?{
Sides()?int
Area()?int
}
type?Square?struct?{
len?int
}
func?(s*?Square)?Sides()?int?{
return?4
}
func?main()?{
s?:=?Square{len:?5}
fmt.Printf("%d\n",s.Sides())
}
我們可以看到 Square 并沒有實現 Shape 接口的所有方法,程序雖然可以跑通,但是這樣編程的方式并不嚴謹,如果我們需要強制實現接口的所有方法,那么我們應該怎么辦呢?
在 Go 語言編程圈里有一個比較標準的作法:
var?_?Shape?=?(*Square)(nil)
聲明一個 _ 變量(沒人用),其會把一個 nil 的空指針,從 Square 轉成 Shape,這樣,如果沒有實現完相關的接口方法,編譯器就會報錯:
cannot use (*Square)(nil) (type *Square) as type Shape in assignment: *Square does not implement Shape (missing Area method)
這樣就做到了個強驗證的方法。
5. 時間
對于時間來說,這應該是編程中比較復雜的問題了,相信我,時間是一種非常復雜的事(比如《你確信你了解時間嗎?[11]》、《關于閏秒[12]》等文章)。而且,時間有時區、格式、精度等等問題,其復雜度不是一般人能處理的。所以,一定要重用已有的時間處理,而不是自己干。
在 Go 語言中,你一定要使用 time.Time 和 time.Duration 兩個類型:
在命令行上,
flag 通過
time.ParseDuration 支持了
time.Duration
JSon 中的
encoding/json 中也可以把
time.Time 編碼成
RFC 3339
[13] 的格式
數據庫使用的
database/sql 也支持把
DATATIME 或
TIMESTAMP 類型轉成
time.Time
YAML 你可以使用
gopkg.in/yaml.v2 也支持
time.Time 、
time.Duration 和
RFC 3339
[14] 格式
如果你要和第三方交互,實在沒有辦法,也請使用 RFC 3339[15] 的格式。
最后,如果你要做全球化跨時區的應用,你一定要把所有服務器和時間全部使用 UTC 時間。
6. 性能提示
Go 語言是一個高性能的語言,但并不是說這樣我們就不用關心性能了,我們還是需要關心的。下面是一個在編程方面和性能相關的提示。
如果需要把數字轉字符串,使用
strconv.Itoa() 會比
fmt.Sprintf() 要快一倍左右
盡可能地避免把
String轉成
[]Byte 。這個轉換會導致性能下降。
如果在 for-loop 里對某個 slice 使用
append()請先把 slice 的容量很擴充到位,這樣可以避免內存重新分享以及系統自動按 2 的 N 次方冪進行擴展但又用不到,從而浪費內存。
使用
StringBuffer 或是
StringBuild 來拼接字符串,會比使用
+ 或
+= 性能高三到四個數量級。
盡可能的使用并發的 go routine,然后使用
sync.WaitGroup 來同步分片操作
避免在熱代碼中進行內存分配,這樣會導致 gc 很忙。盡可能的使用
sync.Pool 來重用對象。
使用 lock-free 的操作,避免使用 mutex,盡可能使用
sync/Atomic包。(關于無鎖編程的相關話題,可參看《
無鎖隊列實現
[16]》或《
無鎖 Hashmap 實現
[17]》)
使用 I/O 緩沖,I/O 是個非常非常慢的操作,使用
bufio.NewWrite() 和
bufio.NewReader() 可以帶來更高的性能。
對于在 for-loop 里的固定的正則表達式,一定要使用
regexp.Compile() 編譯正則表達式。性能會得升兩個數量級。
如果你需要更高性能的協議,你要考慮使用
protobuf
[18] 或
msgp
[19] 而不是 JSON,因為 JSON 的序列化和反序列化里使用了反射。
你在使用 map 的時候,使用整型的 key 會比字符串的要快,因為整型比較比字符串比較要快。
參考
還有很多不錯的技巧,下面的這些參考文檔可以讓你寫出更好的 Go 的代碼,必讀!
Effective Go
[20]
Uber Go Style
[21]
50 Shades of Go: Traps, Gotchas, and Common Mistakes for New Golang Devs
[22]
Go Advice
[23]
Practical Go Benchmarks
[24]
Benchmarks of Go serialization methods
[25]
Debugging performance issues in Go programs
[26]
Go code refactoring: the 23x performance hunt
[27]
參考資料
[1]
Go 編程模式: https://coolshell.cn/articles/series/go編程模式
[2]
Go 編程模式:錯誤處理: https://coolshell.cn/articles/21140.html
[3]
Go 編程模式:Functional Options: https://coolshell.cn/articles/21146.html
[4]
Go 編程模式:委托和反轉控制: https://coolshell.cn/articles/21214.html
[5]
Go 編程模式:Map-Reduce: https://coolshell.cn/articles/21164.html
[6]
Go 編程模式:Go Generation: https://coolshell.cn/articles/21179.html
[7]
Go 編程模式:修飾器: https://coolshell.cn/articles/17929.html
[8]
Go 編程模式:Pipeline: https://coolshell.cn/articles/21228.html
[9]
Go 編程模式:k8s Visitor 模式: https://coolshell.cn/articles/21263.html
[10]
Go 語言簡介(上):接口與多態: https://coolshell.cn/articles/8460.html#接口和多態
[11]
你確信你了解時間嗎?: https://coolshell.cn/articles/5075.html
[12]
關于閏秒: https://coolshell.cn/articles/7804.html
[13]
RFC 3339: https://tools.ietf.org/html/rfc3339
[14]
RFC 3339: https://tools.ietf.org/html/rfc3339
[15]
RFC 3339: https://tools.ietf.org/html/rfc3339
[16]
無鎖隊列實現: https://coolshell.cn/articles/8239.html
[17]
無鎖 Hashmap 實現: https://coolshell.cn/articles/9703.html
[18]
protobuf: https://github.com/golang/protobuf
[19]
msgp: https://github.com/tinylib/msgp
[20]
Effective Go: https://golang.org/doc/effective_go.html
[21]
Uber Go Style: https://github.com/uber-go/guide/blob/master/style.md
[22]
50 Shades of Go: Traps, Gotchas, and Common Mistakes for New Golang Devs: http://devs.cloudimmunity.com/gotchas-and-common-mistakes-in-go-golang/
[23]
Go Advice: https://github.com/cristaloleg/go-advice
[24]
Practical Go Benchmarks: https://www.instana.com/blog/practical-golang-benchmarks/
[25]
Benchmarks of Go serialization methods: https://github.com/alecthomas/go_serialization_benchmarks
[26]
Debugging performance issues in Go programs: https://github.com/golang/go/wiki/Performance
[27]
Go code refactoring: the 23x performance hunt: https://medium.com/@val_deleplace/go-code-refactoring-the-23x-performance-hunt-156746b522f7