見出し画像

Go言語の非効率な書き方3選 ~ベンチマーク機能の測定結果を添えて~

こんにちは、ズッ友です。
ナビタイムジャパンで法人系サービスの開発・運用を担当しています。

私は社内でGo言語のコードレビューをする機会が度々あります。
その際、まだGoを書き慣れていない方が記載している「非効率なコード」を見かけます。
ここでいう非効率とは処理が遅くなる・メモリを余計に使うことを指しています。
この記事ではそんな「非効率なコード」を3つ取り上げ、Go標準のベンチマーク機能による速度・メモリ計測結果とともに紹介します。


ベンチマーク機能

まずは計測に利用するベンチマーク機能について簡単に紹介します。
Goには標準の testing パッケージがあります。
単体テストを書く方は testing.T は普段から利用されていることと思います。
testing パッケージの中には testing.B というものがあり、ベンチマークテストができます。

func BenchmarkSample(b *testing.B) {
	sum := 0
	for i := 0; i < b.N; i++ {
		sum += i
	}
}

上記は数値を合計するだけのベンチマークテストです。
ベンチマークテストの関数は Benchmark から始める命名規則があります。
実行すると結果が出力されます。

$ go test -bench -benchmem .
BenchmarkSample-8    1000000000    0.2552 ns/op    0 B/op    0 allocs/op

1000000000回実行され、1回あたり0.2552ns(ナノ秒)かかることがわかりました。
メモリアロケーションサイズ(B/op)、アロケーション回数(allocs/op)もわかります。

非効率なコード

さて本題です。
本記事では私がコードレビューしていてよく見かける3つのケースを紹介します。

  • 容量なしのスライス宣言

  • += による文字列結合

  • 正規表現を都度コンパイル

容量なしのスライス宣言

Goのスライス宣言方法はいくつかあります。

// 1. nilスライス宣言
var texts []string

// 2. 空スライス宣言
texts := []string{}

// 3. 空スライス宣言(長さ・容量0)
texts = make([]string, 0)

// 4. 空スライス宣言(長さ0・容量100)
texts = make([]string, 0, 100)

スライスに要素を追加していく際、容量に応じて内部の挙動が変わります。

  • 容量に収まる要素数を追加する場合、そのまま同じアドレス上で処理を実行

  • 容量を超える要素数を追加する場合、別アドレスに新しく必要な容量を確保したうえでスライスを作り直して処理を実行

つまり容量を超えるとその分メモリアロケーションが発生します。

ではキャパシティの有無が変わる宣言方法3と4でそれぞれ比較してみましょう。

// BenchmarkNoCapacity 容量設定なし
func BenchmarkNoCapacity(b *testing.B) {
	appendSlice := func() {
		texts := make([]string, 0)
		for i := range 1000 {
			texts = append(texts, strconv.Itoa(i))
		}
	}
	for i := 0; i < b.N; i++ {
		appendSlice()
	}
}

// BenchmarkWithCapacity 容量設定あり
func BenchmarkWithCapacity(b *testing.B) {
	appendSlice := func() {
		texts := make([]string, 0, 1000)
		for i := range 1000 {
			texts = append(texts, strconv.Itoa(i))
		}
	}
	for i := 0; i < b.N; i++ {
		appendSlice()
	}
}

結果

BenchmarkNoCapacity-8    88080    13788 ns/op    38064 B/op   911 allocs/op
BenchmarkWithCapacity-8  101667   12000 ns/op    2880 B/op    900 allocs/op

容量なしの場合、1処理あたりの時間が1.1倍、メモリは13倍使用していることがわかります。
詰める要素数があらかじめわかっている時は容量も含めて宣言するようにしましょう。

+= による文字列結合

文字列結合にもいろいろな書き方があります。

// 1. += による結合
text := ""
text += "テキスト1"
text += "テキスト2"

// 2. fmt.Sprintfによる結合
text := fmt.Sprintf("%s%s", "テキスト1", "テキスト2")

// 3. スライスに入れて結合
texts := []string{"テキスト1", "テキスト2"}
text := strings.Join(texts, "")

// 4. StringBuilderによる結合
var builder strings.Builder
builder.WriteString("テキスト1")
builder.WriteString("テキスト2")
text := builder.String()

今回注目するのは、大量の文字列結合を行う場合です。
文字列結合においてもメモリアロケーションが関係してきます。
+= や Sprintf は結合したうえで新しい文字列として再定義されるため別メモリに格納されます。
それに対し、スライスは先の項で紹介した容量内であれば確保したメモリのうえで動くためアロケーションを抑えられます。
StringBuilderは内部でバイト配列を用いて処理を行っているため、内部的にはスライスを使ったケースと同じ挙動をしています。

ではベンチマークを比較してみましょう。

// BenchmarkStringPlusEqual +=結合
func BenchmarkStringPlusEqual(b *testing.B) {
	appendString := func() {
		text := ""
		for i := range 1000 {
			text += strconv.Itoa(i)
		}
	}
	for i := 0; i < b.N; i++ {
		appendString()
	}
}

