前言
Go中字符串的拼接主要有"+"
、fmt.Sprintf
+%s
、strings.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傳入。
看下處理過程:
- 計算所有字符串的總長度l,記錄非空字符串的個數,記錄字符串的位置,當總長溢出時報錯。
- 若非空字符串個數爲0,返回空字符
""
。 - 若只有一個非空字符串,且沒有存儲在buf中或數組還存儲在當前goroutine的棧中,則根據字符的位置直接返回對應位置的字符串。
- 創建字符串s及字符串指向的字節數組b,修改b則改變s的值。
- 如果buf!=nil且總長度小於32位,則取b=buf[:l]即可存儲所有數據,s指向字節數組b;
- 否則,直接根據總長度分配內存創建字符串,並將地址指向字節數組b.
- 逐個將數據拷貝至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
}
...
}
對於僅拼接字符串的處理過程爲:
- 依次查找
'%'
的位置,'%'
前的數據append至buf中 - 根據其後的format,確認處理過程,拼接字符串使用的是
%s
,處理過程一個%s
對應一個string - append追加字符串至buf中(會面臨頻繁擴容的問題)
- 將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的處理過程:
- 判斷字符串個數,爲0返回空字符串;爲1返回第一個字符串。
- 計算分隔符的總長度,再計算拼接後字符串的總長度
- 如果buf的cap不足以容納所有字符串,進行擴容(創建容量爲2*cap(b.buf)+n的新slice,拷貝舊數據至其中),此時buf足以容納所有數據,後期append無需擴容
- 依次將數據、分隔符append到buf中
- 通過指針將buf轉換爲string
append僅擴容一次
比較
下面比較三種拼接字符串的優缺點:
"+"
拼接字符串
優點:
- 使用簡單
- 對短字符串的拼接有性能優勢(結果或參數不escape,總長度不大於32位時會提前分配32的buf,這時數據可以存儲在buf中)
- 一個表達式中有多個
"+"
仍只處理一次(會將多個拼接的字符串組成成slice再調用concatstrings
)
缺點:
- 當數據很多時,多個
"+"
可能會導致代碼的不簡潔 - 對於需要多個表達式才能拼接所有字符串的數據,意味着每次都需要調用concatstrings,需要重新計算並分配內存,一旦數據很多,性能就會變差
fmt.Sprintf
拼接字符串
優點:
- 適用範圍廣,可以將其他類型轉換爲字符串
- 在表示帶有具體意義的數據時更直觀,尤其是帶有描述性前綴
缺點:
- 處理過程相對複雜,多類型的判斷甚至調用反射,影響效率
- 拼接字符串中並沒有提前計算總長,每次拼接字符串都是使用的append完成,調用append意味着擴容時的內存再分配及數據拷貝等處理,一旦數據較多時,明顯影響性能
strings.Join
拼接字符串
優點:
- 一次計算總長度,只需分配一次總內存,後續無需重新分配內存
- 對於同一分隔符時的拼接有很大的便利性
缺點:
- 對於零散的數據需要主動組裝成slice才能處理
- 對於不同的分隔符不能直接處理
整體比較
從源碼實現的角度,我們可以得出以下結論:
對於拼接字符串,如果一個表達式可以全部使用'+'
的方式,則使用'+'
與strings.Join
的性能接近,否則其性能不如strings.Join
,而fmt.Sprintf
需要經過反射及append的處理,其性能相對來說可能最差。
原因是:三者在拼接字符串過程中,尤其是多個字符串、長度較長的字符串時,strings.Join
僅需分配一次內存,'+'
因使用方式會分配一次或多次,fmt.Sprintf
則針對每個%s
會調用一次append,可能會分配多次。每次重新分配都需要進行數據的重新拷貝,都會影響其性能。
當然,對於拼接數據量很少或很短的數據,尤其是零散的數據(strings.Join
需要組裝數據至slice),三者的效率差異不大,可以按照需求自行決定使用。
整體來說三者的性能:strings.Join
~=單次'+'
>>多次'+'
>fmt.Sprintf
總結
本文主要對常見的3種字符串拼接方式,從其實現的角度分析其在使用時的優缺點,進而協助我們在不同情形使用時,選擇合適的字符串拼接方式。
作爲建議:
- 對於零散的少量數據,可以使用
'+'
來拼接數據; - 對於少量數據且數據間有解釋性的前綴或後綴,可以使用
fmt.Sprintf
; - 對於多數據或者slice數據,可以使用
strings.Join
公衆號
鄙人剛剛開通了公衆號,專注於分享Go開發相關內容,望大家感興趣的支持一下,在此特別感謝。