LoginSignup
606
659

More than 1 year has passed since last update.

初心者歓迎!手と目で覚える正規表現入門・その4(最終回)「中級者テクニックをマスターしよう」

Last updated at Posted at 2016-02-22

はじめに

みなさんこんにちは!
この記事は「手と目で覚える正規表現入門」の第4回(最終回)です。

この連載記事は「知識ゼロからでも理解できる」「実践的なサンプルを提供する」「自分の手と目で動きを確認できる」をモットーにした、正規表現の入門記事です。

第1回~第3回までは「今までまったく正規表現を知らなかった初心者さん」向けに、「最低限これだけは知っておきたい」という内容を書いてきました。
それに対して、今回は「知らなくてもなんとかなる、でも知ってたら便利」という中級者向けの内容を紹介していきます。

ここまで理解できればあなたも「ワタシ、正規表現チョットデキル」と公言してもいいはずです。
がんばって学習しましょう!

対象となる読者

本記事は連載記事なので、読者のみなさんは過去の記事で紹介した知識をすべて理解できている、という前提で進めます。
まだ第1回~第3回の記事を読んでない人は、先にそちらを読んでからこの記事に戻ってきてください。

初心者歓迎!手と目で覚える正規表現入門・その1「さまざまな形式の電話番号を検索しよう」 - Qiita

初心者歓迎!手と目で覚える正規表現入門・その2「微妙な違いを許容しつつ置換しよう」 - Qiita

初心者歓迎!手と目で覚える正規表現入門・その3「空白文字を自由自在に操ろう」 - Qiita

今回も前回までの記事と同様、今回もRubularやAtomを使って実際に正規表現を動かしながら学習できるようにしています。

しっかりと内容が頭に定着するよう、実際に自分の手を動かしながら読み進めることを強くオススメします!

この記事で学ぶこと

本記事で学習する内容は以下の通りです。

  • \b の意味
  • 肯定の先読み、後読み
  • 否定の先読み、後読み
  • 後方参照
  • メタ文字の複雑な組み合わせ
  • 正規表現とパフォーマンス
  • メタ文字のエスケープ
  • [ ] 内のメタ文字の働き
  • {n,}{,n} の意味
  • \W\S\D\B の意味

第1回~第3回までの内容に比べると少々難しく、記事のボリュームも多めですが、がんばって付いてきてください!

動作環境

本記事の正規表現は以下の環境で動作確認しています。

  • Ruby
  • JavaScript
  • Atom

正規表現が使えるプログラミング言語やテキストエディタであれば、本記事のサンプルコードはほぼ同じように動くはずです。
ただし、一部の正規表現はJavaScriptやAtomでは動作しません。
その場合は注意書きを書いています。

上記の3つの環境以外でも、正規表現の仕様や使えるメタ文字が微妙に異なることがあります。
何か動きがおかしいなと思ったら、自分が使っている環境の正規表現の仕様を確認するようにしてください。

それでは第4回の本編を始めましょう!

英単語にぴったりマッチさせる(\b の使い方)

たとえばこんな英語の例文集があったとします。
(例文はネットから適当に拝借してきたものなので特に意味はありません)

sounds that are pleasing to the ear.
ear is the organ of the sense of hearing.
I can't bear it.
Why on earth would anyone feel sorry for you?

この中から "ear"(耳)という単語だけをきれいに抜き出してほしい、と言われたら、あなたはどうしますか?

とりあえず、Rubularに上の例文を貼り付けましょう。

Kobito.Aeuz1M.png

それから Your regular expression 欄に ear と入力してみてください。

Kobito.i8ktCs.png

むむむ。単純に "ear" を検索すると、"hearing" や "bear"、 "earth" も一緒に引っかかってしまいました。。。

こんなときに便利なのが、\b というメタ文字です。
これは「単語の境界」を表します。(位置を示すので アンカー の一種です)
単語の境界とはスペースだったり、ピリオドだったり、ダブルクオートだったり、行頭や行末だったり、様々です。

試しに \b とだけRubularに入力してみてください。
Match result欄には次のように表示されます。

Kobito.J0vUF3.png

水色の部分が単語の境界です。
これらはスペース文字やピリオドにマッチしているのではなく、あくまで単語の直前や直後という 位置 にマッチしています。

というわけで、正規表現欄に \bear\b と入力してみましょう。
ちょっとややこしいですが、上の正規表現は \b + ear + \b という構成になっていて、「直前と直後に単語の境界が来る "ear"」という意味です。("bear"ではありませんよ)

Kobito.BI3Uhl.png

こうすると、きれいに "ear" だけを抜き出すことができました!

検索性の低いメソッドをきれいに抜き出す(\b の使い方・その2)

