第3版が利用可能です。こちらをご覧ください

第9章
正規表現

問題に直面したとき、「そうだ、正規表現を使おう」と考える人がいる。すると、彼らは二つの問題を抱えることになる。

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

元麻は言った。「木の目に逆らって切るときは、多くの力が必要である。問題の目に逆らってプログラムするときは、多くのコードが必要である。」

元麻先生、プログラミングの書

プログラミングのツールやテクニックは、混沌とした進化の中で生き残り、広まっていく。必ずしも美しく、優れたものが勝つわけではなく、例えば、他の成功したテクノロジーと統合されるなど、適切なニッチの中で十分に機能するものが勝つのである。

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

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

正規表現の作成

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

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

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

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

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

var eighteenPlus = /eighteen\+/;

正規表現を書くときに、どの文字にバックスラッシュによるエスケープが必要かを正確に知るには、特殊な意味を持つすべての文字を知る必要がある。当面はこれは現実的ではないかもしれないので、迷ったときは、文字、数字、空白以外の文字の前にはバックスラッシュを置くこと。

マッチのテスト

正規表現オブジェクトには、多くのメソッドがある。最も単純なものはtestである。これに文字列を渡すと、文字列に式中のパターンのマッチが含まれているかどうかを示すブール値を返す。

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

非特殊文字のみで構成される正規表現は、単にその文字の並びを表す。テスト対象の文字列のどこかにabcが発生した場合(開始時だけでなく)、testtrueを返す。

文字のセットのマッチング

文字列に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]はそれらすべてをカバーし、任意の数字にマッチする。

独自の内蔵ショートカットを持つ共通文字グループがいくつかある。数字もその一つである。\d[0-9]と同じ意味である。

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

そのため、次のような式で30-01-2003 15:20のような日付と時刻の形式にマッチさせることができる

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

これは完全にひどく見える。バックスラッシュが多すぎて、表現されている実際のパターンを見つけにくくするバックグラウンドノイズが発生している。この式のわずかに改善されたバージョンを後で確認する。

これらのバックスラッシュコードは、角かっこ内でも使用できる。たとえば、[\d.]は、任意の数字またはピリオド文字を意味する。ただし、角かっこ内で使用すると、ピリオド自体が特殊な意味を失うことに注意する必要がある。同じことが、+などの他の特殊文字にも当てはまる。

文字のセットを反転させる場合、つまり、セット内の文字以外の任意の文字にマッチさせたい場合、開き角かっこの後にキャレット (^) 文字を記述できる。

var 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 文字が発生することが許可されているが、パターンはそれが欠落している場合にもマッチする。

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

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

以下は、1桁と2桁の両方の日、月、および時間を許可する、日付と時刻のパターンの別のバージョンである。また、わずかに読みやすくなっている。

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

中かっこを使用するときは、カンマの後の数字を省略することで、オープンエンドの範囲も指定できる。そのため、{5,}は5回以上を意味する。

部分式のグループ化

一度に複数の要素に対して*+のような演算子を使用するには、かっこを使用できる。かっこで囲まれた正規表現の一部は、それに続く演算子に関する限り、単一の要素としてカウントされる。

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

最初の+文字と2番目の+文字は、それぞれboohooの2番目のoにのみ適用される。3番目の+はグループ全体(hoo+)に適用され、そのようなシーケンスを1つ以上マッチさせる。

前の例の式の末尾にあるiにより、この正規表現は大文字と小文字を区別しなくなり、パターン自体はすべて小文字であるにもかかわらず、入力文字列の大文字のBにマッチさせることができる。

マッチとグループ

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

var 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番目のグループ、というようになる。

var 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で日付と時刻の値を格納する推奨方法について簡単に説明します。

日付型

JavaScriptには、日付、正確には時間を表すための標準的なオブジェクト型があります。それはDateと呼ばれます。単にnewを使用して日付オブジェクトを作成すると、現在の日時を取得できます。

console.log(new Date());
// → Wed Dec 04 2013 14:24:57 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では、月の番号はゼロから始まる(したがって、12月は11)という規則を使用していますが、日の番号は1から始まります。これは紛らわしくて馬鹿げています。注意してください。

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

タイムスタンプは、1970年の開始からのミリ秒数として保存され、1970年より前の時間には負の数が使用されます(その頃に発明された「Unix時間」によって設定された規則に従っています)。日付オブジェクトの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などのメソッドがあり、それらのコンポーネントを抽出できます。また、getYearもありますが、これはあまり役に立たない2桁の年(9314など)を返します。

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

