LoginSignup
52
55

More than 3 years have passed since last update.

Python正規表現モジュールreのドキュメントで「へぇ〜」と思った箇所まとめ

Last updated at Posted at 2021-01-30

re(regular expression)のドキュメントを読んで「へぇ〜」と思った箇所を記述しています。

例えば.....

  • "."は改行以外にマッチ、そして改行にも適用する方法
  • 各行に^や$を適用するにはMULTILINEモードを利用
  • match( ) は改行を乗り越えれない

などなど。そしてドキュメントを読んで、regexモジュールがreモジュールを上回る機能を保有すること(※1)も知りました。ただreモジュールでも十分なのでregexモジュールは扱いません(まずはreモジュールの知識必要ですし)。。

※1 サードパーティの regex モジュールは、標準ライブラリの re モジュールと互換な API を持ちながら、追加の機能とより徹底した Unicode サポートを提供します。

読んだドキュメントは、ver3.9です。

基礎 : 正規表現の参照リスト

正規表現は、文字列の集合を一つの文字列で表現する方法の一つ。そのためには以下の文字を組み合わせて用います(主要のみ抜粋)。

文字 説明 備考
. 改行以外の任意の文字 re.DOTALLで改行にもマッチ(後述)
^ 文字列の先頭 MULTILINEモードで複数行に適用(後述)
$ 文字列の末尾
or 文字列の末尾の改行の直前
MULTILINEモードで複数行に適用(後述)
* 0回以上の繰り返し ① できるだけ多く繰り返したものにマッチ(貪欲※2)
② "*?"にすれば少なく繰り返したものにマッチ(非貪欲※2)
+ 1回以上の繰り返し ①できるだけ多く繰り返したものにマッチ(貪欲)
② "+?"にすれば少なく繰り返したものにマッチ(非貪欲)
? 0回または1回 できるだけ多く繰り返したものにマッチ(貪欲)
{m} m回の繰り返し -
{m,n} m〜n回の繰り返し ① できるだけ多く繰り返したものにマッチ(貪欲)
② m省略→0が設定
③ nの省略→上限は無限
④ {m,n}?にすれば少なく繰り返したものにマッチ(非貪欲)
[] 集合 ① - で範囲指定。
② 集合内に存在する" . ""や"" * "などの特殊文字は効果を失う。→文字列として認識される
③ \wや\Sは効果継続(条件:ASCIIかLOCALEモード)
④ 補集合は先頭に^を付与(例:[^5]は5以外にマッチ)
縦線 和集合(または) 左から右へ順に走査し、マッチした時点で走査終了。(非貪欲)
() グループ化 -
\A 文字列の先頭 ^と同じだが、MULTILINEモードは未対応
\Z 文字列の末尾 $と同じ
\d 任意の数字 [0-9]と同じ
\D 任意の数字以外 [^0-9]と同じ
\s 任意の空白文字 [\t\n\r\f\v]と同じ
\S 任意の空白文字以外 [^\t\n\r\f\v]と同じ
\w 任意の英数字 [a-zA-Z0-9_]と同じ
\W 任意の英数字以外 [\a-zA-Z0-9_]と同じ

※2 貪欲と非貪欲について

正規表現には「*」があるが、標準の「*」は、できるだけ長い文字列にマッチしようとする。例えば、正規表現「a.*b」文字列「a bad dab」にマッチさせると、全体にマッチする。この特性を「貪欲」という。
これに対し、正規表現「a.*?b」の「.*?」は、できるだけ短い文字列にマッチする。たとえば文字列「a bad dab」に対して「a b」にだけマッチする。これを「非貪欲」という。

正規表現ではraw文字を活用する

正規表現は、文字列の集合を一つの文字列で表現する方法でした。ただ、バックスラッシュ(\)などの文字は、\ 単体では、文字列として認識されません。

一例として、"C:\Users\hogehoge\Desktop"を文字列として表現するには、特別な意味を持つ \ を文字列に直さなければいけません。これは\\で変換できるので、"C:\\Users\\hogehoge\\Desktop"で文字列として扱うことができました。

ただ、文字列として扱うにはもっと良い方法があります。それは文字列リテラルの前にrかRをつけるだけ。

# バックスラッシュを文字として記す場合\\とする。(長くて鬱陶しい)
path = 'C:\\Users\\hogehoge\\Desktop'