\b の利用例をもう一つ挙げましょう。
Ruby on Railsの開発経験がある人はご存知かもしれませんが、Railsには I18n.t というメソッドがあります。
これは引数で渡されたキーに応じて、英語や日本語といった各言語の訳文を出力するメソッドです。

参考:3.1 訳文を追加する | Railsガイド

ただし、ビュー(画面のコード)の中では I18n. を省略して、t だけでこのメソッドを呼び出せるようになっています。

具体的にはこんな感じです。

<td>
<%= link_to I18n.t('.show'), user %>
<%= link_to t('.edit'), edit_user_path(user) %>
</td>

2行目の I18n.t('.show') と、3行目の t('.edit')t メソッドを使っている部分です。(例題なので、あえて一貫性のない書き方にしてあります)

「リファクタリングのため、このコードの中で t メソッドを使っている部分だけを見つけたい」と思ったらどうしますか?

たとえば単純に "t" だけを検索するとこうなります。

Kobito.9LFpyL.png

当然ですが、あらゆる "t" にマッチするので、大量の "t" が引っかかります。

しかし、メソッドの呼び出しは I18n.t('.show')t('.edit') のように書かれます。
また、Rubyはカッコを省略できるので t '.edit' みたいな書き方もできます。
つまり、"t" の前後にはピリオドやカッコ、スペース等が入ってきます。
これらは正規表現上、単語の境界と見なされます。

よって、\bt\b という正規表現を使えば、t メソッドを呼びだしている部分だけを抜き出すことができます。
実際にやってみましょう。

Kobito.AbPYpT.png

ご覧の通り、t メソッドだけを抜き出すことができました!
こんなふうに \b を使うと、ソースコードから特定のメソッドや変数を検索したりするときに役立つことがあります。

ファイル名だけをピタリと抜き出す(肯定の後読み)

次に登場するのはこんなテキストです。

type=zip; filename=users.zip; size=1024;
type=xml; filename=posts.xml; size=2048;

このテキストでは「(キー)=(値);」をいくつかつなげることでひとつのレコードが表現されています。

さて、この中からファイル名だけを抜き出してみましょう。
つまり、"users.zip" と "posts.xml" だけを抜き出すわけです。

ここまでに学んできた知識を使えば、次のような正規表現が書けそうです。

filename=[^;]+

上の正規表現の意味がわからない人は第2回の記事を読み直しましょう。
「それぐらいもう理解できてるわっ!」という人はこれをRubularに入力してみてください。

Kobito.3HBm78.png

おー、できたできた!・・・かな?
まあ、悪くはないですが、"filename=" の部分もマッチしてますね。

ならばこうだ!というわけでキャプチャのカッコを付けてみましょう。
つまりこういう正規表現です。

filename=([^;]+)

Kobito.MusL3m.png

はい、Match groups 欄に "users.zip" と "posts.xml" が出てきました。
あとはこれをプログラムか何かで操作してやればOK・・・という方法も確かにアリだと思います。

ただ、中級者以上を目指すのであれば、「先読み」と「後読み」というテクニックもマスターしたいところです。

今回のケースであれば「肯定の後読み」というテクニックが使えます。
とりあえず、(?<=filename=) という正規表現をRubularに入力してみてください。
すると、こんな結果になります。

Kobito.Nuaeie.png

Match result 欄だけを拡大するとこうなります。

Kobito.X9JpHp.png

"=" とファイル名の間にスペースは空いていないのですが、どうもその部分がハイライトされています。
これは実は "filename=" という文字列の「直後の位置」にマッチしています。

一般に (?<=abc) のように書くと "abc" という文字列そのものではなく、その文字列の「直後の位置」(abc であれば c の直後)にマッチします。
これを (肯定の)後読み と言います。

これだけ聞いてもピンと来ないかもしれませんが、とりあえず先に進みます。
(?<=filename=) だけだと「位置」にしかマッチしていないので、ファイル名もマッチさせましょう。
次のような正規表現を Rubular に入力してください。

(?<=filename=)[^;]+

こうすると "users.zip" と "posts.xml" だけがマッチします。

Kobito.zkhetu.png

(?<=filename=)[^;]+ の意味を日本語で書くならこうなります。

「"filename=" という文字列の直後から始まって、";" 以外の文字が1文字以上続く」

繰り返しになりますが、 (?<=filename=) は "filename=" という文字列そのものではなく、その文字列の直後という「位置」を表していることに注意してください。

「うーん、ややこしい!全然わからん!!」という人へ

はい、非常にややこしいですね。
ぶっちゃけ、後読みや先読み(後述します)は無理に使わなくても何とかなります。
人間が目視で確認するなら、前述の filename=[^;]+ みたいな正規表現で十分です。

後読みや先読みを使うと便利なのは、Rubyの scan メソッドや、JavaScriptの match メソッドを使うときです。
Rubyの場合、先ほどの文字列から "users.zip" と "posts.xml" を抜き出したいと思ったら、次のようにして一発で抜き出すことができます。

