Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 17838] ファイルの概要

このコミットは、Go言語の標準ライブラリ strings パッケージ内の Replacer、特に singleStringReplacer におけるバグ修正に関するものです。具体的には、置換対象の文字列が入力文字列の先頭にマッチし、かつ、そのマッチによってまだ出力バッファに何も書き込まれていない場合に、Replacer が正しく動作しないという問題が修正されました。このバグは、buf == nil という条件が、実際には何もマッチしなかった場合ではなく、出力バッファが空であることのチェックとして誤用されていたことに起因します。

コミット

  • コミットハッシュ: 2d6a13997a9e9b154b7761d41cdbc830e02fc18e
  • Author: Brad Fitzpatrick bradfitz@golang.org
  • Date: Thu Oct 24 15:51:19 2013 -0700

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/2d6a13997a9e9b154b7761d41cdbc830e02fc18e

元コミット内容

strings: fix Replacer bug with prefix matches

singleStringReplacer had a bug where if a string was replaced
at the beginning and no output had yet been produced into the
temp buffer before matching ended, an invalid nil check (used
as a proxy for having matched anything) meant it always
returned its input.

Fixes #6659

R=golang-dev, r
CC=golang-dev
https://golang.org/cl/16880043

変更の背景

この変更は、Go言語の strings パッケージが提供する Replacer 型、特に単一の文字列置換を扱う singleStringReplacer における特定のバグを修正するために行われました。

バグの具体的なシナリオは以下の通りです。

  1. Replacer が、入力文字列の先頭にマッチするパターンを検出する。
  2. このマッチが発生する前に、出力バッファ(buf)にはまだ何も書き込まれていない。
  3. Replacer の内部ロジックが、マッチが全く発生しなかったかどうかを判断するために buf == nil という条件を使用していた。

この条件は、本来「何も置換が行われなかった(つまり、入力文字列がそのまま返されるべき)」という状況を検出するために使われるべきでした。しかし、上記のシナリオでは、マッチは発生しているにもかかわらず、出力バッファがまだ空であるため buf == niltrue と評価されてしまい、結果として Replacer は入力文字列をそのまま返してしまっていました。これは、置換が実際には行われたにもかかわらず、その結果が反映されないという誤った動作を引き起こしていました。

この問題は、GoのIssue 6659として報告されており、このコミットはその問題を解決することを目的としています。

前提知識の解説

Go言語の strings パッケージ

strings パッケージは、Go言語の標準ライブラリの一部であり、文字列操作のためのユーティリティ関数を提供します。文字列の検索、置換、分割、結合など、多岐にわたる機能が含まれています。

strings.Replacer

strings.Replacer は、複数の置換ルールを効率的に適用するための型です。NewReplacer 関数を使って作成され、Replace メソッドを呼び出すことで文字列の置換を実行します。例えば、strings.NewReplacer("old1", "new1", "old2", "new2") のように、oldnew のペアを複数指定できます。

singleStringReplacer

strings.Replacer は、内部的に置換ルールの数や複雑さに応じて異なる実装を選択します。このコミットで言及されている singleStringReplacer は、Replacer が単一の置換ルール(例: strings.NewReplacer("Hello", "Hi"))を持つ場合に最適化された内部実装です。この最適化は、単一のパターン検索に特化することで、より高速な置換を可能にします。

文字列置換の一般的なロジック

一般的な文字列置換のアルゴリズムは、入力文字列を走査し、置換パターンにマッチする部分を見つけ、それを置換後の文字列に置き換えていくというものです。この際、部分的に処理された文字列を一時的なバッファに蓄積していくのが一般的です。

  • finder.next(s[i:]): これは、singleStringReplacer が内部的に使用するパターン検索メカニズムの一部です。s[i:] は、入力文字列の現在の位置 i から始まる部分文字列を示し、next メソッドは、その部分文字列内でパターンが次にマッチする位置を返します。マッチが見つからない場合は -1 を返します。
  • buf (バイトスライス): Goでは文字列はイミュータブル(不変)であるため、文字列の変更は新しい文字列を作成することを意味します。Replacer は、置換処理中に新しい文字列を効率的に構築するために、バイトスライス []byte を一時的なバッファとして使用します。このバッファに、置換されていない部分と置換された部分を順次追加していきます。

nil と空のスライス

