正規表現

ある人々は、問題に直面すると、「そうだ、正規表現を使おう」と考えます。すると、彼らは二つの問題を抱えることになります。

Jamie Zawinski

木の繊維に逆らって切ろうとすれば、大きな力が要ります。問題の構造に逆らってプログラムを書こうとすれば、膨大なコードが必要になります。

元馬師、『プログラミングの書』
Illustration of a railroad system representing the syntactic structure of regular expressions

プログラミングツールと技法は、混沌とした進化的な方法で生き残り、広がっていきます。常に最高のものや最も輝かしいものが勝つわけではなく、適切なニッチの中で十分に機能するもの、あるいは別の成功した技術と統合されたものが勝つのです。

この章では、そのようなツールの1つである正規表現について説明します。正規表現は、文字列データのパターンを記述する方法です。それは、JavaScriptや他の多くの言語やシステムの一部を形成する、小さく独立した言語です。

正規表現は、非常に厄介でありながら非常に便利です。その構文は難解であり、JavaScriptが提供するプログラミングインターフェースもぎこちないものです。しかし、文字列を検査および処理するための強力なツールです。正規表現を適切に理解することで、より効果的なプログラマーになることができます。

正規表現の作成

正規表現はオブジェクトの一種です。`RegExp`コンストラクタを使って構築するか、パターンをスラッシュ(`/`)で囲んでリテラル値として記述することができます。

let re1 = new RegExp("abc");
let re2 = /abc/;

これらの正規表現オブジェクトはどちらも、同じパターンを表しています。`a`文字の後に`b`、`c`が続くパターンです。

`RegExp`コンストラクタを使用する場合、パターンは通常の文字列として記述されるため、通常のバックスラッシュに関するルールが適用されます。

スラッシュ文字でパターンを囲む2番目の表記法では、バックスラッシュの扱いが多少異なります。まず、スラッシュはパターンの終わりを示すため、パターンの一部として含めたいスラッシュの前にバックスラッシュを付ける必要があります。さらに、特殊文字コード(`\n`など)の一部ではないバックスラッシュは、文字列の場合のように無視されるのではなく、保持され、パターンの意味を変更します。疑問符やプラス記号などの一部の文字は、正規表現で特別な意味を持ち、文字自体を表す場合はバックスラッシュを前に付ける必要があります。

let aPlus = /A\+/;

一致のテスト

正規表現オブジェクトには、いくつかのメソッドがあります。最も単純なものは`test`です。文字列を渡すと、その文字列が式のパターンに一致するかどうかを示すブール値を返します。

console.log(/abc/.test("abcde"));
// → true
console.log(/abc/.test("abxde"));
// → false

特殊文字以外の文字のみで構成される正規表現は、単純にその文字列を表します。テスト対象の文字列の中に`abc`が発生した場合(先頭だけでなく)、`test`は`true`を返します。

文字の集合

文字列に`abc`が含まれているかどうかは、`indexOf`の呼び出しでも行うことができます。正規表現は、より複雑なパターンを記述できるため便利です。

数字に一致させたいとします。正規表現では、角括弧で囲まれた文字の集合は、その角括弧内の任意の文字に一致するようになります。

次の両方の式は、数字を含むすべての文字列に一致します。

console.log(/[0123456789]/.test("in 1992"));
// → true
console.log(/[0-9]/.test("in 1992"));
// → true

角括弧内では、2つの文字間のハイフン(`-`)を使用して文字の範囲を示すことができます。その順序は文字のUnicode番号によって決定されます。文字0から9は、この順序付け(コード48から57)で互いに隣り合っているので、`[0-9]`はそれらをすべて網羅し、任意の数字に一致します。

一般的な文字グループの多くには、独自の組み込みショートカットがあります。数字はその1つです。`\d`は`[0-9]`と同じ意味です。

\d任意の数字文字
\w英数字(「単語文字」)
\s任意の空白文字(スペース、タブ、改行など)
\D数字ではない文字
\W英数字ではない文字
\S空白文字ではない文字
.改行文字を除く任意の文字

01-30-2003 15:20のような日付と時刻の形式には、次の式で一致させることができます。

let dateTime = /\d\d-\d\d-\d\d\d\d \d\d:\d\d/;
console.log(dateTime.test("01-30-2003 15:20"));
// → true
console.log(dateTime.test("30-jan-2003 15:20"));
// → false

その正規表現は、まったくひどい見た目ですね。半分はバックスラッシュで、実際の表現パターンを見つけるのが難しくなるノイズを生み出しています。この式の少し改良版は後述します。

これらのバックスラッシュコードは、角括弧内でも使用できます。たとえば、`[\d.]`は任意の数字またはピリオド文字を意味します。角括弧内のピリオド自体は、特別な意味を失います。プラス記号(`+`)などの他の特殊文字についても同様です。

文字の集合を反転させるには、つまり、集合内の文字以外の文字に一致させたい場合は、開始括弧の後にキャレット(`^`)文字を記述します。

let nonBinary = /[^01]/;
console.log(nonBinary.test("1100100010100110"));
// → false
console.log(nonBinary.test("0111010112101001"));
// → true

国際的な文字