text = <<-TEXT
type=zip; filename=users.zip; size=1024;
type=xml; filename=posts.xml; size=2048;
TEXT
text.scan(/(?<=filename=)[^;]+/)
# => ["users.zip", "posts.xml"]

後読みを使わない場合はちょっと一手間必要になります。
('='で分割して後ろを取る、といった処理が必要になります)

text = <<-TEXT
type=zip; filename=users.zip; size=1024;
type=xml; filename=posts.xml; size=2048;
TEXT
text.scan(/filename=[^;]+/).map { |s| s.split('=').last }
# => ["users.zip", "posts.xml"]

というわけで、「後読みや先読みを駆使しなければどうしようもない」というケースはあまりありません。
もちろん使いこなせた方が便利ですが、使えなくても何とかなります。

注意:JavaScript や Atom では「後読み」が使えません

ここで紹介した「後読み」ですが、残念ながら JavaScript(JS) や Atom では使えません。
(?<= ) が正規表現に含まれると構文エラーが発生します。

var text = "type=zip; filename=users.zip; size=1024;\ntype=xml; filename=posts.xml; size=2048;\n";

var matched = text.match(/(?<=filename=)[^;=]+/g);
// => SyntaxError: Invalid regular expression: /(?<=filename=)[^;=]+/: Invalid group

Kobito.YsJ5iN.png

ただしネットを調べると、JSでも今後使えるようになる、という情報もありました。

参考:正規表現の後読みが実装された - JS.next

また、以下のGitHub issueでは「JSで使えない正規表現はAtomでも使えない」という旨のコメントが載っていました。(Atomは内部的にJSで実装されているから?)

参考:positive and negative lookbehind (lookaround) · Issue #571 · atom/find-and-replace

JSやAtomは僕の専門ではないので、このあたりの動向に詳しい人がいたらコメント等で教えてください。

特定の楽器を担当しているメンバーを抜き出す(肯定の先読み)

次は後読みの反対の「先読み」を使ってみましょう。
たとえばこんなテキストがあったとします。(どうやらバンドメンバーの紹介みたいですね)

John:guitar, George:guitar, Paul:bass, Ringo:drum
Freddie:vocal, Brian:guitar, John:bass, Roger:drum

このテキストからベース(bass)を担当しているメンバーの名前を抜き出してみましょう。
つまり、"Paul" と "John" を抜き出せばOKです。

このテキストは「(名前):(パート)」の順に情報が並んでいます。
\w+:bass とすれば、とりあえず "Paul:bass" と "John:bass" は抜き出せますが、":bass" の部分がちょっと邪魔です。

Kobito.LSmgZJ.png

そこで「先読み」のテクニックを使います。
まず、(?=:bass) という正規表現を入力してみてください。

Kobito.trgyRB.png

Match result 欄だけを抜き出すとこうなっています。

Kobito.fwpBOw.png

ここでは ":bass" という文字列の「直前の位置」にマッチしています。

一般に (?=abc) のように書くと "abc" という文字列そのものではなく、その文字列の「直前の位置」(abc であれば a の直前)にマッチします。
これを (肯定の)先読み と言います。

さて、"Paul" や "John" といった名前は ":bass" の左側にあるので、名前を抜き出す正規表現も "(?=:bass)" の左側に書いてやります。
つまり、こうなります。

\w+(?=:bass)

これは「":bass" という文字列の直前にある、1文字以上続く英単語の構成文字」という意味です。

上の正規表現をRubularに入力してみましょう。

Kobito.V7GR90.png

はい、ちゃんと "Paul" と "John" を抜き出せました!

JavaScriptで使ってみる

先読みはJSでも使えます。
以下は先読みを使ったJSのサンプルコードです。

var text = "John:guitar, George:guitar, Paul:bass, Ringo:drum\nFreddie:vocal, Brian:guitar, John:bass, Roger:drum";

console.log(text.match(/\w+(?=:bass)/g));
// => [ 'Paul', 'John' ]

Atomで使ってみる

JSと同様、Atomでも先読みは使えます。

Kobito.Gb00Lk.png

Rubyで使ってみる

もちろんRubyでも使えます。

text = <<-TEXT
John:guitar, George:guitar, Paul:bass, Ringo:drum
Freddie:vocal, Brian:guitar, John:bass, Roger:drum
TEXT
text.scan(/\w+(?=:bass)/)
# => ["Paul", "John"]

間違った都道府県名を見つける(否定の後読み)

先読みと後読みは否定条件を指定することも可能です。
たとえば、以下のような(おかしな)都道府県のテキストがあったとします。

東京都
千葉県
神奈川県
埼玉都

みなさんご存知の通り、都道府県名で "都" が使えるのは東京都だけです。
それ以外は間違った都道府県の記述になります。

