6
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

10年早く知りたかった

안녕하신게라!パナソニック コネクト株式会社クラウドソリューション部の加賀です。

正規表現、みんな苦手意識ありますよね。食わず嫌いは良くないよー!

私が正規表現を得意になれたのは、20世紀末頃に生HTMLからデータを抜き出す格闘をしていたからです。でも、正規表現は向いてないんだよと後で知ってショックを受けました。もっと早く知りたかった。

この後悔から本記事では、正規表現の得手不得手を共有します。どこまでが得意で、どこからは苦手なのか。そして、使いどころと使ってはいけないところ。「HTMLは×、TSVは◎、ではCSVは?」という線引きの勘所が伝わればうれしいです。手元のエディタで試せる例も一部紹介します。

インターネット老人会かく語りき

正規表現でHTMLをパースしてはいけない」という回答があります。HTMLを正規表現で解析しようとする質問に対して、それは原理的に無理なのだと諭すアツい回答です。

これが投稿されたのは2009年。20世紀末に泥沼でもがいていた頃、この戒めは存在していませんでした。当時まだStackOverflow(2008年)自体がなかったのですから当然です。

いつの間にかブラウザ上でDOMをいじるのが当たり前になった時期すら、正確には思い出せないくらい昔の話です。Webページから情報を抜き出す手段はそう多くなく、当時の私の手元には長大なHTML文字列と正規表現くらいしかありませんでした。「やめておけ」と言ってくれる先人も、2ch.net(1999年)も、Reddit(2005年)もない時代。トライアンドエラーを繰り返し、来る日も来る日も壁にぶつかりながら、自分の手で動きを確かめるしかなかったのです。今あの回答を読むたび、もっと早く知りたかった、と改めて思い返します。

とはいえ、後悔ばかりでもありません。教えてくれる相手がいない分、私は正規表現を自分の手で書き、動かし、文法を体に叩き込むことができました。後で触れますが、コマンドラインやシェルスクリプトで正規表現も使いこなせるようになれたのは副産物です。遠回りではありましたが、無駄だったとは思っていません。
当時の私が独りで探り当てた正規表現の利活用の境界線を、記事として残しておきます。

得手不得手の線引き

正規表現が本来得意とするのは、平坦に並んだ文字列のパターンを見つけることです。文字の種類、桁数、繰り返し、行頭や区切りの位置、前後の目印。こういう「一列に並んだ法則のある情報」を相手にするのは大得意です。

逆に弱いのが、入れ子や対応関係、つまり「どこまでも深くなりうる構造」です。開きと閉じが正しく対応しているか、何重にネストしているか、といった話になると弱いです。理屈の上でも、正規表現が扱える範囲(正規言語)と、入れ子を持つ構造(文脈自由文法)は別物で、後者は本来パーサの仕事です。

ざっくり表にするとこうなります。極論、ここだけ覚えて帰ってもらえれば十分です。

やりたいこと 正規表現
の土俵か
補足
CSVやTSVの分割・列入れ替え 中身しだい。後述
決まった書式の抽出(郵便番号、ログの一行) 得意中の得意
文字種・桁数・繰り返しのチェック バリデーションの定番
行頭・行末・区切り文字での分割や置換 アンカーや区切りで一発
前後の目印を使った抜き出し 先読み・後読みの出番
同じ文字列が再び現れたかの照合 後方参照で限定的に可能
入れ子の対応づけ(HTMLタグ内、括弧の対応) × パーサの仕事
構造を理解した上での解釈(言語の構文解析) × パーサの仕事
開きと閉じの数を数える × 正規表現は数を数えられない

厳密には、最近の正規表現エンジンは後方参照や再帰・それに準じる機能(PCREの再帰や.NETのバランシンググループなど)を持っていて、本来の正規言語を超えた芸当もできます(それだけで記事1本書けます)。入れ子もやろうと思えばできなくはありません。ただし可読性と移植性は一気に落ちます。入れ子や構造化文書はパーサに任せる。この線引きこそ、本記事で伝えたいことです。

正規表現は筋肉勝負だった

