The Cusp of Helix

Intact Case

OR 条件の挙動

/^|(Abc)/ の挙動を順に説明してみます。以下の説明で、マッチ開始ポインタの位置を で示します。

ひとつ目の条件 ^ 文字列全体の先頭にマッチ
ふたつ目の条件 (Abc) "Abc"にマッチ

OR 条件では、ひとつ目の条件でマッチした場合、ふたつ目以降のマッチは行ないません。/^|(Abc)/ のひとつ目の項目「^」でマッチしたなら、ふたつ目の (Abc) を飛ばして次のマッチに進みます。

「^」のマッチ幅は 0 なので、マッチ位置は本来なら移動しません。ところが、マッチ位置が移動しないとマッチが無限ループしてしまいます。

# マッチ条件 マッチ位置 ポインタ移動
1 /^|(Abc)/
AbcAbcAbc
@AbcAbcAbc
0 文字分移動
2 /^|(Abc)/
@AbcAbcAbc
@@AbcAbcAbc
0 文字分移動
3 /^|(Abc)/
@@AbcAbcAbc
@@@AbcAbcAbc
0 文字分移動

Javascript と Ruby

この無限ループを回避するため、 Javascript と Ruby では次のように処理していると考えられます。

マッチ幅がゼロの場合、マッチ開始ポインタをひとつ進める
# マッチ条件 マッチ位置 ポインタ移動
1 /^|(Abc)/
AbcAbcAbc
@AbcAbcAbc
1 文字分移動
2 /^|(Abc)/
@AbcAbcAbc
@AbcAbc@Abc
マッチ位置の後ろへ移動
3 /^|(Abc)/
@AbcAbc@Abc
@AbcAbc@Abc@
マッチ位置の後ろへ移動

Ruby には後読み言明があるので、以下のようにすれば期待値通りの変換ができます。

'AbcAbcAbc'.gsub(/^|(?<=Abc)/) { '@' }

Javascript は、正規表現の工夫だけでは解決が困難なようです。制御文を許容すれば期待値通りの実装が得られますが、ややスマートさに欠けます。

"AbcAbcAbc".replace(/^(Abc)|(Abc)/g, function(all, br1, br2) { return br1 ? ("@" + br1 + "@") : (br2 + "@"); } );

PHP の解決法

PHP は、 Javascript や Ruby と異なる手法で無限マッチを回避しているようです。

新しいマッチに後に前回のマッチ位置からポインタが進まない場合は、マッチ失敗とする。
# マッチ条件 マッチ位置 ポインタ移動
1 /^|(Abc)/
AbcAbcAbc
@AbcAbcAbc
0 文字分移動
2 /^|(Abc)/
@AbcAbcAbc
"^" はマッチ失敗
@Abc@AbcAbc
マッチ位置の後ろへ移動
3 /^|(Abc)/
@Abc@AbcAbc
@Abc@Abc@Abc
マッチ位置の後ろへ移動
4 /^|(Abc)/
@Abc@Abc@Abc
@Abc@Abc@Abc@
マッチ位置の後ろへ移動

「ポインタが進まなかったらマッチ失敗とする」仕様は、以下のコードで試せます。

preg_replace('/(?<=aaa)|(?=bbb)/', '@', 'aaabbb');

ポインタ幅が 0 のマッチが続いた場合、同じ位置に対して変換が行われることになります。「 (?<=aaa) 」と「 (?=bbb) 」はどちらのパターンも aaa と bbb の間でマッチしますが、両方ともマッチ成功となるなら、変換後は以下の結果になるはずです。

aaa@@bbb

実際には @ はひとつしか挿入されないので、後半の (?=bbb) はマッチ失敗になっていると考えられます。