JavaScriptの初期の単純化された実装と、この単純化されたアプローチが後に標準動作として固定されたという事実のために、JavaScriptの正規表現は、英語以外の言語に現れない文字についてはかなり鈍感です。たとえば、JavaScriptの正規表現に関する限り、「単語文字」は、ラテンアルファベットの26文字(大文字または小文字)、10進数の数字、そして何らかの理由でアンダースコア文字のみです。`é`や`β`などは、間違いなく単語文字ですが、`\w`には一致せず(そして、非単語カテゴリである大文字の`\W`には一致します)。

奇妙な歴史的偶然により、`\s`(空白)にはこの問題がなく、Unicode標準が空白と見なすすべての文字に一致します。これには、改行不能スペースやモンゴル語の母音区切り文字なども含まれます。

正規表現では`\p`を使用して、Unicode標準が特定のプロパティを割り当てるすべての文字に一致させることができます。これにより、より国際的な方法で文字に一致させることができます。しかし、元の言語標準との互換性のために、正規表現の後に`u`文字(Unicode用)を付ける場合にのみ認識されます。

\p{L}任意の文字
\p{N}任意の数値文字
\p{P}任意の句読点文字
\P{L}任意の非文字(大文字のPは反転)
\p{Script=Hangul}指定されたスクリプトからの任意の文字(第5章を参照)

`\w`を、英語以外のテキスト(または「cliché」のような借用語を含む英語テキスト)を処理する必要があるテキスト処理に使用することは、`é`などの文字を文字として扱わないため、危険を伴います。`\p`プロパティグループは、冗長になりがちですが、より堅牢です。

console.log(/\p{L}/u.test("α"));
// → true
console.log(/\p{L}/u.test("!"));
// → false
console.log(/\p{Script=Greek}/u.test("α"));
// → true
console.log(/\p{Script=Arabic}/u.test("α"));
// → false

一方、数字に一致させて何か処理を行う場合は、多くの場合、数字に対して`\d`を使用したいと考えます。なぜなら、任意の数値文字をJavaScriptの数値に変換することは、`Number`のような関数ではできません。

パターンの部分を繰り返す

1桁の数字に一致させる方法がわかりました。では、整数、つまり1つ以上の数字のシーケンスに一致させたい場合はどうでしょうか。

正規表現で何か物の後にプラス記号(`+`)を付けると、その要素を複数回繰り返すことができることを示します。したがって、`/\d+/`は1つ以上の数字文字に一致します。

console.log(/'\d+'/.test("'123'"));
// → true
console.log(/'\d+'/.test("''"));
// → false
console.log(/'\d*'/.test("'123'"));
// → true
console.log(/'\d*'/.test("''"));
// → true

アスタリスク(`*`)は同様の意味を持ちますが、パターンを0回も一致させることができます。アスタリスクの付いたものは、パターンが一致しないことを防ぐことはありません。一致する適切なテキストが見つからない場合は、0インスタンスに一致します。

疑問符(`?`)は、パターンの部分をオプションにします。つまり、0回または1回発生する可能性があります。次の例では、`u`文字が発生する可能性がありますが、パターンはそれが存在しない場合にも一致します。

let neighbor = /neighbou?r/;
console.log(neighbor.test("neighbour"));
// → true
console.log(neighbor.test("neighbor"));
// → true

パターンを正確な回数繰り返すには、中括弧を使用します。たとえば、要素の後に`{4}`を付けることで、その要素を正確に4回繰り返す必要があります。この方法で範囲を指定することも可能です。`{2,4}`は、要素が少なくとも2回、最大4回発生する必要があることを意味します。

以下は、1桁と2桁の日付、月、時を両方許可する日付と時刻パターンの別のバージョンです。解読も少し簡単です。

let dateTime = /\d{1,2}-\d{1,2}-\d{4} \d{1,2}:\d{2}/;
console.log(dateTime.test("1-30-2003 8:45"));
// → true

中括弧を使用する場合は、カンマの後の数字を省略して、開いた範囲を指定することもできます。たとえば、`{5,}`は5回以上を意味します。

部分式をグループ化

`*`や`+`のような演算子を一度に複数の要素に適用するには、括弧を使用する必要があります。括弧で囲まれた正規表現の部分は、その後の演算子に関しては単一の要素としてカウントされます。

let cartoonCrying = /boo+(hoo+)+/i;
console.log(cartoonCrying.test("Boohoooohoohooo"));
// → true

最初の`+`文字と2番目の`+`文字は、それぞれ`boo`と`hoo`の2番目の`o`のみに適用されます。3番目の`+`は` (hoo+)`全体に適用され、そのようなシーケンスを1つ以上一致させます。

例における式の末尾のiは、この正規表現を大文字と小文字を区別しないものにします。そのため、パターン自体がすべて小文字であっても、入力文字列の大文字のBに一致させることができます。

一致とグループ

testメソッドは、正規表現に一致させるための最も簡単な方法です。一致したかどうかだけを返し、それ以外の情報は何も返しません。正規表現にはexec(実行)メソッドもあり、一致が見つからない場合はnullを返し、そうでない場合は一致に関する情報を含むオブジェクトを返します。

let match = /\d+/.exec("one two 100");
console.log(match);
// → ["100"]
console.log(match.index);
// → 8

execから返されるオブジェクトには、一致が成功した場所を文字列内で示すindexプロパティがあります。それ以外では、オブジェクトは文字列の配列のように見え(実際そうでもあります)、最初の要素は一致した文字列です。前の例では、これは私たちが探していた数字のシーケンスです。

文字列値には、同様に動作するmatchメソッドがあります。

