GO編程模式系列(一):切片,接口,時間和性能

在本篇文章中,我會對Go語言編程模式的一些基本技術和要點,這樣可以讓你更容易掌握Go語言編程。其中,主要包括,數組切片的一些小坑,還有接口編程,以及時間和程序運行性能相關的話題。

本文是全系列中第1 / 9篇:Go編程模式

  • Go編程模式:切片,接口,時間和性能

  • Go 編程模式:錯誤處理

  • Go 編程模式:Functional Options

  • Go編程模式:委託和反轉控制

  • Go編程模式:Map-Reduce

  • Go 編程模式:Go Generation

  • Go編程模式:修飾器

  • Go編程模式:Pipeline

  • Go 編程模式:k8s Visitor 模式

目錄

  • Slice深度比較接口編程

  • 接口完整性檢查

  • 時間

  • 性能提示

  • 參考文檔

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 不夠用的時候就會重新分配內存以擴大容量,而如果夠用的時候不不會重新分享內存!

我們再看來看一個例子:

funcmain(){  
   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() 操作將會導致重新分配內存。

深度比較

當我們複雜一個對象時,這個對象可以是內建數據類型,數組,結構體,map……我們在複製結構體的時候,當我們需要比較兩個結構體中的數據是否相同時,我們需要使用深度比較,而不是隻是簡單地做淺度比較。這裏需要使用到反射 reflect.DeepEqual() ,下面是幾個示例。

import(  
   "fmt"
   "reflect"
)


funcmain(){  


   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
}

接口編程

下面,我們來看段代碼,其中是兩個方法,它們都是要輸出一個結構體,其中一個使用一個函數,另一個使用一個“成員函數”。

funcPrintPerson(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)
}


funcmain(){
   var p = Person{
       Name: "Hao Chen",
       Sexual: "Male",
       Age: 44,
   }
   PrintPerson(&p)
   p.Print()
}

你更喜歡哪種方式呢?在 Go 語言中,使用“成員函數”的方式叫“Receiver”,這種方式是一種封裝,因爲 PrintPerson()本來就是和 Person強耦合的,所以,理應放在一起。更重要的是,這種方式可以進行接口編程,對於接口編程來說,也就是一種抽象,主要是用在“多態”,這個技術,在《Go語言簡介(上):接口與多態》中已經講過。在這裏,我想講另一個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
}


funcPrintStr(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”

接口完整性檢查

另外,我們可以看到,Go語言的編程器並沒有嚴格檢查一個對象是否實現了某接口所有的接口方法,如下面這個示例:

type Shape interface{
   Sides()int
   Area()int
}
type Square struct{
   len int
}
func(s* Square)Sides()int{
   return4
}
funcmain(){
   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)

這樣就做到了個強驗證的方法。

時間

對於時間來說,這應該是編程中比較複雜的問題了,相信我,時間是一種非常複雜的事(比如《你確信你瞭解時間嗎?》、《關於閏秒》等文章)。而且,時間有時區、格式、精度等等問題,其複雜度不是一般人能處理的。所以,一定要重用已有的時間處理,而不是自己幹。

在 Go 語言中,你一定要使用 time.Time 和 time.Duration 兩個類型:

  • 在命令行上,flag 通過 time.ParseDuration 支持了 time.Duration

  • JSon 中的 encoding/json 中也可以把time.Time 編碼成 RFC 3339 的格式

  • 數據庫使用的 database/sql 也支持把 DATATIME 或 TIMESTAMP 類型轉成 time.Time

  • YAML你可以使用 gopkg.in/yaml.v2 也支持 time.Time 、time.Duration 和 RFC 3339 格式

如果你要和第三方交互,實在沒有辦法,也請使用 RFC 3339 的格式。

最後,如果你要做全球化跨時區的應用,你一定要把所有服務器和時間全部使用UTC時間。

性能提示

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包。(關於無鎖編程的相關話題,可參看《無鎖隊列實現》或《無鎖Hashmap實現》)

  • 使用 I/O緩衝,I/O是個非常非常慢的操作,使用 bufio.NewWrite() 和 bufio.NewReader() 可以帶來更高的性能。

  • 對於在for-loop裏的固定的正則表達式,一定要使用 regexp.Compile() 編譯正則表達式。性能會得升兩個數量級。

  • 如果你需要更高性能的協議,你要考慮使用 protobuf 或 msgp 而不是JSON,因爲JSON的序列化和反序列化裏使用了反射。

  • 你在使用map的時候,使用整型的key會比字符串的要快,因爲整型比較比字符串比較要快。

參考文檔

還有很多不錯的技巧,下面的這些參考文檔可以讓你寫出更好的Go的代碼,必讀!

  • Effective Go
    https://golang.org/doc/effective_go.html

  • Uber Go Style
    https://github.com/uber-go/guide/blob/master/style.md

  • 50 Shades of Go: Traps, Gotchas, and Common Mistakes for New Golang Devs
    http://devs.cloudimmunity.com/gotchas-and-common-mistakes-in-go-golang/

  • Go Advice
    https://github.com/cristaloleg/go-advice

  • Practical Go Benchmarks
    https://www.instana.com/blog/practical-golang-benchmarks/

  • Benchmarks of Go serialization methods
    https://github.com/alecthomas/go_serialization_benchmarks

  • Debugging performance issues in Go programs
    https://github.com/golang/go/wiki/Performance

  • Go code refactoring: the 23x performance hunt
    https://medium.com/@val_deleplace/go-code-refactoring-the-23x-performance-hunt-156746b522f7

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章