そこで (?<!東京)都 という正規表現を入力してみましょう。
こうすると間違った「都」の使い方を見つけることができます。

Kobito.8F4rPa.png

はい、最後に出てきた "埼玉都" の "都" がマッチしましたね。

一般に (?<!abc) のように書くと "abc" という文字列 以外 の「直後の位置」にマッチします。
これを 否定の後読み と言います。

(?<!東京)都 は「"東京" 以外の文字列の直後に出てくる "都"」の意味になるので、"埼玉都" の "都" がマッチします。

「"東京" 以外の文字列の直後」というのは言葉だけだとピンと来ないかもしれませんが、Rubularに (?<!東京) だけを入力すると分かるかもしれません。

Kobito.YZMHZs.png

上の画像をよく見ると "東京都" の "京" と "都" の間だけハイライトがありませんね。
なので、"東京都" はマッチせず、"埼玉都" がマッチすることになります。

なお、「肯定の後読み」と同様、 「否定の後読み」も JS や Atom では使えません。

「食べ物のサザエ」を見つける(否定の先読み)

一方、否定の先読みを使うこともできます。
ちょっとふざけた例題ですが、次のテキストから「食べ物のサザエ」を抜き出してみましょう。

つぼ焼きにしたサザエはおいしい
日曜日にやってるサザエさんは面白い

日本に長年住んでる人であれば、どちらが食べ物で、どちらがアニメなのか分かりますよね。
こういう場合は サザエ(?!さん) という正規表現を使うと「食べ物のサザエ」を抜き出せます。

Kobito.x2snrb.png

一般に (?!abc) のように書くと "abc" という文字列 以外 の「直前の位置」にマッチします。
これを 否定の先読み と言います。

サザエ(?!さん) は「"さん" 以外の文字列の直前に出てくる "サザエ"」の意味になるので、1行目の "サザエ" が抽出されます。

念のため (?!さん) がどこにマッチするか(むしろマッチしないか)を確認しておきましょう。

Kobito.RcZEuF.png

ご覧の通り、"サザエさん" の "エ" と "さ" の間だけがハイライトされていません。
なので サザエ(?!さん) は ”サザエさん” にマッチしないわけです。

なお、「肯定の先読み」と同様、 「否定の先読み」は JS や Atom でも使えます。

URLがそのまま画面上に表示されているリンクを見つける(後方参照)

第2回の記事では ( ) を使って文字列をキャプチャし、置換するときに \1$1 といった連番で参照する、というテクニックを紹介しました。
これは置換するときでなく、正規表現の内部でも同じように参照することができます。
これを 後方参照 といいます。

たとえば以下のようなHTMLテキストがあったとします。

<a href="http://google.com">http://google.com</a>
<a href="http://yahoo.co.jp">ヤフー</a>
<a href="http://facebook.com">http://facebook.com</a>

この中から「URLがそのまま画面上に表示されているリンク(1行目と3行目)」を検索してみましょう。
この場合は次のような正規表現を使います。

<a href="(.+?)">\1<\/a>

正規表現内で使われている \1 に注目してください。
これは「( ) でキャプチャされた1番目の文字列」を表しています。
つまり、(.+?)\1 は同じ文字列を指すことになります。

というわけで、以下がRubularの実行結果です。

Kobito.9uScid.png

意図したとおり、URLがそのまま画面上に表示されているリンクを抽出できました。

ツイート、アカウント、ツイート日時を抽出する(メタ文字の複雑な組み合わせ)

さて、ここまでに学んだ知識を総動員して、ちょっと複雑な正規表現を作ってみましょう。
今回用意するのはこんなテキストです。

You say yes. - @jnchito 8s
I say no. - @BarackObama 12m
You say stop. - @dhh 7h
I say go go go. - @ladygaga Feb 20
Hello, goodbye. - @BillGates 11 Apr 2015

これは(架空の)ツイート一覧です。
「(ツイート) - (アカウント) (ツイート日時)」という形式でツイートが並んでいます。

ここからツイートとアカウントとツイート日時をそれぞれ抽出してみましょう。
つまり、こんな Match groups を作るのがゴールです。

Kobito.nDwlSA.png

みなさんだったらどんな正規表現を作りますか?
ちょっと難しそうに見えるかもしれませんが、ここまで学んだ知識を使えば何とかなるはずです。

ツイートを抜き出す

ツイートの部分は "You say yes. - " のようになっているので、「行頭からハイフンまでの任意の文字列」というようなパターンにしましょう。

というわけで、次のような正規表現でツイートを抜き出します。

^(.*) - 

Rubularに入力するとちゃんとツイートが抜き出せました。

Kobito.rlSAzW.png

アカウントを抜き出す

