【Go】string 優化誤區及建議

原文連接: blog.thinkeridea.com/201902/go/s…html

本文原標題爲 《string 也是引用類型》,通過 郝林 大佬指點原標題存在誘導性,這裏解釋一下 "引用類型" 有兩個特徵:一、多個變量引用一塊內存數據,不建立變量的副本,二、修改任意變量的數據,其它變量可見。顯然字符串只知足了 "引用類型" 的第一個特色,不能知足第二個特色,顧不能說字符串是引用類型,感謝大佬指正。git

初學 Go 語言的朋友總會在傳 []bytestring 之間有着不少糾結,其實是沒有了解 stringslice 的本質,並且讀了一些程序源碼,也發現不少與之相關的問題,下面相似的代碼估計不少初學者都寫過,也充分說明了做者當時心裏的糾結:github

package main

import "bytes"

func xx(s []byte) []byte{
    ....
    
    return s
}

func main(){
    s := "xxx"
    
    s = string(xx([]byte(s)))
    
    s = string(bytes.Replace([]byte(s), []byte("x"), []byte(""), -1))
}
複製代碼

雖然這樣的代碼並非來自真實的項目,可是確實有人這樣設計,單從設計上看就很糟糕了,這樣設計的緣由不少人說:「slice 是引用類型,傳遞引用類型效率高呀」,主要緣由不瞭解二者的本質。golang

上面這個例子若是以爲有點基礎和可愛,下面這個例子貌似並不那麼容易說明其存在的問題了吧。數組

package main

func xx(s *string) *string{
    ....
    return s
}

func main(){
    s := "xx"
    
    s = *xx(&s)
    
    ss :=[]*string{}
    
    ss = append(ss, &s)
}
複製代碼

指針效率高,我就用指針多好,能夠減小內存分配呀,設計函數都接收指針變量,程序性能會有很大提高,在實際的項目中這種例子也很多見,我想經過這篇文檔來幫助初學者走出誤區,減小拔苗助長的優化技巧。安全

slice 的定義

在以前 「【Go】深刻剖析slice和array」 一文中說了 slice 在內存中的存儲模式,slice 自己包含一個指向底層數組的指針,一個 int 類型的長度和一個 int 類型的容量, 這就是 slice 的本質, []byte 自己也是一個 slice,只是底層數組存儲的元素是 byte。下面這個圖就是 slice 的在內存中的狀態:bash

看一下 reflect.SliceHeader 如何定義 slice 在內存中的結構吧:app

type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}
複製代碼

slice 是引用類型是 slice 自己會包含一個地址,在傳遞 slice 時只須要分配 SliceHeader 就行了, 而 SliceHeader 只包含了三個 int 類型,至關於傳遞一個 slice 就只須要拷貝 SliceHeader,而不用拷貝整個底層數組,因此才說 slice 是引用類型的。ide

那麼字符串呢,計算機中咱們處理的大多數問題都和字符串有關,難道傳遞字符串真的須要那麼高的成本,須要藉助 slice 和指針來減小內存開銷嗎。函數

string 的定義

reflect 包裏面也定義了一個 StringHeader 看一下吧:

type StringHeader struct {
	Data uintptr
	Len  int
}
複製代碼

字符串只包含了兩個 int 類型的數據,其中一個是指針,一個是字符串的長度,從 StringHeader 定義來看 string 並不會發生拷貝的,傳遞 string 只會拷貝 StringHeader 而已。

藉助 unsafe 來分析一下狀況是否是這樣吧:

package main

import (
	"reflect"
	"unsafe"

	"github.com/davecgh/go-spew/spew"
)

func xx(s string) {
	sh := *(*reflect.StringHeader)(unsafe.Pointer(&s))
	spew.Dump(sh)
}

func main() {
	s := "xx"

	sh := *(*reflect.StringHeader)(unsafe.Pointer(&s))
	spew.Dump(sh)

	xx(s)
	xx(s[:1])
	xx(s[1:])
}
複製代碼

上面這段代碼的輸出以下:

(reflect.StringHeader) {
 Data: (uintptr) 0x10f5ee0,
 Len: (int) 2
}
(reflect.StringHeader) {
 Data: (uintptr) 0x10f5ee0,
 Len: (int) 2
}
(reflect.StringHeader) {
 Data: (uintptr) 0x10f5ee0,
 Len: (int) 1
}
(reflect.StringHeader) {
 Data: (uintptr) 0x10f5ee1,
 Len: (int) 1
}
複製代碼

能夠發現前三個輸出的指針都是同一個地址,第四個的地址發生了一個字節的偏移,分析來看傳遞字符串確實沒有分配新的內存,同時和 slice 同樣即便傳遞字符串的子串也不會分配新的內存空間,而是指向原字符串的中的一個位置。

這樣說來把 string 轉成 []byte 還浪費的一個 int 的空間呢,須要分配更多的內存,真是拔苗助長呀,並且類型轉換會發生內存拷貝,從 string 轉爲 []byte 纔是真的把 string 底層數據所有拷貝一遍呢,真是得不償失呀。

string 的兩個小特性

