株式会社デジタル・フロンティア-Digital Frontier

Header

Main

  • TOP
  • DF TALK
  • 【Python】正規表現を使った快適コーディングのすゝめ ~はじめの一歩から三歩ぐらいまで~

【Python】正規表現を使った快適コーディングのすゝめ ~はじめの一歩から三歩ぐらいまで~

2016/1/25

Tag: ,

どーも、TDの小野です。
2016年はじめての投稿になりました(←ホントは年末投稿予定だった、なんてことはない)。今年も弊社とDF Talkをよろしくお願いします。

さて、今回はPySideから少し離れて、プログラミングの基礎のお話をします。
テーマは「正規表現」です。

正規表現のマニュアルって、どのプログラミング言語にもありますけど、読みにくいんですよね。もっといろいろ例を載せてほしいなぁと、勉強し始めたころは思っていた記憶があります。
以前、DF Talkでも正規表現についての記事を書いてはいるのですが、今回は実例を交えながら、もう少し詳しく解説したいと思います。
この記事では、Pythonにおけるコードを書いていますが、正規表現に関してはどの言語も似ているので、Pythonではない言語を使われている方も、まずはここで理解を深めていただければと思います。

正規表現とは

まず正規表現とはなんなのでしょうか?なぜ必要なのでしょうか?
正規表現とは、いくつかの文字列をひとつのパターンで表現する機構です。そして、正規表現をプログラミングに取り入れる目的は、人間が文字列を見たときに感覚的に解析、理解していることを、コンピュータにもできるようにするためです。つまり、きちんと使いこなすことで、文字列の検証、解析が的確にでき、プログラムにおける不具合の解消にもつながります。

簡単な例を挙げましょう。
下の文字列を見たときにあなたはどれがバージョン名か分かりますか?

INU_arm_v001

おそらく全ての人がv001と思ったでしょう。でも、v001と判断したのはどうしてですか?

頭の中をのぞいてみるとこんな考えがあったのではないでしょうか?

「INU, arm, v001は別のグループっぽいな。」
「versionの頭文字vがついてるグループがあるな。」
「バージョンだから数字が入ってそうだな。」

これらの知識を考慮して、人間は適切な情報を文字列から取得しています。
この能力をコンピュータで実現させるために正規表現が必要になります。

正規表現を作ってみよう

バージョン名をコンピュータに認識させるため、まずはルールを決めましょう。
今回はバージョン名のルールを「vからはじまる数字三桁」としました。

冒頭で、「正規表現とは、いくつかの文字列をひとつのパターンで表現する機構」と言いました。そこで、上のルールに則って、実現しうるバージョン名を全て考えてみましょう。
v000、v001、v002、……、v010、v011、……、
v100、v101、v102、……、
……

はい、もうパターンが見えてきましたね。一番初めの文字が絶対”v”で次の文字は0~9、その次も0~9、その次も0~9が入れば全て表現できそうです。これらをひとつの文字列で表してみましょう。
以下のように表せます。

v[0123456789][0123456789][0123456789]

ながっ…
でも、これがPythonの認識できる、「vからはじまる数字三桁」を表した正規表現パターンになります。
vは絶対なのでそのまま。vの後は0~9までの数字でどれでもいいので[0123456789]。三文字分それが続くので[0123456789][0123456789][0123456789]になります。

ここで出てきた [] は文字クラスと呼ばれ、文字の集合を意味します。
[]は文字列の中で一文字を表し、対象となった文字が、中にあるパターンにマッチするかが調べられます。

でも、長すぎない?
はい、長すぎますね。連続する文字コードの場合、このように範囲で書くこともできます。

v[0-9][0-9][0-9]

短くなりましたね。
例えば、「数字は2から8しかありえないよ」ってルールの場合は[2-8]になりますし、
「aからdの文字列か、vか」みたいな時は[a-dv]のように書けます。