この表の一行一行は、当時の私が身をもって確かめたものです。表で弱点だと書いた入れ子も含めて、正規表現で粘ってみた例をひとつずつ振り返ります。単純な形なら書けてしまうもの、そしてどうやっても歯が立たなかったもの。

特定タグに挟まれた中身、綺麗に抜き出せると思ってた

先ほどの表では、HTMLタグの対応づけを「×」としました。とはいえ「タグに挟まれた中身を抜き出すくらい、正規表現でも行けるんじゃね?」と思いませんか。当時の私もそう思っていました。実際、単純な例ならあっさり書けてしまうので、なおさら勘違いします。

(?<=<title>).*?(?=</title>)

(?<=...) が後読み、(?=...) が先読みです。前後の囲いを目印にしつつ、結果には含めない。
これで行けると思いきや、いざ本物のHTMLを流すとボロが出ます。4つ挙げます。

  • <TITLE> のように大文字で書かれるとマッチしない(HTMLのタグ名は大文字小文字を区別しないので <Title> も正しいHTML)
  • <title > のように属性や空白が紛れ込むと、<title> という決め打ちにマッチしなくなる
  • タグ間に改行があるとマッチしない。. は既定で改行にマッチしないので、.*? が1行目の終わりで止まってしまう(. を改行にもマッチさせるフラグ。PerlやPCREなら s、Ruby系のOnigmoなら m が必要)
  • <!-- <title>ダミー</title> --> のようにコメントアウトされた偽物までマッチしてしまう

では穴を塞いでいきましょう。まず大文字小文字。(?i: ... ) で囲えば、その範囲だけ大文字小文字を無視できます。

(?<=(?i:<title>)).*?(?=(?i:</title>))

これで <TiTLe> のような大文字小文字の混在は拾えるようになりました。
他の穴もこの調子で塞いでいきましょう。2つ目と3つ目の属性・空白・改行までなら、力技で詰め込めます。

(?i)<title\b[^>]*>([\s\S]*?)</title\s*>

(?i) で大文字小文字を無視し、[^>]* で属性を許し、. の代わりに [\s\S] で改行も越える。ただし属性を許した瞬間に開きタグが可変長になり、固定長しか置けない後読みが使えません。最初の素直な (?<=...) は捨て、キャプチャ (...) で中身 $1 を取る形に作り直しです。たった3つ塞いだだけで、もう元の面影はありません。そりゃ正規表現が嫌われるワケだ。

最後のコメントに至っては、もう正規表現の領分の外です。<!-- <title>ダミー</title> --> を避けようとするとPCRE専用の裏技が要り、Onigmoでは手も足も出ません(全部入りにしても穴は塞ぎ切れません。その正規表現は脚注に置いておきます1)。

こうして一つ塞ぐたびにパターンは膨れ上がり、最初の素直さはみるみる失われていく。それでも穴は塞ぎ切れません。「平坦な抽出だから大丈夫」と思っていても、相手が構造化文書である以上、例外は無限に湧いてきます。この時点で薄々、これは正規表現の土俵ではないのかもしれない、と感じ始めていました。

入れ子の <div> で詰み

そうこうしているうちに、入れ子の <div> で詰みました。浅い階層なら気合いで書けるのですが、何重にもネストしうる構造を漏れなく扱うパターンは、正規表現のお仕事ではなかったのです。
今なら、あの2009年の回答を一読すれば「それは無理だよ」と即答です。けれど当時の私は、何日も粘っては「もしかして無理なのかも」という無力感を募らせ、できる範囲で対応するしかありませんでした。

では当時どうしたのか。Perl風に書くと、こんな雰囲気です。ループの条件式が正規表現だなんて、今読み返すと可読性が悪いですね(当時はこれぞPerlだと言わんばかりに流行っていた気もしますが)。

# 入れ子は正規表現で数えず、自分でカウンタを持って数える
my $depth = 0;
my $max   = 0;
while ($html =~ /(<\/?div[^>]*?>)/ig) {  # 開きタグと閉じタグが見つかる間ループする
    if ($1 =~ m{^</}) {                 # 先頭が </ なら閉じタグ
        $depth--;                       # 閉じたら一段浅く
    } else {
        $depth++;                       # 開いたら一段深く
        $max = $depth if $depth > $max; # 最深を記録
    }
}
print "最大ネスト数: $max\n";