アカウントを抜き出すのも難しくないと思います。
この場合は「"@" で始まり、任意のアルファベットが続く文字列」というパターンでいいはずです。
実際はアルファベットに限定せず、「英単語を構成する文字」であればOKなので、\w を使うことにします。

(@\w+)

上の正規表現をRubularに入力すると、ご覧の通りアカウントが抜き出せました。

Kobito.2Y0pST.png

ツイート日時(秒、分、時間)を抜き出す

ややこしいのはツイート日時の部分です。
日時は数秒前なら "1s"、数分前なら "12m"、数時間前なら "1h"、1日以上前なら "Feb 20"、1年以上前なら "11 Apr 2015" という形式で表示されます。
ここでは一度に全部処理しようとせず、いったんそれぞれのケースに対応する正規表現を考えていきましょう。

まずは数秒、数分、数時間のケースです。
この場合は「数字 + s/m/h」というパターンになっているので正規表現で書きやすいです。
とりあえず (\d+[smh]) という正規表現にしておけば大丈夫でしょう。

Kobito.YyvBEh.png

はい、最初の3件についてツイート日時を抜き出すことができました。

ツイート日時(1日以上前)を抜き出す

1日以上前の場合は「アルファベット3文字 + 数字」というパターンになっています。
アルファベットを \w で簡易的に表して、(\w{3} \d+) にしてみましょう。

Kobito.edByPR.png

うーん、ちょっとムダにキャプチャしすぎちゃってるので、もう少し厳密な「大文字1文字 + 小文字2文字」というパターンに変えましょう。
というわけで次の正規表現を入力してください。

([A-Z][a-z]{2} \d+)

Kobito.tqjdOx.png

これでもまだ意図していない "11 Apr 2015" の "Apr 2015" にマッチしちゃってますが、まあいったん良しとしましょう。

ツイート日時(1年以上前)を抜き出す

では、最後の1年以上前の日時を抜き出すケースについて考えます。
これは「数字 + アルファベット3文字 + 数字」というパターンになっています。
これを新たに日時を抜き出す3つ目の正規表現として作り直しても良いのですが、パターンだけ見ると先ほどの「アルファベット3文字 + 数字」によく似ています。
そこで、1日以上前と1年以上前を同時に抜き出す正規表現を作りましょう。
違いはアルファベット3文字の前に数字が来るかどうかなので、「(アルファベットの前に)数字あり、またはなし」にすればOKです。
つまり、こんなふうに書けます。

((?:\d+ )?[A-Z][a-z]{2} \d+)

Rubularでもちゃんと抜き出すことができました。

Kobito.I77aQz.png

この正規表現を簡単に説明しておきましょう。

(?:\d+ )? の部分が「数字あり、またはなし」の正規表現です。

「直前の文字が1個、またはゼロ」を表す ?( ) の後ろに置くと、「カッコに囲まれた文字列が1個、またはゼロ」の意味になります。
( ) の後ろに置ける量指定子は ? だけではありません。
+*( ) の後ろに置くことができます。
これは今回初めて紹介した内容なのでぜひ覚えておきましょう。

ただし、単純に ( ) で囲むとキャプチャの対象になってしまうので、(?: ) を使ってキャプチャ対象外にしています。
また、厳密にいうと日にちの後ろにはスペースが入るので、(?:\d+ )\d+ のうしろにスペースが入っています。

すべての日時を一度に抜き出す

さて、ここまで日時を抜き出す正規表現をバラバラに考えてきました。

  • 秒、分、時間の場合 = (\d+[smh])
  • 1日以上前、1年以上前の場合 = ((?:\d+ )?[A-Z][a-z]{2} \d+)

1つの正規表現で両方のケースを検索するためにはどうしたらいいでしょうか?
これは単純に両者をOR条件でくっつければOKです。
OR条件を作る場合は、| というメタ文字を使うんでしたね。
つまりこうすればOKです。

(\d+[smh]|(?:\d+ )?[A-Z][a-z]{2} \d+)

OR条件でくっつけると、すべての日時を抜き出すことができました!

Kobito.k5quei.png

このようにOR条件の | を使うときは、それぞれの条件内でもメタ文字を使って複雑な条件(パターン)を指定することができます。

仕上げ:ツイート、アカウント、日時を一気に抜き出す

さあ、ここまで来れば終わったも同然です。
それぞれの正規表現をつなげれば、ツイートとアカウントとツイート日時を一気に抜き出す正規表現が作れます。
念のため、ここまでに作った正規表現をおさらいしましょう。

  • ツイート = ^(.*) - 
  • アカウント = (@\w+)
  • ツイート日時 = (\d+[smh]|(?:\d+ )?[A-Z][a-z]{2} \d+)

では、正規表現を連結します。

^(.*) - (@\w+) (\d+[smh]|(?:\d+ )?[A-Z][a-z]{2} \d+)

