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