また、大抵の言語では特殊文字が用意されており、Pythonでも以下のように10進数を書き表せれます。

\d

すなわち [0-9] の部分が \d になるので、さらに正規表現は短くなり、 v\d\d\d になります。

また、同じパターンの繰り返しの場合は以下のように回数を指定して書くことが出来ます。

{m} (m:繰り返し回数)

ということで、正規表現はもっと短くなり、v\d{3}と書き表すことができます。

実際に認識させてみよう

では、ここで実際にモジュールを使って、文字列を認識させてみましょう。

Pythonの場合モジュールはreを使い、マッチングにはsearchかmatchメソッドを使います。
例えば、以下のように書くと文字列をパターンにマッチングできます。

import re

name = "v001"
PATTERN = "v\d{3}"

if re.search(PATTERN, name):
    print "Found"
else:
    print "Not found"
# Found

searchとmatchの違いは?

どちらもマッチングが成功した場合、MatchObjectが返ってきます。
失敗した場合はNoneが返ってきます。

PATTERN = "v\d{3}"
name = "v001"
mObj = re.search(PATTERN, name)
print mObj 
# <_sre.SRE_Match object at 0x14191da58>
mObj = re.match(PATTERN, name)
print mObj 
# <_sre.SRE_Match object at 0x1418e0238>

name = "v"
mObj = re.search(PATTERN, name)
print mObj # None
[]

では、違いは何でしょう?

search
→パターンが文字列の中にある場合マッチ

match
→0番目の文字からの文字列がパターンと同じ時マッチ

searchとmatchの違いの典型的な例は、
name = “scene_v0001”
のとき、search関数ではマッチしますが、match関数ではマッチしません。
search関数でマッチする理由は、v000にマッチしているからです。

PATTERN = "v\d{3}"
name = "scene_v0001"
mObj = re.search(PATTERN, name)
print mObj # <_sre.SRE_Match object at 0x1418e0238>

mObj = re.match(PATTERN, name)
print mObj # None

注意しないといけないのは、matchメソッドは完全一致をマッチとするわけではないということです。
文字列の一番初めから探して、一致するものはマッチしたものとして扱われます。
次の例を見てみましょう。

PATTERN = "v\d{3}"
name = "v0001_scene"
mObj = re.search(PATTERN, name)
print mObj # <_sre.SRE_Match object at 0x1418e0238>
mObj = re.match(PATTERN, name)
print mObj # <_sre.SRE_Match object at 0x14191dac0>

どちらもマッチしましたね。これはmatchメソッドも”v000″にマッチしたからです。
このように各メソッドの挙動を把握したうえで、正規表現を作り、使い分ける必要があります。

マッチングルールはできるだけ多く

searchメソッドでマッチングさせる際、以下のような場合に間違って認識してしまう可能性があります。

PATTERN = "v\d{3}"
name = "INU_env123_material"

こういうときも”v123″に一致してしまいます。
このような場合はmatch関数を用いてマッチさせるか、パターンに他の条件を付け加えましょう。

例えば、
「バージョンは絶対文字列の最後にある」
という条件をつけてみましょう。
文字列の末尾、または改行の直前を表すため、特殊文字 $ を取り入れます。
すなわち正規表現パターンは以下のようになります。

v\d{3}$

これで、「三桁の数字の次は文字列の末尾か改行」というルールが付け加わりました。

name = "INU_env123_material"
SEARCH_PAT = r"v\d{3}"
sObj = re.search(SEARCH_PAT, name)
if sObj:
    print "Found"
else:
    print "Not found"
# Found

name = "INU_env123_material"
SEARCH_PAT = r"v\d{3}$"
sObj = re.search(SEARCH_PAT, name)
if sObj:
    print "Found"
else:
    print "Not found"
# Not found

もしくは
「vの前には絶対アンダーバーがある」というルールを加味してみましょう。

_v\d{3}

と書くことによって、vの前にアンダーバーがある場合にだけマッチするように絞り込めます。

