Improve this page Github へのログインが必要です。 簡単な修正は、ここから fork、オンライン編集、pull request ができます。 大きな修正については、 通常の clone で行って下さい。 Page wiki 関連するWikiページを参照・編集 English このページの英語版(原文)

正規表現

by Dmitry Olshansky, std.regex の作者

はじめに

文字列処理は、ほとんどのアプリケーションが何度も実行するありふれた操作です。 多くのプログラミング言語が、よくある文字列操作に特化した様々な機能を提供しているのも、 不思議はありません。 中でも、プログラミング言語 D の標準ライブラリは std.string に素敵な機能の詰め合わせを用意し、 また std.algorithm の汎用アルゴリズムも文字列に使えるようになっています。 とはいえ、固定の機能のセットがすべての欲求に答えるというわけにはいきません。 テキストデータの種類も様々ですから、様々な柔軟性にとんだ解決策が必要なのです。

そこで、短く regex とも呼ばれる 正規表現 が、お手軽な解決策です。 正規表現は、文字列のパターンを定義するための簡潔ながらも強力な言語であり、 置換のメカニズムと組み合わせることで、文字列処理の万能ナイフとなります。 非常に有用なので、いくつもの言語が正規表現を言語組み込みの機能としてサポートしています。 かといって、組み込み機能にすることが 高速な あるいは高機能な処理に繋がる、 とは限りません。むしろ、 典型的な用例に対して 便利で使いやすい構文 を提供できる、というところが焦点です。

D言語では、標準ライブラリモジュール std.regex を提供しています。 D は非常に表現力の高い言語ですから、正規表現を D 言語自身による 高効率な 実装でありながら、組み込み機能と同程度の読みやすさと使いやすさで提供しています。 以下では、組み込み機能にどれほど近い look & feel が達成されているか、見ていきます。

この記事を最後まで読めば、このライブラリの正規表現機能をよく理解でき、 また、そのAPIを一番ストレートに活用する方法を知ることができます。なお、記事中の例は、 読者が正規表現の各要素についてご存じであると想定していますが、APIについては知っている必要はありません。

準備運動

あるテキストが電話番号かどうかをチェックするにはどうすればいいでしょう?

はい、まず何か数字が並んでいて、国コードがその前についているかもしれなくて……。 オーケー、国際電話の書式に従った方が厳格でしょうか。まずは最初の例なので、 プログラム全体を掲載します:

import std.stdio, std.regex;
void main(string argv[]){
    string phone = argv[1]; // 番号はコマンドラインの第一引数で渡されるとする
    if(match(phone, r"^\+[1-9][0-9]* [0-9 ]*$"))
        writeln("電話番号っぽいです。");
    else
        writeln("ちがう、電話番号じゃない。");
}
完成です! ここで一言、正規表現には限界があることはよく覚えておいて下さい。 真に電話番号としての有効性を確かめるには、実際に電話をかけてみるか関係機関に問い合わせなければいけません。

さて、改めて考えてみましょう。この小さなサンプルにはすでに役に立つ内容が詰まっています:

電話番号の例を続けましょう。電話番号全体だけでなく、国コードの部分を取り出すことができると便利です。 ついでに、実験のため、正規表現パターンを regex でコンパイルしたものを明示的に保持する、 ということもやってみます。

string phone = "+31 650 903 7158"; // 架空の番号、偶然何かと一致しても偶然です
auto phoneReg = regex(r"^\+([1-9][0-9]*) [0-9 ]*$");
auto m = match(phone, phoneReg);
assert(m);
assert(m.captures[0] == "+31 650 903 7158");
assert(m.captures[1] == "31");
// regexオブジェクトの型を気にする必要はまったくありませんが、
// 一応、こんな型です。
static assert(is(typeof(phoneReg) : Regex!char));

検索と置換

テキストから、マッチする部分をすべて取り出したいということはよくあります。 簡単な課題として、空白のみの行をすべて取り除く、という処理を考えてみましょう。 幾つかのライブラリに見られるような、入力の上をループする search() とかそういった特別なルーチンはありません。 代わりに、std.regex では普通の foreach でループするという自然な使い方ができます。

auto buffer = std.file.readText("regex.d");
foreach(m; match(buffer, regex(r"^.*[^\p{WhiteSpace}]+.*$","gm"))){
    writeln(m.hit); // hit は m.captures[0] の簡略記法
}

マッチの結果は入力レンジとして動作します。 ループの要素の型は使われた文字列の Captures 型で、部分マッチへのランダムアクセスレンジとなっています。 「レンジ」という概念の詳細についてここでは触れませんが、ここでは、D でデータの列を表す基本的な方法とだけ理解しておいて下さい。 ランダムアクセスというのは、全ての要素にインデックスで直接アクセスできるということです。それがどう関係してくるか、以下の例を見て下さい。

