第4版が利用可能になりました。こちらをお読みください!

第9章正規表現

問題に直面したとき、「正規表現を使えばいい」と思う人もいます。彼らは今、2つの問題を抱えています。

ジェイミー・ザウィンスキー

元馬はこう言いました。「木目に逆らって切るときには、強大な力が必要です。問題に逆らってプログラムするときには、多くのコードが必要です。」

元馬、『プログラミングの書』
A railroad diagram

プログラミングツールはテクニックは、無秩序な進化的な方法で生き残り、広まります。必ずしも見た目の良いものや優れたものが勝つわけではなく、適切なニッチの中で十分に機能するものや、他の成功したテクノロジーと統合されるものが勝つのです。

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

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

正規表現を作成する

正規表現はオブジェクトの一種です。それはRegExpコンストラクタで構築するか、スラッシュ/文字でパターンを囲むリテラル値として書くことができます。

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

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

RegExpコンストラクタを使用すると、パターンは通常の文字列として書かれますので、バックスラッシュの通常のルールが適用されます。

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

let eighteenPlus = /eighteen\+/;

照合をテストする

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

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

特殊文字以外の文字だけで構成されている正規表現は、単に文字列の並びを表しています。テスト対象の文字列のどこかにabcが含まれている場合(先頭にある場合に限らない)、testtrueを返します。

文字の集合

文字列内にabcが含まれているかどうかを調べるには、indexOfを呼び出すこともできます。正規表現を使用すると、より複雑なパターンを表現できます。

数値に一致したいとします。正規表現では、文字の集合を角括弧で囲むと、正規表現のその部分はその角括弧内のいずれかの文字と一致します。

次の2つの表現はどちらも数字を含むすべての文字列と一致します。

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空白以外の文字
.改行を除く任意の文字