マッチング条件を緩める

ルールを増やして絞り込むだけでなく、緩めて広い範囲にマッチさせることもできます。
例えば、「vの前の文字はアンダーバーか、ハイフンかのどちらか」というルールの場合、 [] をつかって、その条件を実現できます。

[_-]v\d{3}

name = "INU_arm-v001"
SEARCH_PAT = r"[_-]v\d{3}"
sObj = re.search(SEARCH_PAT, name)
if sObj:
    print "Found"
else:
    print "Not found"
# Found

では、こんなときは?
「バージョンの桁数が2桁かも…」
ここでは {m,n} という書き方が使えます。mが最小の繰り返し回数、nが最大の繰り返し回数です。

[_-]v\d{2,3}$

○ INU_arm-v001
○ INU_arm_v01
× INU_arm-v0001

最後に$をつけた理由は3番目の例のケースでマッチさせないためです。

マッチした情報を取得する

マッチしたら、マッチしたものの情報が欲しときがありますよね。例えば、バージョン名は何?とか。
そのような場合、括弧でくくるとグループとして扱ってくれ、MatchObjectのgroup関数でアクセスできます。

_(v\d{3})$

SEARCH_PAT = r"_(v\d{3})$"
name = "INU_arm_v003"
sObj = re.search(SEARCH_PAT, name)
if sObj:
    print sObj.group()  # _v003
    print sObj.group(0) # _v003
    print sObj.group(1) # v003
else:
    print "Not found"

group()もしくはgroup(0)で、パターンに一致した文字列が、group(1)で、パターンの括弧でくくられた文字集合に一致した文字列が返ってきます。
グループが複数ある場合はこの数字が前方からの数分、上がっていきます。

例えば、上記の文字列からarmという部位名も取得してみましょう。ルールは「アンダーバーもしくはハイフンで囲まれた、大文字か小文字のアルファベットが一文字以上ある」とします。正規表現パターンは以下のようになります。

_([a-zA-Z]+)_(v\d{2,3})$

アンダーバーに挟まれている括弧(赤文字)が、部位名に一致する文字集合になります。大文字でも小文字でもいいアルファベットなので、[a-zA-Z]にしました。
そして、[a-zA-Z]の後の + 「最低一文字は、直前の文字パターンに一致する文字がある」ことを示します。

SEARCH_PAT = r"_([a-zA-Z]+)_(v\d{2,3})$"
name = "INU_arm_v003"
sObj = re.search(SEARCH_PAT, name)
if sObj:
    print sObj.group(0) # _arm_v003
    print sObj.group(1) # arm
    print sObj.group(2) # v003

 

ちょっとバージョン名から離れて、ファイル名で考えてみる

以下の文字列を見てください。よくあるファイルの命名規則ですよね。

DFT_010_0020_v003
JUM_002_0203A_v001
TS_101B_0035_v007

これらの名前は以下のルールによってつけられました。

「プロジェクトコードは大文字アルファベット」
「シーケンス名は数字三桁」
「ショット名は数字四桁」
「シーケンス名、ショット名は、どちらも大文字アルファベットのsuffixがつく可能性がある」
「バージョン名はvから始まる数字3桁」
「各名称の間はすべてアンダーバーで繋がれる」

ここで、例えば以下のようなファイル名
DFT_010_0020_v003.ma
からプロジェクトコード、シーケンス名、ショット名、バージョン名を取得する場合を考えてみましょう。

プロジェクトコード

まずはプロジェクトコードに注目します。大文字アルファベットを表す正規表現は[A-Z]ですね。
「一文字以上の…」はどのように表現すればいいでしょうか?
ここでは、先ほど出てきた + 記号を使います。 + は、その直前のパターンが最低一回以上繰り返されることを示します。
今は[A-Z]が一回以上繰り返されればいいので、[A-Z]+になります。

シーケンス名

