プログラミング

正規表現の先読み・後読み (Python)

正規表現の先読み(lookahead)と後読み(lookbehind)は、指定パターンに一致する部分を取得する前に、その前(あるいは後ろ)が条件を満たしているかを確認し、満たしている場合のみ指定パターンに一致した部分を取得する方法です。条件に使った前後の文字列は、取得結果には含まれません。

たとえば「120円」という文字列から、「が後ろに付いた数字」を取り出す場合を考えます。先読みを使った\d+(?=円)では、\d+が指定パターン(取り出したい数字)、(?=円)が条件です。が後ろにあることを確認したうえで、取り出すのは数字の120だけです(条件のは結果に含まれません)。一方、ふつうのパターン\d+円は、まで含めて120円を取り出します。

この「確かめるだけで結果に取り込まない」ことを、正規表現では「消費しない」と言います。先読み・後読みのように、消費せずに位置の条件だけを確かめる記法を、まとめてアサーションと呼びます。

先読み・後読みには、条件が満たされることを求める肯定と、満たされないことを求める否定があり、先読みと後読みとの組み合わせにより全4パターンあります(肯定先読み/否定先読み/肯定後読み/否定後読み)。

本記事ではこれらを、Pythonのreモジュールで一つずつ整理します。reモジュールは標準モジュールなのでインストールは不要です。

なお、本記事で用いる正規表現の基本構文およびグループのre.searchやMatchオブジェクトの.span()は過去の記事で扱っています。

動作確認

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

目次

先読み・後読みの一覧

この記事で扱う構文を、先に一覧で示します。表の「条件」とは、(?=...)などの...に置く、前後をチェックするためのパターンのことです。細かい意味や使いどころは、表のあとの各節で例とともにまとめています。

構文呼び方意味
(?=…)肯定先読み指定パターンの直後が、条件に一致することを求める
(?!…)否定先読み指定パターンの直後が、条件に一致しないことを求める
(?<=…)肯定後読み指定パターンの直前が、条件に一致することを求める。Pythonでは条件は固定長のみ
(?<!…)否定後読み指定パターンの直前が、条件に一致しないことを求める。Pythonでは条件は固定長のみ

条件として確かめた部分はマッチ(取得結果)に含まれません。

なお、先読み・後読みはグループではなく位置の条件なので、それ自体はグループ番号を持たず、.group()で取り出す対象にもなりません。後読みには「条件は固定長でなければならない」というPython特有の制約があります。

先読み:指定パターン直後の文字に条件を課す

先読みは、指定パターンの直後が条件に一致するかを確かめます。確かめるだけで、その後ろの部分(条件)は取り出しません。肯定(?=...)と否定(?!...)があります。

肯定先読み(?=...)

(?=...)は「指定パターンの直後が条件に一致するなら、指定パターンにマッチした部分を取得する」という意味です。条件に使った部分はマッチに含めません。

たとえば「が続く数字だけ」を取り出してみます。

print(re.findall(r"\d+(?=円)", "120円と80ドルと300円"))   # ['120', '300']

\d+(?=円)では、\d+が指定パターン、(?=円)が条件です。80ドル80は直後がではない(条件に一致しない)ので取り出されず、条件に一致する120300だけが残ります。取り出されるのは指定パターンの数字だけで、条件のは含まれません。

マッチにが含まれないことを、位置で確認します。

m = re.search(r"\d+(?=円)", "120円")
print(m.group())   # '120'
print(m.span())    # (0, 3)   "円"(位置3)は含まれない

マッチは指定パターン\d+に当たる120(位置0〜2)だけで、条件の(位置3)は含まれません。これは、先読みが条件のを消費しない(確かめるだけで、マッチに取り込まず、照合位置もそこへ進めない)ためで、.span()(0, 3)になりの位置を含まないことに表れています。

否定先読み(?!...)

(?!...)は逆に「指定パターンの直後が条件に一致しないなら、指定パターンにマッチした部分を取得する」という意味です。