auto m = match("Ranges are hot!", r"(\w)\w*(\w)"); // 3文字以上の"単語"文字にマッチ
assert(m.front[0] == "Ranges");
// m.captures はマッチレンジの先頭要素 (.front) の歴史的な別名
assert(m.captures[1] == m.front[1]);
auto captures = m.front;
assert(captures[2] == "s");
foreach(c; captures)
    writeln(c); // Ranges, R, s の3行を表示

このレンジの規則に則ることで、std.regex は他のモジュールと巧く連携することできます。 例えば、以下の例ではテキストバッファ中の空ではない行数を数えています (試すときは std.algorithm の import をお忘れなく!):

auto buffer = std.file.readText("regex.d");
int count = count(match(text, regex(r"^.*\P{WhiteSpace}+.*$","gm")));

余談ですが、これを動かしたおかげで std.regex は現時点で 7128 の空でない行があることがわかりました。それはともかく正規表現に戻りましょう。 手慣れた正規表現ユーザなら、すぐに Unicode プロパティが Perl 式の \p{xxx} でサポートされていることにお気づきでしょう。付け加えると、全ての Script と Block に対応しています。 蛇足かもしれませんが注意しておくと、\P{xxx} はプロパティxxx、ここでは空白文字を持たないことを意味しています。 完全な一覧と詳細については Unicode 標準 UTS 18 を見てください。

もう一つ重要なのは、オプション文字列 "gm" です。g はグローバルの意味で、m は複数行モードの意味です。 グローバルが何を意味するのかは明らかですが、複数行モードには説明が必要かと思います。

歴史的には、正規表現に対応したユーティリティ (unix grep, sed, など) はテキストを1行1行毎に処理していました。 その頃は、^, $ のような位置指定は入力バッファ全体の先頭と終端を意味し、これは行の先頭と終端と一致していました。 正規表現がより普及してくると、複数行のテキストに対してマッチしたいという要求が生まれてきて、 ^ と $ は文字通り改行の前後を意味させることができるようになりました。興味のある方向けに、改行とは (\n | \v | \r | \f | \u0085 | \u2028 | \u2029 | \r\n) と定義されています。言うまでもなく、^ や $ を使わないのならば複数行モードを気にする必要はありません。

さあ、検索の話は終わりました。章のタイトルを見ると、次は置換の話が始まるようです。 例として、"MM/dd/YYYY" 形式の日付をすべて、時刻順ソートが可能な "YYYY-MM-dd" に置き換えてみましょう:

auto text = readText(...);
auto replaced = replace(text,
    regex(r"([0-9]{1,2})/([0-9]{1,2})/([0-9]{4})","g"), "$3-$1-$2");