// BenchmarkStringSPrintf SPrintf結合
func BenchmarkStringSPrintf(b *testing.B) {
	appendString := func() {
		text := ""
		for i := range 1000 {
			text = fmt.Sprintf("%s%s", text, strconv.Itoa(i))
		}
	}
	for i := 0; i < b.N; i++ {
		appendString()
	}
}

// BenchmarkStringSlice スライス結合(キャパシティあり)
func BenchmarkStringSlice(b *testing.B) {
	appendString := func() {
		texts := make([]string, 0, 1000)
		for i := range 1000 {
			texts = append(texts, strconv.Itoa(i))
		}
		strings.Join(texts, "")
	}
	for i := 0; i < b.N; i++ {
		appendString()
	}
}

// BenchmarkStringSliceWithoutCapacity スライス結合(キャパシティなし)
func BenchmarkStringSliceWithoutCapacity(b *testing.B) {
	appendString := func() {
		texts := make([]string, 0)
		for i := range 1000 {
			texts = append(texts, strconv.Itoa(i))
		}
		strings.Join(texts, "")
	}
	for i := 0; i < b.N; i++ {
		appendString()
	}
}

// BenchmarkStringBuilder StringBuilder
func BenchmarkStringBuilder(b *testing.B) {
	appendString := func() {
		var builder strings.Builder
		for i := range 1000 {
			builder.WriteString(strconv.Itoa(i))
		}
	}
	for i := 0; i < b.N; i++ {
		appendString()
	}
}

結果

BenchmarkStringPlusEqual-8               9604     117362 ns/op    1496920 B/op    1899 allocs/op
BenchmarkStringSPrintf-8                 6538     179877 ns/op    1530962 B/op    3900 allocs/op
BenchmarkStringSlice-8                   73125    16422 ns/op     5952 B/op       901 allocs/op
BenchmarkStringSliceWithoutCapacity-8    67736    17882 ns/op     41136 B/op      912 allocs/op
BenchmarkStringBuilder-8                 90127    13471 ns/op     11328 B/op      911 allocs/op

スライス利用は容量設定あり・なしのパターンも比較しています。

+= と Sprintf は速度・メモリ効率ともに悪い結果となっています。
ただしこれは何度も結合する場合なので、一度の結合であれば特に問題ありません。
むしろスライスやStringBuilderを別で用意する必要がないためベターです。
メモリ効率は容量設定スライスが圧倒的によいですね。

この結果を踏まえ、以下のような使い分けをするのがよいでしょう。

  • 一度の結合であれば += や Sprintfを使う

  • 何度も文字列を結合する場合

    • 予め結合量がわかっている場合はスライス利用

    • 不定な場合はStringBuilder利用

正規表現を都度コンパイル

Goで正規表現を利用する場合、Compile や MustCompile 関数を実行して正規表現文字列をRegexp 構造体に変換します。

// 郵便番号の形式
re := regexp.MustCompile(`^[0-9]{3}-[0-9]{4}$`)

このコンパイルが重い処理となっており、何度も実行すると無駄なコストが発生します。
※コンパイルの内部アルゴリズムの詳細は本記事では割愛します。

指定された文字列が郵便番号の形式と一致することを判定する処理で確認してみます。

// BenchmarkRegexpEvery 都度コンパイル
func BenchmarkRegexpEvery(b *testing.B) {
	for i := 0; i < b.N; i++ {
		re := regexp.MustCompile(`^[0-9]{3}-[0-9]{4}$`)
		re.MatchString("012-3456")
	}
}

// BenchmarkRegexpMatchString regexp.MatchStringを利用
func BenchmarkRegexpMatchString(b *testing.B) {
	for i := 0; i < b.N; i++ {
		// 内部でcompileが呼び出されます
		regexp.MatchString(`^[0-9]{3}-[0-9]{4}$`, "012-3456")
	}
}

// BenchmarkRegexpOnce 一度だけコンパイル
func BenchmarkRegexpOnce(b *testing.B) {
	re := regexp.MustCompile(`^[0-9]{3}-[0-9]{4}$`)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		re.MatchString("012-3456")
	}
}

結果

BenchmarkRegexpEvery-8            681630    1757 ns/op     4921 B/op    61 allocs/op
BenchmarkRegexpMatchString-8      682250    1751 ns/op     4921 B/op    61 allocs/op
BenchmarkRegexpOnce-8           21139336    55.78 ns/op    0 B/op        0 allocs/op

都度コンパイルする場合は一度だけに対して30倍以上時間がかかっていますね。
※メモリについてはベンチマーク計測範囲外での変数定義なので 0B/op となっています

正規表現を使いたい関数内やforループの中などでのコンパイルは避け、グローバル変数として定義して再利用する方が効率的です。

最後に

今回計測した結果はナノ秒・キロバイトの世界でした。
しかしこのような実装は積み重なっていくと大きくなっていきます。チリツモです。
普段からよいコードを書き、質のいいアプリケーションを提供できるよう心がけて行きたいと思います。