- Python で高度な文字列操作を行いたい!
このような方に向けて、Python の正規表現テクニックをお伝えします。
正規表現は、文字列データから特定のパターンを検索するために使われるツールです。
応用範囲は広く、以下のような多岐にわたる作業を一瞬で片付けることができます。
- 抽出
- 検索
- 整形・置き換え
正規表現をマスターすれば、手作業では絶対にこなせない処理を一瞬で片付けることができます。「プログラミングをやっていてよかった!」と思えるはずです。
正規表現の基本
具体的なメソッドなどをご紹介する前に、正規表現に関する基礎知識をお伝えします。
プログラムを組む際に必要な最低限の情報に留めていますので、できれば目を通していただければと思います。
正規表現ってなに?
正規表現(Regular Expression)は、文字列の規則性を表現する方法です。
これにより、元となる文字列データからパターンに一致する文字列部分を見つけたり、置き換えしたりといったことがプログラムを通してできるようになります。
メタ文字を理解しよう
正規表現では「文字列の規則性」を表現するため、特殊な意味を持つ記号が用意されています。
メタ文字 | 意味 |
---|---|
. | 任意の一文字 |
* | 直前の文字の0回以上の繰り返し |
+ | 直前の文字の1回以上の繰り返し |
? | 直前の文字の0回または1回出現 |
^ | 文字列の始まり |
$ | 文字列の終わり |
[] | かっこ内の任意の単一文字 |
| | 論理和、いずれかのパターン |
() | かっこ内をグループとする |
これだけでは、どのように使うかのイメージがつきにくいですよね。
具体的な使い方は、この後に丁寧にお伝えしていきます。
re ライブラリのメソッドを解説!
Python で正規表現を扱うためには re ライブラリを使います。
ここではよく使うメソッドを中心に解説します。
- re.search(pattern, string)
- re.match(pattern, string)
- re.findall(pattern, string)
- re.sub(pattern, repl, string)
順番に解説していきます。
re.search(pattern, string)
対象となる文字列から、パターンに一致する最初の箇所を見つけます。
一致するパターンがあった場合には Match オブジェクトが、なかった場合には None が返ってきます。
import re
# パターンと検索対象の文字列を定義
pattern = "python"
text = "I love python code!"
# パターンを検索
match = re.search(pattern, text)
# 結果の表示
if match:
print("一致が見つかりました:", match.group())
else:
print("一致が見つかりませんでした。")
re.match(pattern, string)
文字列の先頭が、指定したパターンに一致するかを検証します。
一致する場合には Match オブジェクトを返し、一致しない場合には None を返します。
import re
# パターンと検索対象の文字列を定義
pattern = "Python"
text = "Python, my love, is code!"
# パターンを検索(re.matchを使用)
match = re.match(pattern, text)
# 結果の表示
if match:
print("一致が見つかりました:", match.group())
else:
print("一致が見つかりませんでした。")
re.match()
関数を使うことにより、直感的に文字列の先頭を判定していることがわかりますのでコードの可読性は向上します。
ただ、汎用性という意味においてはre.search()メソッドを使う方が良いかもしれません。正規表現では文字列の先頭を示す^
(キャレット)が用意されているので、以下のコードとすることでre.match()と同様の結果を得ることができるからです。
import re
# パターンと検索対象の文字列を定義
pattern = "^Python"
text = "Python, my love, is code!"
# パターンを検索(re.matchを使用)
match = re.search(pattern, text)
# 結果の表示
if match:
print("一致が見つかりました:", match.group())
else:
print("一致が見つかりませんでした。")
この辺りは好みの問題にもなってくるので、個々のプロジェクトに合わせて使い分けてみてください。
re.findall(pattern, string)
文字列内のパターンに一致する全ての文字列を探し出し、リストとして返します。
以下は文字列内に存在する電子メールアドレスのリストを取得するためのコードです。
import re
# 電子メールアドレスのパターンと検索対象の文字列を定義
pattern = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b'
text = "お問い合わせはsupport@example.comに、またはadmin@example.orgにご連絡ください。"
# パターンに一致する全ての部分文字列を見つける
emails = re.findall(pattern, text)
# 結果を表示する
print("見つかったメールアドレス:", emails)
実務的には余分なスペース・改行・タブなどを削除してからre.findall()
メソッドを実行することが多いです。
その場合は、以下のようにsplit()
メソッドを実行する形になります。
# 検索対象の文字列
text = "お問い合わせはsupport@example.comに、またはadmin@example.orgにご連絡ください。"
# スペース、タブ、改行などを削除
text.split()
これにより「余計な文字列が紛れていることによりパターンを見つけられない」という事態を避けることができます。
re.sub(pattern, repl, string)
パターンに一致する文字列を、別の文字列に置き換える関数です。
Pyhton の組み込み関数に replace がありますが、re.sub()
はパターンを使うことでより柔軟に置き換え対象を探しに行くイメージになります。
import re
# 検索するパターン
pattern = r"(\d{4})-(\d{2})-(\d{2})"
# 検索対象の文字列
text = "今日の日付は2023-04-20です。明日は2023-04-21です。"
# 置き換え後のパターン: DD/MM/YYYY 形式
repl = r"\3/\2/\1"
# 置換を実行
replaced_text = re.sub(pattern, repl, text)
print("置換前のテキスト:", text)
print("置換後のテキスト:", replaced_text)
上記のコードで行っている作業の流れは以下の通りです。
- 「YYYY-MM-DD」形式のパターンを探す
- 「DD/MM/YYYY」形式に置き換える
組み込み関数のreplace()
ではここまで繊細な操作はできません。
re.sub()
メソッド、とても便利です。
戻り値の Match の使い方
re ライブラリの関数を使うと、戻り値として Match オブジェクトが返ってきます。
ここではよく使う Match オブジェクトのメソッドを解説します。
- group()
- groups()
- groupdict()
- start(), end()
- span()
順番に解説していきます。
group()
パターンに一致した部分を取得するメソッドで、引数に数字を渡すと色々な抽出ができます。
具体例をご覧いただいた方が直感的に理解いただけると思うので、コード例を以下に示します。
import re
# テキストとパターンを定義
text = "今日の日付は2023-04-20です。"
pattern = r'(\d{4})-(\d{2})-(\d{2})'
# パターンに一致する部分を検索
match = re.search(pattern, text)
if match:
# 全体の一致を表示
print("全体の一致:", match.group(0))
# キャプチャグループによる一致を表示
print("年:", match.group(1))
print("月:", match.group(2))
print("日:", match.group(3))
Match オブジェクトの出力結果は、以下のようになります。
コード | 出力結果 |
---|---|
match.group(0) | 2023-04-20 |
match.group(1) | 2023 |
match.group(2) | 04 |
match.group(3) | 20 |
groups()
パターンに一致したグループを要素とするタプルを返します。
コード例としては以下となります。
import re
# テキストとパターンを定義
text = "今日の日付は2023-04-20です。"
pattern = r'(\d{4})-(\d{2})-(\d{2})'
# パターンに一致する部分を検索
match = re.search(pattern, text)
if match:
# groups()メソッドを使用してすべてのキャプチャグループを取得
year, month, day = match.groups()
print(f"年: {year}, 月: {month}, 日: {day}")
正規表現として(\d{4})-(\d{2})-(\d{2})
を指定しているので、左から順番にタプルの要素として追加されていくイメージです。
得られるる Match オブジェクトの内容を表にまとめると以下の通り。
コード | 出力結果 |
---|---|
match.groups()[0] | 2023 |
match.groups()[1] | 04 |
match.groups()[2] | 20 |
得られる Match オブジェクトはタプルとして得られるので、先ほどのコード例のようにアンパッキングでスマートに記述できるのが大きなメリットになります。
groupdict()
これまで見てきた方法では、グループの位置を番号で指定して読み出す必要がありました。
ところが今回のgroupdict()
メソッドを使うことでインデックス番号の代わりに辞書のキーを指定することができます。
つまり、Match オブジェクトから情報取得するのに「あらかじめつけたラベル(辞書のキー)」で操作できることになります。
具体的なコード例を以下の示します。
import re
# テキストとパターンを定義
text = "今日の日付は2023-04-20です。"
pattern = r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})"
# パターンに一致する部分を検索
match = re.search(pattern, text)
if match:
# groupdict()メソッドを使用して名前付きキャプチャグループの情報を取得
date_info = match.groupdict()
print(f"年: {date_info['year']}, 月: {date_info['month']}, 日: {date_info['day']}")
コード例で示した通り、パターンの前に?P<key>
を追加するだけでOKです。
(?P<key>pattern)
start(), end()
start()
とend()
は検索対象の文字列の中で、マッチした文字列の「最初の文字のインデックス番号」と「最後の文字のインデックス番号」を取得できます。
以下は “example” という文字列を検索対象として、”amp” というパターンを探した例です。
import re
# テキストとパターンを定義
text = "example"
pattern = "amp"
# パターンに一致する部分を検索
match = re.search(pattern, text)
if match:
# 一致の開始位置と終了位置
print("一致の開始位置:", match.start())
print("一致の終了位置:", match.end())
else:
print("一致が見つかりませんでした。")
print() 関数の結果は、以下となります。
- 一致の開始位置: 2
- 一致の終了位置: 5
最初の文字のインデックス番号を0として、何番目に対象の文字があるかを判断します。
今回の例では “amp” という文字の塊を検索していますから、以下のように文字の順番を取得してきています。
メソッド | 対象文字 | インデックス番号 |
---|---|---|
match.start() | a | 2 |
match.end() | p | 5 |
ここで得られた番号は、例えば特定の単語の前後にある文脈を取得する際などに使えます。
import re
text = "This is an example. This example is interesting."
pattern = "example"
# パターンに一致する部分を検索
for match in re.finditer(pattern, text):
# 一致の前後10文字を取得して表示
# 開始位置が負の値にならないようにする
start = max(match.start() - 10, 0)
# 終了位置の後の10文字を取得
end = match.end() + 10
context = text[start:end]
print(f"一致のコンテキスト: '{context}'")
テキストデータの処理や分析など、柔軟に行う際に有効です。
span()
start()
とend()
を同時に取得したい場合は、両方とも一度に取得できるspan()
メソッドを使うのが便利です。
import re
# テキストとパターンを定義
text = "example"
pattern = "amp"
# パターンに一致する部分を検索
match = re.search(pattern, text)
if match:
# span()を使用して一致の開始位置と終了位置を取得
start, end = match.span()
print("一致の開始位置:", start)
print("一致の終了位置:", end)
else:
print("一致が見つかりませんでした。")
match.span()
で得られるタプルをアンパッキングして、start, end で受け取っている形です。
正規表現のパフォーマンスを上げる方法
正規表現を使う場合には、検索対象の文字列が膨大になる場合があります。
そうなると、できるだけパフォーマンスを落とさないようなコーディングを行うことが重要。
ここからは Python の re ライブラリのパフォーマンスアップに役立つヒントをお伝えしていきます。
正規表現パターンをコンパイルする
同じパターンを何度も使う場合、パターンをコンパイルすることでパフォーマンスアップが期待できます。
コンパイルにはre.compile()
メソッドを使います。
import re
pattern = re.compile(r"\bexample\b")
matches = pattern.findall("This is an example.")
つまり「正規表現パターンからre
オブジェクトに変換する作業(つまりコンパイル)」を一度で済ますことにより、同じことを繰り返すことを避けることができるのでパフォーマンスアップが期待できるということになります。
キャプチャグループの不要な使用を避ける
キャプチャグループとは、()の中を一つのグループとして後から各グループを参照したりできるようにするものです。
もしも「パターンにマッチしているかだけが知りたい」場合で、ヒットした具体的な文字列は関係ない場合、具体的な文字列を記憶しておく分だけリソースを使ってしまうことになります。
そのような場合に便利なのが、(?:)
という記法です。
# キャプチャグループを使用
pattern = re.compile(r"(example)")
# 非キャプチャグループを使用
pattern = re.compile(r"(?:example)")
一つ目のパターンではexampleという文字列を記憶しますが、二つ目のパターンではマッチしているかどうかのみを記憶するのでexampleという具体的な文字列は記憶しません。
パターンオブジェクトの内容を最低限に抑えることによって、多少のパフォーマンスアップが望めるということになります。
最小一致量子子の利用
正規表現では、できるだけ多くの文字列にヒットさせる「最大一致子子(Greedy Quantifiers)」と、できるだけ少ない文字列にヒットさせようとする「最小一致量子子(Non-Greedy or Lazy Quantifiers)」の二つがあります。
メタ文字で言うと、以下のような分類です。
量子子 | メタ文字の例 |
---|---|
最大一致子子 | * や+ など |
最小一致量子子 | *? や+? など |
# 最大一致
pattern = re.compile(r"<.*>")
# 最小一致
pattern = re.compile(r"<.*?>")
最大一致量子子を使ってしまうと、パターンを見つける際に大量の不要なマッチング検索を行うことになってしまいます。
極力不要なマッチング検索を行わずにパフォーマンスを上げるため、最小一致量子子が推奨されます。
文字列の先頭または末尾にアンカーを使用する
正規表現が文字列の先頭や末尾からの一致を調べるときには、^
や$
を使用しましょう。
これにより、検索範囲が限定されて不要な検索を省略できます。
具体的な文字列を指定する
.
を使うと「任意の1文字に一致」という意味になり便利です。
ところが、これでは検索の際に選択肢が多すぎて検索すべきパターンが大量に存在することになります。
対策として、[a-zA-Z]
などの「できる限り具体的な文字セット」を使用することで、検索範囲が限定されパフォーマンスが向上します。
一歩進んだ正規表現テクニック
ここまでで Python を使った正規表現テクニックは十分なレベルに達していると思いますが、ここからは「さらに一歩進んだアドバンス的な内容」をご紹介します。
正規表現をテストするためのツール
目視では煩雑になってしまうので、積極的にツールを使いましょう。
- Regex101:
- ウェブベースの正規表現テスターで、リアルタイムでマッチング結果を確認できます。
- Pythonの他、JavaScript、PHP、Goの正規表現もサポートしています。
- 正規表現の各部分についての詳細な説明が表示され、学習ツールとしても優れています。
- URL: https://regex101.com/
- RegExr:
- ウェブベースの別の強力な正規表現テストツールです。
- 正規表現のパターンにマッチするテキストのハイライト表示、正規表現の部分ごとの説明などの機能を提供します。
- URL: https://regexr.com/
- Pythex:
- Pythonの正規表現に特化したシンプルなオンラインテスターです。
- リアルタイムでのマッチング結果表示機能を提供し、簡単に正規表現を試すことができます。
- URL: https://pythex.org/
パターンマッチングの例
パターンマッチングさせるための正規表現の例を二つほど挙げます。
- 携帯電話番号
- メールアドレス
現在では ChatGPT などに条件を教えれば正規表現を組んでくれますので、生成系 AI もフルに活用して対応してみてください。
携帯電話番号
正規表現としては、以下になります。
mobile_phone_no = r"0[789]0-\d{4}-\d{4}"
以下に要素を分解して解説します。
正規表現 | 内容 |
---|---|
0 | 数字の “0” から始まる |
[789] | キャリアコードの2桁目が “7, 8, 9” である |
0 | キャリアコードの3桁目が “0” である |
-\d{4} | ハイフンに続いて4桁の数字がある |
-\d{4} | 再びハイフンに続いて4桁の数字がある |
メールアドレス
基本的な正規表現を示すと、以下のとおりです。
mail_address = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
要素分解すると、以下のようになります。
正規表現 | 内容 |
---|---|
[a-zA-Z0-9._%+-]+ | 英数字、ドット、アンダースコア、パーセント、プラス、マイナスが1文字以上続く |
@ | @ 記号に一致 |
[a-zA-Z0-9.-]+ | ドメイン名で使用可能な文字列に一致 |
. | . 記号に一致 |
[a-zA-Z]{2,} | TLD で許可される文字列が少なくとも2文字以上続く |
フラグの使用
re ライブラリでは正規表現の動作を制御する「フラグ」と言うものが用意されています。
フラグを利用することで、以下のようなことが可能です。
- 大文字・小文字の無視
- 複数行モードの有効化
- ドットが改行にもマッチするようにする
一つずつ具体的にみていきましょう。
大文字・小文字の無視
大文字・小文字を無視してマッチングを行うにはre.IGNORECASE
を使います。
import re
text = "This is a Sample text."
pattern = r"sample"
# 大文字小文字を区別せずに検索
match = re.search(pattern, text, re.IGNORECASE)
if match:
print("一致が見つかりました:", match.group())
上記のコードでは “Sample” と頭文字が大文字になっていますが、re.search()
メソッドの第三匹すうにre.IGNORECASE
としていることからマッチしているとみなされます。
複数行モードの有効化
re.MULTILINE
を使うことで、^
と$
が、文字列の開始や終了だけでなく、各行の開始や終了にもマッチするようになります。
import re
text = "This is a Sample text."
pattern = r"sample"
# 複数行モードを有効にして、各行の開始でマッチングを試みる
multi_line_text = "start\nstart another"
matches = re.findall(r"^start", multi_line_text, re.MULTILINE)
print("マッチした行の数:", len(matches))
上記では改行コードを挟んで1行目も2行目も “start” で始まっているので、len(matches)
は2
となります。
ドットが改行にもマッチするようにする
re.DOTALL
を使うと、メタ文字.
が改行コードにもマッチするようになります。
デフォルトでは、.
は改行文字を除く任意の単一文字にヒットします。これを拡張するようなフラグになります。
import re
text = "Hello\nWorld"
pattern = r".+"
# re.DOTALL フラグあり
matches = re.findall(pattern, text, re.DOTALL)
print("re.DOTALL フラグあり:", matches)
上記でre.DOTALLをつけない場合には “Hello” にしかマッチしません。
久々に正規表現を使うとハマるポイントなので、うまくいかない場合にはこのre.DOTALL
を思い出しましょう。
正規表現をデバッグするヒント
以下の事項に気をつけるとよいでしょう。
- 最初から複雑なものを作ろうとしない
- 段階的に組んでいくことで、どの部分が期待と違うかわかる
- 部分的にテスト
- まずはセクションに分けて個別にテスト
- 最後に全体を組み合わせる
- 特殊文字のエスケープを確認
- 特殊文字(
.
、*
、?
など)を意識する - リテラルとして使うなら、エスケープされているか確認
- 特殊文字(
- フラグを意識する
- 本記事内のこちらの箇所を確認しましょう
先読みと後読み
あるパターンの前後に「特定の文字列」があるかを確認する方法です。
細分すると、以下の4パターンに分けられます。
種類 | 記号 | 役割 |
---|---|---|
肯定的先読み | (?=パターン) | 直後にパターンが存在する位置にマッチ |
否定的先読み | (?!パターン) | 直後にパターンが存在しない位置にマッチ |
肯定的後読み | (?<=パターン) | 直前にパターンが存在する位置にマッチ |
否定的後読み | (?<!パターン) | 直前にパターンが存在しない位置にマッチ |
サンプルコードを交えて、より突っ込んで解説します。
肯定的先読み
“John” の直後に “Doe” が続いた場合のみ、”John” にマッチします。
import re
text = "John Doe will attend, but Jane Doe will not."
pattern = r"John(?= Doe)"
matches = re.findall(pattern, text)
print(matches) # ["John"]
“Jane” にはマッチしません。
否定的先読み
以下の場合は、”Jane” の後に “Doe” が続かない場合のみ、”Jane” にマッチします。
import re
text = "John Doe will attend, but Jane Doe will not."
pattern = r"Jane(?! Doe)"
matches = re.findall(pattern, text)
print(matches) # []
ところが “Jane” のあとは “Doe” しかないので、ヒットしません。
肯定後読み
“John” の後に “Doe” がくると、”Doe” にマッチします。
import re
text = "John Doe and Jane Doe"
pattern = r"(?<=John )Doe"
matches = re.findall(pattern, text)
print(matches) # ['Doe']
結果は “Doe” となります。
否定的後読み
“John” の後に “Doe” がこない場合に、”Doe” にマッチします。
import re
text = "John Doe and Jane Doe"
pattern = r"(?<!John )Doe"
matches = re.findall(pattern, text)
print(matches) # ['Doe']
上記では “Jane Doe” の “Doe” がマッチします。
ReDoS 攻撃などのセキュリティ対策をする
複雑な正規表現を利用する場合には「ReDoS(正規表現拒否サービス)攻撃」に気をつける必要があります。
ReDoS 攻撃とは、攻撃者が悪意のある文字列を送信して正規表現エンジンを過剰に消費させ、サービスの遅延や停止させるものです。
これを防ぐための対策をいくつかご紹介します。
- 複雑な正規表現は使わない
- 多重量子子を避ける ex) .*
- 量子子のネストを避ける ex) (a+)+
- タイムアウトの設定
- 正規表現エンジンの長時間稼働を防ぐ
- 処理時間の上限を設定する
- 入力のサニタイズ
- 正規表現エンジンで値を処理する前に検証する
- パフォーマンステスト
- 特定の入力に対する処理時間を確認しておく
Web アプリケーションを作成する際などには、上記に気をつけてみてください。
まとめ
最後に公式ドキュメントをはじめ、理解の役にたつページをご紹介します。
本ブログでは他にも Python に関する情報を発信しています。
ぜひ、別記事もご覧になってみてください。
コメント