Pythonで正規表現でのマッチングを書くと面倒に感じるケースと、その対策で作ったライブラリ

# 追記（2021.5.27）：「代入式」があったなんて知らなかった（ただしPython3.8以降限定）

Python 3.8で「代入式」（代入をすることを式として行う。なのでif文の条件内などにも書ける）が導入されているとのことでした…（@s-hiiragiさんありがとうございます）

https://www.python.org/dev/peps/pep-0572/ に、まさに今回やりたかったことの例が載ってました。
以下で示すコード例を代入式で書き直したものはこちら: https://wandbox.org/permlink/fRZ8Yzjd00wQDSLq

「Pythonで、if文の条件内で代入すること」についてぐぐったりもしてみたのですが、見つけられなかったのですよ…。（多分ぐぐり方が悪かっただけ）

# 前提：どういう場合にPythonで正規表現を書くと面倒に感じるのか

最近は仕事でデータ処理・統計を扱うことが多いため、使うプログラミング言語もPythonが中心になりつつある自分。

ただそれで面倒に感じるのが、正規表現でテキストを抽出して処理すること。そういう場合は、渋々Pythonで書くか、処理した結果を直接Pythonのライブラリに通す必要がないのなら（前処理段階とかではある）元々慣れていたRubyを使うようにするほど。

具体的にはこういう場合が面倒なのである。例えば「テキストファイルを読み込み、行の内容に応じて処理する」ということが私はよくあるのだが、それを想定したコードである。

```py
import re
strings = ['hoge123piyo', 'hogePiyo']

for s in strings:
    # いずれかのパターンにマッチするかを検査したい。
    # 通常だとこのように、マッチング結果を一度変数に
    # 保存しないとならない。
    # そもそもそれが面倒だし、continue を挟まないと
    # ならないので、マッチ条件を増やしたときに行数が
    # 増えすぎる。
    m = re.search(r'\d+', s)
    if m:
        print(s, "matched pattern 1:", int(m[0]))
        continue
    
    m = re.search(r'[A-Z]+', s)
    if m:
        print(s, "matched pattern 2:", m.group(0))
        continue
    # 後での都合（テストケースとして使えるようにすること）
    # から、敢えて「m[0]」と「m.group(0)」と、機能の同じ
    # 2つの書き方を使っています
```

これはRubyならすごくシンプルで、以下の通り。正規表現のマッチング結果が変数に自動的に格納される、case文が使える、ということもあり、正規表現のマッチング条件を1つ増やすごとに3行が増えるPythonとは大きな差なのである。

```rb
def example
  strings = ['hoge123piyo', 'hogePiyo']

  strings.each do |s|
    case s
    when /\d+/
      puts "#{s} matched pattern 1: #{Integer($&)}"
    when /[A-Z]+/
      puts "#{s} matched pattern 2: #{$~[0]}"
    end
  end
end

if $0 == __FILE__
  example
end
```

# 仮想的なコード：「マッチングの結果を変数に格納すること」と「マッチングが取れたか否かを知ること」とが同時にできればよい

もしPythonがRubyやC言語のように、if文の中で代入を許しているのならば、

```py
import re
strings = ['hoge123piyo', 'hogePiyo']

for s in strings:
    if m = re.search(r'\d+', s):
        print(s, "matched pattern 1:", int(m[0]))
    elif m = re.search(r'[A-Z]+', s):
        print(s, "matched pattern 2:", m.group(0))
```

とかける。これにより代入が同時にできることで行数を減らせるほか、elif が使えるようになることで continue が不要になることでも行数を減らすことができる。が、実際はそれは認められていないのである。

# 解決策

マッチング結果の代入と確認が同時にできないのなら、そのためのクラスを作ればよいのでは？という考えに至った。
以下のように使える RegExpMatcher クラスを作る方針。

```py
strings = ['hoge123piyo', 'hogePiyo']

m = RegExpMatcher()
for s in strings:
    if m.search(r'\d+', s):
        print(s, "matched pattern 1:", int(m[0]))
    elif m.search(r'[A-Z]+', s):
        print(s, "matched pattern 2:", m.group(0))
```

RegExpMatcher クラスの実際の定義は以下の通り。
このクラスは、**基本的には re.Match クラスと同様に振舞う**のだが、例外的に **matchメソッドとsearchメソッドが定義されていて**、それぞれ re.match や re.search を実行して自身に格納するのである。

```py
class RegExpMatcher:
    def __init__(self):
        self.matching_ = None
    
    def match(self, pattern, string):
        self.matching_ = re.match(pattern, string)
        return self
    
    def search(self, pattern, string):
        self.matching_ = re.search(pattern, string)
        return self
    
    # 真偽値としては、self.matching_ のそれと一致していてほしい
    def __bool__(self):
        return True if self.matching_ else False
    
    # 上記に定義したもの以外のメソッドは、
    # self.matching_ に対して呼んだ場合と同じ結果になって欲しい
    def __getattr__(self, attr):
        return getattr(self.matching_, attr)
    
    def __getitem__(self, group):
        return self.matching_[group]
```

コード全体は https://wandbox.org/permlink/Il2QaDUcjzqGmTtF に。

# おわりに

こうやって自分でクラスを作りましたが、他にもっと良い方法があることをご存知の方がいらっしゃいましたらお知らせいただきたいです…。

# 補足

今回定義した RegExpMatcher クラスは、re.Match クラスの継承はしておらず、「呼ばれたメソッドを、一部を除いてメンバ変数 self.matching_ に投げる」という実装をしている。これは、re.match や re.search が re.Match クラスのインスタンスを返すとは限らず None を返すこともあり、それを管理することを考えると継承では面倒に感じたためである。