"すべて" の部分は正規表現にグローバルオプションをセットすることで表現されています。これを省くと最初の一個だけを置き換えることになります。 見ておわかりのように、置換文字列は C の有名な printf とは違う形式で記述されています。 $1, $2, $3 は部分マッチの中身で置き換えられます。 部分マッチを参照するだけでなく、マッチより前にくる入力の全体は $` で、後に来る部分全体は $' で参照できます。

さて、ではそろそろ、もっと大きなことをやってみましょう。今度は、古典的なテキスト置換だけではできないことを std.regex ではできる所をお見せします。ウェブショップのカタログを変換して、自分の通貨で値段を表示したいという状況を考えましょう。 はい、現在のレートさえわかっていれば、電卓を使ったり概算したりはできる作業です。 しかしせっかく私たちはプログラマなのですから、テキストを正しい価格に置き換える簡単なプログラムを一発書いてしまいましょう。 例として、英ポンドと米ドルの変換を考えます。

import std.conv, std.regex, std.range, std.file, std.stdio;
import std.string : format;

void main(string[] argv){
    immutable ratio = 1.5824; // 執筆時点での英ポンドの対米ドルレート
    auto to_dollars(Captures!string price){
        real value = to!real(price["integer"]);
        if(!price["fraction"].empty)
            value += 0.01*to!real(price["fraction"]);
        return format("$%.2f",value * ratio);
    }
    string text = std.file.readText(argv[1]);
    auto converted = replace!to_dollars(text,
            regex(r"£\s*(?P<integer>[0-9]+)(\.(?P<fraction>[0-9]{2}))?","g"));
    write(converted);
}

変換レートの取得や、他の種類の通貨への対応は読者への宿題とします。 ここで使ったのは、デリゲートによる置換と呼ばれる機能で、他の言語や正規表現ライブラリにある呼び出し機能に相当するものです。 種は簡単で、replace が、マッチする部分が見つかるたびにユーザーに指定された関数にキャプチャ情報を渡しているのです。 その返値へとマッチ部分が置換されます。"g" フラグがあれば、この作業を残りのマッチにも適用していきます。

この例には、もう一つの素敵な機能を盛り込んでしまう誘惑に耐えきれませんでした - 名前付きグループです。 名前は、キャプチャした部分マッチを指す番号の別名として働きます。 つまり、先ほどの例はまったく同じ正規表現のままで、こう書くこともできました。

real value = to!real(price[1]);
if(!price[3].empty)
            value += 0.01*to!real(price[3]);
可読性も将来の発展性もありませんが。最後に一言、? による選択的なキャプチャも表現されていることにご注意下さい。 マッチしなかった場合は単に空文字列が返ります。

さらなる機能とオプション

中心となる基本機能の紹介は終わりました。追加の機能に移りましょう。 時には、検索のまったく逆のことをする機能もあると便利です。たとえば、正規表現をセパレータにして文字列を分割するときなどです。 次の例では、テキストを文ごとに区切って表示します:

foreach(sentence; splitter(argv[1], regex(r"(?<=[.?!]+(?![?!]))\s*")))
    writeln(sentence);

今度もsplitterの返すものはレンジで、つまりforeachでループできます。 正規表現の lookaround 機能の使い方に着目しましょう。これは、 最後の句読点を区切りの一部として取り除いてしまわないための小技です。細かく見ていくと、(?<=[.?!]) の部分は最初の . か ? か ! の出現が前にある位置を探しています。 これでやりたいことの半分はできました。\s* が "?!" の間のような句読点の間にもマッチしてしまうので、 これを避けるのに否定先読みを 後読みの中で 使って、句読点をすべて確実に通り過ぎます。 正直なところ、? と ! の乱射がこの正規表現を見通し悪くしています。実際にそうである以上にです。 いずれにせよ、先読み式の中で使える表現には特に制約はありません。 後読みの中で先読み等々、なんでもできます。 とはいっても、一般には、これらは最後の手段としてとっておいて、できるだけ控えめに使うことをお勧めします。

続いて完全に別ですが完璧に同じになる話題を始めます。 コンパイル時に正規表現を前もってコンパイルする機能があります:

static r = regex("Boo-hoo");
assert(match("What was that? Boo-hoo?", r));

重要なのは、これで、ここまで全ての例で使ってきたAPIで動くまったく同じ正規表現オブジェクトが作られると言うことです。 コンパイル時コンパイルされた正規表現は初期化に 1μs も掛かりません。 実行時版は私のマシンで 10-20μs かかりました。 ここまでの例のような単純なパターンでもです。

さらにこの方向をもう一歩進めて、正規表現のマッチングを高速化するために、 ネイティブの機械語を生成することもできます。 これも全てコンパイル時に行われますし、繰り返しますが、使い方は全く同じです!

電話番号の例を思い出しながら…

//違いは5文字だけ!
string phone = "+31 650 903 7158";
auto phoneReg = ctRegex!r"^\+([1-9][0-9]*) [0-9 ]*$";
auto m = match(phone, phoneReg);
assert(m);
assert(m.captures[0] == "+31 650 903 7158");
assert(m.captures[1] == "31");

この特定の例では、ちゃんとした解析をしたわけではありませんが、私の環境でざっと50%高速化しました。 ただし、水を差すわけではありませんが、現時点ではこの機能はまだ実験的で、一時的に幾つかの拡張機能が使えなくなっています。 悲しい話をもう少し続けると、コンパイラを酷使するためコンパイラの突然死の恐れがあります。また、この機能はテストがまだ足りていません。 とはいっても、ctRegex の機能が今後改善されていくことは間違いありません。 まとめると、基本的には実行時バージョンで使い始めて、実験してみたい気分になったときや、 パフォーマンスを絞り出したいときにはコンパイル時バージョンをお試しください。

まとめ

以上、std.regex の概観を、APIの展覧に焦点をあててご紹介しました。 簡単、でも意味のある課題を通してまとめてその機能を提示することで、 このライブラリのエレガントさと柔軟さを味わっていただくことができたと思います。 良いところは、API が自然なだけではなく、既存の標準をきちんとサポートしていて、 他の Phobos の機能とも統合されているということです。 最後に std.regex の主要な機能を短くまとめると:

この記事は、意図的に、概要にさらりと触れるだけの形式をとっています。 (Unicodeの規則に基づく)大文字小文字を区別しないマッチや、後方参照、 遅延量化子などの特定の機能の深い詳細については立ち止まりませんでした。 実は、まだまだ掘り下げられていない興味深いチャンスが残っており、さらなる機能が搭載されていく予定です。