Posted at

Go1.10で入るstrings.Builderを検証した #golang

More than 1 year has passed since last update.


はじめに

2018年2月にリリース予定のGo1.10では、strings.Builderという型が(CL74931)が入ります。

このstrings.Builderを用いると、効率良く文字列を生成することができます。

この記事では、まずstrings.Builderの使い方を説明し、次に同様の使い方ができるbytes.Bufferとベンチマークを取ってパフォーマンスを比較してみます。

そしてさらに、strings.Builderの実装をソースコードから読み解き、そこで使われているテクニックを解説します。


使い方

まずは使い方をみていきましょう。

strings.Builderはゼロ値で扱える型です。そのため、使用するのに初期化は特にいらず、次のように変数を定義するだけで使用することができます。

var b strings.Builder

strings.Builderio.Writerインタフェースを実装しているため、次のようにfmt.Fprintなどの関数で利用できます。

fmt.Fprint(&b, "hello")

文字列として取り出すには、次のようにStringメソッドを呼び出します。

var s string = b.String()


ベンチマーク

さて、bytes.Bufferも似たような使い方ができますが、パフォーマンス的にはどちらが有利なのでしょうか?ベンチマークを取ってみようと思います。

ベンチマークに使用するコードは次のとおりです。

package main

import (
"bytes"
"fmt"
"strings"
"testing"
)

func stringsBuilder(b *testing.B, n int, grow bool) {
for i := 0; i < b.N; i++ {
var builder strings.Builder
b.StopTimer()
if grow {
builder.Grow(n * 4)
}
b.StartTimer()
for j := 0; j < n; j++ {
fmt.Fprint(&builder, "TEST")
}
_ = builder.String()
}
}

func bytesBuffer(b *testing.B, n int, grow bool) {
for i := 0; i < b.N; i++ {
var buf bytes.Buffer
b.StopTimer()
if grow {
buf.Grow(n * 4)
}
b.StartTimer()
for j := 0; j < n; j++ {
fmt.Fprint(&buf, "TEST")
}
_ = buf.String()
}
}

func BenchmarkStringsBuilderWithoutGrow(b *testing.B) {
for _, n := range []int{10, 50, 100, 200, 500} {
b.Run(fmt.Sprintf("%d", n), func(b *testing.B) {
stringsBuilder(b, n, false)
})
}
}

func BenchmarkStringsBuilderWithGrow(b *testing.B) {
for _, n := range []int{10, 50, 100, 200, 500} {
b.Run(fmt.Sprintf("%d", n), func(b *testing.B) {
stringsBuilder(b, n, true)
})
}
}

func BenchmarkBytesBufferWithoutGrow(b *testing.B) {
for _, n := range []int{10, 50, 100, 200, 500} {
b.Run(fmt.Sprintf("%d", n), func(b *testing.B) {
bytesBuffer(b, n, false)
})
}
}

func BenchmarkBytesBufferWithGrow(b *testing.B) {
for _, n := range []int{10, 50, 100, 200, 500} {
b.Run(fmt.Sprintf("%d", n), func(b *testing.B) {
bytesBuffer(b, n, true)
})
}
}

strings.Builderbytes.Bufferを使った場合と、さらにGrowメソッドを使って、あらかじめ内部のバッファを確保していた場合と何もしない場合で比べてみました。

結果は次のようになりました。

% go version

go version devel +a99deed39b Mon Jan 8 18:06:27 2018 +0000 darwin/amd64

% go test -bench . -benchmem
goos: darwin
goarch: amd64

BenchmarkStringsBuilderWithoutGrow/10-4 500000 2796 ns/op 160 B/op 5 allocs/op
BenchmarkStringsBuilderWithoutGrow/50-4 200000 6942 ns/op 544 B/op 7 allocs/op
BenchmarkStringsBuilderWithoutGrow/100-4 200000 10813 ns/op 1056 B/op 8 allocs/op
BenchmarkStringsBuilderWithoutGrow/200-4 100000 18351 ns/op 2080 B/op 9 allocs/op
BenchmarkStringsBuilderWithoutGrow/500-4 30000 39791 ns/op 7457 B/op 12 allocs/op

BenchmarkStringsBuilderWithGrow/10-4 1000000 1898 ns/op 32 B/op 1 allocs/op
BenchmarkStringsBuilderWithGrow/50-4 300000 5387 ns/op 32 B/op 1 allocs/op
BenchmarkStringsBuilderWithGrow/100-4 200000 8699 ns/op 32 B/op 1 allocs/op
BenchmarkStringsBuilderWithGrow/200-4 100000 15439 ns/op 32 B/op 1 allocs/op
BenchmarkStringsBuilderWithGrow/500-4 50000 35775 ns/op 32 B/op 1 allocs/op

BenchmarkBytesBufferWithoutGrow/10-4 1000000 2164 ns/op 160 B/op 2 allocs/op
BenchmarkBytesBufferWithoutGrow/50-4 200000 6242 ns/op 752 B/op 4 allocs/op
BenchmarkBytesBufferWithoutGrow/100-4 200000 10146 ns/op 1536 B/op 5 allocs/op
BenchmarkBytesBufferWithoutGrow/200-4 100000 17278 ns/op 3168 B/op 6 allocs/op
BenchmarkBytesBufferWithoutGrow/500-4 30000 39774 ns/op 6625 B/op 7 allocs/op

BenchmarkBytesBufferWithGrow/10-4 1000000 2168 ns/op 160 B/op 2 allocs/op
BenchmarkBytesBufferWithGrow/50-4 200000 5919 ns/op 320 B/op 2 allocs/op
BenchmarkBytesBufferWithGrow/100-4 200000 10095 ns/op 528 B/op 2 allocs/op
BenchmarkBytesBufferWithGrow/200-4 100000 19022 ns/op 1008 B/op 2 allocs/op
BenchmarkBytesBufferWithGrow/500-4 30000 39827 ns/op 2160 B/op 2 allocs/op

PASS

Growメソッドであらかじめバッファを確保していた場合は、strings.Builerの方がアロケーションが少ないことが分かります。また、使用しているメモリも少なく、実行速度もやや早いです。

逆にGrowメソッドを使わないとパフォーマンスが悪くなっています。

strigns.BuilderStringメソッドを呼び出した時にアロケーションが発生しません。

内部バッファは可変長の[]byte型で管理されているため、通常はstring型に変換した場合に、アロケーションが発生します。

しかし、次のようにポインタを使って型をキャストしているため、アロケーションは発生しません。

// String returns the accumulated string.

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


おわりに

strings.Builderでは、Stringメソッドの呼び出し時にアロケーションが発生せず、予めサイズがある程度わかっている場合はbytes.Bufferに比べてパフォーマンスが良さそうです。

本記事のベンチマークでは、Growメソッドを使わない場合は、bytes.Bufferの方が全体的にパフォーマンスがよくなりましたが、その理由についてはよくわかりません。

実装を比べてみて、理由が分かりましたら、追記したいと思います。