したがって、次のように日付と時刻の形式(2003-01-30 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 notBinary = /[^01]/;
console.log(notBinary.test("1100100010100110"));
// → false
console.log(notBinary.test("1100100010200110"));
// → true

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

単一の数字と一致させる方法がわかりました。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つあります。理解もしやすくなります。

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回以上を意味します。

部分表現のグループ化

1つ以上の要素に対して*+などの演算子を使用するには、括弧を使用する必要があります。括弧で囲まれた正規表現の一部は、それに続く演算子に関しては1つの要素としてカウントされます。

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

最初の+文字と2番目の+文字は、それぞれboohoo の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"]

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

しかし、ここではいったん短い寄り道をして、JavaScriptで日付と時刻の値を表す組み込みの仕組みについて説明します。

Dateクラス

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

console.log(new Date());
// → Mon Nov 13 2017 16:19:11 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になります。

タイムスタンプは、1970年の開始からのミリ秒数としてUTCタイムゾーンで保存されます。これは、当時考案された「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関数を呼び出すことで、現在のミリ秒数を得ることができます。

Dateオブジェクトは、getFullYeargetMonthgetDategetHoursgetMinutesgetSecondsなどのメソッドを提供し、それぞれの構成要素を抽出します。getFullYearに加えてgetYearもあり、これは西暦を1900年引いた値(98または119)を返し、ほとんど役に立ちません。

式の中で興味のある部分を括弧で囲むことで、文字列から日付オブジェクトを作成できるようになりました。

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" という文字列からも意味のない日付 00-1-3000 を嬉々として抽出してしまうでしょう。この場合は文字列内のどこにでもマッチする可能性があるため、単に 2 文字目から始まって 2 つ前の文字で終了するでしょう。

マッチが文字列全体にまたがらなければならない場合は、^$ のマーカーを追加できます。キャレットは入力文字列の先頭とマッチし、ドル記号は末尾とマッチします。したがって、/^\d+$/ は数字 1 個以上のみから成る文字列とマッチし、/^!/ は感嘆符で始まる文字列とマッチし、/x^/ は(文字列の先頭に x はあり得ないので)どの文字列ともマッチしません。

一方、ただ日付が単語の境界で始まって終わることを確認するだけが良い場合は、\b マーカーを使用できます。単語の境界は、文字列の先頭、末尾、またはその一方が単語文字(\w の場合など)でもう一方が非単語文字であるものを含む文字列内の任意のポイントです。

console.log(/cat/.test("concatenate"));
// → true
console.log(/\bcat\b/.test("concatenate"));
// → false

境界マーカーが実際の文字とマッチしないことに注意してください。それは単に、パターンの所定の位置に特定の条件が当てはまる場合のみ正規表現がマッチするように強制しているだけです。

選択肢パターン

テキストに、数字だけでなくブタのいずれかの語またはそれらの複数形が続く数字が含まれているかどうかを確認する必要があるとします。

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

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

括弧を使用して、パイプ演算子が適用されるパターンの部分を制限できます。また、複数のそのような演算子を並べて配置して、3 つ以上の選択肢の中から選択します。

マッチングの仕組み

概念的には、exec または test を使用すると、正規表現エンジンはまず文字列の先頭から、次に 2 文字目からなど、文字列内でマッチを探して表現をマッチさせようとし、マッチが見つかるか文字列の末尾に達するまで続けます。見つかった最初のマッチを返します。または、すべてのマッチを見つけることに失敗します。

エンジンは、正規表現をフローダイアグラムのようなものとして扱い、実際のマッチングを実行します。これは、前の例の家畜表現のダイアグラムです

Visualization of /\b\d+ (pig|cow|chicken)s?\b/

表現は、ダイアグラムの左側にあるパスを右側まで探すことができればマッチします。私たちは文字列内の現在の位置を維持し、ボックスを移動するたびに、現在の位置の後の文字列の部分がそのボックスと一致することを確認します。

したがって、ポジション 4 から "the 3 pigs" とマッチングしようとする場合、フローチャートを介した進行状況は次のようになります

バックトラッキング

正規表現 /\b([01]+b|[\da-f]+h|\d+)\b/ は、b が続く 2 進数、h が続く 16 進数 (つまり、基数 16 で、文字 a から f が数字 10 から 15 を表す)、または接尾文字のない通常の 10 進数と一致する。こちらが対応する図です。

Visualization of /\b([01]+b|\d+|[\da-f]+h)\b/

この表現と照合する場合、入力に実際には 2 進数が含まれていない場合でも、最上部の (2 進数) 分岐に入ることがよくあります。たとえば、文字列 "103" と照合すると、3 で初めて、間違った分岐にいることが明らかになります。文字列は表現と一致しますが、現在いる分岐と一致しません。

そこで、マッチャはバックトラックします。ある分岐に入ると、現在の位置 (この場合は文字列の先頭、図の最初の境界ボックスのすぐ後ろ) を記録します。そうすることで、現在の分岐がうまくいかない場合に戻って別の分岐を試すことができます。文字列 "103" の場合、3 文字目に遭遇すると、16 進数の分岐を試行開始します。これも失敗します。数字の後続に h がないからです。そのため、10 進数の分岐を試します。今回は一致し、結局一致が報告されます。

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

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

バックトラックを大量に行う正規表現を記述できます。入力をさまざまな方法で一致させられるパターンに問題が発生します。たとえば、2進数の正規表現を記述中に混乱して、/([01]+)+b/ のように誤って記述する場合があります。

Visualization of /([01]+)+b/

終了する b 文字のない、いくつかの 0 と 1 の長い系列と照合しようとする場合、一致する側はまず数字が無くなるまで内側のループを処理します。次に、b がないことに気づき、1 つ前の位置にバックトラックし、外側ループを 1 回処理し、もう一度内側ループからバックトラックしようとしますが失敗します。これら 2 つのループのすべてのルートを試行し続けます。つまり、作業量が追加の文字ごとに2 倍になります。数ダースの文字にもかかわらず、その結果の一致は実際には永久にかかります。

replace メソッド

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

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

最初の引数は正規表現にすることもできます。その場合、正規表現の最初の一致が置き換えられます。g オプション (global の略) が正規表現に追加されると、文字列の最初の一致だけでなく、すべての一致が置き換えられます。

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

追加の引数を replace に使用して 1 つの一致またはすべての一致どちらを置き換えるかを選択したり、別のメソッド replaceAll を提供したりする方が妥当でした。しかし、残念なことに、その選択肢は正規表現のプロパティに依存しています。

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

console.log(
  "Liskov, Barbara\nMcCarthy, John\nWadler, Philip"
    .replace(/(\w+), (\w+)/g, "$2 $1"));
// → Barbara Liskov
//   John McCarthy
//   Philip Wadler

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

文字列ではなく関数を変数 replace の 2 番目の引数として渡すことができます。置き換えごとに、一致したグループ (および一致全体) を引数として関数呼び出しが行われ、その戻り値が新しい文字列に挿入されます。

小さな例を以下に示します。

let s = "the cia and fbi";
console.log(s.replace(/\b(fbi|cia)\b/g,
            str => str.toUpperCase()));
// → the CIA and FBI

興味深い別の例を以下に示します。

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+) (\w+)/g, minusOne));
// → no lemon, 1 cabbage, and 100 eggs

