RegExp.prototype.execが偶数回しか動かない謎に遭遇したハナシ
先日、既存のコードのテストをしていたところ、正規表現のマッチの結果が実行するごとに変わるという謎の現象に遭遇しました。
適当な例を作るとこんな感じ。
const regExp= /\*\*(.+?)\*\*/g; // MarkdownのBoldの箇所を抽出する正規表現 const markdown = "こんにちは。今日の気温は**33度!**とめっちゃ暑いので熱中症に注意しましょう!" regExp.exec(markdown);
意図としては、Markdownの文章中からBoldのところ(アスタリスク2つで始まり、間になんらかの文字があって、アスタリスク2つで終わる)を抽出するという感じ。(もちろん実際のMarkdownパーサを書くならもっと考慮する事はありますが。。。)
実際には、これがUtil系の処理の一部として組み込まれていて、場合によっては数回叩かれるケースがある、というようなコードでした。
さて、これを実行してみると以下のような結果になります。
regExp.exec(markdown) /* ↓結果 [ '**33度!**', '33度!', index: 12, input: 'こんにちは。今日の気温は**33度!**とめっちゃ暑いので**熱中症に注意**しましょう!', groups: undefined ] */
Node.jsで実行していますが、ブラウザでも同じだとおもいます。
見事「33度!」という値が取り出せているので、これで良さそうな感じがします。
しかしこれと全く同じコードをもう一度実行するとこうなります。
regExp.exec('/hoge?fuga=thisisvalue') null
なんということでしょう…。
ちなみにこれ以降、もう一度実行すると成功し、さらにもう一度実行すると再びnullになるという挙動を繰り返します。
MDNのexecメソッドの説明をみると以下のように書いています。
exec() メソッドは、指定された文字列内で一致するものの検索を実行します。結果の配列、または null を返します。
実はこれが罠で、結果の配列を返すというのは、一致する全ての結果を配列で返すという意味ではないのです…。
今回正規表現にg
フラグが付いているのに気づいたでしょうか…?
これはグローバルフラグと呼ばれるもので、文字列中から正規表現に一致する全ての結果を得たいときに使います。今回の例の場合、これをつけるとexecは実行するたびに、前回ヒットした位置より後ろで、正規表現にマッチする箇所がないかを探すという挙動をします。
探した結果、存在しなければnullを返します。偶数回しかうまくいかなかった原因は、1回目は文字列を先頭から探して、ヒットする箇所があったので値が返却されましたが、2回目の実行では、前回のヒット位置より後ろ側にヒットする箇所がなかったためnullが返ります。これが今回遭遇したバグの原因となっていました。。。
どう解決するか
解決アプローチは複数あります。
まず1つ目。RegExpクラスのインスタンスは前回のヒットした位置をlastIndex
というプロパティで持っています。
とあるので、この値をexecの実行前に0に戻してあげれば、毎回同じ結果を返す事になります。
ただし、通常gフラグを使うという事は、複数マッチする事が想定されているはずなので、毎回0に戻すと、先頭のマッチしか取得できなくなってしまいます。
そこで2つ目のアプローチは、String.prototype.matchを使うというパターン。
こちらはexecとは異なり、gフラグが付いている正規表現を使用すると、一致する全ての箇所が配列となって返ってきます。
"**hoge** fuga piyo **puyo**".match(/\*\*(.+?)\*\*/g); // -> [ '**hoge**', '**puyo**' ]
ただしこちらは、マッチした位置のindexなどの詳しい情報や、カッコを使ったキャプチャ(正規表現内でカッコを使うとカッコ内の文字を取得できる)を使う事ができないため、必要に応じてマッチした文字列をさらに加工する必要が出る可能性があります。
……あ、もちろん先頭のマッチ結果しかいらないならgフラグを外す事でexecの結果は冪等になりますことよ!
要件次第というところかなとは思いますが、一つ言えるのはgフラグが付いている正規表現を使うときはロジックを一度確認してみたほうが良いということですね。
なるべく副作用のない関数を書きたいと心がけているのですが、まさかこんなところに罠が潜んでいるとは…。という感じでした。おわり。