これだけ見ると長くて複雑な呪文ですが、順を追って説明してきたので、どの部分が何を表しているのか、みなさんは理解できるはずです。

Rubularに貼り付けて動作確認してみましょう。

Kobito.e0hk60.png

はい、ちゃんと各パーツを抜き出せました!

このように、正規表現のルールをきっちり理解していれば、長くて複雑な正規表現も読み書きできます。
そしてプログラム内で正規表現を使えば、すっきりとしたコードで複雑な文字列処理を実装することができます。

Railsの入力値チェックは ^ $ ではなく \A \z を使う

この内容は結構長くなる上に言語固有の話が大半を占めるため、別記事として公開しました。

Railsの正規表現でよく使われる \A \z って何?? - Qiita

名前付きキャプチャを使う

こちらも長くなるので別記事として公開しました。名前付きキャプチャの使い方は以下のページをご覧ください。

正規表現で名前付きキャプチャを使う - Qiita

【重要】正規表現とパフォーマンス

さて、ここまではずっと「正規表現はすごいよ!便利だよ!」というスタンスで正規表現の便利機能を紹介してきました。
しかし、残念ながら正規表現も無敵のツールではありません。
実は書き方を間違えると とんでもなく遅い正規表現 を作ってしまうことがあります。

たとえば・・・と、例を見せる前に、注意事項です。

今から紹介する正規表現は必ずローカル環境で実行してください!!

Rubularのようなオンラインツールで実行すると、サーバーの負荷を挙げてしまいかねません!
いいですか?必ず約束を守ってくださいね。

はい、というわけで紹介します。
こんなテキストと正規表現がとんでもなく遅くなるケースの一例です。

  • テキスト: _a____
  • 正規表現: (_+|\w+)*a

たとえばAtomで実行するとどうなるでしょうか?

Kobito.Osj9cJ.png

え?「楽勝で動いた」って?
いいでしょう。

じゃあ次は _a_________ みたいに末尾のアンダースコアを伸ばしてから Find ボタンをクリックしてください。
(Rubularで実行しちゃダメですよ!!)

Kobito.aLo8i8.png

まだ大丈夫?じゃあもっと伸ばしてみたらどうなりますか?
たとえばこれぐらい!

_a_____________________

Kobito.8q4nXu.png

・・・そろそろみなさんの悲鳴が聞こえてきた頃だと思います。(ニヤリ)
この正規表現はAtomだけでなく、RubyやJavaScriptで実行してもすごく遅くなるはずです。

2022.12.24追記
Ruby 3.2では正規表現が高速化されたため、上の例を実行してもすぐに結果が返るようになりました。
LO3ptRSQfQ.gif
技術的な詳しい説明は以下の記事をご覧ください。

(↑追記はここまで)

なぜ遅くなるのかというと、それは バックトラック と呼ばれる正規表現の動作原理にありまして・・・と説明したいのですが、語り出すと長くなってしまうのでここでは省略します。
(わかりやすく説明できるほど僕が理解し切れていないという理由もあります。すいません・・・)

正規表現が遅くなる技術的な説明や、問題を起こす正規表現の具体例をもっと知りたい人は、

  • 正規表現 パフォーマンス
  • 正規表現 遅い
  • 正規表現 バックトラック

といったキーワードでネットを検索してみてください。

なお、僕は正規表現のパフォーマンス問題を学習するにあたって、以下の資料を参考にしました。

(2021.5.17追記)
以下の記事で勉強するのもお勧めです。

また、パフォーマンスの悪い正規表現を意図的に攻撃する「ReDoS攻撃(ReDoS = Regular expressions DoS)」と呼ばれる手法もあります。ずさんな正規表現はサービスの脆弱性にもつながる可能性もあるので要注意です。

ツールを使ってパフォーマンスの善し悪しを確認する

残念ながら「こう書くと遅くなる」「こう書けば絶対安全」というルールは簡単に示せませんが、少なくとも先ほど示した (_+|\w+)*a のように、+*( ) の中にも外にも出てくる正規表現は危険です。
こういう正規表現は内部的な組み合わせの数が爆発的に増え、とんでもなく遅くなることが多いです。

もし「この正規表現、ちょっと怪しいな」と思ったら、実際に動かす前にオンラインツールでパフォーマンスを確認するといいかもしれません。
以下のツールを使うと、パフォーマンスの善し悪しをある程度判定できます。

Online regex tester and debugger

基本的な使い方はRubularと同じです。
正規表現とテキストを入力すると、その正規表現がテキストにマッチするかどうか判定してくれます。
このツールはそれだけでなく、フォームの右上に「マッチするまで(またはマッチに失敗するまで)のステップ数」が表示されます。
(ステップ数は正規表現エンジンの内部的な処理ステップの数です)

以下は遅い正規表現として最初に示した _a____(_+|\w+)*a の実行結果です。
この場合、マッチするまでに491ステップかかったようです。