console.log("one two 100".match(/\d+/));
// → ["100"]

正規表現に括弧でグループ化された部分式が含まれている場合、それらのグループに一致したテキストも配列に表示されます。全体の一致は常に最初の要素です。次の要素は、最初のグループ(式の中で開き括弧が最初に来るもの)に一致した部分、次に2番目のグループ、というようになります。

let quotedText = /'([^']*)'/;
console.log(quotedText.exec("she said 'hello'"));
// → ["'hello'", "hello"]

グループがまったく一致しない場合(たとえば、疑問符が続く場合)、出力配列のその位置にはundefinedが保持されます。グループが複数回一致する場合(たとえば、+が続く場合)、配列に含まれるのは最後の一致のみです。

console.log(/bad(ly)?/.exec("bad"));
// → ["bad", undefined]
console.log(/(\d)+/.exec("123"));
// → ["123", "3"]

括弧を、一致の配列に表示させずにグループ化のみに使用したい場合は、開き括弧の後に?:を付けることができます。

console.log(/(?:na)+/.exec("banana"));
// → ["nana"]

グループは、文字列の部分を抽出するのに役立ちます。文字列に日付が含まれているかどうかを確認するだけでなく、それを抽出してそれを表すオブジェクトを作成したい場合、数字のパターンに括弧を付け、execの結果から日付を直接取り出すことができます。

しかし、まずJavaScriptで日付と時刻の値を表す組み込みの方法について簡単に説明します。

Dateクラス

JavaScriptには、日付、あるいはむしろ時間点を表す標準のDateクラスがあります。単にnewを使用して日付オブジェクトを作成すると、現在の日時が取得されます。

console.log(new Date());
// → Fri Feb 02 2024 18:03:06 GMT+0100 (CET)

特定の時刻のオブジェクトを作成することもできます。

console.log(new Date(2009, 11, 9));
// → Wed Dec 09 2009 00:00:00 GMT+0100 (CET)
console.log(new Date(2009, 11, 9, 12, 59, 59, 999));
// → Wed Dec 09 2009 12:59:59 GMT+0100 (CET)

JavaScriptでは、月の番号は0から始まる(つまり、12月は11)のに対し、日の番号は1から始まるという規則を使用しています。これは混乱を招きやすく、ばかげています。注意が必要です。

最後の4つの引数(時、分、秒、ミリ秒)はオプションであり、指定されていない場合は0とみなされます。

タイムスタンプは、UTCタイムゾーンで1970年の開始からのミリ秒数として格納されます。これは、「Unix時間」によって設定された規則に従っており、その頃に発明されました。1970年以前の時刻には負の数を使用できます。日付オブジェクトのgetTimeメソッドはこの数値を返します。想像できるように、非常に大きな数です。

console.log(new Date(2013, 11, 19).getTime());
// → 1387407600000
console.log(new Date(1387407600000));
// → Thu Dec 19 2013 00:00:00 GMT+0100 (CET)

Dateコンストラクタに単一の引数を渡すと、その引数はそのようなミリ秒数として扱われます。新しいDateオブジェクトを作成してgetTimeを呼び出すか、Date.now関数を呼び出すことで、現在のミリ秒数を取得できます。

日付オブジェクトは、getFullYeargetMonthgetDategetHoursgetMinutesgetSecondsなどのメソッドを提供して、その構成要素を抽出します。getFullYear以外にもgetYearがありますが、これは1900を引いた年(例:98または125)を返し、ほとんど役に立ちません。

興味のある部分式の部分を括弧で囲むことで、文字列から日付オブジェクトを作成できます。

function getDate(string) {
  let [_, month, day, year] =
    /(\d{1,2})-(\d{1,2})-(\d{4})/.exec(string);
  return new Date(year, month - 1, day);
}
console.log(getDate("1-30-2003"));
// → Thu Jan 30 2003 00:00:00 GMT+0100 (CET)

アンダースコア(_)バインディングは無視され、execによって返される配列の完全一致要素をスキップするためだけに使用されます。

境界と先行参照

残念ながら、getDateは文字列"100-1-30000"からも日付を喜んで抽出します。一致は文字列内のどこでも発生する可能性があるため、この場合は2番目の文字から2番目から最後の文字までの一致になります。

一致が文字列全体をカバーする必要があることを強制したい場合は、マーカー^$を追加できます。キャレットは入力文字列の先頭に一致し、ドル記号は末尾に一致します。したがって、/^\d+$/は、1つ以上の数字だけで構成される文字列に一致します。/^!/は、感嘆符で始まる文字列に一致します。/x^/はどの文字列にも一致しません(文字列の先頭にxがあることはできません)。

また、片側に単語文字があり、もう片側に単語以外の文字がある位置である単語境界に一致する\bマーカーもあります。残念ながら、これらは\wと同じ単純な単語文字の概念を使用するため、信頼性が低いと言えます。

これらの境界マーカーは、実際の文字には一致しません。パターン内の出現場所に特定の条件が成り立つことを強制するだけです。

先行参照テストは同様のことを行います。パターンを提供し、入力パターンが一致しない場合は一致を失敗させますが、一致の位置を前方に移動しません。(?=)の間に記述されます。

console.log(/a(?=e)/.exec("braeburn"));
// → ["a"]
console.log(/a(?! )/.exec("a b"));
// → null