シーケンス名は三桁の数字で構成されるので\d{3}で表現できそうです。しかし、「大文字アルファベットのsuffixがつく可能性がある」を考慮しなければなりません。まずはプロジェクトコードと同様に、大文字アルファベットのパターンを[A-Z]にしましょう。
そして、ここで + と同じように、直前のパターンの繰り返しを表す記号を紹介しましょう。

* は直前のパターンが0回以上繰り返される場合に使用します。つまり、「何文字あってもいいし、全くなくてもいい」場合に使えます。
例えば、A[A-Z]*に以下の文字列をマッチさせると、次のような結果になります。
○ A
○ AB
○ ABC
○ ABB
× Ac

? は直前のパターンが0回か1回繰り返される場合に使用します。
例えば、A[A-Z]?に以下の文字列をマッチさせると、次のような結果になります。
○ A
○ AB
× ABC
× ABB
× Ac

今回のシーケンス名ではsuffixの文字数は制限されてないので、\d{3}[A-Z]*で表現できそうです。もしルールが、「一文字だけつく可能性がある」であれば\d{3}[A-Z]?が適切なパターンになるでしょう。

ショット名

同じ考え方でショット名のパターンも作れます。
\d{4}[A-Z]*

バージョン名

バージョン名は今まで使っていたバージョンパターンを使いましょう。

全部くっつける

これらを考慮してアンダーバーでつなぐと、今回のファイル命名規則に沿った正規表現パターンができます。
[A-Z]+_\d{3}[A-Z]*_\d{4}[A-Z]*_v\d{3}

マッチした文字列を後で取得するため、グループとして、プロジェクトコードなどをカッコでくくりましょう。
([A-Z]+)_(\d{3}[A-Z]*)_(\d{4}[A-Z]*)_(v\d{3})

これでマッチさせると以下の様な結果が得られます。

name = "DFT_010_0020_v003.ma"
SEARCH_PAT = r"([A-Z]+)_(\d{3}[A-Z]*)_(\d{4}[A-Z]*)_(v\d{3})"

sObj = re.search(SEARCH_PAT, name)
if sObj:
    print sObj.group(0)  # DFT_010_0020_v003
    print sObj.group(1)  # DFT
    print sObj.group(2)  # 010
    print sObj.group(3)  # 0020
    print sObj.group(4)  # v003
else:
    print "Not found"

これでプロジェクトコード、ショット名などがマッチング結果から取得できますね。
しかし、シーケンス名が何番目のグループかわかりにくかったり、グループの番号が変わった際、全部番号を書き換えないといけないという欠点があります。

例えば、命名規則が変わって、エピソード名がシーケンス名の前に入ることになったとします。


name = "DFT_01_010_0020_v003.ma"
SEARCH_PAT = r"([A-Z]+)_(\d{2})_(\d{3}[A-Z]*)_(\d{4}[A-Z]*)_(v\d{3})"

すると今までグループ2として取得していたシーケンス名をグループ3で取得しなければなりません。プログラムの様々なところでこの正規表現を使っていた場合、番号の書き換えが大量に起こります。

グループに名前をつける

前項の欠点を解決するため、グループに名前をつけることを考えましょう。そうすることで、あとでアクセスしやすくなります。

グループを示す括弧のなかで、(?P<name>…)のように書くと、そのグループの名前を定義できます。
例えばプロジェクトコードは(?P<project>[A-Z]+)のように書くことで、projectという名前でアクセスできます。

name = "DFT_010_0020_v003.ma"
SEARCH_PAT = r"(?P<project>[A-Z]+)_(?P<sequence>\d{3}[A-Z]*)_(?P<shot>\d{4}[A-Z]*)_(?P<version>v\d{3})"

sObj = re.search(SEARCH_PAT, name)
if sObj:
    print sObj.group("project")  # DFT
    print sObj.group("sequence")  # 010
    print sObj.group("shot")  # 0020
    print sObj.group("version")  # v003
