Go字符串拼接方式深入比較

前言

Go中字符串的拼接主要有"+"fmt.Sprintf+%sstrings.Join等方式,已經有很多人從耗時的角度比較這些方式的性能,本文則從源碼的角度去分析下這些方式的實現方式,再去比較性能。

拼接字符串方式

"+"

"+"是Go中支持的最直接的字符串拼接符。

str := "a"+"b"+"c"
func contact(list []string) string{
    r := ""
    for _,v :=range list{
        r += v
    }
    return r
}

關於"+",我們可以在runtime.go中找到相關的func。其調用的具體細節在cmd/compile/internal/gc/walk.go文件中,對應操作符OADDSTR,其處理func是addstr。在拼接的字符串個數小於等於5個時,會直接調用對應的個數的處理concatstring%n func,這些func均在/runtime/string.go中,然後會調用concatstring;大於5個時則會直接調用concatstring。有興趣的朋友可以去看下詳細的調用處理。此處主要關注concatstring,它負責字符串的具體拼接過程。

// The constant is known to the compiler.
// There is no fundamental theory behind this number.
const tmpStringBufSize = 32

type tmpBuf [tmpStringBufSize]byte
// concatstrings implements a Go string concatenation x+y+z+...
// The operands are passed in the slice a.
// If buf != nil, the compiler has determined that the result does not
// escape the calling function, so the string data can be stored in buf
// if small enough.
func concatstrings(buf *tmpBuf, a []string) string {
    idx := 0
    l := 0
    count := 0
    for i, x := range a {
        n := len(x)
        if n == 0 {
            continue
        }
        if l+n < l {
            throw("string concatenation too long")
        }
        l += n
        count++
        idx = i
    }
    if count == 0 {
        return ""
    }

    // If there is just one string and either it is not on the stack
    // or our result does not escape the calling frame (buf != nil),
    // then we can return that string directly.
    if count == 1 && (buf != nil || !stringDataOnStack(a[idx])) {
        return a[idx]
    }
    s, b := rawstringtmp(buf, l)
    for _, x := range a {
        copy(b, x)
        b = b[len(x):]
    }
    return s
}
func rawstringtmp(buf *tmpBuf, l int) (s string, b []byte) {
    if buf != nil && l <= len(buf) {
        b = buf[:l]
        s = slicebytetostringtmp(b)
    } else {
        s, b = rawstring(l)
    }
    return
}
func slicebytetostringtmp(b []byte) string {
    ...
    return *(*string)(unsafe.Pointer(&b))
}
func rawstring(size int) (s string, b []byte) {
    p := mallocgc(uintptr(size), nil, false)

    stringStructOf(&s).str = p
    stringStructOf(&s).len = size

    *(*slice)(unsafe.Pointer(&b)) = slice{p, size, size}

    return
}

根據func的註釋,也可以看出concatstrings就是實現"+"的func。參數a []string是將多個+連接的字符串組裝成slice傳入。

看下處理過程:

  1. 計算所有字符串的總長度l,記錄非空字符串的個數,記錄字符串的位置,當總長溢出時報錯。
  2. 若非空字符串個數爲0,返回空字符""
  3. 若只有一個非空字符串,且沒有存儲在buf中或數組還存儲在當前goroutine的棧中,則根據字符的位置直接返回對應位置的字符串。
  4. 創建字符串s及字符串指向的字節數組b,修改b則改變s的值。
  • 如果buf!=nil且總長度小於32位,則取b=buf[:l]即可存儲所有數據,s指向字節數組b;
  • 否則,直接根據總長度分配內存創建字符串,並將地址指向字節數組b.
  1. 逐個將數據拷貝至b中,返回s即可。

需要注意的是:
當一個表達式中存在多個'+'時,會封裝參數至slice中,再調用concatstrings處理,而不是每個'+'都調用一遍。
對於靜態的字符串,如str := x+ “a”+“b”+“c”,在編譯後直接合並,會處理成str:=x+“abc”
buf在結果不會逃逸出調用func時纔不會爲nil,且其長度爲32個字節,僅能存儲長度較小的字符串
concatstrings最多重新分配內存一次

fmt.Sprintf

fmt.Sprintf是fmt包中根據格式符將數據轉換爲string,拼接字符串時使用的格式符爲%s,用以連接字符串。

具體源碼如下,本文僅關注%s的部分,無關的源碼部分已忽略。

