はじめに
(2011/4/6追記。本件はMicrosoftから将来のリリースで修正するとの回答がありました)
(2011/8/27追記。最新版のVisual Studio 2010+最新の.NET Framework 4で試したところ、まだ修正されていないようです。)
本日、各言語での正規表現エンジンを使って「^」記号(文字列または行の先頭を示す、アンカーあるいはゼロ幅アサーション…と呼ぶらしい)に関する動作を調査しました。背景にあるのはAzukiが内蔵している正規表現検索で行頭マッチングが行われないというユーザ報告の不具合(に近いが対策できず黙認していた動作仕様)です。過去に検索機能を実装した際の調査結果では.NETの正規表現エンジンで「Multilineモード」を有効にしても各行の先頭でない位置でマッチする現象が起こり、使えないと判断しました。今回は、この問題を改めて少し掘り下げて調査した結果報告(?)となります。
調査結果のポイントです。
- Microsoftの.NETが提供する正規表現エンジンでは、マッチング範囲の終了位置を指定すると「^」が常に「マッチング範囲の開始点」にマッチしてしまう(終了位置を指定しなければ常にはマッチしない)
- MSDNの「^」記号の説明やRegex.Matchメソッドの説明からは「^」が文字列の先頭でも行頭でもない箇所にマッチする動作は予想しにくい上に、Regex.Matchのオーバーロード間での動作仕様の統一性が失われている
- Java (1.5以降)の正規表現エンジンでは「Anchoring Bounds」という概念で「^」の扱いをカスタマイズできる(→ Matcherクラスのリファレンス)が、.NETでは同等の機構が無い
- ユーザ指定の正規表現を使うアプリケーションで、特定のマッチング範囲を絞り、「^」をマッチング範囲の開始点にマッチさせたくない場合、実現できないと思われる
ポイント1および2です。Regex.Match( string, int )のオーバーロードを使ってマッチング開始点だけを指定した場合には、行頭でも文字列先頭でもない位置がマッチング範囲の開始点であっても「^」記号は該当位置にマッチしません。しかしRegex.Match( string, int, int )のオーバーロードを使うと、マッチします。したがってオーバーロード引数を追加するだけで動作仕様が変化してしまうため、不自然な印象を受けます。次に例を記します。
string text = "abc";
new Regex( "^[a-z]" ).Match( text, 1 ); // どこにもマッチしない
new Regex( "^[a-z]" ).Match( text, 1, text.Length ); // 1文字目のbでマッチする
ポイント3です。Javaの正規表現エンジンでは「Anchoring Bounds」という概念があり、「^」記号の扱いをカスタマイズできます。Anchoring Boundsを使うよう設定すると、「^」記号および「$」記号のマッチング時にマッチング範囲の前後が考慮されない — つまり問答無用でマッチング範囲の始点に「^」記号がマッチするようになります。そしてAnchoring Boundsを使わないよう設定すると、マッチング範囲の開始点が文字列先頭あるいは行頭でない限り、「^」記号はマッチング範囲の開始点にマッチしません。このように「^」記号のマッチング動作を明示的に指定できる機構があれば、それを使うことで問題回避できますが、残念ながら.NETにはありません。
ポイント4です。どうやら.NETの正規表現エンジンではJavaでいう「Anchoring Bounds」の扱いがRegex.Matchのオーバーロードごとに異なっており、Regex.Match(string,int)はAnchoring Boundsを使わず、Regex.Match(string,int,int)はAnchoring Boundsを使う動作となっています。ここで、もしAnchoring Boundsを「常に使いたい」場合は外部でマッチング対象の文字列をSubstringすることで代替できます。しかしAnchoring Boundsを「常に使いたくない」場合、Regexクラスの外部でこれを実現する方法は無いように思われます。
この仕様はAPIからもドキュメントの記述内容からも想定できるものでなく、また統一が取れていないという点も考えると、意図的な設計結果とは思えません。本件は、改めてMicrosoft社に報告と確認をしておこうと考えています。
以下、各言語・環境での検証コードおよび検証結果を記します。なお言うべきことはすでに記したので、細かい説明はしません。興味のある方や再現してみたい気分になった方へ向けた情報です。
.NET (C#, VB.NET)
C#での検証コードを以下に示します。
string text = "abc\ndef\nghi";
Match m;
m = new Regex( "^[a-z]", RegexOptions.None ).Match( text, 1 );
Console.WriteLine( " [1, *)==>{0} '{1}'", m.Success ? m.Index.ToString() : "#", m.Groups[0].Value );
m = new Regex( "^[a-z]", RegexOptions.Multiline ).Match( text, 1 );
Console.WriteLine( "M[1, *)==>{0} '{1}'", m.Success ? m.Index.ToString() : "#", m.Groups[0].Value );
m = new Regex( "^[a-z]", RegexOptions.None ).Match( text, 1, 8 );
Console.WriteLine( " [1, 9)==>{0} '{1}'", m.Success ? m.Index.ToString() : "#", m.Groups[0].Value );
m = new Regex( "^[a-z]", RegexOptions.Multiline ).Match( text, 1, 8 );
Console.WriteLine( "M[1, 9)==>{0} '{1}'", m.Success ? m.Index.ToString() : "#", m.Groups[0].Value );
Visual Studio 2005(Microsoftの.NET 2.0)での実行結果は次の通りです。Anchoring Boundsが範囲の終了位置を指定しない場合には使われず、指定した場合には使われます。
[1, *)==># ''
M[1, *)==>4 'd'
[1, 9)==>1 'b'
M[1, 9)==>1 'b'
Mono 2.6.7での実行結果は次の通りです。Microsoftの.NETと異なりAnchoring Boundsは常に使われません。
[1, *)==># ''
M[1, *)==>4 'd'
[1, 9)==># ''
M[1, 9)==>4 'd'
Java
Javaでの検証コードを以下に示します。
import java.util.regex.*;
public class ReTest {
public static void main( String args[] ) {
String text = "abc\ndef\nghi";
Pattern pattern;
Matcher m;
boolean ok;
//------------------------------
pattern = Pattern.compile( "^[a-z]" );
m = pattern.matcher( text )
.region( 1, 9 )
.useAnchoringBounds( false );
ok = m.find();
printResult( m, ok );
pattern = Pattern.compile( "^[a-z]", Pattern.MULTILINE );
m = pattern.matcher( text )
.region( 1, 9 )
.useAnchoringBounds( false );
ok = m.find();
printResult( m, ok );
//------------------------------
pattern = Pattern.compile( "^[a-z]" );
m = pattern.matcher( text )
.region( 1, text.length() )
.useAnchoringBounds( false );
ok = m.find();
printResult( m, ok );
pattern = Pattern.compile( "^[a-z]", Pattern.MULTILINE );
m = pattern.matcher( text )
.region( 1, text.length() )
.useAnchoringBounds( false );
ok = m.find();
printResult( m, ok );
//------------------------------
pattern = Pattern.compile( "^[a-z]" );
m = pattern.matcher( text )
.region( 1, text.length() )
.useAnchoringBounds( true );
ok = m.find();
printResult( m, ok );
pattern = Pattern.compile( "^[a-z]", Pattern.MULTILINE );
m = pattern.matcher( text )
.region( 1, text.length() )
.useTransparentBounds( true );
ok = m.find();
printResult( m, ok );
//------------------------------
pattern = Pattern.compile( "^[a-z]" );
m = pattern.matcher( text )
.useAnchoringBounds( true )
.region( 1, 9 );
ok = m.find();
printResult( m, ok );
pattern = Pattern.compile( "^[a-z]", Pattern.MULTILINE );
m = pattern.matcher( text )
.region( 1, 9 )
.useAnchoringBounds( true );
ok = m.find();
printResult( m, ok );
}
static void printResult( Matcher m, boolean success )
{
System.out.printf(
"%c%c[%d, %2d)==>%s '%s'",
(m.pattern().flags() & Pattern.MULTILINE) != 0 ? 'M' : ' ',
m.hasAnchoringBounds() ? 'A' : ' ',
m.regionStart(),
m.regionEnd(),
success ? m.start() : "*",
success ? m.group() : ""
);
System.out.println();
}
}
Java 1.6.0での実行結果は次の通りです。Anchoring Boundsの使用・不使用を明示的に指定できるため、いずれの動作も実現可能です。
[1, 9)==>* ''
M [1, 9)==>4 'd'
[1, 11)==>* ''
M [1, 11)==>4 'd'
A[1, 11)==>1 'b'
MA[1, 11)==>1 'b'
A[1, 9)==>1 'b'
MA[1, 9)==>1 'b'
Python
Python 2.2.3およびPython 3.2での検証コードを以下に示します。
import re
text = "abc\ndef\nghi"
pattern = re.compile( '^[a-z]' )
m = pattern.search( text, 1 )
if m == None:
print( ' [1, *]=None' )
else:
print( ' [1, *]=%d' % (m.pos) )
pattern = re.compile( '^[a-z]', re.MULTILINE )
m = pattern.search( text, 1 )
if m == None:
print( 'M[1, *]=None' )
else:
print( 'M[1, *]=%d' % (m.pos) )
pattern = re.compile( '^[a-z]' )
m = pattern.search( text, 1, 10 )
if m == None:
print( ' [1,10]=None' )
else:
print( ' [1,10]=%d' % (m.pos) )
pattern = re.compile( '^[a-z]', re.MULTILINE )
m = pattern.search( text, 1, 10 )
if m == None:
print( 'M[1,10]=None' )
else:
print( 'M[1,10]=%d' % (m.pos) )
結果は次の通りです。Anchoring Boundsは常に使われます。
[1, *]=None
M[1, *]=1
[1,10]=None
M[1,10]=1
Ruby
Ruby 1.9.1p0での検証コードを以下に示します。ただしRubyではマッチング終了位置を指定する方法が分からなかったため、終了位置を変更しての検証はできませんでした。
text = "abc\ndef\nghi"
pattern = Regexp.compile( '^[a-z]' )
m = pattern.match( text, 1 )
p ' [1, *]=%d' % m.offset(0)
pattern = Regexp.compile( '^[a-z]', Regexp::MULTILINE )
m = pattern.match( text, 1 )
p 'M[1, *]=%d' % m.offset(0)
結果は次の通りです。
" [1, *]=4"
"M[1, *]=4"