Matcher#appendReplacement()の問題

経験豊富なJava屋さんの中では常識なのかもしれませんが、、String#replaceAll(String, String)またはString#replaceFirst(String, String)で第二引数の置換文字列(replacement)に$を含めると以下の2パターンの例外が発生します。

public class ReplacementSample {
	public static void main(String[] args) {
		String target = "hogefoovar";
		String regexp = "hoge";
		String[] repList = { "abc$", "abc$def" };
		for (String rep : repList) {
			try {
				System.out.println("target : " + target + ", regexp : " + regexp + ", replacement : " + rep);
				String result = target.replaceFirst(regexp, rep);
			} catch (Exception e) {
				e.printStackTrace();
			}
		}
	}
}
[webmaster@localhost tmp]$ java ReplacementSample
target : hogefoovar, regexp : hoge, replacement : abc$
java.lang.StringIndexOutOfBoundsException: String index out of range: 4
        at java.lang.String.charAt(String.java:687)
        at java.util.regex.Matcher.appendReplacement(Matcher.java:711)
        at java.util.regex.Matcher.replaceFirst(Matcher.java:861)
        at java.lang.String.replaceFirst(String.java:2147)
        at ReplacementSample.main(ReplacementSample.java:13)
target : hogefoovar, regexp : hoge, replacement : abc$def
java.lang.IllegalArgumentException: Illegal group reference
        at java.util.regex.Matcher.appendReplacement(Matcher.java:713)
        at java.util.regex.Matcher.replaceFirst(Matcher.java:861)
        at java.lang.String.replaceFirst(String.java:2147)
        at ReplacementSample.main(ReplacementSample.java:13)
[webmaster@localhost tmp]$

Jadデコンパイルしたソースを眺めると、該当箇所はこんな感じ。(java.util.regex.Matcher.appendReplacement内)

    } else
    if(c == '$')
    {
        i++;
        int j = s.charAt(i) - 48;
        if(j < 0 || j > 9)
            throw new IllegalArgumentException("Illegal group reference");
        i++;
        boolean flag = false;
        do
        {
            if(flag || i >= s.length())
                break;
            int k = s.charAt(i) - 48;
            if(k < 0 || k > 9)
                break;
            int l = j * 10 + k;
            if(groupCount() < l)
            {
                flag = true;
            } else
            {
                j = l;
                i++;
            }
        } while(true);
        if(group(j) != null)
            stringbuffer1.append(group(j));
    } else

IllegalArgumentExceptionが発生している方は意図的にthrowしていてそういう意図だというのはわかるんだけど、まだピンと来なかったのでJavadocを読むと・・

http://sdc.sun.co.jp/java/docs/j2se/1.5.0/ja/docs/ja/api/java/util/regex/Matcher.html#appendReplacement(java.lang.StringBuffer,%20java.lang.String)

$g が検出されると、group(g) を評価した結果にすべて置換されます。$ の後の最初の数値は、常にグループ参照の一部として処理されます。後続の数値が正当なグループ参照を構成する場合、これらは g に組み込まれます。数 0 〜 9 だけが、グループ参照の潜在的コンポーネントと見なされます。たとえば、2 番目のグループが文字列 "foo" にマッチすると、置換文字列 "$2bar" の引き渡しが行われて、"foobar" が文字列バッファに追加されます。前にバックスラッシュ (\$) を付けることで、ドル記号 ($) をリテラルとして置換文字列に含めることができます。

なるほど、第一引数の正規表現でグループ化したマッチ結果を$1みたいに受け取れる仕様で、「ドル記号を文字として認識したいならエスケープしてね」って事のようです。

  • StringIndexOutOfBoundsException

 →$のあとに数字が来るはずと思って次の文字を取りにいこうとして落ちてる

  • IllegalArgumentException

 →メッセージの通り、グループ化したマッチの参照になってないから怒ってる


そう言われるとそうなのかという感じはしますが、「次の文字が数字じゃなかったら文字列を意図している」事を理解してくれる方が色々問題が起きなくていい気がします。

検索するとSDNのBug Databaseで1.4のベータの頃から定期的に「バグじゃないの?」と言ってる人を見かけます。確かにバグっぽい挙動に見える仕様だと思います。

「問題かな」と思うのは、StringクラスのJavadocには一切記述がない事で(Matcherクラスにしか書いてない)、String#replaceAllとString#replaceFirstのJavadocには転記するか、Matcher#appendReplacementへの参照をつける方が丁寧なんじゃないかなと。