最初の例ではeが一致するために必要ですが、一致した文字列の一部ではありません。(?! )表記は否定的な先行参照を表します。これは、括弧内のパターンが一致しない場合にのみ一致するため、2番目の例では、後にスペースがないa文字のみに一致します。

選択パターン

テキストに数字だけでなく、数字の後にpigcowchickenのいずれかの単語、またはそれらの複数形が続くかどうかを知りたいとします。

3つの正規表現を作成して順番にテストすることもできますが、より良い方法があります。パイプ文字(|)は、左側のパターンと右側のパターンの間の選択肢を示します。次のような式で使用できます。

let animalCount = /\d+ (pig|cow|chicken)s?/;
console.log(animalCount.test("15 pigs"));
// → true
console.log(animalCount.test("15 pugs"));
// → false

括弧を使用して、パイプ演算子が適用されるパターンの部分を制限でき、複数のそのような演算子を隣り合わせに配置して、2つ以上の選択肢を選択できます。

一致のメカニズム

概念的には、execまたはtestを使用する場合、正規表現エンジンは、文字列の先頭から最初に式に一致させようとし、次に2番目の文字から、というように、一致が見つかるか文字列の末尾に達するまで試みて、文字列内で一致を検索します。最初に検出された一致を返すか、一致が見つからないと失敗します。

実際の一致を行うために、エンジンは正規表現をフローチャートのように扱います。これは、前の例の家畜の表現のダイアグラムです。

Railroad diagram that first passes through a box labeled 'digit', which has a loop going back from after it to before it, and then a box for a space character. After that, the railroad splits in three, going through boxes for 'pig', 'cow', and 'chicken'. After those it rejoins, and goes through a box labeled 's', which, being optional, also has a railroad that passes it by. Finally, the line reaches the accepting state.

ダイアグラムの左側を右側までたどることができるパスが見つかった場合、式は一致します。文字列内の現在位置を維持し、ボックスを通過するたびに、現在位置の後の文字列の部分がそのボックスに一致することを確認します。

バックトラック

正規表現/^([01]+b|[\da-f]+h|\d+)$/は、bが続く2進数、hが続く16進数(つまり、16進数で、文字aからfが数字10から15を表す)、またはサフィックス文字のない通常の10進数のいずれかに一致します。これが対応するダイアグラムです。

Railroad diagram for the regular expression '^([01]+b|\d+|[\da-f]+h)$'

この式に一致させる場合、入力に実際には2進数が含まれていない場合でも、多くの場合、上位(2進数)の分岐が入力されます。たとえば、文字列"103"に一致させる場合、間違った分岐にいることが明らかになるのは3の時です。文字列は式に一致しますが、現在いる分岐には一致しません。

そのため、マッチャーはバックトラックします。分岐を入力すると、現在の位置(この場合は文字列の先頭、ダイアグラムの最初の境界ボックスのすぐ後)を記憶して、現在の分岐がうまくいかない場合に別の分岐を試すことができます。文字列"103"の場合、3文字に遭遇した後、マッチャーは16進数の分岐を試行し始めますが、これも失敗します。なぜなら、数値の後にhがないからです。次に、10進数の分岐を試行します。これは適合し、すべてが完了した後、一致が報告されます。

マッチャーは完全一致が見つかり次第停止します。これは、複数の分岐が文字列に一致する可能性がある場合、最初の分岐(正規表現内の分岐の出現順)のみが使用されることを意味します。

バックトラックは、+*などの繰り返し演算子でも発生します。/^.*x/"abcxe"に対して一致させる場合、.*部分は最初に文字列全体を消費しようとします。次に、エンジンはパターンに一致させるためにxが必要であることに気づきます。文字列の終わりを超えてxがないため、星演算子は1文字少ないものに一致しようとします。しかし、マッチャーはabcxの後にもxを見つけられないため、再びバックトラックし、星演算子をabcに一致させます。これで必要な場所にxが見つかり、位置0から4までの成功した一致が報告されます。

非常に多くのバックトラックを実行する正規表現を作成することは可能です。この問題は、パターンが入力の一部に多くの異なる方法で一致できる場合に発生します。たとえば、2進数の正規表現を作成中に混乱すると、誤って/([01]+)+b/のようなものを記述してしまう可能性があります。

Railroad diagram for the regular expression '([01]+)+b'

末尾に`b`文字がない0と1の長い系列にマッチしようとした場合、 マッチャーはまず、桁がなくなるまで内部ループを処理します。その後、`b`がないことに気づき、1つ前に戻り、外部ループを1回処理し、再び諦め、内部ループからさらに戻ろうとします。これら2つのループのあらゆる可能な経路を試行し続けます。これは、追加文字ごとに作業量が倍増することを意味します。わずか数十文字でも、結果として得られるマッチングには事実上永遠の時間がかかります。

replaceメソッド

文字列値には、文字列の一部を別の文字列に置き換えるために使用できる`replace`メソッドがあります。

console.log("papa".replace("p", "m"));
// → mapa

最初の引数には正規表現を使用することもできます。その場合、正規表現の最初のマッチが置き換えられます。正規表現の後に`g`オプション(グローバル用)を追加すると、最初のマッチだけでなく、文字列内の**すべての**マッチが置き換えられます。

console.log("Borobudur".replace(/[ou]/, "a"));
// → Barobudur
console.log("Borobudur".replace(/[ou]/g, "a"));
// → Barabadar

