正規表現のグループ:番号の規則・名前付きグループ・エンジン差

正規表現のグループを.NET・Python・PHP・Dartで実測。量指定子のまとめ方、番号の付き方、エンジン別の名前付きグループの書き方と番号のずれ、IPパターンの穴、.NETのバランシンググループまで。

2026年11月改訂: この記事は2021年に韓国語で書いた講座(原題は「下位表現」)を、日本語の読者向けに書き直したものです。元記事の5つのパターンを .NET 10、Python 3.9・3.12 の re、PHP 8.5(PCRE2 10.47)、Dart 3.11(JavaScript の規則に従う RegExp)で同じ入力のまま実行し直しました。マッチ結果はどのエンジンでも同じでしたが、最後の説明は成り立ちませんでした。「有効な IP アドレスだけを見つける」はずのパターンが、256.1.1.1 の中の 56.1.1.1、1.2.3.456 の中の 1.2.3.45、そして 01.02.03.04 にもマッチしました。番号の規則、名前付きグループ、エンジン別の書き方、.NET のバランシンググループは新しく加えた内容です。

正規表現に丸かっこを書き、その結果を取り出して使う人向けの記事です。どのグループが何番になるのか、なぜグループにマッチの一部しか残らないのか、Python で動いた名前付きグループがなぜ C# でエラーになるのかを扱います。グループ(サブパターン)の役割は2つです。パターンの一部をまとめて量指定子や | をかたまり全体にかけること、そしてマッチした文字列をキャプチャしてあとで読んだり参照したりすることです。驚かされるのはたいてい2つ目の役割のほうです。

キャプチャした文字列を参照する \1 や \k<name> は後方参照の記事で、(?=…) のような幅ゼロのグループは先読み・後読みの記事で扱っています。

まとめる:量指定子を複数の文字にかける

元記事の最初の例です。

テキスト:abcc / abcabc(1行に1つ)
abc{2}      → abcc     ({2} は c だけにかかる)
(abc){2}    → abcabc   ({2} がグループ全体にかかる)

全エンジンで同じでした。量指定子は直前の1要素だけにかかり、丸かっこは複数の要素を1つにします。2つ目の例は同じ考え方で IP アドレスのパターンを短くしていました。

\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}
(\d{1,3}\.){3}\d{1,3}
テキスト:192.168.0.1 / 127.0.0.1 / 123.456.789.101

どちらのパターンも全エンジンで3行すべてにマッチしました。アドレスではない 123.456.789.101 もです。そこで元記事は下の長いパターンに進みました。短いほうでグループ1に何が残るかも見ておきましょう。192.168.0.1 では 0.、最後の行では 789. です。繰り返したグループには最後の繰り返しだけが残ります。 .NET は Groups[1].Captures に履歴を持っていて、実行すると 192.、168.、0. が入っていました。Python の re、PHP、Dart には同じ機能がありません。Python ではサードパーティの regex モジュール(2026.11.9)の m.captures(1) が ['192.', '168.', '0.'] を返しました。

元記事の IP パターンを再検証して直す

元記事のパターンは、有効なオクテットの範囲を | で並べています。

(((\d{1,2})|(1\d{2})|(2[0-4]\d)|(25[0-5]))\.){3}((1[0-9]{2})|(2[0-4]\d)|(25[0-5])|(\d{1,2}))

記事の入力では説明どおり 192.168.0.1、127.0.0.1、10.1.2.4 にマッチし、123.456.789.101 は飛ばしました。ところが別の入力では、不正なアドレスの一部にマッチしました。

入力 マッチ(全エンジン共通) 問題
256.1.1.1 56.1.1.1 最初のオクテットの途中から始まる
1.2.3.456 1.2.3.45 最後のオクテットの途中で終わる
01.02.03.04 01.02.03.04 先頭のゼロを許す

パターンはオクテット1つ1つは正しく表していますが、アドレスの外側に何が来てよいかは何も言っていません。アンカーがないので、エンジンはどこからでも始め、どこででも終われます。2つの修正案を 192.168.0.1、10.1.2.4、256.1.1.1、1.2.3.456、01.02.03.04、1.2.3.4.5 で試しました。

テキストから探す:(?<![\d.])(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}(?![\d.])
入力値の検証  :^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$
結果(両方・全エンジン):192.168.0.1、10.1.2.4

[1-9]?\d が先頭のゼロを防ぎ、先読み・後読みが数字の途中で始まったり終わったりするのを防ぎます。必要なのはアドレス全体だけなので、内側のグループはすべてキャプチャしない (?:…) にしました。実際の入力チェックにはライブラリのほうがよいことも多いのですが、何を受け付けるかは確かめておくべきです。Python の ipaddress.ip_address は不正な4つの入力をすべて拒否しました。一方 .NET の IPAddress.TryParse は 256.1.1.1、1.2.3.456、1.2.3.4.5 は拒否したものの、01.02.03.04 を 1.2.3.4 として、さらに 10.1 を 10.0.0.1 として受け付けました。