Screen Shot 2016-02-22 at 08.44.00.png

ステップ数は入力するテキストによっても変わります。
たとえば _a_____________________ のようなテキストを入力してみましょう。
(このサイトであれば途中で中断されるので大丈夫です)

Kobito.rOJd0I.png

ご覧の通り、赤いボックスで ERROR と表示されました。
ERROR になったのはステップ数があまりにも多くなりすぎたせいです。
つまり、これはパフォーマンスの悪い正規表現だということがわかります。

こんな感じで正規表現やテキストをいろいろ変えていくと、パフォーマンスの善し悪しを判別できます。

ちなみにステップ数が表示されるのは画面右にある FLAVOR から "pcre(php)" か "python" を選んだときだけです。

Kobito.NwrljS.png

なので、表示されるステップ数と Ruby や JS で実行したときのステップ数とは完全に一致しないかもしれません。
とはいえ、パフォーマンスの目安として確認するのであれば、言語や環境を問わず、十分参考になると思います。
なぜなら、大半の正規表現エンジンは NFA(Non determistic Finite Automaton)と呼ばれる仕組みを使っており、動作原理はどれもほぼ同じだからです。

その他、知っておくと役立つ知識

さあ、「手と目で覚える正規表現入門」も残りわずかです。
ここからあとは、これまで説明してこなかった「知っておくと役立つ知識」を紹介します。

それほど難しい内容を説明するわけではないので、各セクションは簡単な説明にとどめます。

メタ文字のエスケープ

第1回の記事からここまでをふりかえると、かなりたくさんのメタ文字を紹介してきました。
正規表現を書いた時に特別扱いされる文字をまとめると、以下のようになります。

  • \\t\w 等で使われる)
  • ^
  • $
  • *
  • +
  • ?
  • .
  • |
  • {, }
  • (, )
  • [, ]
  • //abc/ のような記述が正規表現リテラルになる言語の場合)

しかし、場合によっては純粋に "(abc)" という文字列や、 "+" という記号を正規表現中で検索したくなる場合もあります。
そんなときはバックスラッシュを付けて特殊な文字をエスケープします。

たとえば、"users[100]" や "users[123]" という文字列があり、"[100]" や "[123]" の部分だけマッチさせたい場合は \[\d+\] と書きます。

Kobito.dxLRfS.png

[ ] はそのまま使うとメタ文字になりますが、\[ \] とバックスラッシュでエスケープしてやると、純粋に "[ ]" という文字にマッチするようになります。
これはその他のメタ文字(特別扱いされる文字)についても同様です。

よくありがちな間違いは "user.rb" や "base.css" のような拡張子付きのファイル名を検索するときに \w+.\w{1,3} のように書いてしまうことです。
ピリオド(.)は「任意の1文字」の意味なので、上の正規表現だと "user#rb" や "base%css" にもマッチしてしまいます。

Kobito.M7kL1d.png

拡張子付きのファイル名を検索するときは \w+\.\w{1,3} のようにピリオドを忘れずにエスケープしてくださいね!

Kobito.qlhBDi.png

[ ] 内で働きが変わるメタ文字と変わらないメタ文字

いずれか1文字を表す [ ] の中にメタ文字が入ると、メタ文字ではなく「ただの文字」として扱われるものがあります。
たとえば、[()$.*+?|{}] と書くと、「"(" か ")" か "$" か・・・ "}" のいずれか1文字」の意味になります。

試しにこんなテキストを使って確かめてみて下さい。

begin
  5.times { |n| puts (-10 * n + 1 / 0).zero? ^ true }
rescue
  puts $!
end

このテキストに対して [()$.*+?|{}] という正規表現を入力してみましょう。
すると、メタ文字の働きが消え、「ただの文字」として各種記号にマッチしているのがわかると思います。

Kobito.YgJg1m.png

一方、[\w\d\s\n] と書いた場合は「英単語を構成する文字、または半角数字、または空白文字、または改行文字のいずれか1文字」の意味になり、メタ文字としての働きを保ったままになります。
以下はその実行結果です。

Kobito.B71A0Q.png

さて、特殊なのは -^ です。
過去の記事でも説明しましたが、これは [ ] 内の位置によって意味が変わります。

[a-z] と書いた場合は「"a" または "b" ・・・ または "z" のいずれか1文字」の意味になります。(文字の範囲を表す)
しかし、[-az][az-] と書いた場合は「"a" または "z" または "-" のいずれか1文字」の意味になります。

[^abc] は「"a" でもなく "b" でもなく "c" でもない任意の1文字」の意味になります。(否定条件を表す)
しかし、[abc^] のように先頭以外の場所に ^ を書いた場合は「"a" または "b" または "c" または "^" のいずれか1文字」の意味になります。

おまけ:[\b] はバックスペース文字を表す