これは文字列を取得し、数値と英数字の単語の後方をすべて見つけ、見つかると文字列の数を1つ減らします。

(\d+) グループは、関数の amount 引数と一致し、(\w+) グループは unit と一致します。この関数は、常に \d+ と一致するため、amount を数値に変換し、1つまたは0つしか残っていない場合に調整を行います。

貪欲

replace を使用して、JavaScript コードからすべてのコメントを削除する関数を記述することができます。ここでは、最初の試行

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

or 演算子の前のパートは、スラッシュ文字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 text = "Harry is a suspicious character.";
let regexp = new RegExp("\\b(" + name + ")\\b", "gi");
console.log(text.replace(regexp, "_$1_"));
// → _Harry_ is a suspicious character.

\b の境界マーカーを作成する場合は、スラッシュで囲まれた正規表現ではなく通常文字列に記述するため、2 つのバックスラッシュを使用する必要があります。RegExp コンストラクターの 2 番目の引数は正規表現のオプションを含んでいます。この場合は、グローバルでかつ大文字と小文字が区別されないことを示す "gi" が使用されます。

しかし、ユーザーがオタクの 10 代の場合、名前は "dea+hl[]rd" になる可能性があります。その場合、ユーザーの名前は実際には一致しない無意味な正規表現になります。

これを回避するには、特殊な意味を持つ文字の前にバックスラッシュを追加します。

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

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を明示的に使用したい場合)は通常、グローバル正規表現を使用する必要がある唯一の状況です。

一致をループ

一般的な方法は、文字列のパターンのすべての出現部分をスキャンして、ループ本体で一致オブジェクトにアクセスできるようにする方法です。これはlastIndexexecを使用して行うことができます。

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

この方法は、代入式の(=)値が代入値であるという事実に基づいています。while文の条件としてmatch = number.exec(input)を使用すると、各反復の開始時に一致を実行し、その結果をバインディングに保存し、一致が見つからなくなるとループを停止します。

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ファイルと呼ばれます)は次のとおりです。

タスクは、このような文字列をオブジェクトに変換することで、プロパティには最初のセクションヘッダーより前に記述された設定の文字列が格納され、サブオブジェクトにはセクションが格納され、そのサブオブジェクトにはセクションの設定が格納されます。