# rを置くとraw文字列として処理される。
path = r'C:\Users\hogehoge\Desktop'

正規表現は、文字列を扱うので基本的に r もしくは R をつけておくことが望ましい。このような記述法を文字列をraw文字列記法といい、正規表現パターンには基本的にraw文字列記法を使います。

本題 : 以下、ドキュメントを読んで「へぇ〜」と思ったこと

以下、ドキュメントを読んで「へぇ〜」と思ったことです。

今後活用したい手法(詳細は後述)

  • "."は改行以外にマッチ、そして改行にも適用する方法
  • 各行に^や$を適用するにはMULTILINEモードを利用
  • match( )は改行を乗り越えれない
  • re.compileを使った方が良い/良くないケース
  • findall( ) 以上の情報を得たいならfinditer( ) を利用
  • re.escape( ) でパターン中の特殊文字をエスケープ
  • 正規表現内にコメントを記述する方法
  • [ ]内の特殊文字は特殊な意味を失う
  • 先読み,後読みアサーション

知っておいて損は無さそう(詳細の記述無し)

  • re.subnはre.subと同じ操作だが、戻り値が多少異なる。
  • 繰り返しの修飾子 (* 、 +、 ?、 {m,n} など) は直接入れ子にはできない
  • 通常groupは1,2などの数値でアクセスするが、(?P<name>...)でnameによるアクセスが可能

"."は改行以外にマッチ、そして改行にも適用する方法

"."を改行にも適用するには、re.match( ) やre.search( ) などの引数flagsにre.DOTALLを付与します。flagsのデフォルト値は0で指定しなければ何も作用しません。

  • re.search(pattern, string, flags=0)
  • re.match(pattern, string, flags=0)
  • re.fullmatch(pattern, string, flags=0)
  • re.findall(pattern, string, flags=0)

flagsはオプション的な役割があり、例えば大文字・小文字を区別しないマッチングの指定や、コンパイル済み表現に関するデバッグ情報を表示など痒いところに手が届くのでありがたい・・

このflagsの中で、re.DOTALL(re.Sでも可)を指定すれば、'.' を、改行を含むあらゆる文字にマッチさせることができます。以下のように、re.DOTALLを指定しなければ1行目のみの取得ですが、指定すれば改行を超えて、2行目も取得できています。

result = re.search(".*","1行目\n2行目") 
print(result)
# <re.Match object; span=(0, 3), match='1行目'>


result = re.search(".*","1行目\n2行目",flags=re.DOTALL) 
print(result)
# <re.Match object; span=(0, 7), match='1行目\n2行目'>

ちなみに、flags以外に以下のようにインラインフラグとして、(?s)を指定しても同じ動作になります。ただ、インラインフラグは正規表現が複雑になった場合、可読性が低下するのでflagsで別に指定したい・・

result = re.search(r"(?s).*","1行目\n2行目") 
print(result)
# <re.Match object; span=(0, 7), match='1行目\n2行目'>

flagsで他に便利だなぁと思ったのは次の「各行に^や$を適用するにはMULTILINEモードを利用」です。

各行に^や$を適用するにはMULTILINEモードを利用

flagsには、re.MULTILINE(re.Mでも可)というものが存在します。

re.MULTILINEを指定すると、パターン文字 '^' は文字列の先頭、もしくは各行の先頭 (各改行の直後) でマッチ。同様にパターン文字 '$' は文字列の末尾、もしくは各行の末尾 (各改行の直前) でマッチします。


result = re.findall("^.","1行目\n2行目") 
print(result)
# ['1']

result = re.findall("^.","1行目\n2行目",flags=re.MULTILINE) 
print(result)
# ['1', '2']


result = re.findall("行目$","1行目\n2行目") 
print(result)
# ['行目']

result = re.findall("行目$","1行目\n2行目", flags=re.MULTILINE) 
print(result)
# ['行目', '行目']

インラインフラグとして、(?m)を指定しても同じ動作になります。

result = re.findall("(?m)行目$","1行目\n2行目") 
print(result)
# ['行目', '行目']

match( ) は改行を乗り越えれない。

一体どういうことか?

具体例をあげて説明すると、以下の文章に対して、「1月5日が存在するか調べ、あれば天気を取得したい状況」を考えたい(雑な例で申し訳ございません)。

