😋

strings.Replacer を積極的に使おう

2024/12/25に公開

はじめに

Go Advent Calendar 2024 に参加の皆さま、お疲れさまでした。今年も良い Go ライフを送れたでしょうか。来年も良い Go 縁があると良いですね。

さて今日は strings.Replacer の話をしようと思います。

キーワード置換やってますか

GitHub を見ているとよくこんなコードを目にする事があります。

s = strings.ReplaceAll(s, "hoge", "moge")
s = strings.ReplaceAll(s, "foo", "bar")
s = strings.ReplaceAll(s, "fizz", "buzz")

複数ある特定のキーワードを置換するコードですが、実はとても効率が悪いのです。strings.ReplaceAll は第一引数の文字列を走査し、第二引数にマッチした場合に第三引数へ置換し続ける実装です。つまり、strings.ReplaceAll を呼び出す毎にこれらの処理が繰り返し実行される事になります。

そこで strings.Replacer

皆さんは strings.Replacer を使った事があるでしょうか。これを使う事で何度も実行される置換処理を1回で済ませる事ができます。

rep := strings.NewReplacer(
    "hoge", "moge",
    "foo", "bar",
    "fizz", "buzz",
)
s = rep.Replace(s)

strings.ReplaceAll と strings.Replacer の比較

package replacer_test

import (
	"strings"
	"testing"
)

type Replacer []struct {
	from string
	to   string
}

func (r Replacer) Replace(s string) string {
	for _, ss := range r {
		s = strings.ReplaceAll(s, ss.from, ss.to)
	}
	return s
}

func BenchmarkReplacer1(b *testing.B) {
	rep := Replacer{
		{"foo", "bar"},
		{"hello", "world"},
		{"甲", "乙"},
	}
	s := strings.Repeat("このfooはhello-wideでfoo甲です。", 5000)

	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		_ = rep.Replace(s)
	}
}

func BenchmarkReplacer2(b *testing.B) {
	rep := strings.NewReplacer(
		"foo", "bar",
		"hello", "world",
		"甲", "乙",
	)
	s := strings.Repeat("このfooはhello-wideでfoo甲です。", 5000)

	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		_ = rep.Replace(s)
	}
}

実はこのベンチマーク程度ではそれほど差が出ません。

BenchmarkReplacer1-12               1173           1031753 ns/op          614401 B/op          3 allocs/op
BenchmarkReplacer2-12               1143            931853 ns/op          409624 B/op          3 allocs/op

どんな時にパフォーマンスに差異が発生するかというと、置換の個数に依存します。例えば以下の様に置換候補を増やすとどうなるでしょう。

	rep := strings.NewReplacer(
		"foo", "bar",
		"hello", "world",
		"甲", "乙",
		"fizz", "buzz",
		"いちご", "strawberry",
		"りんご", "apple",
		"ばなな", "banana",
	)
cpu: AMD Ryzen 5 5600U with Radeon Graphics
BenchmarkReplacer1-12                702           1713491 ns/op          802819 B/op          4 allocs/op
BenchmarkReplacer2-12                757           1463078 ns/op          393244 B/op          3 allocs/op
PASS

はっきりと差に表れ始めました。もし辞書などを使って10個や20個、文字列を置換する様な事があるのであれば、strings.Replacer を使った方が良いでしょう。もちろんメモリ使用量の観点では少ない場合でも使った方が効率的ではあります。

置換候補が少ないなら使わなくてもいい?

strings.Replacer の文字列走査は単一です。ですので例えば

  • foo → bar
  • bar → foo

という置換があった場合の動作が異なります。前述の自前実装の場合、置換対象の文字列 foo は結果として foo に戻ってしまいます。多くの場合、この結果は期待しないものだと思います。しかし strings.Replacer は bar のままです。
期待しない動作を望まないのであれば strings.Replacer を使った方が良いでしょう。そして多くの場合、置換候補は固定です。一度 strings.NewReplacerReplacer を作っておけば使いまわせるのです。

Replacer.WriteString も便利

Replacer には WriteString というメソッドも用意されています。最終的に結果文字列を変数に格納するのであればそれほどベンチマークの差異が発生しませんが、結果をファイルに出力する様なケースでは大きな差が発生します。

func BenchmarkWriter1(b *testing.B) {
	rep := Replacer{
		{"foo", "bar"},
		{"hello", "world"},
		{"甲", "乙"},
		{"fizz", "buzz"},
	}
	s := strings.Repeat("このfooはhello-wideでfoo甲です。", 5000)

	var buf bytes.Buffer
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		buf.WriteString(rep.Replace(s))
	}
}

func BenchmarkWriter2(b *testing.B) {
	rep := strings.NewReplacer(
		"foo", "bar",
		"hello", "world",
		"甲", "乙",
		"fizz", "buzz",
	)
	s := strings.Repeat("このfooはhello-wideでfoo甲です。", 5000)

	var buf bytes.Buffer
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		rep.WriteString(&buf, s)
	}
}
BenchmarkWriter1-12                  596           2150994 ns/op         1449942 B/op          4 allocs/op
BenchmarkWriter2-12                  674           1591118 ns/op          398274 B/op          0 allocs/op
PASS

今度は少ない置換候補であってもメモリ使用量だけでなくパフォーマンスも変わってきました。例えば HTTP レスポンスとして文字列置換結果を返したい様なケースでは、積極的に strings.Replacer.WriteString を使っていくのが良いと思います。

まとめ

strings.ReplaceAllstrings.Replacer.Replace の比較を行いました。置換候補が少ないケースではメモリ使用率では strings.Replacer の方が有利ながら実行速度は変わりませんでした。逆に置換候補が多くなると strings.Replacer.Replace の方がメモリ使用率も実行速度も高くなります。
文字列置換を複数回行う様なケースでは積極的に strings.Replacer.Replace を使うのが良いと思います。さらにファイルやネットワーク通信に、文字列置換結果を書き込むようなケースでは strings.Replacer.WriteString を使う事で大きなパフォーマンス向上を期待する事ができます。
ぜひ使っていきましょう。

Discussion