このフォーマットは行ごとに処理する必要があるので、ファイルを個々の行に分割するのが適切な開始点です。splitメソッドについては、第4章で説明しています。ただし、一部のオペレーティングシステムでは、行の区切りに改行文字だけでなく、復帰文字の後に改行("\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;
  string.split(/\r?\n/).forEach(line => {
    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(...)) は、 while の条件として代入を使用するトリックに似ています。多くの場合、 match の呼び出しが成功するかどうかはわかりません。そのため、これのテストを行う if ステートメント内でのみ結果のオブジェクトにアクセスできます。楽しい else if 形式のチェーンを崩さないために、一致の結果をバインディングに割り当て、その代入を if ステートメントのテストとしてすぐに使用しています。

行がセクションヘッダーでもプロパティでもなければ、関数は式 /^\s*(;.*)?$/ を使用してそれがコメントであるか空の行かどうかを確認します。それがどのように機能するか分かりますか?カッコ内の部分はコメントに一致し、 ? はホワイトスペースのみを含む行とも一致することを確認します。行が想定される形式のいずれとも一致しない場合、関数は例外をスローします。

国際文字

JavaScript の最初の単純な実装と、この単純なアプローチが後に標準的な動作として固定されたという事実により、JavaScript の正規表現は英語以外の言語の文字に対してはかなり不適切です。たとえば、JavaScript の正規表現に関して言えば、「ワード文字」はラテン アルファベット (大文字または小文字)、10 進数字、そして何らかの理由でアンダースコア文字の 26 文字のうちの 1 つにすぎません。 éβ などの確実にワード文字であるものは、 \w とは一致せず (ただし、非ワードカテゴリの \W (大文字) とは一致します)。

奇妙な歴史的な偶然にも、 \s (空白) にはこの問題がなく、Unicode 標準で空白と見なされるすべての文字、ノンブレーキングスペースやモンゴル母音セパレーターなどのものを含むすべての文字と一致します。

もう 1 つの問題は、正規表現が既定では 第 5 章 で説明したとおりコードユニットで動作し、実際の文字ではないということです。つまり、 2 つのコードユニットで構成される文字は奇妙に動作します。

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

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

正規表現に u オプション(Unicode 用)を追加して、そのような文字を適切に処理する必要があります。その一方で、残念なことに、既存のコードはこの挙動に依存している可能性があるため、誤った挙動が既定のままになっています。

これは標準化されたばかりで、執筆時点ではまだ広くサポートされていませんが、\p を正規表現(Unicode オプションを有効にする必要があります)で使用して、Unicode 標準で指定の特性が割り当てられたすべての文字に一致させることができます。

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

Unicode では、多数の便利な特性が定義されていますが、必要な特性を見つけることは必ずしも簡単ではありません。\p{Property=Value} 記法を使用して、その特性に指定の値を持つすべての文字に一致させることができます。\p{Name} の場合のように、特性名が省略された場合は、Alphabetic などのバイナリ特性または Number などのカテゴリの名前に等しいものと想定されます。

サマリー

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

/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/任意のホワイトスペース文字
/./改行以外の任意の文字
/\b/単語の境界
/^/入力の開始
/$/入力の終了

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

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

正規表現はオプションを使用できます。オプションは、後ろのスラッシュの後に記載します。i オプションは一致しますが、大文字と小文字を区別しません。g オプションは式を *グローバル* にし、これにより replace メソッドは最初のインスタンスではなく、すべてのインスタンスを置換します。y オプションはスティッキーにし、一致を検索するときに前方検索して文字列の一部をスキップしないようにします。u オプションは Unicode モードにし、2 つのコード単位を使う文字の処理に関する問題を修正します。

正規表現は、扱いづらいハンドルを持つシャープツールです。特定のタスクを大幅に単純化できますが、複雑な問題に適用するとすぐに制御できなくなることがあります。正規表現の使用法を知る上では、無理に適合させようとするのを我慢することが必要です。

演習

これらの演習に取り組んでいる間、まぎらわしくなり、いくつかの正規表現の不可解な動作にイライラすることがほとんど避けられないでしょう。以下のようなオンラインツールで表現を入力して、可視化が意図した内容と一致するかどうかを確認し、さまざまな入力文字列に対する応答をテストするとよいでしょう。

正規表現ゴルフ

コードゴルフ とは、できるだけ少ない文字で特定のプログラムを表現しようとするゲームに使用される用語です。同様に、正規表現ゴルフ とは、特定のパターンと *のみ* そのパターンに一致するように、できるだけ小さい正規表現を作成する実践です。

以下の各項目に対して、指定されたサブストリングがいずれか文字列に含まれるかどうかをテストする正規表現を作成します。正規表現は、説明されたサブストリングのいずれか 1 つを含む文字列のみと一致する必要があります。明示的に明記されていない限り、単語の境界について心配しないでください。表現が機能したら、さらに小さくできないか確認してください。

  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", "learning ape", "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."

最も明白なソリューションは、少なくとも片側に単語でない文字を含む引用符のみを置換することです。/\W'|'\W/ などです。ただし、行の開始と終了も考慮する必要があります。

さらに、\W パターンによって一致した文字も置換に含めるようにする必要があります。そうしないと、それらの文字が削除されてしまいます。これは、それらを括弧で囲み、置換文字列 ($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] を使用します。