たとえば「httpのうち、httpsではないもの」を取り出します。

print(re.findall(r"http(?!s)", "http://a https://b http://c"))   # ['http', 'http']

http(?!s)では、httpが指定パターン、(?!s)sが条件です。「直後がsでないhttp」を表すので、httpshttpは直後がs(条件に一致)なので除外され、残り2つのhttpがマッチします。

補足として、\d+のような繰り返しの直後に否定先読みを置くと、直感に反する結果になることがあります。

print(re.findall(r"\d+(?!円)", "120円"))   # ['12']

が続かない数字」を期待すると、120の後ろはなので、何もマッチしないように思えます。しかし\d+は貪欲なので、まず120にマッチし、その直後がで条件に当たると、エンジンは1文字戻して12を試します。12の直後は0ではない)なので条件に一致せず、否定先読みが成立して12がマッチします。

このような後戻り(バックトラック)を避けるには、否定先読みに「直後が数字でないこと」も加え、\d+が数字の途中で切れないようにします。

print(re.findall(r"\d+(?![\d円])", "120円と80点"))   # ['80']

(?![\d円])は「直後が数字でもでもない」という条件です。120円120は直後が、そこで1文字戻した12も直後が0(数字)なので、どちらも条件に合わず取り出されません。80は直後がなので取り出されます。さきほどの\d+(?!円)12を拾ったのに対し、条件に\dを加えたことで数字の途中で切れなくなりました。

後読み:指定パターン直前の文字に条件を課す

後読みは、指定パターンの直前が条件に一致するかを確かめます。先読みと同じく、その前の部分(条件)は取り出しません。肯定(?<=...)と否定(?<!...)があります。ただしPythonでは、後読みの条件は固定長である必要があります。

肯定後読み(?<=...)

(?<=...)は「指定パターンの直前が条件に一致するなら、指定パターンにマッチした部分を取得する」という意味です。

たとえば「$に続く数字(金額)」を取り出します。

print(re.findall(r"(?<=\$)\d+", "$100と$250"))   # ['100', '250']

(?<=\$)\d+では、\d+が指定パターン、(?<=\$)$が条件です。「直前が$である数字」を表します。$は行末を表すメタ文字なので、文字として扱うには\$とエスケープします。取り出されるのは指定パターンの数字だけで、条件の$は含まれません。

これも位置で確認します。

m = re.search(r"(?<=\$)\d+", "$100")
print(m.group())   # '100'
print(m.span())    # (1, 4)   "$"(位置0)は含まれない

$は位置0、数字100は位置1〜3です。マッチは100だけなので.span()(1, 4)で、条件の$(位置0)は含まれません。

否定後読み(?<!...)

(?<!...)は「指定パターンの直前が条件に一致しないなら、指定パターンにマッチした部分を取得する」という意味です。

たとえば「マイナス符号が付いていない数字(負でない数)」を取り出します。

print(re.findall(r"(?<!-)\b\d+", "5 -3 80 -12"))   # ['5', '80']

(?<!-)\b\d+では、\d+が指定パターン、(?<!-)-が条件です。「直前が-でない数字」を表すので、-3-12の数字は直前が-(条件に一致)なので除外され、580が残ります。ここで\b(単語境界)を添えているのは、数字の先頭で判定するためです。これがないと、複数桁の数字で一部だけが拾われることがあります(-121は直前が-なので除外されても、2は直前が1-ではないため、拾われてしまいます)。

後読みは固定長だけ(Pythonの制約)

Pythonの後読みは、条件((?<=...)...の部分)が固定長(常に同じ文字数にマッチする)でなければなりません。長さが決まらない条件を後読みに入れると、コンパイル時(re.compilere.searchなどを呼んだ時点)にエラーになります。

# 固定長ならOK
re.compile(r"(?<=abc)x")     # "abc" は常に3文字
re.compile(r"(?<=\d{3})x")   # ちょうど3文字

# 長さが決まらないとエラー
try:
    re.compile(r"(?<=\d+)x")     # \d+ は1文字以上で長さが不定