1月2日:雨。明日1月3日は晴れの予報。
1月3日:晴れ。明日1月4日は晴れの予報。
1月4日:晴れ。明日1月5日は晴れの予報。
1月5日:晴れ。明日1月6日は晴れの予報。

1つの手法として、match( ) で先頭に1月5日があるか調べて取得するという方法を思いついた。いざ取得!

s = '1月2日:雨。明日1月3日は晴れの予報。\n'\
    '1月3日:晴れ。明日1月4日は晴れの予報。\n'\
    '1月4日:晴れ。明日1月5日は晴れの予報。\n'\
    '1月5日:晴れ。明日1月6日は晴れの予報。'

result = re.match("^1月5日",s)
print(result)

# None



「あれ?・・・あ!改行するにはflagsにre.MULTILINEが必要か!」

s = '1月2日:雨。明日1月3日は晴れの予報。\n'\
    '1月3日:晴れ。明日1月4日は晴れの予報。\n'\
    '1月4日:晴れ。明日1月5日は晴れの予報。\n'\
    '1月5日:晴れ。明日1月6日は晴れの予報。'

result = re.match("^1月5日",s,re.M)
print(result)

# None



「なんで取得できないのぉぉぉ!!!」

実は、match( ) はMULTILINEモードにおいても文字列の先頭でのみしかマッチしません。

今回のように複数行の先頭をマッチさせたい場合は、re.searchとMULTILINEモードを使います(以下)。

s = '1月2日:雨。明日1月3日は晴れの予報。\n'\
    '1月3日:晴れ。明日1月4日は晴れの予報。\n'\
    '1月4日:晴れ。明日1月5日は晴れの予報。\n'\
    '1月5日:晴れ。明日1月6日は晴れの予報。'

result = re.search("^1月5日",s,re.M)
print(result)
# <re.Match object; span=(65, 69), match='1月5日'>

re.compileを使った方が良い/良くないケース

結論:正規表現パターンを何度も再利用するならre.compileを使用する。

結論としてはこれに尽きる気がします。以下、細かな説明です。
まず、reモジュールにおける正規表現の処理は「関数で実行する方法」と「re.compileで正規表現オブジェクトを取得し、当オブジェクトのメソッドで実行する方法」の二種類があります。以下、両者の実行結果ですが結果は同じになります。

#関数で実行
result = re.match(pattern, string)
#正規表現オブジェクト取得 → メソッドで実行
prog = re.compile(pattern)
result = prog.match(string)

結果に違いはありません。よって、使い分ける際の決定的な違いは「正規表現パターンを再利用するか否か」だと思います。ドキュメントにも、「re.compile() を使い、結果の正規表現オブジェクトを保存して再利用するほうが、一つのプログラムでその表現を何回も使うときに効率的」と記されています。効率化以外にも、コンパイル済みの正規表現を繰り返し使用するのでパターンマッチングの実行速度は微量に上がるだろうし。

逆に、正規表現パターンを一度しか使用しないのに、re.compile( ) を用いることは控えたいです。なぜなら「再利用を変に示唆してしまう」ことや「初心者にとってはre.compile( ) は直感的に理解し辛い」などのデメリットが目立つ気がしたから・・・

両者に関して他に違いがあるとすれば、match( ) やsearch( ) などは両者で引数が異なります。

関数

re.search(pattern, string, flags=0)

メソッド

Pattern.search(string[, pos[, endpos]])

正規表現オブジェクトでは、pos(文字列のどこから探し始めるか)とendpos(文字列がどこまで検索されるかを制限)を指定できます。逆にflagsがないので、flagsを適用したいならre.compile( ) にて予め指定する必要があるっぽい。もしposとendposを活用するならre.compile( ) を用いるのもありかもしれない。

つまりまとめると..

  • re.compileを使った方が良いケース

    • 正規表現パターンを再利用したい
    • (メソッド特有のpos,endposを活用したい)
  • 使わない方が良いケース

    • 正規表現パターンを再利用しない場合
    • 初心者にも読んでもらう機会がある場合

findall( ) 以上の情報を得たいならfinditer( ) を利用

finditer( ) を使ったことは無かったけど、とても便利そう。