字符串還有兩個小特性,針對字面量(就是直接寫在程序中的字符串),會建立在只讀空間上,而且被複用,看一下下面的一個小例子:

package main

import (
	"reflect"
	"unsafe"

	"github.com/davecgh/go-spew/spew"
)

func main() {
	a := "xx"
	b := "xx"
	c := "xxx"
	spew.Dump(*(*reflect.StringHeader)(unsafe.Pointer(&a)))
	spew.Dump(*(*reflect.StringHeader)(unsafe.Pointer(&b)))
	spew.Dump(*(*reflect.StringHeader)(unsafe.Pointer(&c)))
}
複製代碼

從輸出能夠了解到,相同的字面量會被複用,可是子串是不會複用空間的,這就是編譯器給咱們帶來的福利了,能夠減小字面量字符串佔用的內存空間。

(reflect.StringHeader) {
 Data: (uintptr) 0x10f5ea0,
 Len: (int) 2
}
(reflect.StringHeader) {
 Data: (uintptr) 0x10f5ea0,
 Len: (int) 2
}
(reflect.StringHeader) {
 Data: (uintptr) 0x10f5f2e,
 Len: (int) 3
}
複製代碼

另外一個小特性你們都知道,就是字符串是不能修改的,若是咱們不但願調用函數修改咱們的數據,最好傳遞字符串,高效有安全。

不過有了 unsafe 這個黑魔法,字符串的這一個特性也就不那麼可靠了。

package main

import (
	"fmt"
	"reflect"
	"strings"
	"unsafe"
)

func main() {
	a := strings.Repeat("x", 10)

	fmt.Println(a)
	strHeader := *(*reflect.StringHeader)(unsafe.Pointer(&a))

	sliceHeader := reflect.SliceHeader{
		Data: strHeader.Data,
		Len:  strHeader.Len,
		Cap:  strHeader.Len,
	}

	b := *(*[]byte)(unsafe.Pointer(&sliceHeader))

	b[1] = 'a'

	fmt.Println(a)
}
複製代碼

從輸出裏面竟然發現字符串被修改了, 咱們沒有辦法直接修改字符串,可是能夠利用 slicestring 自己結構的特性,建立一個 slice 讓它的指針指向 string 的指針位置,而後藉助 unsafe 把這個 SliceHeader 轉成 []byte 來修改字符串,字符串確實被修改了。

xxxxxxxxxx
xaxxxxxxxx
複製代碼

看了上面的例子是否是開始擔憂把字符串傳給其它函數真的不會更改嗎?感受很不放心的樣子,難道使用任何函數都要了解它的內部實現嗎,其實這種狀況極少發生,還記得以前說的那個字符串特性嗎,字面量字符串會放到只讀空間中,這個很重要,能夠保證不是任何函數想修改咱們的字符串就能夠修改的。

package main

import (
	"reflect"
	"unsafe"
)

func main() {
	defer func() {
		recover()
	}()

	a := "xx"

	strHeader := *(*reflect.StringHeader)(unsafe.Pointer(&a))
	sliceHeader := reflect.SliceHeader{
		Data: strHeader.Data,
		Len:  strHeader.Len,
		Cap:  strHeader.Len,
	}
	b := *(*[]byte)(unsafe.Pointer(&sliceHeader))
	b[1] = 'a'
}
複製代碼

運行上面的代碼發生了一個運行時不可修復的錯誤,就是這個特性其它函數不能確保輸入字符串是不是字面量,也是不會惡意修改咱們字符串的了。

unexpected fault address 0x1095dd5
fatal error: fault
[signal SIGBUS: bus error code=0x2 addr=0x1095dd5 pc=0x106c804]

goroutine 1 [running]:
runtime.throw(0x1095fde, 0x5)
	/usr/local/go/src/runtime/panic.go:608 +0x72 fp=0xc000040700 sp=0xc0000406d0 pc=0x10248d2
runtime.sigpanic()
	/usr/local/go/src/runtime/signal_unix.go:387 +0x2d7 fp=0xc000040750 sp=0xc000040700 pc=0x1037677
main.main()
	/Users/qiyin/project/go/src/github.com/yumimobi/test/a.go:22 +0x84 fp=0xc000040798 sp=0xc000040750 pc=0x106c804
runtime.main()
	/usr/local/go/src/runtime/proc.go:201 +0x207 fp=0xc0000407e0 sp=0xc000040798 pc=0x1026247
runtime.goexit()
	/usr/local/go/src/runtime/asm_amd64.s:1333 +0x1 fp=0xc0000407e8 sp=0xc0000407e0 pc=0x104da51
複製代碼

關於字符串轉 []bytego-extend 擴展包中有直接的實現,這種用法在 go-extend 內部方法實現中也有大量使用, 實際上由於原數據類型和處理數據的函數類型不一致,使用這種方法轉換字符串和 []byte 能夠極大的提高程序性能

上面這兩個函數用的好,能夠極大的提高咱們程序的性能,關於 exstrings.UnsafeToBytes 咱們轉換不肯定是不是字面量的字符串時就須要確保調用的函數不會修改咱們的數據,這往常在調用 bytes 裏面的方法十分有效。