ただし、これで安心できるのはタグが素直に入れ子になっている理想的な入力だけです。コメントアウトされた <!-- <div> --> まで一つと数えてしまいますし、閉じ忘れやタグの対応ズレがあればカウントはあっさり合わなくなります。先の例と同じように、穴を見つけて塞ぐたびにコードは膨らみ続け「やってもやっても穴が塞がらない」感覚が続きます。構造の解釈はパーサに任せるしかない。そう思い知らされました。

使ってはいけないとき

限界が見えると、正規表現で解くべきでない場面も見えてきます。3つあります。

1つ目、入れ子や構造化データの解釈。HTML・XML・JSON・プログラミング言語のソースなどです。専用のパーサが必ずあるので、不完全な車輪を再発明してはなりませぬ。

2つ目、単純な文字列操作で済むとき。「含むか」「先頭が一致するか」「区切りで割りたいだけ」なら、containsstartsWithsplit で十分です。なんでも正規表現で書けますが、未来の自分が解読できずに白目をむきます。

3つ目、外部入力をそのまま食わせるとき。書き方しだいで、特定の入力にだけマッチが爆発的に遅くなるReDoS(正規表現を悪用したサービス妨害)を踏むことがあります。(a+)+$ のように繰り返しの中へさらに繰り返しを入れた形が危険で、バックトラッキング方式のエンジン(Onigmoもそうです)だと最悪で処理時間が指数関数的に伸びます。対策はこのあたりです。

  • 繰り返しの入れ子を避ける
  • アトミックグループ (?>...) や possessive(++ *+。Onigmo対応)でバックトラックを抑える
  • 入力長に上限を設ける
  • 可能ならRE2など線形時間のエンジンを使う

なお最短マッチ ? に変えるだけでは防げないので、そこは過信しないでください。

入れ子、今ならこうする

HTMLのような入れ子構造から情報を取り出したいなら、今はもう正規表現を使いません。当時作ろうとしたり、喉から手が出るほど欲しかったパーサやツールが、今はそこら中に転がっています。便利な時代になりました。

ブラウザ上なら、開発者ツールのコンソールで querySelector を使えばCSSセレクタで狙った要素を一発で取れます。さっきまで正規表現で苦労していた2つを、そのままやり直してみましょう。

// (?<=<title>).*?(?=</title>) で抜いていた<title>の中身
document.querySelector("title").textContent;

// 正規表現では行き詰まった入れ子の<div>も、深さを気にせず辿れる
document.querySelectorAll("div div");   // div の中の div をまとめて取得

div div のように書くだけで、何階層ネストしていようが「divの中のdiv」を正しく拾ってくれます。あのカウンタと格闘した苦労は何だったのか、という呆気なさです。

サーバ側やスクレイピングでも、HTMLパーサが各言語に用意されています。PythonならBeautiful Soup、Node.jsならcheerioが定番です。これらは構造を理解した上で要素を辿るので、入れ子だろうが属性の順番がバラバラだろうが正しく扱えます。私が必死に正規表現でやっていたことを、今やライブラリが安全に肩代わりしてくれます。構造を相手にする処理はパーサに任せるのが正解です。

あの格闘から得たもの

転んでもただでは起きません。得たものを2つ紹介。

ひとつが、コマンドラインの正規表現に異様に強くなれたことです。HTMLで書き倒したおかげで、perl・sed・grepの正規表現が指先から自然に出てくるようになりました。サーバ上で巨大なログをgrepで絞り、sedで整形し、perlのワンライナーで変換してsortしてuniq。この一連がよどみなく繋がります。BREやERE、PCREでエスケープの要否が逆になるような方言差も、毎日触れていれば勝手に手が覚えるもので、詳しくは過去の記事に譲ります。

もうひとつが、平坦なテキストから決まった形を抜き出したり並べ替えたりする感覚です。そして何より大きいのは、その感覚と一緒に「どこまでが平坦か」という線引きごと身についたことです。日々の相手はログや設定ファイル、そしてCSVやTSVといった区切りデータ。どれも一見平坦で、正規表現でさっと捌けそうに見えます。冒頭の表で「?」とした行が、まさにこれです。

