一、Go程式設計模式:切片,介面,時間和效能

zhaocrazy發表於2022-02-06

Slice

首先,我們先來討論一下Slice,中文翻譯叫“切片”,這個東西在Go語言中不是陣列,而是一個結構體,其定義如下:

type slice struct {
    array unsafe.Pointer //指向存放資料的陣列指標
    len   int            //長度有多大
    cap   int            //容量有多大
}

用圖示來看,一個空的slice的表現如下:

一、Go程式設計模式:切片,介面,時間和效能
熟悉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]

一、Go程式設計模式:切片,介面,時間和效能

透過上圖我們可以看到,因為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 不再共享,如下圖所示:

一、Go程式設計模式:切片,介面,時間和效能

從上圖我們可以看以看到 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 的變化)

一、Go程式設計模式:切片,介面,時間和效能

如果要解決這個問題,我們只需要修改一行程式碼。

dir1 := path[:sepIndex]

修改為

dir1 := path[:sepIndex:sepIndex]

新的程式碼使用了 Full Slice Expression,其最後一個引數叫“Limited Capacity”,於是,後續的 append() 操作將會導致重新分配記憶體。

深度比較

當我們複雜一個物件時,這個物件可以是內建資料型別,陣列,結構體,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
}

介面程式設計

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

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語言簡介(上):介面與多型》中已經講過。在這裡,我想講另一個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 的介面,而 CountryCity 都實現了介面方法 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 的介面,我們用這個介面把“業務型別” CountryCity 和“控制邏輯” Print() 給解耦了。於是,只要實現了Stringable 介面,都可以傳給 PrintStr() 來使用。

這種程式設計模式在Go 的標準庫有很多的示例,最著名的就是 io.Readioutil.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 {
    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)

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

時間

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

在 Go 語言中,你一定要使用 time.Timetime.Duration 兩個型別:

  • 在命令列上,flag 透過 time.ParseDuration 支援了 time.Duration
  • JSon 中的 encoding/json 中也可以把time.Time 編碼成 RFC 3339 的格式
  • 資料庫使用的 database/sql 也支援把 DATATIMETIMESTAMP 型別轉成 time.Time
  • YAML你可以使用 gopkg.in/yaml.v2 也支援 time.Timetime.DurationRFC 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() 編譯正規表示式。效能會得升兩個數量級。
  • 如果你需要更高效能的協議,你要考慮使用 protobufmsgp 而不是JSON,因為JSON的序列化和反序列化裡使用了反射。
  • 你在使用map的時候,使用整型的key會比字串的要快,因為整型比較比字串比較要快。
本文非本人所作,轉載左耳朵耗子部落格和出處 酷 殼 – CoolShell
本作品採用《CC 協議》,轉載必須註明作者和本文連結
滴水穿石,石破天驚----曉瘋子

相關文章