プログラミング

正規表現のグループ : キャプチャ・名前付き・後方参照(Python)

正規表現の丸かっこ(...)グループと呼ばれ、パターンの一部をひとまとめにしたり、マッチした部分を取り出したりするのに使います。本記事では、グループの中身を取り出す方法、取り出さずにまとめるだけの非キャプチャグループ、名前で扱う名前付きグループ、そして同じ内容の再出現を捉える後方参照を、Pythonのreモジュールで例とともに一つずつ整理します。

使うのは標準ライブラリのreモジュールだけで、追加インストールは不要です。\d*|といった基本的な記号は当サイトの別の記事「正規表現の基本構文とパターン検索(Python)」で扱っています。

本記事は、正規表現のグループに絞って解説します。

動作確認

Python3.12で実施しています。reモジュールを使用するため、import reを冒頭に入れてください。

目次

グループの一覧

この記事で扱うグループ関連の構文を、先に一覧で示します。細かい意味や使いどころは、表のあとの各節で例とともに記述しています。

構文意味
(…)囲んだ部分の文字列を取り出せるようにする(キャプチャグループ)
(?:…)選択や繰り返しのためにまとめるだけで、取り出さない(非キャプチャグループ)
(?P<name>…)名前を付けて、名前で取り出せる(名前付きキャプチャグループ)
\1(?P=name)前のグループと同じ文字列に、もう一度マッチさせる(後方参照)

なお、グループ化の構文には(?>...)アトミックグループ、Python3.11以降)もあります。これはバックトラックを抑えて、特定のパターンで処理が極端に遅くなる「破壊的バックトラック」を防ぐためのものですが、本記事では対象外とします。

補足:バックトラック

バックトラックは、正しい結果が得られなかった場合に、処理を巻き戻して別のパターンを試していくアルゴリズムです。これを(a+)+のように量指定子(繰り返し)を入れ子にした場合に、計算量が膨大になりCPU使用率を圧迫することがあります。これを破壊的バックトラックといいます。

グループで囲んだ部分を取り出す

グループ(…)で囲むと、マッチした文字列のうち囲んだ部分を取り出せます。

実際に取り出すには、reの関数を使います。ここでは取り出し方を2つ示します。すべてのマッチからまとめて取り出すre.findallと、1か所だけを詳しく調べるre.searchです。

すべてのマッチからまとめて取り出す(re.findall)

グループを使うと、re.findallは囲んだ部分だけを返します。戻り値はグループの数で変わり、グループが0個ならマッチ全体、1個ならその中身、2個以上なら各グループを並べたタプルのリストになります。

print(re.findall(r"\d+円", "120円と80円"))     # ['120円', '80円'] 
print(re.findall(r"(\d+)円", "120円と80円"))   # ['120', '80']   
print(re.findall(r"(\d+)年(\d+)月", "2020年4月と2021年12月"))   # [('2020', '4'), ('2021', '12')]
print(re.findall(r"(\d+)年(\d+)月", "2020年4月と2021年"))   # [('2020', '4')]

このように、re.findallは見つかったすべてのマッチから、囲んだ部分をまとめて取り出せます。1か所だけを取り出して詳しく調べたいときは、次のre.searchを使います。

1か所を取り出して詳しく扱う(re.searchとMatchオブジェクト)

re.search(パターン, 文字列)は、最初に見つかった1か所をMatchオブジェクトとして返します(見つからなければNone)。re.findallは文字列やタプルを返しましたが、re.searchはMatchオブジェクトを返すので、そのメソッドでマッチした内容や位置を取り出せます。

.group(0)はマッチ全体、.group(1).group(2)はかっこで囲んだ部分を左から順に、.groups()は全グループをまとめたタプルを返します。

m = re.search(r"(\d+)年(\d+)月", "2024年12月")
print(m.group(0))   # '2024年12月'   マッチ全体
print(m.group(1))   # '2024'         1つ目のグループ
print(m.group(2))   # '12'           2つ目のグループ
print(m.groups())   # ('2024', '12') 全グループのタプル

見つからないとき、re.searchNoneを返します。Noneに対して.group()を呼ぶとエラーになるため、結果を使う前に確認します。

m = re.search(r"(\d+)月", "今月")
print(m)   # None   数字がないのでマッチしない

実際のコードでは、if m:のように、マッチしたかどうかを確かめてから.group()を使います。

マッチした位置を知りたいときは.span()を使います。引数なしでマッチ全体、番号を渡すとそのグループの位置を「(開始, 終了)」のタプルで返します。

m = re.search(r"(\d+)年(\d+)月", "2024年12月")
print(m.span())    # (0, 8)   マッチ全体の位置
print(m.span(1))   # (0, 4)   グループ1の位置
print(m.span(2))   # (5, 7)   グループ2の位置

.span()で返されるのは第2引数で与えた文字列に対する位置です。位置は0から数え、終了の位置は含みません(Pythonのスライスやrangeと同じです)。たとえば(5, 7)は5・6文字目を指します。