`replace`で正規表現を使用することの真の威力は、置換文字列でマッチしたグループを参照できるという点にあります。たとえば、1行に1つの名前という形式で、`Lastname, Firstname`の形式で人の名前を含む大きな文字列があるとします。これらの名前を入れ替え、カンマを削除して`Firstname Lastname`形式にするには、次のコードを使用できます。

console.log(
  "Liskov, Barbara\nMcCarthy, John\nMilner, Robin"
    .replace(/(\p{L}+), (\p{L}+)/gu, "$2 $1"));
// → Barbara Liskov
//   John McCarthy
//   Robin Milner

置換文字列の`$1`と`$2`は、パターン内の括弧で囲まれたグループを参照しています。`$1`は最初のグループにマッチしたテキストに、`$2`は2番目のグループにマッチしたテキストに置き換えられ、`$9`まで続きます。全体のマッチは`$&`で参照できます。

`replace`の第2引数として、文字列ではなく関数を渡すことができます。各置換について、マッチしたグループ(全体のマッチも含む)を引数として関数呼び出しが行われ、その戻り値が新しい文字列に挿入されます。

例を以下に示します。

let stock = "1 lemon, 2 cabbages, and 101 eggs";
function minusOne(match, amount, unit) {
  amount = Number(amount) - 1;
  if (amount == 1) { // only one left, remove the 's'
    unit = unit.slice(0, unit.length - 1);
  } else if (amount == 0) {
    amount = "no";
  }
  return amount + " " + unit;
}
console.log(stock.replace(/(\d+) (\p{L}+)/gu, minusOne));
// → no lemon, 1 cabbage, and 100 eggs

このコードは文字列を受け取り、数値に続いて英数字の単語があるすべての出現箇所を見つけ、そのような数量を1つ減らした文字列を返します。

`(\d+)`グループは関数の`amount`引数になり、`(\p{L}+)`グループは`unit`にバインドされます。関数は`amount`を数値に変換し(これは以前`\d+`にマッチしているので常に機能します)、残りが1つまたは0しかない場合に調整を行います。

グリーディ

JavaScriptコードからすべてのコメントを削除する関数を`replace`を使用して記述できます。最初の試みを以下に示します。