Goにおいて、nil スライスと空のスライス([]byte{})は異なります。

  • nil スライスは、基底配列を持たず、長さも容量も0です。
  • 空のスライスは、基底配列を持つことができ、長さも容量も0です。

この違いは、特にスライスが初期化されたかどうか、または何らかのデータが追加されたかどうかを判断する際に重要になります。このバグでは、buf が初期化された直後(まだ何も追加されていない状態)では nil であり、最初のマッチが入力文字列の先頭で発生した場合、buf に何も追加されないまま次のループに進むため、buf == niltrue のままになってしまうことが問題でした。

技術的詳細

このバグは、singleStringReplacerReplace メソッド内のロジックに潜んでいました。

元のコードでは、置換処理の最後に以下の条件分岐がありました。

if buf == nil {
    return s
}

この if buf == nil のチェックは、「置換が一度も行われなかった場合」、つまり入力文字列 s がそのまま返されるべきかどうかを判断するために使用されていました。

しかし、このチェックには問題がありました。

  1. bufvar buf []byte として宣言されており、初期状態では nil です。
  2. ループ内で r.finder.next(s[i:]) が呼び出され、パターンがマッチした場合、match 変数にはマッチした位置が格納されます。
  3. もし match0 (つまり、入力文字列の先頭でマッチ) であった場合、buf = append(buf, s[i:i+match]...) の部分で s[i:i+0]、つまり空のスライスが buf に追加されます。
  4. append 関数は、nil スライスに要素を追加した場合でも、結果として nil ではない(空の)スライスを返すことがあります。しかし、この特定のケース(append(nil, []byte{}...))では、Goのバージョンや実装によっては bufnil のままであるか、または nil ではないが空のスライスになる可能性がありました。
  5. さらに重要なのは、buf = append(buf, r.value...) の部分で置換後の文字列が buf に追加されますが、もし r.value が空文字列(例: NewReplacer("Hello", "") のように、文字列を削除する置換)であった場合、buf は依然として nil のままか、または空のままになる可能性がありました。

結果として、入力文字列の先頭でマッチが発生し、かつ置換後の文字列が空であるか、または最初のマッチが先頭で発生したために buf にまだ実質的なデータが追加されていない状況では、buf == niltrue と評価されてしまい、Replace メソッドは誤って元の入力文字列 s をそのまま返してしまっていました。これは、置換が実際には行われたにもかかわらず、その結果が反映されないというバグでした。

このバグは、strings.NewReplacer("Hello", "") のようなケースで、入力が "Hello""Hellox" の場合に顕著に現れました。本来であれば "Hello""" に、"Hellox""x" になるべきですが、バグのある実装ではそれぞれ "Hello""Hellox" が返されていました。

コアとなるコードの変更箇所

src/pkg/strings/replace.gosingleStringReplacer.Replace メソッドが変更されました。

