正規表現の * や + は「直前の文字が何回続いてもいい」という意味だが、実際にマッチする範囲は「できるだけ長く」なのか「できるだけ短く」なのかで挙動が変わる。これが greedy(貪欲)と non-greedy(非貪欲)の違いで、? を1つ足すか足さないかだけで結果がまるで別物になる。今回はこの違いを、実際にWP-CLIの出力パースで使っている自社コードを題材に整理する。
quantifier(数量詞)は「デフォルトで貪欲」
補足: quantifier(数量詞)とは、正規表現で「直前の要素が何回繰り返すか」を指定する記号のこと。
*(0回以上)・+(1回以上)・?(0または1回)・{n,m}(n〜m回)が代表的。
正規表現の数量詞は、指定がなければ「マッチできる限り長く」マッチしようとする。これが greedy(貪欲)と呼ばれるデフォルトの挙動だ。
| 記法 | 意味 | 挙動 |
|---|---|---|
.* |
0文字以上(貪欲) | できるだけ長くマッチしようとする |
.*? |
0文字以上(非貪欲) | できるだけ短くマッチしようとする |
.+ |
1文字以上(貪欲) | できるだけ長くマッチしようとする |
.+? |
1文字以上(非貪欲) | できるだけ短くマッチしようとする |
貪欲な .* は「まず入力全体を食べてしまい、パターンの残りが満たせなければ1文字ずつ手放していく(バックトラック)」という動き方をする。非貪欲な .*? はその逆で「まず0文字から始め、パターンの残りが満たせなければ1文字ずつ広げていく」。同じ入力・同じパターンでも、この探索の向きが違うだけで最終的にマッチする範囲が変わることがある。
実例1: ノイズ混じりの出力からJSONだけを取り出す
このアプリはWP-CLIの wp plugin list --format=json のような構造化出力を取得してプラグイン一覧の判定に使っているが、環境によってはPHPのDeprecated警告などがJSON本体の前後に混入する(core/wpcli_json.py のモジュールコメントに詳細な背景がある)。ここで「まず出てきた { から、最初に出てきた } まで」という非貪欲な発想でJSONを切り出そうとすると、ネストしたJSONで壊れる。
import re, json
noisy = 'PHP Deprecated: something in file on line 5\n{"a": {"b": 1}, "c": 2}\n'
# 非貪欲: 最初に出てきた `}` で止まってしまう
m = re.search(r'\{.*?\}', noisy, re.DOTALL)
print(m.group(0))
# => '{"a": {"b": 1}' ← 内側のオブジェクトの `}` で止まり、外側が閉じていない
json.loads(m.group(0))
# => json.decoder.JSONDecodeError: Expecting ',' delimiter
非貪欲な .*? は「できるだけ短く」マッチしようとするため、内側のオブジェクト {"b": 1} が閉じた時点で「もうこれでパターン全体(\{.*?\})が満たせる」と判断し、そこで止まってしまう。結果、外側のJSONが尻切れになる。逆に貪欲な \{.*\} であれば、まず文字列の終端まで食べてから手放していくため、最後に出てくる } まで正しく届く。
m = re.search(r'\{.*\}', noisy, re.DOTALL)
print(m.group(0))
# => '{"a": {"b": 1}, "c": 2}' ← 正しく外側まで含む
json.loads(m.group(0)) # OK
実際の core/wpcli_json.py::extract_json_from_wpcli_output() は、この「最初の開き括弧から、最後の閉じ括弧まで」という発想を正規表現ではなく str.find() / str.rfind() で実装している。
def _try_slice(open_ch, close_ch):
first = stdout.find(open_ch)
if first == -1:
return None, False
last = stdout.rfind(close_ch) # ← 最後の出現位置(貪欲な発想と同じ結果)
if last == -1 or last <= first:
return None, False
candidate = stdout[first:last + 1]
try:
return json.loads(candidate), True
except (json.JSONDecodeError, ValueError):
return None, False
貪欲な正規表現 \{.*\} を使わずに find / rfind で実装している理由は、長い出力に対して .* のバックトラックが起きると、開き括弧・閉じ括弧が多数入り乱れた最悪ケースで処理時間が急激に伸びる可能性があるためだ(正規表現エンジンが候補を総当たりで確認し直す挙動に起因する)。find / rfind は文字列を1回ずつ走査するだけなので、この種の速度劣化が原理的に起きない。「貪欲マッチと同じ結果を、正規表現より軽い手段で得る」という設計判断になっている。
実例2: 数量詞は greedy/non-greedy 以外の使い分けもある
core/db_backup_diagnostics.py では、DBバックアップの進捗をSSH越しに監視するために、リモートシェルが吐く進捗行を正規表現でパースしている。
PROGRESS_LINE_RE = re.compile(
r'^__WPMM_PROGRESS__\s+elapsed=(\d+)s\s+size=\s*(\d+)B\s*$'
)
ここで使われている \s*(0文字以上の空白)は greedy/non-greedy の対立ではなく、「空白があってもなくても許容する」という別の実務的な理由で選ばれている。size= の直後に空白が入るかどうかは、リモートサーバーのOSによって変わる。wc -c < file の出力はLinuxでは詰めて出るが、FreeBSD系(さくらインターネット等)では数値を右詰めにするため size= 6747025B のように前方に空白が入ることがある。size=(\d+) という空白を想定しない書き方だと、FreeBSD環境では1件もマッチせず進捗ログが空になる不具合につながる。\s* を挟むことで、空白の有無というサーバー差異を1本のパターンで吸収している。
実例3: 非貪欲にしても結果が変わらないケースもある
core/site_paths.py には、スクリーンショットのファイル名からサイト名部分とタイムスタンプ部分を分離する正規表現がある。
after_re = re.compile(r'^(.+?)_(\d{8}_\d{6})\.(jpg|png)$')
ここでは (.+?) と非貪欲にしているが、実際に検証すると、この特定のパターンでは貪欲な (.+) に変えても結果は変わらない。
import re
name = "Site_20260101_20260615_093000.jpg"
re.match(r'^(.+?)_(\d{8}_\d{6})\.(jpg|png)$', name).groups()
# => ('Site_20260101', '20260615_093000', 'jpg')
re.match(r'^(.+)_(\d{8}_\d{6})\.(jpg|png)$', name).groups()
# => ('Site_20260101', '20260615_093000', 'jpg') ← 同じ結果
理由は、パターンの末尾が _(\d{8}_\d{6})\.(jpg|png)$ という「8桁数字・アンダースコア・6桁数字・拡張子・文字列終端」という固定長かつ厳密な形になっており、この条件を満たす位置が文字列中にただ1箇所しか存在しないためだ。貪欲・非貪欲のどちらで探索しても、バックトラックの末に同じ唯一の分割点にたどり着く。
この例が示すのは、「非貪欲にしておけば安全」という思い込みだけで ? を付けるのは必ずしも正しい判断根拠にならない、という点だ。パターンの残りの部分が入力中の位置を一意に固定できるほど厳密であれば、greedy/non-greedy の選択は結果に影響しない。逆に実例1のように、パターンの残りが「何かの } があればそこで満たせてしまう」ような緩いものであれば、greedy/non-greedy の選択がそのまま結果の正しさを左右する。
まとめ
貪欲な数量詞は「まず全部食べてから手放す」、非貪欲な数量詞は「まず何も食べずに広げていく」という探索の向きの違いであり、この違いがマッチ結果に影響するかどうかは、パターンの残りの部分がどれだけ厳密に位置を固定しているかに依存する。ネストした構造を含む可能性がある文字列(JSONなど)を正規表現で切り出す場合は、非貪欲な .*? が「最初に閉じたところ」で止まってしまう罠に注意し、必要なら貪欲マッチや find/rfind のような別の手段を検討するとよい。一方で、末尾が厳密に固定されたパターンでは、greedy/non-greedy の選択自体が実質的な意味を持たないこともある。どちらを選ぶ前に、まず「このパターンの残りは、入力中の位置を一意に決められるほど厳密か」を確認する習慣が、正規表現の挙動を正しく予測する近道になる。