文字列の「置換」を便利にする正規表現と「キャプチャグループ」:ECMAScriptで学ぶ正規表現(3)
正規表現の基本と、ECMAScript(JavaScript)における利用方法を紹介する連載。今回は、キャプチャグループの指定方法と参照方法、マッチした部分の置換について。
文書作成で正規表現を使うメリットに、ふぞろいのパターンを見つけてそれを置換で整えたり、置換で順番を入れ替えたりすることが一気にできることがあります。今回は、文書作成の効率をアップする正規表現による置換を紹介していきます。
キャプチャグループの指定方法
キャプチャグループとは、正規表現パターンの一部をメタ文字としての丸かっこ(……)で囲ったものをいいます。キャプチャとは「捕捉する」といった意味で、大きく以下の2つの目的で使用されます。
- 数量詞(.や*など)を続けるために、リテラルやメタ文字などをグループ化する
- マッチした文字列中で、その一部をそれぞれ別の文字列として取得する
このように、キャプチャグループは非常に便利なものです。それぞれ見ていきましょう。
パターンの繰り返しを指定する
第2回で取り上げた数量詞によって、リテラルやメタ文字などの項目の繰り返しを指定することができます。ここで、キャプチャグループを用いると、グループ化されたリテラルやメタ文字全体の繰り返しを指定できます。下記の「capture_group.js」では、文字列がインターネットのドメイン名を表すものかどうかチェックしています。
const regexp = /([\w-]+\.)+[\w-]+/; (1) const str1 = 'atmarkit.itmedia.co.jp'; console.log(str1.match(regexp)); // (2) ['atmarkit.itmedia.co.jp', 'co.', index: 0, input: 'atmarkit.itmedia.co.jp', groups: undefined] const str2 = 'atmarkit-itmedia-co-jp'; (2) console.log(str2.match(regexp)); // null
マッチングにはmatch()メソッドを用いています。(1)では、ドメイン名の構成要素が1文字以上の英数字(アンダースコア(_)を含む)+ハイフン(-)と区切りとしてのドット(.)のみであるとして、それをキャプチャグループとして扱って繰り返しを指定しています。キャプチャグループを使わない場合、ドメイン名のドットの数の変化に対応することが難しくなりますが、これをシンプルなパターンで記述することができます。(2)では、同じく英数字とハイフンから構成されますが、ドットを含まないためドメインとは見なされていません。
マッチした部分をキャプチャする
ここでいうキャプチャとは、検索によって一致した文字列の一部を、後から参照できるように覚えておくことです。キャプチャグループを使用すると、マッチした結果の一部分だけを取り出したり、その部分を別の文字列に置き換えたりすることができます。下記の「capture_group_1.js」は、日付を表すパターンの数字部分だけをキャプチャグループに指定しています。
const regexp = /(\d+)\/(\d+)\/(\d+)/; // 日付を表すパターン const str1 = '2022/2/3は節分です。'; console.log(str1.match(regexp)); // (4) ['2022/2/3', '2022', '2', '3', index: 0, input: '2022/2/3は節分です。', groups: undefined]
パターンにある\dは第2回で紹介した文字クラスで、半角のアラビア数字を意味します。最後の実行結果を見てください。結果は4個の要素で返されているのが分かります(キャプチャグループを使わない場合は、日付全体がマッチしたとされて1個の要素で返る)。1番目はマッチした文字列全体、2番目以降はキャプチャグループに相当する文字列です。
第2回までは、match()メソッドなどの結果を単にマッチしたかどうかを調べるために用いてきました。もちろん、そのような使い方でも全く構わないのですが、キャプチャグループを使うならマッチした部分を細かく取り出す、といった使い方ができます。下記の「capture_group_2.js」では、マッチしたキャプチャグループを個別に取り出して表示しています。なお、日付で区切りに使われるスラッシュ(/)は正規表現リテラルにおいて特別な意味を持つので、エスケープしていることに注意してください。
const regexp = /(\d+)\/(\d+)\/(\d+)/; const str1 = '2022/2/3は節分です。'; let result = str1.match(regexp); console.log(`Matched date: ${result[0]}`); // Matched date: 2022/2/3 console.log(`Matched year: ${result[1]}`); // Matched year: 2022 console.log(`Matched month: ${result[2]}`); // Matched month: 2 console.log(`Matched day: ${result[3]}`); // Matched day: 3
マッチしない場合のキャプチャグループ
キャプチャグループを指定したが、実際にはマッチしなかったというとき、match()メソッドの戻り値はどうなるでしょうか? マッチした、しないにかかわらず、match()メソッドの戻り値の要素数は同じになります。このとき、マッチしなかったキャプチャグループについては、undefinedが値として設定されます。下記の「capture_group_noexists.js」は、これを確かめています。
const regexp = /(\d+\/)?(\d+)\/(\d+)/; // 年部分はあってもなくてもよいパターン const str1 = '2/3は節分です。'; let result = str1.match(regexp); console.log(`Matched date: ${result[0]}`); // Matched date: 2/3 console.log(`Matched year: ${result[1]}`); // Matched year: undefined console.log(`Matched month: ${result[2]}`); // Matched month: 2 console.log(`Matched day: ${result[3]}`); // Matched day: 3
正規表現パターンは、年部分があってもなくてもよいという構成になっていますが、年部分がマッチしなくても値としてはundefinedが設定されていることが分かります。
入れ子になったキャプチャグループ
キャプチャグループは、入れ子にすることができます。つまり、あるキャプチャグループの中に別のキャプチャグループの指定を入れられるということです。下記の「capture_group_nested.js」では、HTMLタグをタグ名と属性に分解させて、しかもそれらをそれぞれ参照できるようにしています。
let regexp = /<(([a-z]+)\s*([^>]*))>/; (1) const str1 = '<div id="top">'; const result = str1.match(regexp); console.log(`Complete tag: ${result[0]}`); // Complete tag: <div id="top"> console.log(`Tag cotent: ${result[1]}`); // Tag cotent: div id="top" console.log(`Tag name: ${result[2]}`); // Tag name: div console.log(`Attribute: ${result[3]}`); // Attribute: id="top"
(1)において正規表現パターンを作成していますが、属性についてはあってもなくても大丈夫なようになっています。ただし、属性が複数ある場合には対応できません。これはどうしたらよいか、考えてみるとよいでしょう(ヒントは冒頭の「パターンの繰り返しを指定する」にあります。解答例は配布サンプルにcapture_group_nested_multi.jsとして収録しておきました)。
キャプチャグループの後方参照
キャプチャグループは、後方参照が可能です。後方参照とは、ある正規表現パターンにおいてすでに一致したキャプチャグループがあるとき、その内容を同じパターン内の後続のマッチングに用いることをいいます。後方参照には、\Nを指定します(改行を表すメタ文字\nと紛らわしいので、ここでは大文字を用いることにします)。Nは1〜99の数値で、キャプチャグループを指定した順に番号が振られます。下記の「backward_reference.js」は、HTMLタグの<h1>などのペアをマッチングします。
const regexp = /<(h[1-6])>.*<\/\1>/; // (h[1-6]) がキャプチャグループ const str1 = '<h1>Heading 1</h1>'; (1) console.log(str1.match(regexp)); // (2) ['<h1>Heading 1</h1>', 'h1', index: 0, input: '<h1>Heading 1</h1>', groups: undefined] const str2 = '<h2>Heading 2</h2>'; (2) console.log(str2.match(regexp)); // (2) ['<h2>Heading 2</h2>', 'h2', index: 0, input: '<h2>Heading 2</h2>', groups: undefined] const str3 = '<h3>Heading 3</h4>'; (3) console.log(str3.match(regexp)); // null
ここでは、キャプチャグループが1個指定されています。これは、h1〜h6に一致します。そして、これは\1によって後方参照されています。(1)ではh1が一致しますが、これはキャプチャされて\1によって後方参照され、実際にはh1としてマッチングが試みられます。(2)ではh2が一致しますが、これも同様にh2としてマッチングが試みられます。(3)ではh3が一致しますが、実際にあるのはh4なため、マッチしません。これから分かるように、必ず<h1>〜</h1>というようにペアにならなければならない場合など(ほかには引用符のペアなど)、これを正確に指定するのに役立ちます。
キャプチャしない非キャプチャグループ
このようにキャプチャグループは便利な仕組みですが、パフォーマンス的にはやや不利に働きます。特に、参照の必要のない単なるグループとして使用する場合には、パフォーマンスの低下はできれば避けたいものです。そこで、キャプチャしない(参照できない)非キャプチャグループというものが用意されています。
非キャプチャグループにするには、丸かっこ内側のパターンの前にクエスチョンとコロンの組み合わせ(?:)を置きます。下記の「no_capture_group.js」は、既出のcapture_group.jsを非キャプチャグループ指定としたものです。マッチングは成功しますが、キャプチャされていないので配列の要素には含まれません(結果の配列は、一致したパターンのみの1要素となる)。
const regexp = /(?:[\w-]+\.)+[\w-]+/; // 非キャプチャグループ const str1 = 'atmarkit.itmedia.co.jp'; console.log(str1.match(regexp)); // (1) ['atmarkit.itmedia.co.jp', index: 0, input: 'atmarkit.itmedia.co.jp', groups: undefined]
[NOTE]matchAll()メソッドによる検索
ここまで、マッチングにmatch()メソッドを使ってきましたが、全ての一致を調べるmatchAll()メソッドもあります。match()メソッドで正規表現パターンにgフラグを指定すれば、matchAll()メソッドと同様に全ての一致を調べることができます。これらの違いは、match()メソッドが結果を配列で返すのに対し、matchAll()メソッドは結果を繰り返し記述子(イテレータ)で返す点です。
重要なのは、matchAll()メソッドの実行時点ではまだ検索を実行していないことです。for……of文などでループを回した時点で初めて検索し、、結果を返します。ある程度の結果が得られた時点で、break文などでループを中断してしまえば、それ以上は検索されないので、使用する局面によっては効率的です。gフラグ付きのmatch()メソッドでは、呼び出した時点で全ての検索を行い、結果を配列にしますから、結果を全て利用しない場合には無駄になってしまいます。
正規表現による置換
ECMAScriptでは、文字列の置換は基本的にreplace()メソッドで行います。replace()メソッドは、引数に検索する文字列と置換後の文字列を指定して呼び出すと、置換後の文字列を返してくれます。このとき、検索文字列に正規表現パターンを指定すると、正規表現による検索と置換が可能になります。
置換文字列におけるキャプチャグループの参照
正規表現による置換では、一致した文字列をごっそりと別の文字列に置き換えるということもあるでしょうが、多くの場合は上記のキャプチャグループによる置換を用います。置換後の文字列にキャプチャグループの参照を含めることで、一致した文字列をそのまま生かしながら別の文字列へ変換できます。
キャプチャグループの参照は、置換文字列に$n(nは1から99の数値。キャプチャグループを指定した順に番号が振られる)を含めることで行います。下記の「replace_paren.js」は、半角丸かっこで構成されたかっこ数字を、全角丸かっこ数字に変換します。かっこの使い方は得てして不統一になるものですが、それをそろえたいという場合などに有用です。
const regexp = /\((\d+)\)/; (1) const str = '(1)半角だけの括弧数字です。'; console.log(str.replace(regexp, '($1)')); (2) // 「(1)半角だけの括弧数字です。」
(1)では正規表現パターンを作成しています。このとき、半角パーレンをエスケープしていることに注意してください。やや複雑なパターンに見えますが、半角丸かっこはエスケープしてリテラル扱いにし、その内側の半角丸かっこはキャプチャグループを作成するためのものとなります。ここをキャプチャするのは、これをそのまま置換後に使用するためです。
(2)では$1で、1番目のキャプチャグループ、すなわち「\d+」を参照しています。$1によって、実際に一致した文字列そのものに置き換わります。
なお、存在しないキャプチャグループを参照した場合、空文字列にはならずに「$5」といったリテラルになります。
[NOTE]大文字小文字への変換
置換とは少し異なりますが、キャプチャグループ内の英文字を大文字や小文字に全て変換したい、という場合があります。Javaでは「\U$1\E」として大文字へ、「\L$1\E」として小文字へ変換できます。単に「\U$1」と指定できる処理系もあるようです。残念ながら、ECMAScriptではこのような変換はサポートされていません。正規表現とtoLowerCase()/toUpperCase()メソッドの組み合わせで対応するしかないようです。
置換文字列における特殊な文字
置換文字列には、「$n」といったキャプチャグループの参照を含めて、以下の表に示す文字を含めることができます。
文字 | 説明 |
---|---|
$$ | $そのもの |
$& | 一致した文字列 |
$` | バッククォート。一致した文字列の直前の文字列 |
$' | シングルクォート。一致した文字列の直後の文字列 |
$n | キャプチャグループの指定(nは1〜99) |
$ |
名前付きキャプチャグループの指定(後述) |
表1 置換文字列における特殊な文字 |
$$はメタ文字におけるエスケープのような位置付けで、「$」をリテラルとして使いたい場合に使用します。$&、$`、$'はそれぞれ一致した文字列、一致した文字列の直前の文字列、一致した文字列の直後の文字列を表しますが、少々分かりにくいので下記の「replace_str.js」を例として示します。
const regexp = /(\d+)\/(\d+)/; const str1 = '今日2/3は節分です。'; console.log(str1.replace(regexp, '$&')); // 今日2/3は節分です。 console.log(str1.replace(regexp, '$`')); // 今日今日は節分です。 console.log(str1.replace(regexp, "$'")); // 今日は節分です。は節分です。
$&は、キャプチャグループに関係なく、一致した文字列をそのまま置換後の文字列に使用します(ですから置換前と置換後は同じになります)。$`は、一致した文字列の直前の文字列すなわち「今日」に置き換わります。$'は、一致した文字列の直後の文字列すなわち「は節分です。」に置き換わります。
replaceAll()メソッドによる置換
ECMAScript 2020からはreplaceAll()メソッドが使用可能になりました。replaceAll()メソッドは、その名の通りreplace()メソッドの全置換版です。引数に検索する文字列と置換後の文字列を指定して呼び出すと、全ての一致する検索文字列を置換し、置換後の文字列を返してくれます。
ただし、検索文字列に正規表現パターンを用いて、gフラグ(全部を置換)を指定した場合、replace()メソッドとrelpaceAll()メソッドの動きは同じになります。このとき、replaceAll()メソッドの呼び出しにおいてはgフラグが必須となり、指定しない場合にはエラーとなります。
下記の「replace_all.js」は、2つの日付の変換をreplace()メソッドとreplaceAll()メソッドの両方で行ってみたものです。見ての通り、結果は全く同じになります。
const regexp = /(\d+)-(\d+)-(\d+)/g; const str = '2022-2-3は節分です。2022-2-4は立春です。'; console.log(str.replace(regexp, '$1/$2/$3')); // 2022/2/3は節分です。2022/2/4は立春です。 console.log(str.replaceAll(regexp, '$1/$2/$3')); // 2022/2/3は節分です。2022/2/4は立春です。
名前付きキャプチャグループ
ECMAScript 2018からは、キャプチャグループに名前を付けることができるようになりました(名前付きキャプチャグループ)。名前付きキャプチャグループを使用すると、「$1」といった番号ではなく名前でキャプチャグループを参照できるので、参照順の指定ミスなどが起きにくくなり、キャプチャグループの意味も伝わりやすくなります。
名前付きキャプチャグループの指定
名前付きキャプチャグループは、キャプチャグループを指定する丸かっこの中で、パターンの前に「?
const regexp = /(?<year>\d+)\/(?<month>\d+)\/(?<day>\d+)/; const str1 = '2022/2/3は節分です。'; let groups = str1.match(regexp).groups; console.log(`Matched year: ${groups.year}`); // Matched year: 2022 console.log(`Matched month: ${groups.month}`); // Matched month: 2 console.log(`Matched day: ${groups.day}`); // Matched day: 3
名前付きキャプチャグループを使用すると、match()メソッドの呼び出し結果のgroupsプロパティに、それぞれのキャプチャグループが入ります。個別の参照は、groupsプロパティに対してキャプチャグループ名をプロパティとして指定します。上記の通り、極めてシンプルかつ明確な記述で個々のキャプチャグループを参照できます。
置換文字列における名前付きキャプチャグループの参照
replace()メソッドなどで使用する置換文字列における名前付きキャプチャグループの参照は、「$
const regexp = /(?<year>\d+)-(?<month>\d+)-(?<day>\d+)/; const str1 = '2022-2-3は節分です。'; console.log(str1.replace(regexp, '$<year>/$<month>/$<day>')); // 2022/2/3は節分です。
$1のような単なる番号の参照でなく、$
名前付きキャプチャグループの後方参照
名前付きキャプチャグループでも後方参照が可能です。\Nを使う代わりに、キャプチャグループで指定された名前を\k
const regexp = /(?<quote>["']).*\k<quote>/; const str1 = '"Hello, world!!"'; (1) console.log(str1.match(regexp)); // (2) ['"Hello, world!!"', '"', index: 0, input: '"Hello, world!!"', groups: undefined] const str2 = "'Hello, world!!'"; (2) console.log(str2.match(regexp)); // (2) [''Hello, world!!'', ''', index: 0, input: ''Hello, world!!'', groups: undefined] const str3 = '"Hello, world!!\''; (3) console.log(str3.match(regexp)); // null
(1)と(2)では、ダブルクォーテーションとシングルクォーテーションを対で使っているため、正しくマッチしています。(3)は、開きがダブルクォーテーションであるのに閉じがシングルクォーテーションであるので、マッチしません。なお、シングルクォーテーションで囲っている文字列にシングルクォーテーションを含めるためにエスケープしていますので、注意してください(このエスケープは正規表現とは無関係です)。
まとめ
今回は、ECMAScriptにおけるキャプチャグループの使い方と、それを使用した置換について紹介しました。
次回は、第2回でも取り上げた文字クラスについて、さらに掘り下げて紹介します。
筆者紹介
WINGSプロジェクト
有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティー(代表山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手掛ける。2021年10月時点での登録メンバーは55人で、現在も執筆メンバーを募集中。興味のある方は、どしどし応募頂きたい。著書、記事多数。
・サーバーサイド技術の学び舎 - WINGS(https://wings.msn.to/)
・RSS(https://wings.msn.to/contents/rss.php)
・Twitter: @yyamada(https://twitter.com/yyamada)
・Facebook(https://www.facebook.com/WINGSProject)
Copyright © ITmedia, Inc. All Rights Reserved.