function stripComments(code) {
  return code.replace(/\/\/.*|\/\*[^]*\*\//g, "");
}
console.log(stripComments("1 + /* 2 */3"));
// → 1 + 3
console.log(stripComments("x = 10;// ten!"));
// → x = 10;
console.log(stripComments("1 /* a */+/* b */ 1"));
// → 1  1

`|`演算子の前の部分は、2つのスラッシュ文字に続いて改行文字以外の任意の数の文字にマッチします。複数行コメントについては、より複雑になります。空の文字集合にない任意の文字にマッチする方法として`[^]`を使用します。ブロックコメントは改行で続く可能性があり、ピリオド文字は改行文字にマッチしないため、ここではピリオドを使用できません。

しかし、最後の行の出力は間違っているようです。なぜでしょうか?

バックトラッキングに関するセクションで説明したように、式の部分`[^]*`は、まず可能な限り多くの文字にマッチします。それがパターンの次の部分を失敗させる原因となる場合、マッチャーは1文字戻って、そこから再度試行します。この例では、マッチャーは最初に文字列の残りの部分をすべてマッチさせようとし、その後そこから戻ります。4文字戻った後、`*/`の出現箇所を見つけ、それにマッチします。これは私たちが望んでいたものではありません。意図は単一のコメントにマッチすることであり、コードの最後まで行って最後のブロックコメントの終わりを見つけることではありませんでした。

この動作のため、繰り返し演算子(`+`、`*`、`?`、`{}`)は**グリーディ(貪欲)**であると言われます。つまり、可能な限り多くの文字にマッチし、そこからバックトラックします。それらの後に疑問符を付けると(`+?`、`*?`、`??`、`{}?`)、それらは**ノン・グリーディ(非貪欲)**になり、可能な限り少ない文字にマッチすることから始め、残りのパターンがより小さなマッチに合わない場合にのみ、より多くの文字にマッチします。

そして、それがまさにこの場合に私たちが望むものです。アスタリスクが`*/`に到達する最小限の文字列にマッチさせることで、1つのブロックコメントだけを消費し、それ以上は消費しません。

function stripComments(code) {
  return code.replace(/\/\/.*|\/\*[^]*?\*\//g, "");
}
console.log(stripComments("1 /* a */+/* b */ 1"));
// → 1 + 1

正規表現プログラムの多くのバグは、ノン・グリーディな演算子がより適切に機能する場所に意図せずグリーディな演算子を使用していることが原因です。繰り返し演算子を使用する場合は、ノン・グリーディなバリアントを使用することをお勧めします。

RegExpオブジェクトの動的な作成

場合によっては、コードを記述する際にマッチさせる必要のある正確なパターンがわからない場合があります。テキスト内でユーザーの名前をテストしたいとします。文字列を構築し、それに対して`RegExp`コンストラクターを使用できます。

let name = "harry";
let regexp = new RegExp("(^|\\s)" + name + "($|\\s)", "gi");
console.log(regexp.test("Harry is a dodgy character."));
// → true

文字列の`\s`部分を作成する際には、スラッシュで囲まれた正規表現ではなく通常の文字列で記述しているため、バックスラッシュを2つ使用する必要があります。`RegExp`コンストラクターの第2引数には、正規表現のオプションが含まれます。この場合は、グローバルと大文字小文字を区別しないため` "gi"`です。

しかし、ユーザーがオタクのティーンエイジャーであるため、名前が`"dea+hl[]rd"`である場合はどうでしょうか?これは、ユーザーの名前には実際にはマッチしない無意味な正規表現になります。

これを回避するために、特別な意味を持つ文字の前にバックスラッシュを追加できます。

let name = "dea+hl[]rd";
let escaped = name.replace(/[\\[.+*?(){|^$]/g, "\\$&");
let regexp = new RegExp("(^|\\s)" + escaped + "($|\\s)",
                        "gi");
let text = "This dea+hl[]rd guy is super annoying.";
console.log(regexp.test(text));
// → true

searchメソッド

文字列の`indexOf`メソッドは正規表現で呼び出すことはできませんが、正規表現を期待する別のメソッド`search`があります。`indexOf`と同様に、式が見つかった最初のインデックスを返し、見つからなかった場合は-1を返します。

console.log("  word".search(/\S/));
// → 2
console.log("    ".search(/\S/));
// → -1

残念ながら、マッチを特定のオフセットから開始するように指示する方法(`indexOf`の第2引数のように)はなく、これは多くの場合役に立ちます。

lastIndexプロパティ

`exec`メソッドも、文字列内の特定の位置から検索を開始する便利な方法を提供しません。しかし、**不便な**方法を提供します。

正規表現オブジェクトにはプロパティがあります。そのようなプロパティの1つは`source`で、式が作成された文字列が含まれています。もう1つのプロパティは`lastIndex`で、いくつかの限定的な状況下で、次のマッチがどこから始まるかを制御します。

その状況とは、正規表現にグローバル(`g`)またはスティッキー(`y`)オプションが有効になっている必要があり、マッチが`exec`メソッドを介して行われる必要があることです。繰り返しますが、より分かりやすい解決策は、`exec`に追加の引数を渡すことを許可することですが、混乱はJavaScriptの正規表現インターフェースの重要な機能です。

let pattern = /y/g;
pattern.lastIndex = 3;
let match = pattern.exec("xyzzy");
console.log(match.index);
// → 4
console.log(pattern.lastIndex);
// → 5

マッチが成功した場合、`exec`への呼び出しにより、`lastIndex`プロパティが自動的にマッチの後にポイントするように更新されます。マッチが見つからなかった場合、`lastIndex`は0にリセットされます。これは、新しく作成された正規表現オブジェクトにもある値です。

グローバルオプションとスティッキーオプションの違いは、スティッキーが有効になっている場合、マッチは`lastIndex`で直接開始した場合にのみ成功するのに対し、グローバルの場合は、マッチを開始できる位置を前方検索する点です。

let global = /abc/g;
console.log(global.exec("xyz abc"));
// → ["abc"]
let sticky = /abc/y;
console.log(sticky.exec("xyz abc"));
// → null

複数の`exec`呼び出しで共有正規表現値を使用する場合、`lastIndex`プロパティへのこれらの自動更新は問題を引き起こす可能性があります。正規表現が、以前の呼び出しから残っているインデックスで誤って開始される可能性があります。

let digit = /\d/g;
console.log(digit.exec("here it is: 1"));
// → ["1"]
console.log(digit.exec("and now: 1"));
// → null

グローバルオプションのもう1つの興味深い効果は、文字列の`match`メソッドの動作を変更することです。グローバル式で呼び出された場合、`exec`によって返されるものと同様の配列を返すのではなく、`match`は文字列内のパターンの**すべての**マッチを見つけ、マッチした文字列を含む配列を返します。

console.log("Banana".match(/an/g));
// → ["an", "an"]

そのため、グローバル正規表現には注意が必要です。必要な場合(`replace`への呼び出しと、`lastIndex`を明示的に使用する場合)は、通常、それらを使用する場合です。

よくあることとして、文字列内の正規表現のすべてのマッチを見つけることがあります。これは`matchAll`メソッドを使用して行うことができます。

let input = "A string with 3 numbers in it... 42 and 88.";
let matches = input.matchAll(/\d+/g);
for (let match of matches) {
  console.log("Found", match[0], "at", match.index);
}
// → Found 3 at 14
//   Found 42 at 33
//   Found 88 at 40

このメソッドは、マッチ配列の配列を返します。`matchAll`に渡される正規表現には、`g`を有効にする**必要があります**。

INIファイルの解析

この章の締めくくりとして、正規表現が必要となる問題を見てみましょう。インターネットから敵に関する情報を自動的に収集するプログラムを作成していると想像してください。(ここでは、そのプログラム全体ではなく、設定ファイルを読み取る部分のみを記述します。申し訳ありません。)設定ファイルは次のようになります。

searchengine=https://duckduckgo.com/?q=$1
spitefulness=9.7

; comments are preceded by a semicolon...
; each section concerns an individual enemy
[larry]
fullname=Larry Doe
type=kindergarten bully
website=http://www.geocities.com/CapeCanaveral/11451

[davaeorn]
fullname=Davaeorn
type=evil wizard
outputdir=/home/marijn/enemies/davaeorn

この形式(広く使用されているファイル形式で、通常は *INI* ファイルと呼ばれます)の正確なルールは次のとおりです。

私たちの課題は、このような文字列を、最初のセクションヘッダーの前に記述された設定の文字列を保持するプロパティと、セクションのサブオブジェクトを保持するサブオブジェクトを持つオブジェクトに変換することです。

この形式は行ごとに処理する必要があるため、ファイルを個別の行に分割することから始めると良いでしょう。第4章splitメソッドを見ました。ただし、一部のオペレーティングシステムでは、改行文字だけでなく、キャリッジリターン文字と改行文字の組み合わせ("\r\n")を使用して行を区切っています。splitメソッドは正規表現を引数として受け入れるため、/\r?\n/のような正規表現を使用して、"\n""\r\n"の両方を許可する分割を行うことができます。

function parseINI(string) {
  // Start with an object to hold the top-level fields
  let result = {};
  let section = result;
  for (let line of string.split(/\r?\n/)) {
    let match;
    if (match = line.match(/^(\w+)=(.*)$/)) {
      section[match[1]] = match[2];
    } else if (match = line.match(/^\[(.*)\]$/)) {
      section = result[match[1]] = {};
    } else if (!/^\s*(;|$)/.test(line)) {
      throw new Error("Line '" + line + "' is not valid.");
    }
  };
  return result;
}

console.log(parseINI(`
name=Vasilis
[address]
city=Tessaloniki`));
// → {name: "Vasilis", address: {city: "Tessaloniki"}}

コードはファイルの行を処理し、オブジェクトを構築します。トップレベルのプロパティはオブジェクトに直接格納され、セクション内で見つかったプロパティは別のセクションオブジェクトに格納されます。sectionバインディングは、現在のセクションのオブジェクトを指します。

重要な行には、セクションヘッダーとプロパティ行の2種類があります。行が通常のプロパティの場合、現在のセクションに格納されます。セクションヘッダーの場合、新しいセクションオブジェクトが作成され、sectionはそのオブジェクトを指すように設定されます。

式が部分的な一致ではなく、行全体に一致するように^$を繰り返し使用することに注意してください。これらを省略すると、ほとんどの場合動作しますが、一部の入力では奇妙な動作をするコードになり、バグの追跡が困難になります。

パターンif (match = string.match(...))は、代入式(=)の値が代入された値であるという事実を利用しています。matchの呼び出しが成功するかどうかはわからないことが多いため、結果のオブジェクトには、これをテストするif文の中でしかアクセスできません。else ifの便利な連鎖を壊さないように、一致の結果をバインディングに代入し、その代入をif文のテストとしてすぐに使用します。

行がセクションヘッダーでもプロパティでもない場合、関数は/^\s*(;|$)/式を使用して、空白のみ、または空白の後にセミコロン(行の残りをコメントにする)が含まれる行に一致させることで、コメントまたは空行かどうかを確認します。行が期待される形式のいずれにも一致しない場合、関数は例外をスローします。

コードユニットと文字

JavaScriptの正規表現で標準化されているもう1つの設計上のミスは、デフォルトでは.?などの演算子が実際の文字ではなくコードユニット(第5章で説明)で動作することです。これは、2つのコードユニットで構成される文字が奇妙に動作することを意味します。

console.log(/🍎{3}/.test("🍎🍎🍎"));
// → false
console.log(/<.>/.test("<🌹>"));
// → false
console.log(/<.>/u.test("<🌹>"));
// → true

問題は、最初の行の🍎が2つのコードユニットとして扱われ、{3}が2番目のユニットのみに適用されることです。同様に、ドットは2つのコードユニットで構成されるバラの絵文字ではなく、単一のコードユニットに一致します。

このような文字を正しく処理するには、正規表現にu(Unicode)オプションを追加する必要があります。

console.log(/🍎{3}/u.test("🍎🍎🍎"));
// → true

要約

正規表現は、文字列のパターンを表すオブジェクトです。これらのパターンを表すために独自の言語を使用します。

/abc/文字列
/[abc]/文字セットのいずれかの文字
/[^abc]/文字セットに含まれていない任意の文字
/[0-9]/文字範囲の任意の文字
/x+/パターンxの1つ以上の出現
/x+?/1つ以上の出現(非貪欲)
/x*/0個以上の出現
/x?/0個または1個の出現
/x{2,4}/2~4個の出現
/(abc)/グループ
/a|b|c/いくつかのパターンのいずれか
/\d/任意の数字文字
/\w/英数字(「単語文字」)
/\s/任意の空白文字
/./改行文字以外の任意の文字
/\p{L}/u任意の文字
/^/入力の先頭
/$/入力の末尾
/(?=a)/先読みテスト

正規表現には、指定された文字列が一致するかどうかをテストするtestメソッドがあります。また、一致が見つかった場合、一致したすべてのグループを含む配列を返すexecメソッドもあります。このような配列には、一致が開始された位置を示すindexプロパティがあります。

文字列には、正規表現と一致させるmatchメソッドと、一致の開始位置のみを返すsearchメソッドがあります。replaceメソッドは、パターンのマッチを置換文字列または関数で置き換えることができます。

正規表現には、閉じスラッシュの後に記述されるオプションがあります。iオプションは、大文字と小文字を区別しない一致を行います。gオプションは式をグローバルにします。これは、その中でもreplaceメソッドが最初のインスタンスではなく、すべてのインスタンスを置換することを引き起こします。yオプションは式をスティッキーにします。つまり、一致を探す際に文字列の一部をスキップして先読みしません。uオプションはUnicodeモードを有効にし、\p構文を有効にし、2つのコードユニットを占める文字の処理に関する多くの問題を修正します。

正規表現は、扱いづらいハンドルを持つ鋭利なツールです。いくつかのタスクを大幅に簡素化しますが、複雑な問題に適用するとすぐに管理不能になる可能性があります。正規表現の使い方を知るということは、きれいに表現できないものを無理やり当てはめようとする衝動に抵抗することの一部です。

練習問題

これらの練習問題に取り組む過程で、正規表現の不可解な動作に混乱し、フラストレーションを感じることは避けられません。時々、debuggex.comのようなオンラインツールに式を入力して、その視覚化が意図したとおりになっているかどうかを確認し、さまざまな入力文字列に対する応答方法を試行すると役立ちます。

正規表現ゴルフ

コードゴルフとは、特定のプログラムをできるだけ少ない文字数で表現しようとするゲームのことです。同様に、正規表現ゴルフとは、指定されたパターン、そしてそのパターンだけを一致させるために、できるだけ小さな正規表現を作成する練習です。

以下の各項目について、指定されたパターンが文字列に存在するかどうかをテストする正規表現を作成します。正規表現は、パターンを含む文字列のみに一致する必要があります。式が機能したら、さらに小さくできるかどうかを確認してください。

  1. carcat

  2. popprop

  3. ferretferry、およびferrari

  4. iousで終わる任意の単語

  5. 空白文字に続いてピリオド、カンマ、コロン、またはセミコロン

  6. 6文字以上の単語

  7. e(またはE)を含まない単語

章の要約の表を参照してください。いくつかのテスト文字列を使用して各ソリューションをテストしてください。

// Fill in the regular expressions

verify(/.../,
       ["my car", "bad cats"],
       ["camper", "high art"]);

verify(/.../,
       ["pop culture", "mad props"],
       ["plop", "prrrop"]);

verify(/.../,
       ["ferret", "ferry", "ferrari"],
       ["ferrum", "transfer A"]);

verify(/.../,
       ["how delicious", "spacious room"],
       ["ruinous", "consciousness"]);

verify(/.../,
       ["bad punctuation ."],
       ["escape the period"]);

verify(/.../,
       ["Siebentausenddreihundertzweiundzwanzig"],
       ["no", "three small words"]);

verify(/.../,
       ["red platypus", "wobbling nest"],
       ["earth bed", "bedrøvet abe", "BEET"]);


function verify(regexp, yes, no) {
  // Ignore unfinished exercises
  if (regexp.source == "...") return;
  for (let str of yes) if (!regexp.test(str)) {
    console.log(`Failure to match '${str}'`);
  }
  for (let str of no) if (regexp.test(str)) {
    console.log(`Unexpected match for '${str}'`);
  }
}

引用スタイル

物語を書いて、会話の部分をすべて単一引用符で囲んだとします。今度は、aren’tのような縮約で使用されている単一引用符を維持しながら、すべての会話の引用符を二重引用符に置き換えたいと考えています。

これら2種類の引用符の使用を区別するパターンを考えて、適切な置換を行うreplaceメソッドの呼び出しを作成してください。

let text = "'I'm the cook,' he said, 'it's my job.'";
// Change this call.
console.log(text.replace(/A/g, "B"));
// → "I'm the cook," he said, "it's my job."
ヒントを表示…

最も分かりやすい解決策は、少なくとも片側に非文字の文字を持つ引用符のみを置き換えることです。たとえば/\P{L}'|'\P{L}/uなどです。しかし、行の先頭と末尾も考慮する必要があります。

さらに、\P{L}パターンによって一致した文字も置換に含めるようにする必要があります。これにより、それらが削除されなくなります。これは、それらを括弧で囲み、置換文字列($1$2)にそれらのグループを含めることで実現できます。一致しないグループは、何もないものに置き換えられます。

再び数値

JavaScriptスタイルの数値のみに一致する式を作成します。数値の前にオプションのマイナスまたはプラス記号、小数点、指数表記(5e-3または1E10)をサポートする必要があります。指数の前にもオプションの記号を使用できます。また、ドットの前後に数字がある必要はありませんが、数値はドットだけではいけません。つまり、.55.は有効なJavaScriptの数値ですが、ドットだけではいけません。

// Fill in this regular expression.
let number = /^...$/;

// Tests:
for (let str of ["1", "-1", "+15", "1.55", ".5", "5.",
                 "1.3e2", "1E-4", "1e+12"]) {
  if (!number.test(str)) {
    console.log(`Failed to match '${str}'`);
  }
}
for (let str of ["1a", "+-1", "1.2.3", "1+1", "1e4.5",
                 ".5.", "1f5", "."]) {
  if (number.test(str)) {
    console.log(`Incorrectly accepted '${str}'`);
  }
}
ヒントを表示…

第一に、ピリオドの前にあるバックスラッシュを忘れないでください。

数値の前、そして指数部の前のオプションの符号とのマッチングは、[+\-]? あるいは (\+|-|) (プラス、マイナス、または何もなし)を用いて行うことができます。

この演習のより複雑な部分は、"5."".5" の両方にマッチングさせつつ、"." にはマッチングさせないという問題です。これに対して、良い解決策は、| 演算子を用いて2つのケースを分離することです ― 1つ以上の数字にオプションでドットと0個以上の数字が続く場合、またはドットに1つ以上の数字が続く場合のどちらかです。

最後に、e を大文字小文字を区別しないようにするには、正規表現に i オプションを追加するか、[eE] を使用してください。