正規表現は他の言語と共通しているところも多く、一度覚えると汎用性が高い便利な表現です。
このページでは、正規表現の根幹をなす特殊文字について確認し、Pythonのライブラリ「re」の簡単な使い方、そして先読み・後読みアサーションについてまとめます。
参考ページ
正規表現
正規表現を使うと、文字列の検索や抽出などを柔軟に行うことができます。
例えば「彼は靴を履いていた。鮮やかな赤色だった。」という文章内に色が含まれているか確かめたいなら「赤色」を文字列のfindメソッドに渡せば良いですが、赤以外だった場合、findでは対応するのが難しくなります。
すべての色彩を網羅するわけにはいきませんし、「色」だけの検索では、具体的な色名が含まれているのかどうかもわかりません。
正規表現では特殊文字というものが使えます。
何にでもマッチする文字「.」を使うと、上記の問題にも対応できます。
import re
s = "彼は靴を履いていた。鮮やかな黄色だった。"
result = re.search(".色", s)
if result:
print(result.group())
# 出力は「黃色」
特殊文字には次のようなものがあります。
特殊文字 | 役割 | パターン例 | 左とマッチする文字列の例 |
---|---|---|---|
. | 改行以外の文字にマッチ(デフォルトモード) | . | a, b, c, 赤, 海 |
* | 直前の正規表現を0回以上繰り返しにマッチ | Go*gle | Ggle, Google, Goooogle |
+ | 直前の正規表現の1回以上の繰り返し | Go+gle | Gogle, Google, Gooogle |
? | 直前の正規表現がない、もしくは1回は存在する | Go?gle | Ggle, Gogle |
{n} | 直前の正規表現をn回くり返したものにマッチ | GRe{4}N | GReeeeN |
{n, m} | 直前の正規表現をn回以上、m回以下のくり返し | GRe{3,4}N | GReeeN, GReeeeN |
{n,} {,m} | 直前の正規表現のn回以上のくり返し m回以下のくり返し | GRe{5,}N GRe{,4}N | GReeeeeN, GReeeeeeeN GReeeeN, GRN |
[] | カッコ内の一文字のどれかとマッチ ハイフンを使うとその範囲内 カッコ内の特殊文字をエスケープしてマッチ | [赤青黄] [a-z] [A-Z] [0-9] [+?] | 赤, 青, 黄 aからz AからZ 0から9 +, ? |
() | カッコ内でグループ化 | (トン)+ | トン, トントン, トントントン |
| | 左右を「または」でつなぐ | Coffee|Tea |Milk | Coffee, Tea, Milk |
^ | 文字列の先頭を意味する []内では[]の中身を否定する | [^a-z] | aからz以外の文字 |
$ | 文字列の末尾を意味する | ||
\ | 特殊文字をエスケープする | \. | . |
パターンが「.*。」や「.+。」、対象の文字列が「吾輩は猫である。名前はまだ無い。」とすると、マッチするのは文字列末尾の。まで含めた「吾輩は猫である。名前はまだ無い。」となります。
これは、より多くマッチする仕様だからです。
もし、より少ないところでマッチさせたいなら、パターンを「.*?。」や「.+?。」のように「?」を加えると、「吾輩は猫である。」がマッチします。
バックスラッシュを使った特殊文字もあります。
特殊文字 | 役割 | マッチする文字例 | 補足 |
---|---|---|---|
\b | 単語の境界 | \bin\bで単語「in」とマッチ | 「inside」や「contain」とはマッチしない。 |
\B | 単語の境界ではない | in\Bで「inside」、\Binで「contain」などとマッチ | |
\d | 10進数にマッチ | 0から9の数字 | Pythonでは全角数字にもマッチ |
\D | 10進数でない文字とマッチ | ||
\s | 空白文字とマッチ | スペース、改行、タブ文字等 | |
\S | 空白文字でないものとマッチ | ||
\w | 英数字とアンダースコアにマッチ | [a-zA-Z0-9_]と同じ | |
\W | 英数字とアンダースコア以外 |
Pythonでは「\n」が改行を意味するように「\b」はバックスペースを意味するため、パターンとして渡すときには「\\b」のようにエスケープする必要があります。
またその他のバックスラッシュを使った正規表現の特殊文字に関しても、エスケープしないとLinterからの警告が出るかもしれません。
パターンにバックスラッシュをふくめるときにはraw文字列を使うとスッキリ書けます。
# pattern = "\\bin\\b" pattern = r"\bin\b"
上のような場合には「\b」は正規表現の単語の境界だと解釈されます。
またraw文字列の「\n」は改行ではなく、「\」と「n」の2文字です。
正規表現のライブラリ
関数とメソッド
- Pythonで正規表現を使用するには、標準ライブラリの「re」をインポート。
- 正規表現のパターンと対象の文字列を、使いたい関数に渡します。
ここから主要な関数を挙げます。
re.match(pattern, string)
パターンと対象文字列の※先頭から照合。
合致していたらマッチオブジェクト、していなければNoneが戻ってくる。
※文字列の先頭だけでなく途中にある文字列も照合したければ↓のsearchを使う。
$ python >>> import re >>> re.match("ab", "abcd") <re.Match object; span=(0, 2), match='ab'> >>> re.match("ab", "aabb") >>> # None
re.search(pattern, string)
パターンと対象の文字列に合致するところがあればマッチオブジェクト、なければNone。
>>> re.search("ab", "aabb") <re.Match object; span=(1, 3), match='ab'>
>>> result = re.search("(a)(b)(c)", "abc") >>> result <re.Match object; span=(0, 3), match='abc'> >>> result.span() (0, 3) >>> # group()やgroup(0)でマッチした文字列全体 >>> result.group() 'abc' >>> # パターンを()でグループ化したものの取り出し >>> result.group(1) 'a' >>> result.group(2) 'b' >>> result.group(3) 'c' >>> dir(result) ['__class__', '__class_getitem__', '__copy__', '__deepcopy__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'end', 'endpos', 'expand', 'group', 'groupdict', 'groups', 'lastgroup', 'lastindex', 'pos', 're', 'regs', 'span', 'start', 'string']
re.findall(pattern, string)
対象の文字列内にあるパターンをリストとして抽出。
re.finditer(pattern, string)
対象の文字列内にあるパターンをイテレータにして返す。
>>> s = "12 323 32f fhdd 852" >>> re.findall(r"\d+", s) ['12', '323', '32', '852'] >>> iter = re.finditer("[a-z]+", s) >>> iter <callable_iterator object at 0x7f0714b8c8e0> >>> for i in iter: ... print(i) ... <re.Match object; span=(9, 10), match='f'> <re.Match object; span=(11, 15), match='fhdd'>
re.split(pattern, string, maxsplit=0)
パターンで文字列を分割したリストを返す。
>>> re.split("[*/+-]", "5+4*6/7") ['5', '4', '6', '7']
re.sub(pattern, replacement, string, count=0)
文字列内でパターンに合致したものをreplacementで置き換えたものを返す。
なければ元の文字列が戻ってくる。
>>> re.sub(r"\d", "X", "2023/07/25") 'XXXX/XX/XX' >>> re.sub(r"\d", "X", "2023/07/25", count=4) 'XXXX/07/25'
ひとつのパターンを複数の文字列に対して使うなら、正規表現オブジェクトを作成する方が良いかもしれません。
re.compile(pattern)
正規表現オブジェクトを返す。
オブジェクトにはパターンがキャッシュされるので、メソッド(関数と同名のmatchやsearchなど)に対象の文字列を渡してマッチングを行います。
>>> regex = re.compile(r"\w{5,}@gmail.com") >>> regex.match("noname@gmail.com") <re.Match object; span=(0, 16), match='noname@gmail.com'> >>> regex.match("name@gmail.com") >>> regex.findall("ab@yahoo.co.jp cdefg@gmail.com hijklm@gmail.jp") ['cdefg@gmail.com']
メソッドの挙動は関数のものとほとんど同じですが、マッチを開始・終了する位置(インデックス)を指定できるものがあります。
モード
デフォルトでは、「^」や「$」は文字列全体の先頭と末尾を意味していて、改行ごとの先頭と末尾ではマッチしません。
一行ごとの先頭と末尾でマッチするようにするには、関数やメソッドにフラグ「re.M」もしくは「re.MULTILINE」を渡します。
import re
s = """
1 2
3 4
5 6
"""
odd = re.findall(r"^\d", s, flags=re.MULTILINE)
even = re.findall(r"\d$", s, re.M)
print(odd)
# ['1', '3', '5']
print(even)
# ['2', '4', '6']
また特殊文字「.」が改行を含めたすべての文字とマッチさせるには、フラグ「re.S」もしくは「re.DOTALL」を渡します。
その他のフラグに関しては以下を参照してください。
Flags - Python3 ドキュメント
アサーション
ここからは、理解するのに苦労したアサーションについて。
アサーションはあるパターンの開始位置を示したものです。
特殊文字のところで紹介した「^」や「$」、「\b」などもアサーションの一種です。
「^」は文字列の先頭から、「$」が末尾を表しているように、パターンの開始位置を示しています。
そして、他のパターンに追加の条件を与えるものです。
「^」だけでは意味がなく、「^090」のようにすることで、「先頭から090と並ぶ文字列」と条件を追加しています。
これから説明する先読み・後読みアサーションは、より便利に他のパターンに追加のパターンを与えることができます。
先読みアサーション
先読みアサーションには肯定・否定の2種類あります。
肯定先読み
肯定先読みがどんなものかと言うと、パターンを先読みし、パターンに合うならマッチを続け、合わなかったらマッチをキャンセルします。
これだけではよくわからないと思うので、もう少し具体的な例を挙げます。
肯定先読みの文法
(?=パターン)
パターンが「\w+(?=\.\w{3})(?=\.zip)」と「target.zip」とのマッチングを考えてみます。
パターンとしては冗長ですが、先読みを2つ付けています。
まずパターンの「\w+」と文字列の「target」は一致するため、ひとまずそこまでマッチを進めます。
次のパターンは「(?=\.\w{3})」であるため、「.zip」を先読みします。
ドットに続いて3文字のアルファベットで一致しました。
今度は「(?=\.zip)」に進みます。
ここでパターンが試行される文字列の位置は、再び「.zip」からになります。あくまで先読みしていただけで、文字列は消費されません(次の項目の否定先読みに関しても同様です)。
パターンはキャンセルされなかったため、最終的にマッチしたのは「target」になります。
もしどちらかの先読みと一致しなかったのなら、マッチした文字列は存在せず「None」です。
import re
s = "target.zip"
result = re.search(r"\w+(?=\.\w{3})(?=\.zip)", s)
if result:
print(result.group())
# target
今回のパターンマッチを文章で表すと「英数字がひとつ以上続き、かつその先の文字列がドットに続く3文字の英数字である、かつそれは.zipである、ならば、最初の『英数字がひとつ以上ある』にマッチしたものが最終的な結果」となります。
イメージとしては、ifとandの組み合わせみたいなものだと思います。
ちなみに(?=)のところはキャプチャされないため、result.group(1)をやったとしても「IndexError: no such group」が戻ってくるでしょう。
否定先読み
否定先読みは肯定のときと逆で、パターンを先読みし、パターンでなかったらマッチを続け、パターンだったときにはマッチをキャンセルします。
否定先読みの文法
(?!パターン)
今度のパターンは「\d\d/\d\d(?![(]Tue[)])」、文字列は「07/24(Mon) 07/25(Tue) 07/26(Web)」とのマッチです。
肯定の時と同じように、月/日のパターンまでひとまずマッチを進め、その先の文字列が(Tue)でなければ、結果の文字列として確定、(Tue)だった場合は、マッチをキャンセルします。
import re
s = "07/24(Mon) 07/25(Tue) 07/26(Web)"
result = re.findall(r"\d\d/\d\d(?![(]Tue[)])", s)
print(result)
# ['07/24', '07/26']
マッチするすべての文字列は火曜日を除いた「07/24, 07/26」になります。
後読みアサーション
肯定後読み
肯定後読みは、現在位置から後ろに戻ってパターンを確認し、パターン通りならマッチを続け、パターンでないならマッチをキャンセルします。
肯定後読みの文法
(?<=パターン)
パターン「(?<=[A-C]\.)\S+」、文字列「日本の国花はなに? A.桜 B.向日葵 C.朝顔」では、「\S+」とマッチしたら、その文字列の後ろに戻り、[ABC].と続くか確認し、その通りであったらマッチを確定、そうでなかったらマッチをキャンセルします。
import re
s = "日本の国花はなに? A.桜 B.向日葵 C.朝顔"
result = re.findall(r"(?<=[A-C]\.)\S+", s)
print(result)
# ['桜', '向日葵', '朝顔']
findallを使って取り出されるのは、花の名前である['桜', '向日葵', '朝顔']です。
否定後読み
否定後読みは、現在位置から後ろに戻ってパターンを確認し、パターン通りでないならマッチを続け、パターンならマッチをキャンセルします。
否定後読みの文法
(?<!パターン)
パターン「(?<!-)[1-9]」、文字列「1 -3 5 -7 -9」とのマッチでは、1から9の数字だったらそこから後ろに戻って「-」が付いていないか確認し、付いていなかったらマッチを確定、付いていたらマッチをキャンセルします。
import re
s = "1 -3 5 -7 -9"
result = re.findall(r"(?<!-)[1-9]", s)
print(result)
# ['1', '5']
マッチした文字列のリストは['1', '5']となります。
否定読みの挙動について
\d+や\w+など不特定な数をともなうパターンと否定読みとの組み合わせが、イメージ通りではなかったので、ここにメモとして残しておきます。
次のコードは、肯定先読みと否定先読みの例です。
import re
s = "15% 20l 30g 50g"
positive = re.findall(r"\d+(?=g)", s)
print(positive)
# ['30', '50']
negative = re.findall(r"\d+(?!g)", s)
print(negative)
# ['15', '20', '3', '5']
数字に続く記号が「g」だったときと、「g」でないときの結果をそれぞれfindallで抽出しています。
「g」だったときの結果は、['30', '50']でこれは特に問題はありません。
しかし「g」ではなかったときの結果は、['15', '20', '3', '5']となっています。
期待していた結果は[15, 20]です。
なのに、30gのところは、3のところまでマッチが確定し、50gのときには5までマッチしています。
どうやら肯定と否定では挙動が違うようです。
これはPython独特の仕様なのかと思って、『JavaScript』で試してみると、
> let s = "15% 20l 30g 50g"; undefined > s.match(/\d+(?=g)/g); [ "30", "50" ] > s.match(/\d+(?!g)/g); [ "15", "20", "3", "5" ]
結果は変わりませんでした。
\d+ではなく\d\dに変えると、否定先読みも期待通りの結果になります。
import re
s = "15% 20l 30g 50g"
negative = re.findall(r"\d\d(?!g)", s)
print(negative)
# ['15', '20']
否定後読みでも同様です。
import re
s = "$15 20 30 $50"
negative1 = re.findall(r"(?<!\$)\d+", s)
print(negative1)
# ['5', '20', '30', '0']
negative2 = re.findall(r"(?<!\$)\d\d", s)
print(negative2)
# ['20', '30']