本記事の前半で「単語の境界」を表す \b というメタ文字を紹介しました。
これを [ ] の中に含めて [\b] のように書くと、単語の境界ではなく「バックスペース文字(0x08)」として扱われます。
・・・が、バックスペース文字を検索する機会は滅多にないと思うので、覚える必要はないでしょう。
(少なくとも僕は必要になったことがないです)

「n 個以上」や「n 個以下」を指定する

第1回の記事では {n}{n,m} で「直前の文字が n 個」や「直前の文字が n 個以上 m 個以下」としていするパターンを説明しました。

そのバリエーションで {n,}{,n} という書き方があります。
これはそれぞれ「直前の文字が n 個以上」と「直前の文字が n 個以下」の意味になります。

あまり実用的な例ではありませんが、次のようなテキストで確認してみましょう。

google
gooogle
goooogle
gooooogle
goooooogle

go{4,}gle という正規表現を入力すると、「"o" が4文字以上」の場合にマッチします。

Kobito.KSaNSt.png

go{,3}gle を入力すると、「"o" が3文字以下」の場合にマッチします。

Kobito.SjY3IJ.png

小文字とは逆の意味になる \W \S \D \B

これまでに \w\d\s\b というメタ文字を紹介しました。
正規表現にはこれと逆の意味になる \W\D\S\B が存在します。
それぞれのメタ文字の意味は以下の通りです。

  • \W = 英単語の構成文字以外(記号や空白文字など)
  • \D = 半角数字以外
  • \S = 空白文字以外
  • \B = 単語の境界以外の位置

最後の \B はちょっと意味がわかりにくいと思いますが、前半に登場した例文を使って確かめてみると意味がわかるかもしれません。

sounds that are pleasing to the ear.
ear is the organ of the sense of hearing.
I can't bear it.
Why on earth would anyone feel sorry for you?

\Bear\B をRubularに入力するとこうなります。

Kobito.F575rV.png

\B は「単語の境界以外の位置」なので、スペースやピリオドが左右にない "hearing" の "ear" がマッチします。

まとめ

本記事では以下のようなことを学びました。

  • \b は単語の境界を表す
  • (?=abc) は「abcという文字列の直前の位置」を表す(先読み)
  • (?<=abc) 「abcという文字列の直後の位置」を表す(後読み)
  • (?!abc) は「abcという文字列以外の直前の位置」を表す(否定の先読み)
  • (?<!abc) 「abcという文字列以外の直後の位置」を表す(否定の後読み)
  • キャプチャした文字列は正規表現内でも \1\2 といった連番で参照できる(後方参照)
  • ?*+ といった量指定子は ( ) の後ろに付けることもできる
  • | を使ったOR条件では、各条件内でもメタ文字が使える
  • 書き方によっては、とんでもなく遅い正規表現ができあがることもある
  • メタ文字はバックスラッシュ(\)でエスケープする
  • [ ] 内ではメタ文字の種類や使われる位置によって各文字の働きが異なる
  • {n,}{,n} はそれぞれ「直前の文字がn個以上」「n個以下」の意味になる
  • \W\S\D\B はそれぞれ \w\s\d\b の逆の意味になる

こういった内容は「そこそこ正規表現が使える」と思っている人でも全部は理解できていなかったりします。
なので、ここまでマスターすれば「正規表現使い」として十分なスキルを身につけていると言えるはずです!

なお、他にも言語や環境によっては、もっといろいろなメタ文字やテクニックを使えることがあります。
さらに知識を増やしたい人は、自分が使っている言語や実行環境の正規表現ドキュメントを読んでみてください。

さて、「手と目で覚える正規表現入門」は今回で終了です。
第1回から読んでくれた方は正規表現を理解できたでしょうか?

この連載では「正規表現ってすごい!面白い!」と思ってもらえるような例題作りに重点を置きました。
単に正規表現のルールを理解するだけでなく、「なるほど、こんな使い方ができるのか」「たしかにこんなときに使うと便利そうだな」と感じてもらえるような例題を考えてみたつもりです。

この連載を一通り読んで、「自分も今度正規表現を使ってみよう」と思ってもらえたら非常に嬉しいです。
もし何かわかりづらい点があればコメント等で教えてください。

それではここまで読んでくださって、どうもありがとうございました!
(あ、もし記事の内容に満足したらぜひ ストック してやってください♪)

おさらい:過去記事へのリンク集

初心者歓迎!手と目で覚える正規表現入門・その1「さまざまな形式の電話番号を検索しよう」 - Qiita

初心者歓迎!手と目で覚える正規表現入門・その2「微妙な違いを許容しつつ置換しよう」 - Qiita

初心者歓迎!手と目で覚える正規表現入門・その3「空白文字を自由自在に操ろう」 - Qiita

606
659
4

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
606
659