グループに番号が付く規則

規則は1つです。開きかっこを左から数える。 グループ0はマッチ全体です。

パターン:((a)(b(c)))     テキスト:abc
グループ1 = abc、2 = a、3 = bc、4 = c   (全エンジン共通)
ツリー図:パターン ((a)(b(c))) の中にグループ1があり、abc にマッチした。グループ1の中にグループ2 (a)(a にマッチ)とグループ3 (b(c))(bc にマッチ)がある。グループ3の中にグループ4 (c)(c にマッチ)がある。
((a)(b(c))) を abc に使ったときのグループ番号。番号は開きかっこが左から現れる順に付くので、外側のグループは内側のグループより必ず小さい番号になる。試したすべてのエンジンで同じだった。

したがって元記事の IP パターンにはグループが11個あります。その中身を読むと、さらに2つの規則が見えてきます。192.168.0.1 の結果です。

グループ .NET / Python / PHP Dart(JS の規則)
1 ((…)\.) 0. 0.
2(オクテット) 0 0
3 (\d{1,2}) 0 0
4 (1\d{2}) 168 null
7(最後のオクテット) 1 1
11 (\d{1,2}) 1 1

グループ5、6、8、9、10は参加しておらず、全エンジンで null でした。注目はグループ4です。{3} の3回目の繰り返しでオクテット 0 を捕まえたのは3番目の選択肢なのに、.NET・Python・PHP はグループ4を2回目の繰り返しの 168 のまま報告しました。JavaScript の規則では繰り返しのたびに内側のグループを空にするので、Dart は null でした。繰り返しグループの内側のグループを読むと、結果がエンジン次第になります。

| で分かれた選択肢は番号を共有しません。 (a)|(b) を ab に使うと、全エンジンで1つ目のマッチは a(グループ2は null)、2つ目は b(グループ1は null)でした。PCRE には選択肢どうしで番号を共有するブランチリセット (?|(a)|(b)) があり、PHP では2つのマッチともグループ1が a、b でした。ほかのエンジンはすべて拒否しました。

.NET   : Invalid pattern '(?|(a)|(b))' at offset 3. Unrecognized grouping construct.
Python : unknown extension ?| at position 1
Dart   : FormatException: Invalid group (?|(a)|(b))

キャプチャしないグループ (?:…)

まとめたいだけでキャプチャが要らないなら (?:…) と書きます。全エンジンで動き、番号を動かしません。(?:ab)+(c) は ababc にマッチし、グループ1は c でした。長いパターンでは、読まないグループすべてに ?: を付けておくと、あとでグループを足してもコード側の番号がずれません。

.NET にはこれを一括で行うスイッチがあります。RegexOptions.ExplicitCapture(インラインなら (?n))を指定すると、名前のない ( ) はキャプチャせず、名前付きグループだけが残ります。(\d{4})-(?<m>\d{2}) を 2026-11 に使うと、グループは3つではなく2つ(0 と m)でした。試したほかのエンジンにこのオプションはありません。

名前付きグループ:同じ考え方、3つの書き方

名前付きグループは何をキャプチャしているかが読み取れ、パターンを直しても番号ほど壊れません。問題は書き方です。2026-11 に対する結果です。

パターン .NET Python 3.9 / 3.12 PHP Dart
(?<y>\d{4})-(\d{2}) マッチ エラー unknown extension ?<y マッチ マッチ
(?P<y>\d{4})-(\d{2}) エラー Unrecognized grouping construct マッチ マッチ エラー Invalid group
(?'y'\d{4})-(\d{2}) マッチ エラー マッチ エラー

もっと大きな落とし穴は番号です。Python・PHP・Dart は名前の有無に関係なく左から番号を付け、グループ1 = 2026、グループ2 = 11 です。.NET は名前のないグループを先に数え、名前付きグループをそのあとに並べます。 そのためグループ1 = 11、グループ2 = 2026 となり、「同じ」置換が違う結果になります。

パターン (?<y>\d{4})-(\d{2})、テキスト 2026-11、置換 $2/$1
.NET    → 2026/11
PHP     → 11/2026
Python  → 11/2026   ((?P<y>…) のパターン、置換 \2/\1)
Dart    → 11/2026   (replaceAllMapped で m[2]、m[1])

名前付きグループは名前で参照する習慣が安全です(.NET ${y}、Python \g<y>、Dart m.namedGroup('y'))。1つのパターンの中で名前による参照と番号による参照を混ぜないようにしましょう。

同じ名前を2回使えるかもエンジン次第です。(?<d>\d+)-x|(?<d>[a-z]+)-y を abc-y に使うと .NET は動きました(グループ d は1つで値は abc)。PHP は two named subpatterns have the same name (PCRE2_DUPNAMES not set) というエラーで、(?J) を付けると動きました。Python((?P<d>…) の表記)は redefinition of group name 'd' as group 2; was group 1、Dart は Duplicate capture group name でした。