function findDate(string) {
  var dateTime = /(\d{1,2})-(\d{1,2})-(\d{4})/;
  var match = dateTime.exec(string);
  return new Date(Number(match[3]),
                  Number(match[2]) - 1,
                  Number(match[1]));
}
console.log(findDate("30-1-2003"));
// → Thu Jan 30 2003 00:00:00 GMT+0100 (CET)

単語と文字列の境界

残念ながら、findDateは、文字列"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

境界マーカーは実際の文字を表すものではないことに注意してください。これは、パターン内で表示される場所で特定の条件が満たされる場合にのみ、正規表現が一致することを強制するだけです。

選択パターン

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

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

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

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

マッチングのメカニズム

正規表現は、フロー図として考えることができます。これは、前の例の家畜式の図です。

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

図の左側から右側へのパスが見つかれば、式は文字列に一致します。文字列内の現在の位置を保持し、ボックスを通過するたびに、現在の位置の後の文字列の部分がそのボックスに一致することを確認します。

したがって、正規表現で"the 3 pigs"をマッチさせようとすると、フローチャートの進捗状況は次のようになります。

概念的には、正規表現エンジンは、次のように文字列内のマッチを検索します。文字列の先頭から開始し、そこでマッチを試みます。この場合、そこには単語境界があるため、最初のボックスは通過しますが、数字がないため、2番目のボックスで失敗します。次に、文字列の2番目の文字に進み、そこで新しいマッチを開始しようとします...そして、マッチが見つかるか、文字列の末尾に到達して、実際にはマッチがないと判断するまで続けます。

バックトラッキング

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

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

この式をマッチングすると、入力に実際には2進数が含まれていなくても、上部(2進数)分岐に入ることがよくあります。たとえば、文字列"103"をマッチングするとき、3で現在間違った分岐にいることが明らかになります。文字列は式に*一致します*が、現在いる分岐には一致しません。

したがって、マッチャーは*バックトラック*します。分岐に入るとき、現在の位置(この場合は、図の最初の境界ボックスのすぐ後の、文字列の先頭)を覚えておき、現在の分岐がうまくいかない場合は戻って別の分岐を試すことができるようにします。文字列"103"の場合、3文字に遭遇した後、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回実行し、再び諦め、内側のループからさらに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

1つのマッチを置換するか、すべてのマッチを置換するかの選択が、replaceへの追加の引数または別のメソッドreplaceAllを提供することによって行われるのが賢明だったでしょう。しかし、残念なことに、その選択は正規表現のプロパティに依存しています。

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

console.log(
  "Hopper, Grace\nMcCarthy, John\nRitchie, Dennis"
    .replace(/([\w ]+), ([\w ]+)/g, "$2 $1"));
// → Grace Hopper
//   John McCarthy
//   Dennis Ritchie

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

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

簡単な例を次に示します。

var s = "the cia and fbi";
console.log(s.replace(/\b(fbi|cia)\b/g, function(str) {
  return str.toUpperCase();
}));
// → the CIA and FBI

より興味深い例を次に示します。

var 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にバインドされます。関数はamountを数値に変換します(これは常に機能します。なぜなら\d+にマッチしたからです)。そして、1つまたはゼロしか残っていない場合にいくつかの調整を行います。

欲張り

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つのコメントにマッチすることで、コードの最後まで進んで最後のブロックコメントの終わりを見つけることではありません。

この動作のため、反復演算子(+*?、および{})は欲張りであると言います。つまり、可能な限り多くマッチし、そこからバックトラックします。それらの後に疑問符を付けると(+?*???{}?)、それらは欲張りでなくなり、可能な限り少ないマッチから開始し、残りのパターンがより小さいマッチに適合しない場合にのみ、より多くマッチします。

そして、それはまさにこの場合に私たちが望むものです。アスタリスクに*/につながる最小の文字の範囲にマッチさせることで、1つのブロックコメントのみを消費します。

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

正規表現プログラムの多くのバグは、欲張り演算子を意図せずに使用したことが原因で、欲張りでない演算子の方がうまく機能する場合があります。反復演算子を使用する場合は、最初に欲張りでないバリアントを検討してください。

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

コードを作成するときに、マッチする必要がある正確なパターンがわからない場合があります。テキストの中でユーザーの名前を探し、それを強調するためにアンダースコア文字で囲みたいとします。プログラムが実際に実行されるまで名前がわからないため、スラッシュベースの表記を使用することはできません。

ただし、文字列を作成し、その文字列でRegExpコンストラクターを使用できます。例を次に示します。