--- a/src/pkg/strings/replace.go
+++ b/src/pkg/strings/replace.go
@@ -364,17 +364,18 @@ func makeSingleStringReplacer(pattern string, value string) *singleStringReplace
 
 func (r *singleStringReplacer) Replace(s string) string {
 	var buf []byte
-	i := 0
+	i, matched := 0, false
 	for {
 		match := r.finder.next(s[i:])
 		if match == -1 {
 			break
 		}
+		matched = true
 		buf = append(buf, s[i:i+match]...)
 		buf = append(buf, r.value...)\n 		i += match + len(r.finder.pattern)
 	}
-	if buf == nil {\n+	if !matched {\n 		return s
 	}
 	buf = append(buf, s[i:]...)

また、src/pkg/strings/replace_test.go に新しいテストケースが追加されました。

--- a/src/pkg/strings/replace_test.go
+++ b/src/pkg/strings/replace_test.go
@@ -261,10 +261,21 @@ func TestReplacer(t *testing.T) {\n \ttestCases = append(testCases,\n \t\ttestCase{abcMatcher, \"\", \"\"},\n \t\ttestCase{abcMatcher, \"ab\", \"ab\"},\n+\t\ttestCase{abcMatcher, \"abc\", \"[match]\"},\n \t\ttestCase{abcMatcher, \"abcd\", \"[match]d\"},\n \t\ttestCase{abcMatcher, \"cabcabcdabca\", \"c[match][match]d[match]a\"},\n \t)\n \n+\t// Issue 6659 cases (more single string replacer)\n+\n+\tnoHello := NewReplacer(\"Hello\", \"\")\n+\ttestCases = append(testCases,\n+\t\ttestCase{noHello, \"Hello\", \"\"},\n+\t\ttestCase{noHello, \"Hellox\", \"x\"},\n+\t\ttestCase{noHello, \"xHello\", \"x\"},\n+\t\ttestCase{noHello, \"xHellox\", \"xx\"},\n+\t)\n+\n \t// No-arg test cases.\n \n \tnop := NewReplacer()\n```

## コアとなるコードの解説

### `src/pkg/strings/replace.go` の変更

`singleStringReplacer.Replace` メソッドの変更は非常にシンプルですが、効果的です。

1.  **`i, matched := 0, false`**:
    -   元のコードでは `i := 0` のみでした。
    -   変更後、`i` に加えて `matched` という新しいブール型変数が導入され、`false` で初期化されます。
    -   この `matched` 変数の目的は、**「少なくとも一度でもパターンマッチが成功したかどうか」**を正確に追跡することです。

2.  **`matched = true` の追加**:
    -   `for` ループ内で `match := r.finder.next(s[i:])` の結果、`match == -1` でない(つまり、パターンが見つかった)場合に、`matched = true` が設定されます。
    -   これにより、たとえマッチが入力文字列の先頭で発生し、`buf` にまだ実質的なデータが追加されていなくても、マッチが成功したという事実が `matched` 変数によって記録されるようになります。

3.  **`if buf == nil` から `if !matched` への変更**:
    -   これがバグ修正の核心です。
    -   元の `if buf == nil` は、`buf` が `nil` であること(つまり、まだ何も書き込まれていない状態)を「何もマッチしなかった」ことの代理として使用していました。しかし、前述の通り、これは先頭マッチや空文字列への置換の場合に誤動作を引き起こしました。
    -   変更後、`if !matched` とすることで、**「一度もパターンマッチが成功しなかった場合」**にのみ元の入力文字列 `s` を返すようにロジックが修正されました。これにより、`buf` の状態に依存せず、実際の置換の有無に基づいて正確な判断ができるようになりました。

この変更により、`singleStringReplacer` は、入力文字列の先頭でパターンがマッチし、かつ置換後の文字列が空である場合でも、正しく置換を実行し、期待される結果を返すようになります。

### `src/pkg/strings/replace_test.go` の変更

新しいテストケースが `TestReplacer` 関数に追加されました。これらは特にIssue 6659で報告されたシナリオをカバーしています。

```go
	// Issue 6659 cases (more single string replacer)

	noHello := NewReplacer("Hello", "")
	testCases = append(testCases,
		testCase{noHello, "Hello", ""},
		testCase{noHello, "Hellox", "x"},
		testCase{noHello, "xHello", "x"},
		testCase{noHello, "xHellox", "xx"},
	)
  • noHello := NewReplacer("Hello", "") は、「Hello」という文字列を空文字列に置換する Replacer を作成します。
  • testCase{noHello, "Hello", ""}: 入力文字列が置換対象の文字列そのもので、かつ先頭にマッチする場合のテスト。期待される出力は空文字列です。
  • testCase{noHello, "Hellox", "x"}: 入力文字列の先頭に置換対象の文字列がマッチし、その後に続く文字列がある場合のテスト。期待される出力は「x」です。
  • testCase{noHello, "xHello", "x"}: 置換対象の文字列が入力文字列の途中にあり、その前に文字列がある場合のテスト。期待される出力は「x」です。
  • testCase{noHello, "xHellox", "xx"}: 置換対象の文字列が入力文字列の途中にあり、その前後にも文字列がある場合のテスト。期待される出力は「xx」です。

これらのテストケースは、特に「先頭マッチ」と「空文字列への置換」という、元のバグが顕在化した状況を網羅しており、修正が正しく機能していることを検証します。

関連リンク

  • Go CL (Code Review) リンク: https://golang.org/cl/16880043

参考にした情報源リンク

  • コミットデータ: /home/orange/Project/comemo/commit_data/17838.txt
  • GitHubコミットページ: https://github.com/golang/go/commit/2d6a13997a9e9b154b7761d41cdbc830e02fc18e
  • Go Issue 6659 (直接的な検索結果は見つかりませんでしたが、コミットメッセージに記載されています)
  • Go言語の strings パッケージのドキュメント (一般的な知識として)
  • Go言語のスライスに関するドキュメント (nilスライスと空スライスの違いに関する一般的な知識として)