except re.error as e:
    print(e)   # look-behind requires fixed-width pattern

try:
    re.compile(r"(?<=\d{2,4})x")   # 2〜4文字で幅がある
except re.error as e:
    print(e)   # look-behind requires fixed-width pattern

選択(|)を使う場合は、どの選択肢も同じ長さなら後読みの条件に使えますが、長さが違うとエラーになります。

re.compile(r"(?<=cat|dog)x")   # "cat" も "dog" も3文字

try:
    re.compile(r"(?<=cat|fish)x")   # 3文字と4文字で長さが違う
except re.error as e:
    print(e)   # look-behind requires fixed-width pattern

先読みにはこの制約はありません。先読みの条件は、長さが決まらなくても構いません。

re.compile(r"(?=\d+)x")   # 先読みは長さが不定でもよい

この違いは、照合の向きから来ています。後読みは「現在位置から手前へ何文字か戻って照合する」という動きのため、戻る文字数(=条件の長さ)が決まっている必要があります。長さが不定だと、何文字戻ればよいか決められません。先読みは現在位置から前向きに照合するだけなので、この制約がありません。

複数の条件をまとめて確かめる(先読みの活用)

先読みは消費しないので、同じ位置から複数の条件を重ねて確認できます。これは「文字列が複数の条件を同時に満たすか」を調べるのに便利です。

ここではre.fullmatchを使います。re.fullmatchは、文字列全体がパターンに一致するときだけMatchを返し、一致しなければNoneを返します。

「6文字以上で、数字を1つ以上含む」かどうかを判定してみます。

pattern = r"(?=.*\d).{6,}"
print(bool(re.fullmatch(pattern, "abc123")))    # True   6文字・数字あり
print(bool(re.fullmatch(pattern, "abcdefg")))   # False  7文字だが数字なし
print(bool(re.fullmatch(pattern, "ab12")))      # False  数字はあるが6文字未満

ここでは.{6,}(6文字以上)が指定パターン、(?=.*\d)が条件です。(?=.*\d)は「どこかに数字が1つ以上ある」という条件で、.*で任意の文字を読み飛ばし、その先に\dがあるかを見ています。先読みは消費しないので、文字列の先頭でまず条件「数字を含むか」を確認し、続けて指定パターン.{6,}で全体の長さを照合できます。両方を満たすときだけfullmatchが成立します。

条件を増やすときは、(?=...)を必要なだけ並べます。たとえば「8文字以上で、数字と大文字をそれぞれ1つ以上含む」なら次のようになります。

pattern = r"(?=.*\d)(?=.*[A-Z]).{8,}"
print(bool(re.fullmatch(pattern, "Passw0rd")))   # True
print(bool(re.fullmatch(pattern, "password")))   # False  数字も大文字もない

パスワードの条件チェックのように、いくつもの条件をAND結合したい場面で使われる書き方です。

まとめ

正規表現の先読み・後読みについて、reモジュールで例を示しながら確認しました。指定パターンが取り出したい本体、条件が...に置いて前後をチェックするパターンです。

  • (?=...):肯定先読み。指定パターンの直後が条件に一致するときだけ成立する(条件は取り出さない)。
  • (?!...):否定先読み。指定パターンの直後が条件に一致しないときだけ成立する。
  • (?<=...):肯定後読み。指定パターンの直前が条件に一致するときだけ成立する。Pythonでは条件は固定長のみ。
  • (?<!...):否定後読み。指定パターンの直前が条件に一致しないときだけ成立する。Pythonでは条件は固定長のみ。

ここで、「成立する」は「指定パターンにマッチした部分を取得する」ことを意味します。先読み・後読みは、前後を条件にしつつ、その部分はマッチに含めない(消費しない)のが共通の性質です。マッチした位置に文字を差し込む置換(re.sub)と組み合わせると、桁区切りの挿入のような処理にも使えます。

なお、次の記事「正規表現のフラグ(Python)」で正規表現のグループを取り扱います。

-プログラミング
-