正規表現
Dmitry Olshansky(std.regexの作者)による
はじめに
文字列処理は、ほとんどのアプリケーションが何らかの形で対処しなければならない日常的な作業である。 多くのプログラミング言語が、標準ライブラリに 文字列操作の一般的なニーズに対応するさまざまな専用関数を装備していることは驚くことではない。 プログラミング言語 D の標準ライブラリには、std.string にさまざまな関数が用意されているほか、 std.algorithm には文字列を処理する汎用関数も用意されている。 しかし、当然ながら柔軟なテキストデータには柔軟なソリューションが必要であるため、固定機能ではすべてのニーズをカバーすることはできない。
そこで役立つのが、簡潔に「正規表現」と呼ばれることが多い正規表現である。 正規表現は、文字列の集合のパターンを定義するためのシンプルかつ強力な言語である。 パターンマッチング、データ抽出、置換と組み合わせることで、テキスト処理のスイスアーミーナイフとなる。 正規表現は非常に重要であると考えられており、多くのプログラミング言語に組み込みのサポートが提供されている。しかし、組み込みであるからといって、 処理速度が速くなったり、より多くの機能が使えるようになることを意味するわけではない。 典型的な操作に便利な構文を提供し、うまく統合するだけの問題である。
プログラミング言語 D には、標準ライブラリモジュールstd.regex が用意されている。 D は表現力の高いシステム言語であるため、正規表現を 言語自体に効率的に実装することができ、なおかつ、読みやすさと使いやすさも実現している。 また、純粋な D による実装には、従来のコンパイル言語では考えられないような機能がいくつか備わっている。 詳しくは記事の最後に記載する。
記事の最後までお読みいただければ、このライブラリにおける正規表現の機能についてよく理解でき、 そのAPIを最もシンプルかつ効率的に活用する方法も理解できるだろう。この記事の例題では、 読者が正規表現の要素についてある程度理解していることを前提としているが、必須ではない。
ウォームアップ
電話番号かどうかを、見ただけで確認するにはどうすればよいだろうか?
はい、数字が含まれているもので、その前に国番号があるかもしれません... 国際的なフォーマットに従うことで、より厳密になります。今回は初めてなので、 完全なプログラムを組み立ててみましょう。
+1 555 123 4567
import std.stdio, std.regex; void main() { string phone = readln(); // stdinの1行目にphoneが渡されると仮定する。 if (matchFirst(phone, r"^\+[1-9][0-9]* [0-9 ]*$")) writeln("It looks like a phone number."); else writeln("Nope, it's not a phone number."); }そして、以上だ!しかし、正規表現の限界を念頭に置いておこう。電話番号の有効性を本当に確認するには、 その番号に電話をかけてみるか、当局に問い合わせる必要がある。
そして、これでおしまい!ただし、正規表現の限界を念頭に置いておこう。電話番号の有効性を本当に確認するには、 実際にダイヤルしてみるか、当局に問い合わせる必要がある。
この小さな例を掘り下げてみよう。なぜなら、実際には多くの興味深いことが示されているからだ。
- r"..." のような生の文字列リテラルは、正規表現パターンを自然な表記で記述することを可能にする。
- matchFirst 文字列にマッチするものがあれば、最初のマッチを見つける関数。マッチしたかどうかを確認するには、 文などのブール値のコンテキストで明示的に戻り値をテストする。if
- +、*、(、) 、[、]、$ などの特殊な正規表現文字に一致させる場合は、バックスラッシュによるエスケープを忘れないようにする。例えば、\+ 。
- 大量のテキスト処理を行わない限り、パターンとしてプレーンな文字列を渡してもまったく問題ない。 実際の作業に使用される内部表現はキャッシュされ、その後の呼び出しを高速化する。
電話番号の例を続けると、国番号の正確な値を取得することは有用である。 また、数値全体も同様である。実験のために、regex 経由でコンパイルされた正規表現パターンを明示的に取得し、その動作を確認してみよう。
import std.regex; string phone = "+31 650 903 7158"; // 架空のものであり、偶然の一致は単なるものである auto phoneReg = regex(r"^\+([1-9][0-9]*) [0-9 ]*$"); auto m = matchFirst(phone, phoneReg); assert(m); // また、booleanコンテキスト - 空でないことをテストする assert(!m.empty); // 上の行と同じ assert(m[0] == "+31 650 903 7158"); assert(m[1] == "31"); // 正規表現オブジェクト型はあまり必要ないだろう。 // しかし、好奇心旺盛な人のためにここに書いておく。 static assert(is(typeof(phoneReg) : Regex!char));
検索と置換
文字列の検証では最初の一致を見つけることがよくあるテーマであるが、もう一つのよくあるニーズは テキストの一部で発見されたすべての一致を抽出することである。簡単なタスクを選び、 すべての空白のみの行をフィルタリングする方法を見てみよう。search() や類似のライブラリで見られるような、入力に対してループする特別なルーチンはない。 代わりに、std.regex は、プレーンなforeachによるループのための自然な構文を提供する。
auto buffer = std.file.readText("regex.d"); foreach (m; matchAll(buffer, regex(r"^.*[^\p{WhiteSpace}]+.*$","m"))) { writeln(m.hit); // hitはm[0]のエイリアスである。 }
組み込みのように見えるかもしれないが、それは一般的な慣例に従っているだけである。 この場合、matchAll は、入力範囲の正しい「プロトコル」に従うオブジェクトを 適切なメソッドのセットを持つだけで返す。入力範囲は、他の言語で見られるイテレータに似ている。 同様に、matchFirst の結果とmatchAllの各要素は ランダムアクセス可能な範囲であり、配列の「ビュー」のようなものとして動作する。
import std.regex; auto m = matchAll("Ranges are hot!", r"(\w)\w+(\w)"); // 少なくとも3つの"単語"記号 assert(m.front[0] == "Ranges"); // front - 入力範囲の先頭 // m.capturesはマッチ範囲の最初の要素(.front)の歴史的エイリアスである。 assert(m.captures[1] == m.front[1]); auto sub = m.front; assert(sub[2] == "s"); foreach (item; sub) writeln(item); // 行が表示される: Ranges, R, s
std.regexは、他のモジュールとの連携において、いくつかの優れた利点を得る。例えば、 テキストバッファ内の空ではない行を数えるには、次のようにする。
import std.algorithm, std.file, std.regex; auto buffer = std.file.readText(r"std\typecons.d"); int count = count(matchAll(buffer, regex(r"^.*\P{WhiteSpace}+.*$", "m")));
経験豊富な正規表現ユーザーであれば、Unicode プロパティが perl スタイルの p{xxx} でサポートされていることを即座に理解できるだろう。また、スクリプトとブロックのすべてもサポートされている。 ここで、p{xxx} は xxx プロパティを持たないことを意味し、すなわち、ここでは空白文字ではないことを意味する。Unicode は知っておくべき重要なテーマであり、 ここでカバーしようとしても不十分である。詳細は、アクセス可能なstd.uniのドキュメントと、 Unicode標準UTS 18による
もうひとつ重要なのは、オプション文字列 - "m" である。ここで、mはマルチラインモードを表す。 歴史的に、正規表現パターンをサポートするユーティリティ(UNIXのgrepやsedなど)は、テキストを1行ずつ処理していた。 その当時、^や$などのアンカーは、入力バッファ全体がその行と同じであることを意味していた。 正規表現がより一般的になるにつれ、テキストの塊の中で複数の行を認識する必要性が明らかになった。 このようなモードでは、^ および $ は改行の前後を文字通り一致させるために定義された。 興味のある方のために、現在の(Unicode)改行は、( 言うまでもなく、^ や $ を使用しないのであれば、複数行モードをオンにする必要はない。
置換
検索については説明したので、今度は置換について説明しよう。 例えば、テキスト内のすべての日付を「MM/dd/YYYY」形式に置き換えてソート可能な「YYYY-MM-dd」形式にするには、
import std.regex; auto text = "2/31/1998"; auto replaced = replaceAll(text, r"([0-9]{1,2})/([0-9]{1,2})/([0-9]{4})".regex, "$3-$1-$2"); assert(replaced == "1998-2-31");
r"pattern".regex printf $1 $2 $3 $` $' これは、 と書くのと同じ別の表記でありregex("pattern") 、 UFCSと呼ばれる。
それでは、もう少し大きなことを目指してみよう。今回は、std.regexが 古典的なテキスト置換だけでは達成できないことを示そう。例えば、ウェブショップのカタログを翻訳して、 自国の通貨で価格を表示させたいとしよう。電卓を使ったり、頭の中で計算したりすることもできるが、 現在の為替レートを考慮すると、プログラマーである私たちはもっと良い方法がある。 テキストを正しい価格に変換する簡単なプログラムをまとめよう。例えば、英国ポンドを米ドルに変換する。
ボトルは1ポンド、4本で3ポンド
。import std.conv, std.regex, std.range, std.stdio; import std.string : format; void main() { immutable ratio = 1.5824; // この文章を書いている時点では、英国ポンドから米ドルへ auto toDollars(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 = readln(); auto converted = replaceAll!toDollars(text, regex(r"£\s*(?P<integer>[0-9]+)(\.(?P<fraction>[0-9]{2}))?", "g")); write(converted); }
現在の為替レートを取得し、より多くの通貨をサポートすることは、読者の課題として残されている。 ここで使われているのは、他の言語や正規表現ライブラリに見られるコールアウト機能に類似した、 「replace with delegate」と呼ばれる機能である。その仕組みは単純だ。replaceAll が一致するものを見つけると、ユーザーが指定したコールバックtoDollarsを キャプチャした部分で呼び出し、その戻り値を使って置換を行う。
そして、私はその例にさらに別の機能、名前付きグループを追加せずにはいられなかった。 名前付きグループの構文は(?P<name>regex) である。 名前は、捕捉された部分式の数に対するエイリアスと同じように機能する。 つまり、まったく同じ正規表現を使用して、影響を受ける行を次のように変更することもできる。
real value = to!real(price[1]); if (!price[3].empty) value += 0.01*to!real(price[3]);
ただし、可読性は損なわれ、将来性もありません。
また、オプションのキャプチャは依然として表現されているが、一致しない場合は空文字列になることに注意。
分割する
さて、コアとなる機能を紹介したので、次に追加機能を紹介しよう。 時には、検索のほぼ反対のことを行うのが便利なこともある。つまり、正規表現を区切り文字として使用して、入力を分割するのだ。 次のサンプルでは、文章ごとにテキストが出力される。
これはサンプルテキストだ! いくつかの文章がある。うまくいったかな?
import std.regex, std.stdio; auto text = readln('\0'); // すべての標準入力を読む foreach (sentence; splitter(text, regex(r"(?<=[.?!]+(?![?!]))\s*"))) writeln(sentence);
splitter が返す型は再び範囲であるため、foreach ループが可能となる。 正規表現におけるルックアラウンドの使用法に注目してほしい。最後の句読点を削除することが目的ではないため、これは巧妙なテクニックである。(?=regex) はルックアヘッドであり、(?<=regex) はルックアバウトである。
この例を分解すると、(?<=[.?!]) の部分は、. 、? 、または! の直後に後方参照を行う。 \s* は「!」のような句読点の要素間にも一致するため、 句読点がもうないことを確認するために、否定後方参照(?!regex) が後方参照内に導入される。
確かに、?と!が大量に並ぶため、この正規表現は実際以上にわかりにくい。 ただし、ルックアラウンド式の内容には制限がないため、 ルックアヘッドをルックバックの内側に入れることも可能である。 しかし一般的には、正規表現は最後の手段として取っておき、控えめに使用することが推奨される。
静的正規表現
機能の追求はやめて、パフォーマンスについて考えよう。Dには、ここで提供できるものがある。 まず、コンパイル時に定数正規表現をプリコンパイルする機能がある。
import std.regex; static r = regex("Boo-hoo"); assert(match("What was that? Boo-hoo?", r));
重要なのは、これまで見てきたすべての API で動作する、まったく同じRegex オブジェクトであるということだ。 初期化にはほとんど時間がかからない。データセグメントから既製の構造をコピーするだけだ。 私のマシンでは、初期化に約 1 μs の実行時間が必要だった。逆に、実行時に正規表現をコンパイルすると、約 10~20 μs かかった。
さらにこの方向性を追求すると、 デフォルトの実行時エンジンを使用する代わりに、与えられた正規表現に一致する特殊なDコードを構築する機能がある。 コードが正規表現から始まり、後に速度要件を満たすために、 恐ろしいほど大量のコードに書き換えられることはよくあることではないだろうか?書き換える必要はない。
電話番号の例を思い出してみよう。
import std.regex; string phone = "+31 650 903 7158"; // たった5文字の違いだ! 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");
興味深いことに、コードはほぼまったく同じに見える(変更された文字は合計5文字)が、 正規表現パターンに相当するDコードを生成する。ctRegex は、実行時と同じAPIをサポートするStaticRegex オブジェクトを生成する。Regex これが重要な点である。APIは一貫性を保ちつつ、私たちが求めていた大幅な高速化を実現している。 これにより、正規表現エンジンを使用した迅速な開発が可能になり、必要に応じてリリースビルドでctRegex に切り替えることで、実行時の速度を向上させることができる(ビルドは遅くなる)。
この特定のケースでは、私の場合はおよそ50%高速化されたが、 このケースの包括的な分析は行っていない。 とはいえ、ctRegex が今後、大幅に改善されることは間違いない。 私たちは、エキサイティングな新しい可能性のほんの一部に触れたに過ぎない。 実際の使用パターンに関するより多くのデータ、CT-regexのパフォーマンス、その他のベンチマークについては、 こちらを参照のこと。
結論
この記事では、std.regex の API を紹介することに焦点を当てたチュートリアルを説明した。 簡単なながらも意味のある一連のタスクに従うことで、その機能が組み合わさって明らかになり、 このライブラリソリューションの優雅さと柔軟性が強調された。 良い点は、API が自然であるだけでなく、確立された 標準に従い、"phobos" の他の部分ともうまく統合されていることだ。 主な機能をまとめると、std.regexは以下の通りである。
- 完全にUnicode対応であり、Unicode標準のフルレベル1サポートに適合している
- 無制限の一般化されたルックアラウンドを含む、多くの最新の拡張機能が満載。 これにより、他のライブラリからの正規表現の移植が容易になる。
- 少数の柔軟なツールで構成されるスリムなAPIで設計されている:matchFirst/matchAll 、replaceFirst/replaceAll 、splitter 。
- 統一性があり強力で、正規表現のプリコンパイルや、 ctRegexによるコンパイル時に特別に調整されたコードの生成など、ユニークな機能を備えている。これらすべてが共通のインターフェースに問題なく収まっている。
この記事の形式は、意図的に概要に重点を置いているが、 大文字小文字を区別しないマッチング(Unicodeの単純なケースフォールディングルール)、 バックリファレンス、非積極的量化子など、特定の機能について詳しく説明することをやめていない。さらに、表現力を高め、より優れたパフォーマンスを実現するために、さらに多くの機能が追加される予定である。
DEEPL APIにより翻訳、ところどころ修正。
このページの最新版(英語)
このページの原文(英語)
翻訳時のdmdのバージョン: 2.109.1
ドキュメントのdmdのバージョン: 2.109.1
翻訳日付 :
HTML生成日時:
編集者: dokutoku