else:
    print "Not found"

これだと命名規則が変わったとしても、コードの書き換えはほとんど発生しません。
また、一気に取得することもできます。そのためにはgroupsやgroupdict関数を使います。

SEARCH_PAT = r"(?P<project>[A-Z]+)_(?P<sequence>\d{3}[A-Z]*)_(?P<shot>\d{4}[A-Z]*)_(?P<version>v\d{3})"
sObj = re.search(SEARCH_PAT, name)
if sObj:
    print sObj.groups()  # ('DFT', '010', '0020', 'v003')
    print sObj.groupdict()  # {'project': 'DFT', 'version': 'v003', 'shot': '0020', 'sequence': '010'}
else:
    print "Not found"

 

その他機能紹介

最後にいくつか機能をご紹介します。

1. 「ここがこうなら、あそここう」機能

なんと名前をつけていいか分かりませんが…
同じ文字列が絶対出てくる場合、グループ名を使って「ここにはあそこと同じ文字が入るよ」というのを定義できます。言葉で説明してもわかりにくいので例をあげましょう。

例えばファイルのパスが以下の様な場合に、フォルダ構造とファイル名が正しいかを検証したいとします。

C:\project\DFT\sequences\010\0020\common\DFT_010_0020_v003.ma

ファイルを管理するツールを作っていると、3階層目のプロジェクトコードとファイルのプロジェクトコード、5階層目のシーケンス名とファイルのシーケンス名などが一致しないといけないことなどはよくあります。
このルールをマッチングに取り入れる際に、次のような正規表現が使えます。
(?P=name)

実際に正規表現パターンで上のパスを表現してみました。

(?P<project>[A-Z]+)\\sequences\\(?P<sequence>\d{3}[A-Z]*)\\(?P<shot>\d{4}[A-Z]*)\\common\\(?P=project)_(?P=sequence)_(?P=shot)_(?P<version>v\d{3})

(?P<project>[A-Z]+)が3階層目のプロジェクトコードに一致し、ファイル名部分の(?P=project)が3階層目のプロジェクトコードと同じ文字列が入ることを示します。
この正規表現でsearchマッチングを行うと、
C:\project\DFT\sequences\010\0020\common\DFT_010_0020_v003.ma にはマッチしますが、
C:\project\DFT\sequences\010\0020\common\DDD_010_0020_v003.ma にはマッチしません。

name = r"C:\project\DFT\sequences\010\0020\common\DFT_010_0020_v003.ma"
SEARCH_PAT = r"(?P<project>[A-Z]+)\\sequences\\(?P<sequence>\d{3}[A-Z]*)\\(?P<shot>\d{4}[A-Z]*)\\common\\(?P=project)_(?P=sequence)_(?P=shot)_(?P<version>v\d{3})"
sObj = re.search(SEARCH_PAT, name)
if sObj:
    print "OK"
else:
    print "Not good"

# OK
    
name = r"C:\project\DFT\sequences\010\0020\common\DDD_010_0020_v003.ma"
sObj = re.search(SEARCH_PAT, name)
if sObj:
    print "OK"
else:
    print "Not good"

# Not good

この機能を使うことで、正しい名前がつけられているか、チェックすることができます。

2. 「ここがこうなら、あそここう」機能

これもなんと名前をつけて良いか…。
正規表現で条件によって違うパターンをマッチさせることができます。

例えば以下のファイル名を見てみましょう。

DFT_010_0020_comp_v001.aep

このファイルから出力されるファイルには以下のように必ずoutputを示す“o_”のprefixがつくとします。
また、イメージシーケンス番号がバージョン名と拡張子の間に入るとします。

o_DFT_010_0020_comp_v001.0010.exr

Pythonの正規表現では、この2つのファイル名を一つのパターンで表すことができます。
そのためには「ファイル名が”o_”で始まっていたら、イメージ番号がバージョンの後に入って、拡張子が変わる」という条件を正規表現パターンに組み込む必要があります。