取り出さずにまとめる(非キャプチャグループ(?:...)

グループには「取り出す」だけでなく「まとめる」役割もあります。たとえば、選択(|)や繰り返し(*+)を一部だけに効かせたいとき、丸かっこでまとめます。ただし、まとめたいだけで取り出す必要がないこともあります。その場合は(?:...)を使うと、グループとしてまとめつつ、キャプチャ(取り出し)はしません。

キャプチャグループと非キャプチャグループでのre.findallの比較を行います。

print(re.findall(r"(赤|青)色", "赤色と青色"))     # ['赤', '青'] 
print(re.findall(r"(?:赤|青)色", "赤色と青色"))   # ['赤色', '青色'] 

(...)は囲んだ部分「だけ」を取り出しますが、その「囲んだ部分だけを取り出す」機能をなくして「まとめる」機能だけを残したのが、非キャプチャグループ(?:...)です。このため、(?:赤|青)色は非キャプチャなので、「赤または青」だけを取り出す機能はなく、「赤または青」を「まとめる」機能だけを持ちます。よって、まとめた部分が後ろのと結合して、「赤色」「青色」を返します。

非キャプチャグループは、グループ番号にも影響しません。キャプチャグループを増やすと後ろのグループの番号がずれますが、(?:...)なら番号は変わりません。

m1 = re.search(r"(Mr\.|Ms\.) (\w+)", "Mr. Smith")
print(m1.group(2))   # 'Smith'   敬称がグループ1、名前がグループ2
print(m1.span(2))    # (4, 9)    名前の位置

m2 = re.search(r"(?:Mr\.|Ms\.) (\w+)", "Mr. Smith")
print(m2.group(1))   # 'Smith'   敬称は非キャプチャなので、名前がグループ1
print(m2.span(1))    # (4, 9)    名前の位置

敬称をキャプチャグループにすると、名前は.group(2)になります。敬称を非キャプチャにすれば、名前は.group(1)のままです。必要な部分だけをキャプチャしておくと、番号の管理が楽になります。.span()についても同様です。

補足として、キャプチャグループを繰り返し(+*{n}など)と組み合わせると、グループが保持するのは最後の1回分だけになります。

print(re.findall(r"(ab)+", "ababab"))     # ['ab']        キャプチャ 
print(re.findall(r"(?:ab)+", "ababab"))   # ['ababab']    非キャプチャ 

(ab)+は"ababab"全体にマッチしますが、グループ(ab)が覚えているのは最後の"ab"だけになります。取り出す必要がなければ(?:ab)+にすれば、キャプチャしないのでマッチ全体['ababab']が得られます。

名前で扱う(名前付きグループ(?P<name>...)

キャプチャグループには名前を付けられます。(?P<name>...)と書き、.group("name")で名前を指定して取り出します。.groupdict()は、名前と中身をまとめた辞書を返します。

m = re.search(r"(?P<year>\d+)年(?P<month>\d+)月", "2024年12月")
print(m.group("year"))    # '2024'
print(m.span("year"))     # (0, 4)   year の位置
print(m.group("month"))   # '12'
print(m.span("month"))    # (5, 7)   month の位置
print(m.groupdict())      # {'year': '2024', 'month': '12'}

番号ではなく名前で扱えるので、グループが増えてもどれが何かが分かりやすくなります。なお、名前付きグループは番号でもアクセスできます(名前と番号の両方が使えます)。

print(m.group(1), m.group(2))   # 2024 12

re.findallは名前を返しません。名前付きでも、結果はこれまでと同じくタプル(または文字列)のリストです。名前で取り出したい場合は、re.search.groupdict()を使います。

print(re.findall(r"(?P<year>\d+)年(?P<month>\d+)月", "2020年4月")) # [('2020', '4')]

同じ内容の再出現を捉える(後方参照)

パターンの中で\1と書くと、「グループ1がマッチしたのと同じ内容」を表します。これを後方参照と言います。同じ単語の連続や、対になる記号などを捉えるのに使います。

print(re.findall(r"\b(\w+) \1\b", "the the cat dog dog"))   # ['the', 'dog']

(\w+)が単語を捕まえ、\1が「その単語と同じ文字列」を表すので、このパターンは「同じ単語が2回続く箇所」にマッチします。"the the"と"dog dog"はマッチし、1回だけの"cat"はマッチしません。

名前付きグループには(?P=name)で後方参照します。

print(re.findall(r"\b(?P<word>\w+) (?P=word)\b", "the the dog dog"))   # ['the', 'dog']

後方参照が捉えるのは「同じ内容(マッチした文字列)」であって、「同じパターン」ではありません。たとえば(\d+)のあとの\1は、「先に捕まえた数字とまったく同じ数字」を表し、「任意の数字」ではありません

まとめ

正規表現のグループについて、reモジュールで例を示しながら確認しました。

  • (...):キャプチャグループ。re.search.group(番号).groups()re.findallで中身を取り出せる。位置は.span()で得られる。
  • (?:...):非キャプチャグループ。まとめるだけで取り出さず、グループ番号にも影響しない。
  • (?P<name>...):名前付きグループ。.group("name").groupdict()で名前で扱える。
  • \1(?P=name):後方参照。同じ「内容」の再出現を捉える。

グループは、マッチした内容を構造的に取り出すための基本になります。

なお、次の記事「正規表現の先読み・後読み (Python) 」で正規表現のパターンマッチングに条件をつける先読み・後読みを取り扱います。

-プログラミング
-