たとえばタブ区切り(TSV)で「氏名・部署」の順に並んだ行を「部署・氏名」へ入れ替えるなら、グルーピングと後方参照を組み合わせてこう書けます。

項目 内容
検索 ^(.+?)\t(.+)$
置換 $2\t$1

(.+?)(.+) で前半と後半を捕まえ、置換側で $2 $1 と順序を入れ替えるだけ。列の入れ替えも定型フォーマットの整形も一瞬です。TSVはフィールドにタブや改行が入らない前提なら正真正銘の平坦データなので、これは正規表現の得意技です。

表中「?」のこたえ

問題は、同じノリでCSVに手を出したときです。実務のCSVは "加賀, 泰光",経理部 のように引用符で囲ったフィールドの中にカンマが入ったり、フィールド内に改行や "" エスケープが紛れ込んだりします。こうなると「引用符の対応」という小さな構造を抱えるので、, で機械的に割る正規表現は、あのHTMLと同じく破綻します。構造が相手ならCSVパーサの出番です。一見平坦でも、引用符やエスケープのルールが顔を出した途端にパーサ案件。HTMLで叩き込まれた嗅覚が、CSV相手でもそのまま働きます。逆に、引用符のない素直なCSVやTSVなら何も怖くありません。つまり中身しだいで◎にも×にも転ぶ。表の「?」は、この「場合による」のことでした。この見極めができること自体が、格闘の末の一番の収穫でした。

手元で試すなら、サクラエディタ(1999年)の置換が手軽です。置換ダイアログで正規表現を有効にして、上の検索・置換を入れるだけ。初心者でもすぐ真似できて効果が大きい使い方です。Excelでも列の入れ替えはできますが、数値が勝手に変換される心配も、保存形式に気を使う必要もなく、クリップボードとエディタだけで完結できるのは、地味に嬉しいポイントです。

【コラム】正規表現エンジン特有のクセ
サクラエディタ(Onigmoエンジン)を話題に出したので、1つだけ補足を。後読み (?<=...) の中には +* のような可変長を置けません((?<=\s+) はエラー、(?<=abc)(?<=ab|abcd) はOK)。先読みにこの制限はないので、迷ったら先読みを優先するのが楽です。
この挙動は Onigmo 6.2.0(bregonig.dll 同梱)で確認したものです。サクラエディタはANSI版とUnicode版で同梱DLLが異なり、\w \d の全角の扱いなどに差が出ることがあるので、お使いの環境で一度お試しください。

まとめ

正規表現が書けるようになった理由を振り返ると、文法を暗記したからではなく、得手不得手の線引きを、誰も教えてくれない時代に独りで叩き込んだから、というだけの話でした。ずいぶん遠回りをしました。

平坦に並んだ文字列から決まった形を拾うのは正規表現の独擅場で、抽出も検証も分割も置換もここに入ります。逆に、入れ子や構造を解釈する仕事はパーサに。単純な処理は素直なメソッドに。外部からの入力を複雑なパターンで扱ってReDoSを呼び込まない。持ち帰ってほしいのは、このくらいです。

過去の自分に向けて書いたつもりです。もちろん正規表現を学ぼうとしているあなたも含まれています。
試しに手元のエディタの置換あたりで一度動かしてみてください。正規表現もただの難読呪文ではなく使える道具に変わってくれます。
やりたいことを前にして、正規表現で書くべきか、別の手段を使うべきか、その判断ができるようになるだけで、食わず嫌いから脱することができるのです。


お断り
記事内容は個人の見解であり、所属組織の立場や戦略・意見を代表するものではありません。
あくまでエンジニアとしての経験や考えを発信していますので、ご了承ください。

  1. コメントまで避けようとすると、PCREのバックトラック制御を使って (?i)<!--[\s\S]*?-->(*SKIP)(*F)|<title\b[^>]*>([\s\S]*?)</title\s*> のように「コメントを食べて捨てる」枝を先に置くことになります。ただしOnigmoには (*SKIP) がないので、この手は使えません。そしてここまで盛っても、<title data-x="a>b"> のように属性値へ > が混じれば [^>]* がそこで切れて破綻します。穴は、まだ残っています。

6
0
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
6
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?