まずは、“o_”の部分をグループとして扱わないと行けないので、(?P<output>o_)としましょう。
そして、このグループは、存在する場合としない場合があるので“?”が後に付き、(?P<output>o_)?になります。
バージョン名までは、今までのパターンを用いましょう。すると、次のように表現できます。

(?P<output>o_)?[A-Z]+_\d{3}[A-Z]*_\d{4}[A-Z]*_comp_v\d{3}\.

通常であればこのあとファイル拡張子がつくので、aepなどが続けばよさそうですね。
ここで、バージョン名の後に、イメージ番号が続く場合の正規表現を考えてみましょう。
ファイル拡張子が決定しているなら、以下のようになりますね。
\d{4}\.exr

では、課題である、“o_”で始まっている時だけこのパターンに一致させるようにするにはどうすればいいでしょうか。

ここで次の表現を使います。
(?(id/name)yes-pattern|no-pattern)

“id/name”の部分にはグループ名やインデックス番号が入ります。
そのグループに一致するものがあるなら、”yes-pattern”の部分に書かれている正規表現がマッチングに使用され、一致していないなら、”no-pattern”の部分に書かれている正規表現が使用されます。
すなわち、今回の場合は(?(output)\d{4}\.(exr|aep))と書けば、outputグループがマッチしたとき、yes-patternである
\d{4}\.exrが使われ、マッチしなかったときはaepが使われます。

全体の正規表現パターンは
(?P<output>o_)?[A-Z]+_\d{3}[A-Z]*_\d{4}[A-Z]*_comp_v\d{3}\.(?(output)\d{4}\.(exr|aep))
のようになります。
グループ番号でも構いません。
(o_)?[A-Z]+_\d{3}[A-Z]*_\d{4}[A-Z]*_comp_v\d{3}\.(?(1)\d{4}\.(exr|aep))
output”でアクセスする代わりに、”1″でアクセスしています。

SEARCH_PAT = r"(o_)?[A-Z]+_\d{3}[A-Z]*_\d{4}[A-Z]*_comp_v\d{3}\.(?(1)\d{4}\.(exr|aep))"
imageName = "o_DFT_010_0020_comp_v001.0010.exr"
sObj = re.search(SEARCH_PAT, imageName)
if sObj:
    print "OK"
else:
    print "Not good"
# OK

sceneName = "DFT_010_0020_comp_v001.aep"

sObj = re.search(SEARCH_PAT, sceneName)
if sObj:
    print "OK"
else:
    print "Not good"
# OK

badName = "o_DFT_010_0020_comp_v001.exr"

sObj = re.search(SEARCH_PAT, badName)
if sObj:
    print "OK"
else:
    print "Not good"
# Not good

3つ目のbadNameでは”o_”で始まっているにも関わらず、イメージ番号がバージョンの後に入っていないので、 Not goodになります。
このように条件によって違うパターンをマッチできました。

まとめ

いかがだったでしょうか。今回は長々と正規表現について解説させていただきました。
正規表現、奥深いですよね。文字列解析などに使うと本当に便利です。
Mayaなどではワイルドカードが用意されており、そちらを使うことも多いですが、予期せぬ文字列など、きちんと処理できない場合があります。
正規表現を正しく使うことで、効率的なコーディングだけでなく、文字列の検証により適切なデータを使用できるようになり、不具合の減少にもつながります。

正規表現と共に、どなた様も快適なコーディングをお楽しみください。

ではまた次回。

※免責事項※
本記事内で公開している全ての手法・コードの有用性、安全性について、当方は一切の保証を与えるものではありません。
これらのコードを使用したことによって引き起こる直接的、間接的な損害に対し、当方は一切責任を負うものではありません。
自己責任でご使用ください。

Pocket

コメント

コメントはありません

コメントフォーム

コメントは承認制ですので、即時に反映されません。ご了承ください。

*