// Sprintf formats according to a format specifier and returns the resulting string.
func Sprintf(format string, a ...interface{}) string {
    p := newPrinter()
    p.doPrintf(format, a)
    s := string(p.buf)
    p.free()
    return s
}

func (p *pp) doPrintf(format string, a []interface{}) {
    end := len(format)
    argNum := 0         // we process one argument per non-trivial format
    afterIndex := false // previous item in format was an index like [3].
    p.reordered = false
formatLoop:
    for i := 0; i < end; {
        p.goodArgNum = true
        lasti := i
        for i < end && format[i] != '%' {
            i++
        }
        if i > lasti {
            p.buf.writeString(format[lasti:i])//寫入'%'前的字符串
        }
        if i >= end {//結束
            // done processing format string
            break
        }

        // Process one verb
        i++

        // Do we have flags?
        p.fmt.clearflags()
    simpleFormat:
        for ; i < end; i++ {
            c := format[i]
            switch c {
            ...
            default:
                // Fast path for common case of ascii lower case simple verbs
                // without precision or width or argument indices.
                if 'a' <= c && c <= 'z' && argNum < len(a) {
                    if c == 'v' {
                        // Go syntax
                        p.fmt.sharpV = p.fmt.sharp
                        p.fmt.sharp = false
                        // Struct-field syntax
                        p.fmt.plusV = p.fmt.plus
                        p.fmt.plus = false
                    }
                    p.printArg(a[argNum], rune(c))
                    argNum++
                    i++
                    continue formatLoop
                }
                // Format is more complex than simple flags and a verb or is malformed.
                break simpleFormat
            }
        }
    ...
}

func (p *pp) printArg(arg interface{}, verb rune) {
    ...
        case string:
        p.fmtString(f, verb)
    ...
}

func (p *pp) fmtString(v string, verb rune) {
    switch verb {
    ...
    case 's':
        p.fmt.fmtS(v)
    ...
    }
}

func (f *fmt) fmtS(s string) {
    s = f.truncateString(s)//轉換精度,僅用於number,字符串可忽略
    f.padString(s)
}

// padString appends s to f.buf, padded on left (!f.minus) or right (f.minus).
func (f *fmt) padString(s string) {
    if !f.widPresent || f.wid == 0 {//僅在format number時使用
        f.buf.writeString(s)
        return
    }
    width := f.wid - utf8.RuneCountInString(s)//僅用%s,f.width=0,因此width<0
    if !f.minus {//f.minus僅在存在負數時爲true
        // left padding
        f.writePadding(width)
        f.buf.writeString(s)
    } else {
        // right padding
        f.buf.writeString(s)//寫入
        f.writePadding(width)//此處無padding
    }
}

func (b *buffer) writeString(s string) {
    *b = append(*b, s...)
}

// writePadding generates n bytes of padding.
func (f *fmt) writePadding(n int) {
    if n <= 0 { // No padding bytes needed.
        return
    }
    ...
}

對於僅拼接字符串的處理過程爲:

  1. 依次查找'%'的位置,'%'前的數據append至buf中
  2. 根據其後的format,確認處理過程,拼接字符串使用的是%s,處理過程一個%s對應一個string
  3. append追加字符串至buf中(會面臨頻繁擴容的問題)
  4. 將buf轉爲string

注意:fmt.Sprintf並沒有計算字符串的總長度,而是針對每個%s進行處理,每個%s的處理最終都會調用append,而使用append可能會出現擴容的問題,尤其是多個字符串時,可能會出現多次擴容的情況。

strings.Join

strings.Join是strings包中針對字符串數組拼接的func,Join支持指定字符串slice間的分隔符。

// Join concatenates the elements of a to create a single string. The separator string
// sep is placed between elements in the resulting string.
func Join(a []string, sep string) string {
    switch len(a) {
    case 0:
        return ""
    case 1:
        return a[0]
    }
    n := len(sep) * (len(a) - 1)
    for i := 0; i < len(a); i++ {
        n += len(a[i])
    }

    var b Builder
    b.Grow(n)
    b.WriteString(a[0])
    for _, s := range a[1:] {
        b.WriteString(sep)
        b.WriteString(s)
    }
    return b.String()
}
// A Builder is used to efficiently build a string using Write methods.
// It minimizes memory copying. The zero value is ready to use.
// Do not copy a non-zero Builder.
type Builder struct {
    addr *Builder // of receiver, to detect copies by value
    buf  []byte
}
// Grow grows b's capacity, if necessary, to guarantee space for
// another n bytes. After Grow(n), at least n bytes can be written to b
// without another allocation. If n is negative, Grow panics.
func (b *Builder) Grow(n int) {
    b.copyCheck()
    if n < 0 {
        panic("strings.Builder.Grow: negative count")
    }
    if cap(b.buf)-len(b.buf) < n {
        b.grow(n)
    }
}
// grow copies the buffer to a new, larger buffer so that there are at least n
// bytes of capacity beyond len(b.buf).
func (b *Builder) grow(n int) {
    buf := make([]byte, len(b.buf), 2*cap(b.buf)+n)
    copy(buf, b.buf)
    b.buf = buf
}
// WriteString appends the contents of s to b's buffer.
// It returns the length of s and a nil error.
func (b *Builder) WriteString(s string) (int, error) {
    b.copyCheck()
    b.buf = append(b.buf, s...)
    return len(s), nil
}

// String returns the accumulated string.
func (b *Builder) String() string {
    return *(*string)(unsafe.Pointer(&b.buf))
}

Join的處理過程:

  1. 判斷字符串個數,爲0返回空字符串;爲1返回第一個字符串。
  2. 計算分隔符的總長度,再計算拼接後字符串的總長度
  3. 如果buf的cap不足以容納所有字符串,進行擴容(創建容量爲2*cap(b.buf)+n的新slice,拷貝舊數據至其中),此時buf足以容納所有數據,後期append無需擴容
  4. 依次將數據、分隔符append到buf中
  5. 通過指針將buf轉換爲string

append僅擴容一次

比較

下面比較三種拼接字符串的優缺點:

"+"拼接字符串

優點:

  1. 使用簡單
  2. 對短字符串的拼接有性能優勢(結果或參數不escape,總長度不大於32位時會提前分配32的buf,這時數據可以存儲在buf中)
  3. 一個表達式中有多個"+"仍只處理一次(會將多個拼接的字符串組成成slice再調用concatstrings

缺點:

  1. 當數據很多時,多個"+"可能會導致代碼的不簡潔
  2. 對於需要多個表達式才能拼接所有字符串的數據,意味着每次都需要調用concatstrings,需要重新計算並分配內存,一旦數據很多,性能就會變差

fmt.Sprintf拼接字符串

優點:

  1. 適用範圍廣,可以將其他類型轉換爲字符串
  2. 在表示帶有具體意義的數據時更直觀,尤其是帶有描述性前綴

缺點:

  1. 處理過程相對複雜,多類型的判斷甚至調用反射,影響效率
  2. 拼接字符串中並沒有提前計算總長,每次拼接字符串都是使用的append完成,調用append意味着擴容時的內存再分配及數據拷貝等處理,一旦數據較多時,明顯影響性能

strings.Join拼接字符串

優點:

  1. 一次計算總長度,只需分配一次總內存,後續無需重新分配內存
  2. 對於同一分隔符時的拼接有很大的便利性

缺點:

  1. 對於零散的數據需要主動組裝成slice才能處理
  2. 對於不同的分隔符不能直接處理

整體比較

從源碼實現的角度,我們可以得出以下結論:

對於拼接字符串,如果一個表達式可以全部使用'+'的方式,則使用'+'strings.Join的性能接近,否則其性能不如strings.Join,而fmt.Sprintf需要經過反射及append的處理,其性能相對來說可能最差。

原因是:三者在拼接字符串過程中,尤其是多個字符串、長度較長的字符串時,strings.Join僅需分配一次內存,'+'因使用方式會分配一次或多次,fmt.Sprintf則針對每個%s會調用一次append,可能會分配多次。每次重新分配都需要進行數據的重新拷貝,都會影響其性能。

當然,對於拼接數據量很少或很短的數據,尤其是零散的數據(strings.Join需要組裝數據至slice),三者的效率差異不大,可以按照需求自行決定使用。

整體來說三者的性能:strings.Join~=單次'+'>>多次'+'>fmt.Sprintf

總結

本文主要對常見的3種字符串拼接方式,從其實現的角度分析其在使用時的優缺點,進而協助我們在不同情形使用時,選擇合適的字符串拼接方式。

作爲建議:

  1. 對於零散的少量數據,可以使用'+'來拼接數據;
  2. 對於少量數據且數據間有解釋性的前綴或後綴,可以使用fmt.Sprintf
  3. 對於多數據或者slice數據,可以使用strings.Join

公衆號

鄙人剛剛開通了公衆號,專注於分享Go開發相關內容,望大家感興趣的支持一下,在此特別感謝。

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