var name = "harry";
var text = "Harry is a suspicious character.";
var regexp = new RegExp("\\b(" + name + ")\\b", "gi");
console.log(text.replace(regexp, "_$1_"));
// → _Harry_ is a suspicious character.

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

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

これを回避するために、信頼できない文字の前にバックスラッシュを追加できます。アルファベット文字の前にバックスラッシュを追加することは、\b\nのようなものが特別な意味を持つため、悪い考えです。ただし、英数字または空白でないものをすべてエスケープするのは安全です。

var name = "dea+hl[]rd";
var text = "This dea+hl[]rd guy is super annoying.";
var escaped = name.replace(/[^\w\s]/g, "\\$&");
var regexp = new RegExp("\\b(" + escaped + ")\\b", "gi");
console.log(text.replace(regexp, "_$1_"));
// → 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)オプションを有効にしており、マッチがexecメソッドを介して発生する必要があるということです。繰り返しになりますが、より健全な解決策は、execに渡す追加の引数を許可することだったでしょうが、正気はJavaScriptの正規表現インターフェイスを定義する特性ではありません。

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

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

複数のexec呼び出しにグローバルな正規表現値を使用すると、lastIndexプロパティへのこれらの自動更新が問題を引き起こす可能性があります。正規表現が、前の呼び出しから残ったインデックスから誤って開始されている可能性があります。

var 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を使用して、ループ本体でマッチオブジェクトにアクセスできる方法で、文字列内のパターンのすべての出現箇所をスキャンすることです。

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

これは、代入式(=)の値が代入された値であるという事実を利用したものです。したがって、while文の条件としてmatch = number.exec(input)を使用することで、各反復の開始時にマッチを実行し、その結果を変数に保存し、マッチが見つからなくなるとループを停止します。

INIファイルの解析

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

searchengine=http://www.google.com/search?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

[gargamel]
fullname=Gargamel
type=evil sorcerer
outputdir=/home/marijn/enemies/gargamel

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

私たちのタスクは、このような文字列を、それぞれnameプロパティと設定の配列を持つオブジェクトの配列に変換することです。各セクションに1つずつ、そして最上位のグローバル設定用に1つ、このようなオブジェクトが必要になります。

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

function parseINI(string) {
  // Start with an object to hold the top-level fields
  var currentSection = {name: null, fields: []};
  var categories = [currentSection];

  string.split(/\r?\n/).forEach(function(line) {
    var match;
    if (/^\s*(;.*)?$/.test(line)) {
      return;
    } else if (match = line.match(/^\[(.*)\]$/)) {
      currentSection = {name: match[1], fields: []};
      categories.push(currentSection);
    } else if (match = line.match(/^(\w+)=(.*)$/)) {
      currentSection.fields.push({name: match[1],
                                  value: match[2]});
    } else {
      throw new Error("Line '" + line + "' is invalid.");
    }
  });

  return categories;
}

このコードは、ファイル内のすべての行を調べて、「現在のセクション」オブジェクトを更新します。まず、/^\s*(;.*)?$/式を使用して、行を無視できるかどうかを確認します。どのように機能するか分かりますか?括弧で囲まれた部分はコメントに一致し、?は空白のみを含む行にも一致するようにします。

行がコメントでない場合、コードは行が新しいセクションを開始するかどうかを確認します。その場合、新しい現在のセクションオブジェクトを作成し、後続の設定が追加されます。

最後に意味のある可能性は、行が通常のセッティングであり、コードが現在のセクションオブジェクトに追加することです。

行がこれらの形式のいずれにも一致しない場合、関数はエラーをスローします。

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

パターンif (match = string.match(...))は、whileの条件として代入を使用するトリックに似ています。 matchの呼び出しが成功するかどうか確信が持てないことが多いため、このテストを行うifステートメント内でのみ結果のオブジェクトにアクセスできます。 ifフォームの快適なチェーンを壊さないために、マッチの結果を変数に代入し、その代入をifステートメントのテストとしてすぐに使用します。

国際文字

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

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

他のプログラミング言語の一部の正規表現の実装には、「すべての大文字」、「すべての句読点」、「制御文字」などの特定のUnicode文字カテゴリに一致する構文があります。このようなカテゴリのサポートをJavaScriptに追加する計画がありますが、残念ながら近い将来に実現する見込みはないようです。

まとめ

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

/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/ 任意の数字文字
数字 英数字(「単語文字」)
単語文字 任意の空白文字
/./ 改行を除く任意の文字
/\b/ 単語の境界
/^/ 入力の開始
/$/ 入力の終了

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

