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

January 19, 2018

この記事はQiitaの記事をエクスポートしたものです。内容が古くなっている可能性があります。

はじめに

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の方が全体的にパフォーマンスがよくなりましたが、その理由についてはよくわかりません。 実装を比べてみて、理由が分かりましたら、追記したいと思います。