2つの比較のために、「He was carefully disguised but captured quickly by police.」という文章から、全ての副詞(-lyで終わる)を取得します。

まずはfindall( ) から。findall( ) は全ての副詞がリストで得れる。ただテキストのみしか得ることができません。

text = "He was carefully disguised but captured quickly by police."
re.findall(r"\w+ly", text)
# ['carefully', 'quickly']



対して、finditer( ) を使うと、テキスト情報以外に「その副詞が存在する位置」を得れます。


text = "He was carefully disguised but captured quickly by police."

for m in re.finditer(r"\w+ly", text):
    print('%02d-%02d: %s' % (m.start(), m.end(), m.group(0)))

# 07-16: carefully
# 40-47: quickly

位置情報が必要になる場合は重宝できる。

そもそも、このfinditer( ) 」が何者か説明すると・・・・・

finditer( ) とは?

string 中の正規表現 pattern の重複しないマッチ全てに渡る マッチオブジェクトをyieldするイテレータを返します。 string は左から右へ走査され、マッチは見つかった順で返されます。空マッチは結果に含まれます。

つまり、forループやnext関数などを用いればマッチオブジェクトを取得できるので、マッチオブジェクト内のメソッドと属性にアクセスできます。

例えばマッチオブジェクトの属性であるmatch.stringを使えば、findite( ) へ渡された文字列("He was carefully disguised but captured quickly by police.")が取得できます。とはいえ、元文がメモリに確保されているorすぐにアクセスできる状況が多いので、これはあまり使う機会がない気が・・・

re.escape( ) でパターン中の特殊文字をエスケープ

パターン中の特殊文字をエスケープする。いちいち手動でエスケープしなくて良いのでありがたや。。
URLのマッチングや正規表現メタ文字を含みうる任意のリテラル文字列にマッチしたい時に便利です。

url = "https://qiita.com/search?sort=&q=created%3A%3E2020-12-31+re"
print(re.escape(url))
# https://qiita\.com/search\?sort=\&q=created%3A%3E2020\-12\-31\+re

正規表現内にコメントを書ける。