傳字符串和字符串指針的區別

以前分析了傳遞 slice 並無 string 高效,況且轉換數據類型自己就會發生數據拷貝。

那麼在這篇文章的第二個例子,爲何說傳遞字符串指針也很差呢,要了解指針在底層就是一個 int 類型的數據,而咱們字符串只是兩個 int 而已,另外若是瞭解 GC 的話,GC 只處理堆上的數據,傳遞指針字符串會致使數據逃逸到堆上,閱讀標準庫的代碼會有不少註釋說明避免逃逸到堆上,這樣會極大的增長 GC 的開銷,GC 的成本可謂是很高的呀。

疑惑

這篇文章說 「傳遞 slice 並無 string 高效」,爲何還會有 bytes 包的存在呢,其中不少函數的功能和 strings 包的功能一致,只是把 string 換成了 []byte, 既然傳遞 []byte 沒有 string 效率好,這個包存在的意義是什麼呢。

咱們想一下轉換數據類型是會發生數據拷貝,這個成本但是大的多呀,若是咱們數據自己就是 []byte 類型,使用 strings 包就須要轉換數據類型了。

另外咱們對比兩個函數來看下一下即便傳遞 []byte 沒有 string 效率好,可是標準庫實現上卻會致使兩個函數有很大的性能差別的。

strings.Repeat 函數:

func Repeat(s string, count int) string {
	// Since we cannot return an error on overflow,
	// we should panic if the repeat will generate
	// an overflow.
	// See Issue golang.org/issue/16237
	if count < 0 {
		panic("strings: negative Repeat count")
	} else if count > 0 && len(s)*count/count != len(s) {
		panic("strings: Repeat count causes overflow")
	}

	b := make([]byte, len(s)*count)
	bp := copy(b, s)
	for bp < len(b) {
		copy(b[bp:], b[:bp])
		bp *= 2
	}
	return string(b)
}
複製代碼

bytes.Repeat 函數:

func Repeat(b []byte, count int) []byte {
	// Since we cannot return an error on overflow,
	// we should panic if the repeat will generate
	// an overflow.
	// See Issue golang.org/issue/16237.
	if count < 0 {
		panic("bytes: negative Repeat count")
	} else if count > 0 && len(b)*count/count != len(b) {
		panic("bytes: Repeat count causes overflow")
	}

	nb := make([]byte, len(b)*count)
	bp := copy(nb, b)
	for bp < len(nb) {
		copy(nb[bp:], nb[:bp])
		bp *= 2
	}
	return nb
}
複製代碼

上面兩個函數的實現很是類似,除了類型不一樣 strings 包在處理完數據發生了一次類型轉換,使用 bytes 只有一次內存分配,而 strings 是兩次。

咱們能夠藉助 exbytes.ToString 函數把 bytes.Repeat 的返回沒有任何成本的轉換會咱們須要的字符串,若是咱們輸入也是一個字符串的話,還能夠藉助 exstrings.UnsafeToBytes 來轉換輸入的數據類型。

例如:

s := exbytes.ToString(bytes.Repeat(exstrings.UnsafeToBytes("x"), 10))
複製代碼

不過這樣寫有點太麻煩了,實際上 exstrings 包裏面正在修改 strings 裏面一些相似函數的問題,全部的實現基本和標準庫一致,只是把其中類型轉換的部分用 exbytes.ToString 優化了一下,能夠提高性能,也能提高開發效率。

exstrings.UnsafeRepeat 函數:

func UnsafeRepeat(s string, count int) string {
	// Since we cannot return an error on overflow,
	// we should panic if the repeat will generate
	// an overflow.
	// See Issue golang.org/issue/16237
	if count < 0 {
		panic("strings: negative Repeat count")
	} else if count > 0 && len(s)*count/count != len(s) {
		panic("strings: Repeat count causes overflow")
	}

	b := make([]byte, len(s)*count)
	bp := copy(b, s)
	for bp < len(b) {
		copy(b[bp:], b[:bp])
		bp *= 2
	}
	return exbytes.ToString(b)
}
複製代碼

若是用上面的函數只須要下面這樣寫就能夠了:

s:=exstrings.UnsafeRepeat("x", 10)
複製代碼

go-extend 裏面還收錄了不少實用的方法,你們也能夠多關注。

總結

  • 千萬不要爲了使用 []byte 來優化 string 傳遞,類型轉換成本很高,且 slice 自己也比 string 更大一些。
  • 程序中是使用 string 仍是 []byte 須要根據數據來源和處理數據的函數來決定,必定要減小類型轉換。
  • 關於使用 strings 仍是 bytes 包的問題,主要關注點是數據原始類型以及想得到的數據類型來選擇。
  • 減小使用字符串指針來優化字符串,這會增長 GC 的開銷,具體能夠參考 大堆中避免大量的GC開銷 一文。

轉載:

本文做者: 戚銀(thinkeridea

本文連接: blog.thinkeridea.com/201902/go/s…

版權聲明: 本博客全部文章除特別聲明外,均採用 CC BY 4.0 CN協議 許可協議。轉載請註明出處!

相關文章
相關標籤/搜索