応用:.NET のバランシンググループ

.NET には、ここで試したほかのエンジンにないグループがあります。(?<name>…) はキャプチャをスタックに積み、(?<-name>…) は1つ取り出し、(?<new-old>…) は old を取り出しながら、その位置から現在位置までの文字列を new としてキャプチャします。スタックが空でなければ失敗させる条件式 (?(name)(?!)) と組み合わせると、正規表現でかっこの対応を確かめられます。

var p = @"^[^()]*(?:(?:(?<open>\()[^()]*)+(?:(?<inner-open>\))[^()]*)+)*(?(open)(?!))$";
foreach (var s in new[] { "f(a(b)c)", "x(1)+(2(3))", "f(a(b)c", "f(a)b)c(" }) {
    var m = Regex.Match(s, p);
    Console.WriteLine($"{s,-12} {(m.Success ? "match" : "no match")}  inner=[{string.Join(", ", m.Groups["inner"].Captures.Select(c => c.Value))}]");
}

.NET 10 での実行結果です。

f(a(b)c)     match  inner=[b, a(b)c]
x(1)+(2(3))  match  inner=[1, 3, 2(3)]
f(a(b)c      no match  inner=[]
f(a)b)c(     no match  inner=[]

inner には対応の取れたかっこの中身が内側から順にたまります。長いテキストの中から対応の取れたかっこのかたまりを探すなら \((?>[^()]+|\((?<d>)|\)(?<-d>))*(?(d)(?!))\) を使います。f(a(b)c) + g(x) + h(y からは (a(b)c) と (x) を返し、閉じていない (y は飛ばしました。

ほかのエンジンはこの書き方を拒否しました。Python は unknown extension ?<o、PHP は subpattern name expected、Dart は Invalid capture group name です。PCRE は同じ問題を再帰で解きます。PHP で \((?:[^()]++|(?R))*\) は同じテキストから (a(b)c) と (x) を返し、Python の regex モジュールも同じでした。Python の re は unknown extension ?R でエラーです。バランシンググループを使うと .NET の線形時間エンジンも使えません:RegexOptions.NonBacktracking is not supported in conjunction with expressions containing: 'captured group conditional …'。入れ子の深い文法なら小さなパーサーを書くほうが保守しやすいのですが、設定値やテンプレートのプレースホルダーを手早くチェックする程度なら、正規表現1つで足りることも多いです。

早見表と検証方法

やりたいこと 書き方
複数の文字を繰り返す (abc){2}
キャプチャせずにまとめる (?:…)
名前付きグループ .NET・Dart (?<n>…)、Python (?P<n>…)、PHP はどちらも
繰り返しグループの全回分 .NET Groups[i].Captures、Python regex の captures()
選択肢どうしで番号を共有 PHP の (?|…) だけ
かっこの対応チェック .NET バランシンググループ、PHP (?R)
IPv4 アドレス全体 上のアンカー付きオクテットのパターン、または動作を確かめたパーサー

検証環境:.NET 10.0.14(Regex.Matches、Regex.Replace)、Python 3.9.6・3.12.14(re.finditer、re.sub)、PHP 8.5.6+PCRE2 10.47(PREG_UNMATCHED_AS_NULL を付けた preg_match_all、preg_replace)、Dart 3.11.5(RegExp.allMatches)。Node.js がなかったので、JavaScript の規則は Dart で確かめました。パターンと入力は1つの JSON ファイルから読み、全エンジンが同じバイト列を見るようにしています。

2026年11月、Apple Silicon Mac(macOS)で実測。

よくある質問

(\d{1,3}\.){3}\d{1,3} で 192.168.0.1 にマッチさせると、グループ1が「0.」だけなのはなぜ?

繰り返したグループには最後の繰り返しだけが残るからです。全エンジンでグループ1は「0.」でした。.NET は Groups[1].Captures に以前の繰り返し(192.、168.、0.)も残し、Python はサードパーティの regex モジュールの captures(1) で同じ情報を取れます。

.NET の名前付きグループの番号はどう付く?

名前のないグループをすべて数えたあとに付きます。(?<y>\d{4})-(\d{2}) を 2026-11 に使うと、.NET はグループ1 = 11、グループ2 = 2026 で、置換 $2/$1 の結果は 2026/11 でした。Python・PHP・Dart は左から番号を付けるので、同じ番号の置換は 11/2026 になりました。

Python で (?<name>...) と書ける?

re モジュールでは書けません。Python 3.9 と 3.12 のどちらも「unknown extension ?<y at position 1」でした。Python は (?P<name>...) が必要です。PHP は両方を受け付け、.NET と Dart は (?<name>...) だけです。

  • #正規表現
  • #グループ
  • #名前付きグループ
  • #Python
  • #.NET
  • #PHP
  • #Dart

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