正規表現にコメント入れる方法は2通りで、1つ目が (?#...) の括弧内に記述する方法。2つ目が、flagsにてre.VERBOSEを設定する方法です。

上にコメントを添えるだけでも良いですが、説明する際に、指し棒を使っての説明すればわかりやすいように、正規表現内にコメントを書くことで説明の幅が広がる気がします。ただ、初見だと正規表現内のコメントは混乱を招きそう・・・

以下、(?#...)とre.VERBOSEを使ったコメント例です。


# flags=re.IGNORECASEを使った例
result = re.findall("\d(?#the integral part:この記述は無視される。)",text) 


# ただre.VERBOSE使った方がわかりやすいと思う....
a = re.compile(r"""\d +  # the integral part
                   \.    # the decimal point
                   \d *  # some fractional digits""", re.VERBOSE)

re.VERBOSEはガッツリコメントを書きたいときに使えます。以下、(?#...)とre.VERBOSEの説明です。

  • (?#...)
    括弧の中身は単純に無視されるので、...にコメントを記述する。

  • re.VERBOSE
    改行・コメント・空白が無視される。更に#から行末までの全ての文字は無視される(#がエスケープされていない場合)。ちなみにインラインフラグは(?m)。

[ ] 内の特殊文字は特殊な意味を失う

集合を示す[ ]の中に記述した特殊文字はその特殊な意味を失ってしまいます。。例えば [(+*)] はリテラル文字 '(' 、 '+' 、 '*' 、または ')' のどれにでもマッチします。以下、例です。

#本来なら「あああああ」にマッチする。
result = re.search("[.*]","あああああ") 
print(result)
# None

#*が文字として認識されている。
result = re.search("[.*]","あ*") 
print(result)
# <re.Match object; span=(1, 2), match='*'>

先読み,後読みアサーション

これは少し変わった視点からのアプローチが可能です。
具体的には、直前/直後が特定のパターンだった場合はマッチするといった形式です。

先読みアサーション(?=regex)
次の正規表現では直後にbarがあるfoo(barは含まない)に一致する。
例:foo(?=bar)

もしfoo(?=bar|hoge)にすれば、直後にbarもしくはhogeがあるfoo(barは含まない)に一致する。

否定先読みアサーション(?!regex)
次の正規表現では直後にbarがないfoo(barは含まない)に一致する。
例:foo(?!bar)

後読みアサーション(?<=regex)
次の正規表現では直前に barがあるfoo(barは含まない)に一致する。
例:(?<=bar)foo

後読みアサーションは先読みと異なり、regexは固定長である必要がある。なので繰り返しを示す(?<=b.*b)などはエラーがでる。固定長の(?<=b(a|b)r)などの固定長は可能。

否定後読みアサーション(?<!regex)
次の正規表現では直前にbarがないfoo(barは含まない)に一致する。固定長の制約は同上。
例:(?<!bar)foo

非常にややこしいので表にまとめます。

名称 記述 マッチするケース 備考
先読みアサーション (?=regex) 直後にregexがある
否定先読みアサーション (?!regex) 直後にregexが無い
後読みアサーション (?<=regex) 直前にregexがある regexは固定長。( .*などは不可)
否定後読みアサーション (?<!regex) 直前にregexが無い regexは固定長。( .*などは不可)

こんな状況だと結構役に立つ

以下のテキストファイルから「先読み、後読みアサーション」を用いて、h4を除くタグ(h1~h3タグ)内のreが含まれる文章を抜き出したいという状況(非実用的な例題)があります。


<h1>reモジュールについて</h1>
<h2>正規表現のシンタックス</h2>
テキスト...............
<h2>モジュールコンテンツ</h2>
<h3>re.compile(pattern, flags=0)</h3>
<h4>re.compile( )の引数について</h4>
<h3>re.ASCII</h3>
<h3>re.DEBUG</h3>
<h3>re.IGNORECASE</h3>
<h3>re.search(pattern, string, flags=0)</h3>
<h4>re.search( )の引数について</h4>
<h2>正規表現オブジェクト</h2>
<h3>Pattern.search(string[, pos[, endpos]])</h3>
<h3>Pattern.match(string[, pos[, endpos]])</h3>
<h2>正規表現一覧</h2>

これらは以下の正規表現(たった一行)で抜き出せます。

#テキストファイル読み込み
with open("./assertion_demo.txt") as f:
    s = f.read()

#h4を除くタグ(h1~h3タグ)内のreが含まれる文章を抽出
result = re.findall(r"(?<=<h[1-3]>)re.*(?=</h[1-3]>)",s) 
print(result)
# ['reモジュールについて', 're.compile(pattern, flags=0)', 're.ASCII', 're.DEBUG', 're.IGNORECASE', 're.search(pattern, string, flags=0)']

なぜ抽出できたのか。
まず、この r"(?<=<h[1-3]>)re.*(?=</h[1-3]>)"の部分は大きく3つに分解でき、それぞれの説明として・・

  • (?<=<h[1-3]>)

    • 後読みアサーション
    • 直前が<h[1-3]>に該当するならマッチする。
  • re.*

    • reから始まる文字列をマッチ
  • (?=</h[1-3]>)

    • 先読みアサーション
    • 直後が</h[1-3]>に該当するならマッチする。

これの条件が組み合わさったことで、h4を除くタグ(h1~h3タグ)内のreが含まれる文章が抽出できました。先読み, 後読みアサーションはややこしいですが、活用できたら正規表現の幅が広がるので勉強したいです。

そして何より、hogehoge(?=regex)があった時、hogehogeが抽出できる点がありがたい。

まとめ

以上、私が「へぇ〜」と思った箇所です。

当初はreモジュールのドキュメントを読むつもりなどありませんでしたが、reモジュールを使う機会があったので、ドキュメントで概要を調べていたら、いつの間にか吸い込まれていました。

一旦、気になった箇所をまとめましたが、正規表現については今後も調べていきます。といっても、正規表現には、grep、AWK、sed、Perl、Tcl、lexなど様々な種類があり、本気で勉強すればそれだけで何百時間といきそうなので、一旦Pythonのreモジュールの知識だけでも深める予定です。

あと課題としては、reモジュールよりも多機能のregexモジュールですかね・・・・・
こちらもドキュメント読んで、近々まとめる予定です。

最後まで見ていただきありがとうございました。誤りの指摘、記事に関連した+αの情報や発展的なコメントなどお待ちしております。

52
55
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
52
55