文字列には、正規表現に一致させるためのmatchメソッドと、正規表現を検索して一致の開始位置のみを返すsearchメソッドがあります。 replaceメソッドは、パターンのマッチを置換文字列で置き換えることができます。または、replaceに関数を渡すこともできます。この関数は、マッチテキストとマッチしたグループに基づいて置換文字列を作成するために使用されます。

正規表現には、終了スラッシュの後に記述されるオプションを含めることができます。 iオプションはマッチを大文字と小文字を区別しないようにし、gオプションは式をグローバルにします。これにより、特に、replaceメソッドは最初のインスタンスだけでなく、すべてのインスタンスを置き換えるようになります。

RegExpコンストラクターを使用して、文字列から正規表現値を作成できます。

正規表現は扱いにくいハンドルが付いた鋭いツールです。一部のタスクを非常に簡素化しますが、複雑な問題に適用するとすぐに管理不能になる可能性があります。それらの使い方を知ることの一部は、それらがまともな形で表現できないものを無理に押し込もうとする衝動に抵抗することです。

練習問題

これらの練習に取り組む過程で、正規表現の不可解な動作によって混乱し、不満を感じることはほぼ避けられません。場合によっては、debuggex.comのようなオンラインツールに式を入力して、その視覚化が意図したものと対応しているかどうかを確認し、さまざまな入力文字列にどのように応答するかを実験すると役立つ場合があります。

正規表現ゴルフ

コードゴルフは、特定のプログラムをできるだけ少ない文字数で表現しようとするゲームに使用される用語です。同様に、正規表現ゴルフは、与えられたパターンに一致する、できるだけ小さな正規表現を記述する練習であり、そのパターンのみに一致させることです。

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

  1. carcat

  2. popprop

  3. ferretferry、およびferrari

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

  5. 空白文字の後にドット、カンマ、コロン、またはセミコロン

  6. 6文字より長い単語

  7. 文字eを含まない単語

ヘルプについては、章のまとめの表を参照してください。いくつかのテスト文字列で各解決策をテストします。

// Fill in the regular expressions

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

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

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

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

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

verify(/.../,
       ["hottentottententen"],
       ["no", "hotten totten tenten"]);

verify(/.../,
       ["red platypus", "wobbling nest"],
       ["earth bed", "learning ape"]);


function verify(regexp, yes, no) {
  // Ignore unfinished exercises
  if (regexp.source == "...") return;
  yes.forEach(function(s) {
    if (!regexp.test(s))
      console.log("Failure to match '" + s + "'");
  });
  no.forEach(function(s) {
    if (regexp.test(s))
      console.log("Unexpected match for '" + s + "'");
  });
}

引用符スタイル

あなたが物語を書き、対話部分をマークするために全体を通してシングルクォーテーションマークを使ったと想像してください。今、あなたは対話の引用符をすべてダブルクォーテーションマークに置き換えたいと考えていますが、aren’tのような短縮形に使われているシングルクォーテーションマークは保持したいとします。

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

var 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)を含めることで行えます。一致しないグループは何も置換されません。

再び数字

一連の数字は、単純な正規表現/\d+/で一致させることができます。

JavaScriptスタイルの数字のみに一致する式を作成してください。数字の前にオプションのマイナスまたはプラス記号、小数点、指数表記 (5e-3または1E10) をサポートする必要があります。また、指数の前にはオプションの符号が付いていることに注意してください。また、ドットの前または後に数字がある必要はありませんが、数字はドット単独であってはなりません。つまり、.55.は有効なJavaScriptの数字ですが、単独のドットは有効ではありません

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

// Tests:
["1", "-1", "+15", "1.55", ".5", "5.", "1.3e2", "1E-4",
 "1e+12"].forEach(function(s) {
  if (!number.test(s))
    console.log("Failed to match '" + s + "'");
});
["1a", "+-1", "1.2.3", "1+1", "1e4.5", ".5.", "1f5",
 "."].forEach(function(s) {
  if (number.test(s))
    console.log("Incorrectly accepted '" + s + "'");
});

まず、ドットの前のバックスラッシュを忘れないでください。

数字の前、および指数の前のオプションの符号とのマッチングは、[+\-]?または(\+|-|)(プラス、マイナス、または何もなし)で行うことができます。

練習のより複雑な部分は、"."にも一致せずに"5."".5"の両方に一致させるという問題です。このための良い解決策は、|演算子を使用して2つのケースを分離することです。つまり、1つ以上の数字の後にオプションでドットとゼロ以上の数字が続くか、ドットの後に1つ以上の数字が続くかのどちらかです。

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