2024年12月12日
進化した正規表現:JavaScriptの正規表現の歴史と未来
(2024-8-20)by Steven Levithan
本記事は、原著者の許諾のもとに翻訳・掲載しております。
クイックサマリー:以前は、JavaScriptの正規表現は他の言語の正規表現に比べてパフォーマンスが劣っていたものの、近年改良が重ねられ、他の言語に見劣りしなくなっています。この記事では、Steven Levithan氏がJavaScriptの正規表現の歴史と現状を評価し、より読みやすく、保守性とレジリエンスに優れた正規表現の書き方をアドバイスします。
モダンJavaScriptの正規表現は、皆さんがよく知っている従来の正規表現と比べると随分進化しました。正規表現はテキストを検索して置き換えるツールとして非常に優れている一方で、書くのも理解するのも難しいという根強い評判があります(しかし今から説明するように、この認識は時代遅れかもしれません)。
正規表現に関するこの認識は、JavaScriptに特に当てはまります。PCREやPerl、.NET、Java、Ruby、C++、Pythonといったよりモダンな言語の正規表現に比べてパフォーマンスが劣るJavaScriptの正規表現は、長年人気が低迷していました。しかしそれはもはや過去の話です。
この記事では、JavaScriptの正規表現が経てきた改良の歴史を説明し(ネタバレ:ES2018とES2024が大きな転機となりました)、モダンな正規表現の機能を実例とともに紹介します。さらに、JavaScriptの正規表現を他のモダンな言語の正規表現に匹敵する、あるいは勝るものにした軽量なJavaScriptライブラリについて紹介し、最後にJavaScriptの今後のバージョンで正規表現を改良し続けるために現在検討されている提案をいくつかお見せします(中には現在お使いのブラウザにすでに実装されているものもあります)。
JavaScriptにおける正規表現の歴史 #
1999年に標準化されたECMAScript 3は、Perl由来の正規表現をJavaScriptに導入しました。大幅な改良が加わり、正規表現はかなり便利になりましたが(他のほとんどのPerl由来の正規表現とも互換性を得ました)、それでもいくつか大きな漏れがありました。JavaScriptの次に標準化されたバージョンであるES5が登場するまで10年かかりましたが、その間に他のプログラミング言語と正規表現の実装にいくつか便利な機能が新たに追加され、それらの正規表現はより強力で読みやすくなりました。
しかしそれは当時の話です。
none
JavaScriptの新しいバージョンが出ると、ほぼ毎回正規表現に何らかの改良が行われていたことをご存知でしたか?
では実際に見てみましょう。
以下の機能の中には理解しにくいものもあるかもしれませんが、ご心配なく。主要な機能のいくつかは後で詳しく説明します。
- ES5(2009)では、正規表現のリテラル文字が評価されるたびに新しいオブジェクトが作成されるようにすることで、直感的でない振る舞いが修正され、正規表現のリテラル文字が文字クラス内でスラッシュをエスケープせずに使用できるようになりました(
/[/]/
)。 - ES6/ES2015では2つの正規表現フラグが新たに追加されました。パーサーで正規表現を使いやすくした
y
(sticky
)と、厳格なエラーとともにUnicode関連のいくつかの重要な改良を追加したu
(unicode
)です。また、RegExp.prototype.flags
ゲッター、RegExp
のサブクラス化対応、フラグを変更しながら正規表現をコピーする機能も追加されました。 - ES2018で、JavaScriptの正規表現はかなり改良されました。
s
(dotAll
)フラグ、後読み、名前付きキャプチャ、Unicodeプロパティ(ES6のu
フラグを必要とする\p{...}
と\P{...}
により指定)が追加されました。後で説明するように、これらはすべて極めて便利な機能です。 - ES2020では、文字列メソッドの
matchAll
が追加されました。これについても後ほど説明します。 - ES2022では、マッチしたサブ文字列が開始と終了のインデックスを取得できるようにする
d
(hasIndices
)フラグが追加されました。 - 最後に、ES2024ではES6で実装された
u
フラグのアップグレードとしてv
(unicodeSets
)フラグが追加されました。v
フラグは、複数文字による一連の「文字列プロパティ」を\p{...}
に追加し、\p{...}
と\q{...}
を使用して文字クラス内に複数文字のエレメントを追加するほか、ネストした文字クラス、差集合[A--B]
、積集合[A&&B]
を追加し、文字クラス内に異なるエスケープ規則を追加します。また、否定集合[^...]
内におけるUnicodeプロパティの大文字と小文字を区別しないマッチングも修正されました。
これらの機能は、現時点で安全にコードに組み込むことが可能です。これらの中で最新の機能であるv
フラグは、Node.js 20および2023年世代のブラウザでサポートされています。その他の機能は2021年以前のブラウザでサポートされています。
ES2019からES2023までの各エディションでは、\p{...}
と\P{...}
により指定可能なUnicodeプロパティも新たに追加されています。さらに、ES2021では文字列メソッドreplaceAll
も追加されました。ただし、正規表現を渡された際の挙動でES3のreplace
と唯一異なる点は、g
フラグを使用していない場合に例外をスローすることです。
余談:正規表現の良し悪しは何で決まる? #
これらの変更が実装された今、JavaScriptの正規表現は他の正規表現と比べてどうなのでしょうか。考え方は色々ありますが、主な要素を以下に挙げます。
-
パフォーマンス
これは重要な要素ではありますが、成熟した正規表現の実装は概ねかなり速いので、最も重要ではないかもしれません。JavaScriptは正規表現のパフォーマンスは強力ですが(少なくとも、Node.js、Chromiumベースのブラウザ、さらにはFirefoxで採用されているV8のIrregexpエンジンや、Safariで使われているJavaScriptCoreでは優れています)、バックトラッキングを制御する構文を一切持たないバックトラッキングエンジンを使用している点が大きな制約であり、ReDoSの脆弱性が広がる要因になっています。 -
一般的または重要なユースケースを扱う先進機能への対応
JavaScriptはES2018とES2024で先進機能への対応が大きく進みました。後読み(無限の長さに対応)やUnicodeプロパティ(複数文字による「文字列プロパティ」、差集合、積集合、スクリプト拡張機能に対応)など一部の機能において、JavaScriptは今やクラス最高レベルです。これらの機能は、他の多くの正規表現でサポートされていないか、堅牢性が劣ります。 -
読みやすく保守性に優れたパターンを書ける
ネイティブのJavaScriptは、この点で長年主要な言語の中で最低の評価を受けてきました。その理由は、ちょっとした空白やコメントを入力できるようにするx
(拡張)フラグが欠けているからです。正規表現のサブルーチンとサブルーチン定義グループ(PCREとPerlの機能)は、文法的に正しい正規表現を書き、合成により複雑なパターンを構築することを可能にする強力な機能ですが、これらもありません。
つまり、良い面も悪い面もあるということです。
JavaScriptの正規表現は極めて強力になりましたが、正規表現をより安全で、読みやすく、保守しやすくできる重要な機能がまだ欠けています(これは、その力を利用するのをためらう理由にもなっています)
(訳注:原文同様 XへのPOSTリンクとなっています)
幸い、これらの穴はすべてJavaScriptライブラリによって埋めることができます。それについては後ほど説明します。
JavaScriptのモダンな正規表現機能を使用する #
皆さんがあまり知らないかもしれない、もっと便利なモダン正規表現機能をいくつかご紹介します。先に言っておきますが、これはやや上級者向けのガイドです。正規表現を使い始めてまだ日が浅い方は、手始めに以下の素晴らしいチュートリアルをご覧いただくといいかもしれません。
- RegexLearnとRegexOneはインタラクティブなチュートリアルで、練習問題もあります。
- JavaScript.infoの正規表現の章は、JavaScriptに特化した詳細なガイドです。
- Demystifying Regular Expressions(動画)は、Lea Verou氏がHolyJS 2017で行った、初心者向けの素晴らしい講演です。
- Learn Regular Expressions In 20 Minutesは、実際に正規表現テスターを動かしながら構文を説明する動画です。
名前付きキャプチャ #
正規表現が一致するかどうかを確認するだけでなく、一致する文字列からサブ文字列を抽出し、コード上で何らかの操作を行いたい場合も多いでしょう。名前付きキャプチャグループを使うことで、正規表現とコードがより読みやすくなり、自己文書化するような方法でそれを行うことができます。
以下の例では、2つの日付を持つレコードをマッチングさせ、値を取得します。
const record = 'Admitted: 2024-01-01\nReleased: 2024-01-03';
const re = /^Admitted: (?<admitted>\d{4}-\d{2}-\d{2})\nReleased: (?<released>\d{4}-\d{2}-\d{2})$/;
const match = record.match(re);
console.log(match.groups);
/* → {
admitted: '2024-01-01',
released: '2024-01-03'
} */
この正規表現を理解できなくても大丈夫です。これをもっと読みやすくする方法を後で説明します。ここで重要なのは、名前付きキャプチャグループが(?<name>...)
構文を使用し、マッチした文字列のgroups
オブジェクト上にその結果が格納されるということです。
また、名前付き後方参照を使用して、名前付きキャプチャグループが\k<name>
によりマッチングした文字列を再度マッチングすることもでき、検索や置換の中でそれらの値を以下のように使用することができます。
// Change 'FirstName LastName' to 'LastName, FirstName'
const name = 'Shaquille Oatmeal';
name.replace(/(?<first>\w+) (?<last>\w+)/, '$<last>, $<first>');
// → 'Oatmeal, Shaquille'
置換コールバック関数内で名前付き後方参照を使用したい上級者は、最後の引数としてgroups
オブジェクトを指定します。以下に例を挙げます。
function fahrenheitToCelsius(str) {
const re = /(?<degrees>-?\d+(\.\d+)?)F\b/g;
return str.replace(re, (...args) => {
const groups = args.at(-1);
return Math.round((groups.degrees - 32) * 5/9) + 'C';
});
}
fahrenheitToCelsius('98.6F');
// → '37C'
fahrenheitToCelsius('May 9 high is 40F and low is 21F');
// → 'May 9 high is 4C and low is -6C'
後読み #
後読み(ES2018で導入)は、JavaScriptの正規表現で以前からサポートされていた先読みを補完する機能です。先読みと後読みはアサーション(文字列の始まりを示す^
や、単語の境界を示す\b
と似ています)であり、マッチの過程で文字を一切消費しません。後読みは、サブパターンが現在のマッチ位置の直前に見つかるかどうかで成否が決まります。
例えば、以下の正規表現は後読み(?<=...)
を使用して、「fat」の後に続く「cat」という単語(「cat」という単語のみ)をマッチングします。
const re = /(?<=fat )cat/g;
'cat, fat cat, brat cat'.replace(re, 'pigeon');
// → 'cat, fat pigeon, brat cat'
また、否定後読み(?<!...)
を使用してアサーションを反転させることもできます。そうすることで、正規表現は前に「fat」が付かないすべての「cat」をマッチングするようになります。
const re = /(?<!fat )cat/g;
'cat, fat cat, brat cat'.replace(re, 'pigeon');
// → 'pigeon, fat cat, brat pigeon'
後読みの実装はJavaScriptのものが最も優れています(匹敵するのは.NETだけです)。他の正規表現は後読みの中に可変長パターンを認めるかどうか、いつ認めるかについて一貫性のない複雑な規則を設けていますが、JavaScriptはあらゆるサブパターンについて後読みを認めています。
matchAll
メソッド #
ES2020ではJavaScriptのString.prototype.matchAll
が追加され、詳細なマッチ情報が必要な場合に正規表現マッチをループで実行しやすくなりました。以前は他の方法も利用できましたが、matchAll
の方が容易な場合が多く、長さゼロのマッチを返す可能性がある正規表現の結果に対してループ実行する際に無限ループに陥らないようにするなど、落とし穴も回避します。
matchAll
はイテレータ(配列ではなく)を返すため、for...of
ループにおいて使いやすいという利点があります。
const re = /(?<char1>\w)(?<char2>\w)/g;
for (const match of str.matchAll(re)) {
const {char1, char2} = match.groups;
// Print each complete match and matched subpatterns
console.log(`Matched "${match[0]}" with "${char1}" and "${char2}"`);
}
注:正規表現でmatchAll
を使用する場合、g
フラグ(global
)が必須となります。また、他のイテレータと同様にArray.from
または配列スプレッドを使用してすべての結果を配列として受け取ることも可能です。
const matches = [...str.matchAll(/./g)];
Unicodeプロパティ #
Unicodeプロパティ(ES2018で追加)は、\p{...}
構文とその否定形である`P{...}`を使用することで多言語テキストを強力に制御することを可能にします。マッチングできるプロパティは数百に及び、多岐にわたるUnicodeのカテゴリ、スクリプト、スクリプト拡張機能、バイナリプロパティを網羅します。
注:詳細についてはMDNのドキュメントをご覧ください。
Unicodeプロパティではu
(unicode
)またはv
(unicodeSets
)フラグの使用が必須です。
v
フラグ #
v
(unicodeSets
)フラグは、u
フラグのアップグレードとしてES2024で追加されました。これらのフラグを同時に使用することはできません。デフォルトのUnicode非対応モードでひそかにバグが紛れ込まないよう、ベストプラクティスとしていずれかのフラグを常に使用することが推奨されます。どちらを使用するかの判断は極めて単純です。v
フラグの環境(Node.js 20および2023年世代のブラウザ)のみのサポートで問題なければv
フラグを使用し、それ以外の場合はu
フラグを使用します。
v
フラグが実装されたことで、いくつかの新しい正規表現機能が追加されました。中でも特筆すべきなのが差集合と積集合です。これにより、A--B
(文字クラス内)を使用してAの文字列はマッチングし、Bの文字列はマッチングしないとか、A&&B
を使用してAとB両方の文字列をマッチングすることが可能になります。以下の例をご覧ください。
// Matches all Greek symbols except the letter 'π'
/[\p{Script_Extensions=Greek}--π]/v
// Matches only Greek letters
/[\p{Script_Extensions=Greek}&&\p{Letter}]/v
v
フラグに関する詳細や、vフラグの他の新機能については、Google Chromeチームによるこちらの説明をご覧ください。
絵文字のマッチングについて #
絵文字とは🤩🔥😎👌などのことですが、絵文字がテキスト上で符号化される方法は複雑です。正規表現で絵文字をマッチングしたい場合、1つの絵文字が1つのUnicodeコードポイントで構成されることもあれば、多くのUnicodeコードポイントで構成されることもあることを認識しておくことが重要です。絵文字の正規表現を独自に展開する多くの人(ライブラリもです!)がこの点を見落とし(あるいは適切に実装しておらず)、バグを発生させてしまっています。
「👩🏻🏫」(女性教師:明るい肌の色)の絵文字に関する以下の詳細は、絵文字がいかに複雑になり得るかを示しています。
// Code unit length
'👩🏻🏫'.length;
// → 7
// Each astral code point (above \uFFFF) is divided into high and low surrogates
// Code point length
[...'👩🏻🏫'].length;
// → 4
// These four code points are: \u{1F469} \u{1F3FB} \u{200D} \u{1F3EB}
// \u{1F469} combined with \u{1F3FB} is '👩🏻'
// \u{200D} is a Zero-Width Joiner
// \u{1F3EB} is '🏫'
// Grapheme cluster length (user-perceived characters)
[...new Intl.Segmenter().segment('👩🏻🏫')].length;
// → 1
幸い、JavaScriptは\p{RGI_Emoji}
により任意の完全な絵文字を個別にマッチングできる簡単な方法を追加しました。これは一度に複数のコードポイントをマッチングできる高度な「文字列プロパティ」のため、ES2024のv
フラグが必要になります。
v
フラグに対応していない環境で絵文字のマッチングを行いたい場合は、emoji-regexとemoji-regex-xsの2つの素晴らしいライブラリをチェックしてみてください。
より読みやすく、保守性とレジリエンスに優れた正規表現を書く #
正規表現機能は長年にわたり改良を重ねてきましたが、ネイティブのJavaScriptの正規表現は、複雑なものになると読むにしろ保守するにしろ、まだ難しい場合があります。
Regular Expressions are SO EASY!!!! pic.twitter.com/q4GSpbJRbZ
— Garabato Kid (@garabatokid) July 5, 2019
ES2018で追加された名前付きキャプチャは、正規表現の自己文書化を推し進めた素晴らしい機能であり、ES6のString.raw
タグにより、RegExp
コンストラクタを使用する際にバックスラッシュをすべてエスケープ処理する必要がなくなりました。可読性に関する主な改良はそれくらいです。
しかし、regex
という名前の軽量で高性能なJavaScriptライブラリ(筆者作)があり、これを使用することで正規表現が劇的に読みやすくなります。このライブラリは、欠けている主な機能をPCRE(Perl-Compatible Regular Expressions)から追加し、ネイティブのJavaScriptの正規表現を出力することで可読性の向上を実現しています。Babelのプラグインとして使用することもできるため、ビルド時にregex
の呼び出しがトランスパイルされます。したがって、デベロッパーエクスペリエンスが改善され、ユーザーにランタイムコストがかかることもありません。
PCREは、PHPが正規表現のサポートに使用している人気のCライブラリで、他の無数のプログラミング言語やツールで使用されています。
regex
という名前のタグ付きテンプレートリレラルを提供するregexライブラリが、どのようにしてわかりやすく保守しやすい複雑な正規表現を書く手助けをするのか、簡単に説明します。以下で説明する新しい構文は、すべてPCREで同様に機能します。
ちょっとした空白とコメント #
regex
は、デフォルトで空白や行コメント(#
で始まる)を自由に正規表現に追加できるようにすることで、可読性を改善しています。
import {regex} from 'regex';
const date = regex`
# Match a date in YYYY-MM-DD format
(?<year> \d{4}) - # Year part
(?<month> \d{2}) - # Month part
(?<day> \d{2}) # Day part
`;
これは、PCREのxx
フラグを使用するのと同じです。
サブルーチンとサブルーチン定義グループ #
サブルーチンは\g<name>
(nameは名前付きグループを指します)のように書き、参照されたグループを独立したサブパターンとして扱い、現在の位置でマッチしようとします。これにより、サブパターンの合成と再利用が可能になり、可読性と保守性が改善されます。
例えば、以下の正規表現は「192.168.12.123」のようなIPv4のアドレスとマッチします。
import {regex} from 'regex';
const ipv4 = regex`\b
(?<byte> 25[0-5] | 2[0-4]\d | 1\d\d | [1-9]?\d)
# Match the remaining 3 dot-separated bytes
(\. \g<byte>){3}
\b`;
さらに、サブルーチン定義グループを通じて参照のみで使用するサブパターンを定義することができます。こちらは先ほど見た名前付きキャプチャの項目の正規表現の改善例です。
const record = 'Admitted: 2024-01-01\nReleased: 2024-01-03';
const re = regex`
^ Admitted:\ (?<admitted> \g<date>) \n
Released:\ (?<released> \g<date>) $
(?(DEFINE)
(?<date> \g<year>-\g<month>-\g<day>)
(?<year> \d{4})
(?<month> \d{2})
(?<day> \d{2})
)
`;
const match = record.match(re);
console.log(match.groups);
/* → {
admitted: '2024-01-01',
released: '2024-01-03'
} */
モダンな正規表現のベースライン #
regex
にはデフォルトでv
フラグが含まれているため、有効化し忘れることがありません。また、ネイティブのv
フラグをサポートしていない環境では、自動的にu
フラグに切り替わりつつも、v
フラグのエスケープ規則を適用し、正規表現の前方互換性と後方互換性を確保します。
また、エミュレートされたx
(ちょっとした空白とコメント)およびn
(「名前付きキャプチャのみ」モード)フラグをデフォルトで暗黙的に有効化するため、毎回これらの上位モードを選択する必要がありません。さらに、タグ付きテンプレートリテラルであるため、RegExp
コンストラクタの場合のようにバックスラッシュ\\\\
をエスケープ処理する必要がありません。
アトミックグループと絶対最大量指定子で破壊的なバックトラックを回避できる #
アトミックグループと絶対最大量指定子も、regex
ライブラリで追加された強力な機能です。これらは主にパフォーマンスと破壊的なバックトラック(ReDoSとも呼ばれ、特定の正規表現が特定の、あまり一致しない文字列を検索する際に膨大な時間がかかる深刻な問題)に対するレジリエンスに関する機能ですが、より単純なパターンを書けるようにすることで可読性も改善します。
注:詳しくはregex
のドキュメントをご覧ください。
今後実装されそうなJavaScript正規表現の改良 #
JavaScriptの正規表現を改良するための様々な提案が現在検討されています。JavaScriptの将来バージョンに組み込まれる見込みの高い3つの提案について以下にご紹介します。
重複する名前付きキャプチャグループ #
これはステージ3(最終化間近)の改良案です。さらに良いことに、直近の情報によるとこの改良はすべての主要ブラウザで機能します。
名前付きキャプチャが最初に導入された際、すべての(?<name>...)
キャプチャで一意の名前を使用することが求められました。しかし、正規表現には複数の代替パスがある場合もあり、それぞれのパスで同じグループ名を使用した方がコードを単純化できます。
以下の例をご覧ください。
/(?<year>\d{4})-\d\d|\d\d-(?<year>\d{4})/
この改良案は、まさにこの例の構文を可能にするもので、このような構文で「重複するキャプチャグループ名」エラーが発生するのを防ぎます。ただし、各代替パスの中では引き続き一意の名前を使用する必要があります。
パターン修飾子(別名:フラググループ) #
こちらもステージ3の改良案です。Chrome/Edge 125およびOpera 111ではすでにサポートされており、近々Firefoxでもサポートされる予定です。Safariについては今のところ不明です。
パターン修飾子は(?ims:...)
、(?-ims:...)
、(?im-s:...)
のいずれかを使用し、正規表現の特定の部分のみについてi
、m
、s
フラグのオンとオフを切り替えます。
以下の例をご覧ください。
/hello-(?i:world)/
// Matches 'hello-WORLD' but not 'HELLO-WORLD'
RegExp.escape
による正規表現の特殊文字のエスケープ処理 #
この改良案は、長い時間を経てようやく最近ステージ3に到達しました。まだどの主要ブラウザも対応していません。謳い文句どおりRegExp.escape(str)
関数を提供することで、正規表現の特殊文字がすべてエスケープ処理された状態で文字列を返し、リテラル文字列としてマッチングすることを可能にします。
この機能がすぐに必要な場合、escape-string-regexpが最も広く使われているパッケージです(毎月5億回以上ダウンロードされています)。このnpmパッケージは、最低限のエスケープ処理を行う超軽量の専用ユーティリティです。ほとんどのケースではこれで十分ですが、エスケープ処理を行った文字列が正規表現内のどの位置でも安全に使用できることを確認する必要がある場合、escape-string-regexp
はこの記事ですでに紹介したregex
ライブラリを推奨しています。regex
ライブラリは、埋め込み文字列のエスケープ処理を補間により、コンテクストを意識した方法で行います。
まとめ #
この記事では、JavaScriptの正規表現の過去、現在、そして未来についてお話しました。
正規表現についてさらに詳しく学びたい方は、Awesome Regexに優れた正規表現テスター、チュートリアル、ライブラリ、その他のリソースが紹介されているのでぜひご覧ください。正規表現のクロスワードパズルに挑戦してみたい方は、regexleをお試しください。
皆さんの実りある構文解析と正規表現の可読性向上の一助になれば幸いです。
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa