Goはあまり意識せず文字列を連結してしまうとパフォーマンスが出ない時があります。
例えば以下のコード。
1package main
2
3import (
4 "strings"
5)
6
7// 単純な文字列連結(+= を使用)
8func concatWithPlus(values []string) string {
9 s := ""
10 for _, value := range values {
11 s += value
12 }
13 return s
14}
15
16// strings.Builder を使用した効率的な連結
17func concatWithBuilder(values []string) string {
18 var sb strings.Builder
19 for _, value := range values {
20 _, _ = sb.WriteString(value)
21 }
22 return sb.String()
23}単純な文字列連結のconcatWithPlus関数の場合、Goの文字列はイミュータブルなので、s += value は既存のsを直接更新しないです。
新しいバッファを確保して既存の文字列とvalueをコピーした新しい文字列を生成します。つまり、連結の数が多くなればなるほどパフォーマンス悪化につながるわけです。
ではconcatWithBuilder関数はどうなのか。
strings.Builder は内部的に可変のバイトスライスを持っており、必要に応じてバッファを再利用・拡張しながら書き込みます。そのため、不要なメモリ確保やコピーがほとんど発生しません。
本当にそうなのか?試しにベンチを取ってみました。
1package main
2
3import "testing"
4
5func generateTestData(n int) []string {
6 values := make([]string, n)
7 for i := range values {
8 values[i] = "a"
9 }
10 return values
11}
12
13func BenchmarkConcatWithPlus(b *testing.B) {
14 values := generateTestData(10000)
15 b.ResetTimer()
16 for i := 0; i < b.N; i++ {
17 _ = concatWithPlus(values)
18 }
19}
20
21func BenchmarkConcatWithBuilder(b *testing.B) {
22 values := generateTestData(10000)
23 b.ResetTimer()
24 for i := 0; i < b.N; i++ {
25 _ = concatWithBuilder(values)
26 }
27}1BenchmarkConcatWithPlus-11 272 4313605 ns/op 53164427 B/op 10004 allocs/op
2BenchmarkConcatWithBuilder-11 46014 25966 ns/op 46584 B/op 16 allocs/op処理時間も圧倒的に早くなりましたし、メモリ割り当ての回数もかなり少ないことがわかりますね。
また今回のケースでは、結合する文字列の総長が分かっているので、strings.Builder に事前にバッファを確保しておくことで、再確保やコピーをさらに減らすことができます。
つまり以下のようにできます。
1func concatWithBuilderGrow(values []string) string {
2 var sb strings.Builder
3 // 事前に必要な容量を計算
4 totalLen := 0
5 for _, v := range values {
6 totalLen += len(v)
7 }
8 sb.Grow(totalLen)
9
10 for _, value := range values {
11 _, _ = sb.WriteString(value)
12 }
13 return sb.String()
14}この関数を追加して再度ベンチを取ってみます。
1BenchmarkConcatWithPlus-11 270 4314205 ns/op 53164433 B/op 10004 allocs/op
2BenchmarkConcatWithBuilder-11 46016 25966 ns/op 46584 B/op 16 allocs/op
3BenchmarkConcatWithBuilderGrow-11 46462 25581 ns/op 10240 B/op 1 allocs/op処理時間はあまり変わりませんが、メモリ確保量とメモリアロケーション回数を結構節約できました。
Growメソッドでバイトスライスを事前に割り当てることがすごく大事だとわかります。
また、書籍には5個以上の文字列を連結するケースからstrings.Builderが効いてくると書いていました。
ユーザーの姓名からフルネームを作る程度の処理では、可読性を優先し+演算子を使うのがいいらしいですが、ここら辺はプロジェクトとか事情によって色々変わってくるなと。
stringと[]byteのどちらかを使う際に、[]byteによる文字列変換を検討した方がより良いパフォーマンスにつながることがあります。
I/Oはstringではなく[]byteで行われることがほとんどだからです。
例えば以下のコード。こちらはstringによる文字列変換を行っています。
1func readTrimmedText(reader io.Reader) ([]byte, error) {
2 b, err := io.ReadAll(reader)
3 if err != nil {
4 return nil, err
5 }
6 return []byte(strings.TrimSpace(string(b))), nil
7}TrimSpace関数にstringを渡すために[]byteをstringにキャストし、さらに返り値が[]byteなので[]byteにキャストしています。これは無駄な変換作業です。
これを下のようにしてみます。
1func readTrimmedBytes(reader io.Reader) ([]byte, error) {
2 b, err := io.ReadAll(reader)
3 if err != nil {
4 return nil, err
5 }
6 return bytes.TrimSpace(b), nil
7}stringsパッケージにさまざまな便利関数が用意されているように、bytesパッケージにも変換のための便利関数が用意されています。
今回の場合、bytes.TrimSpaceを使い[]byteのまま変換作業を行うことで、余分な変換を防ぐことができます。
試しにベンチを取ってみます。
1func generateTestData(n int) []string {
2 values := make([]string, n)
3 longStr := strings.Repeat("Hello, 世界! ", 80) // 約1KB
4 for i := range values {
5 values[i] = " " + longStr + " \n"
6 }
7 return values
8}
9func concatTestData(values []string) io.Reader {
10 return strings.NewReader(strings.Join(values, ""))
11}
12
13func BenchmarkReadTrimmedText(b *testing.B) {
14 values := generateTestData(10000)
15 b.ResetTimer()
16 for i := 0; i < b.N; i++ {
17 reader := concatTestData(values)
18 _, err := readTrimmedText(reader)
19 if err != nil {
20 b.Fatal(err)
21 }
22 }
23}
24
25func BenchmarkReadTrimmedBytes(b *testing.B) {
26 values := generateTestData(10000)
27 b.ResetTimer()
28 for i := 0; i < b.N; i++ {
29 reader := concatTestData(values)
30 _, err := readTrimmedBytes(reader)
31 if err != nil {
32 b.Fatal(err)
33 }
34 }
35}1BenchmarkReadTrimmedText-11 409 2945782 ns/op
2BenchmarkReadTrimmedBytes-11 597 2037673 ns/op結構処理速度が上がりました。