レベル: 中級 Martin Streicher (martin.streicher@gmail.com), Editor in Chief, McClatchy Interactive
2008年 1月 08日 この、「PHP での正規表現をマスターする」シリーズの第 2 回では、困難なテキスト処理に関するさまざまな問題を、いくつかの高度な正規表現 (regex) 演算子を使って解決するための方法を学びます。
データという言葉と情報という言葉は、お互いに交換可能なものとして使われていますが、両者の間には大きな違いがあります。データは事実を表します。データは、温度のリストであったり、最近の売上に関する長々とした記録であったり、あるいは部品の現在の在庫リストであったりします。情報には洞察が含まれます。情報は天気の予報であったり、損益の報告書であったり、あるいは売上の傾向であったりします。データは 1 と 0 として記録されます。情報はシナプスによってつなぎ合わされます。
データと情報との間にはソフトウェア・アプリケーションがあります。ソフトウェア・アプリケーションは、データを情報に、また情報をデータに変換するエンジンです。例えば、ある本をオンラインで購入する場合、ショッピング・アプリケーションが購入者の情報 (本の標題や購入者の ID、銀行口座などの情報) をさまざまに加工し、注文番号や販売価格、クレジット・カードによる取引の詳細、在庫状況の調整結果などのデータにします。同様に、ショッピング・アプリケーションはこのデータを、倉庫から出荷するための要求や、送付ラベルや追跡番号など、つまり販売するために必要な情報に作り直します。
もちろん、アプリケーションを作成する上での複雑さは、そのアプリケーションによって行われる変換に直接比例します。例えば、Web サイトのゲスト・ブック (名前や住所をデータベースのフィールドに変換します) は単純です。一方、多種多様な情報をビジネスのデータ・モデルの形にし、さらにそのデータを意志決定を導くための情報に変換するオンライン・ストアは、非常に複雑です。プログラミングにおける技巧は、データと情報とを巧みに操作することであり、美術の明暗法で光を捉えるための技法に似ています。
「第 1 回」で紹介したとおり、正規表現はデータを操作するための最も強力なツールの 1 つです。正規表現は、手軽な手法を使ってデータの形式を記述し、またデータを分解します。例えば、/^([+-]?[0-9]+)([CF])$/ という正規表現を使うと、摂氏あるいは華氏の任意の温度を処理することができます。
この正規表現は、行の先頭を検出し (キャレット ^ で表現されます) 、次にプラス記号またはマイナス記号、またはそのいずれでもないかを判断し ([+-]?)、次に整数 ([0-9]+) かどうか、単位修飾子 (摂氏または華氏のいずれか) かどうか ([CF])、そして行の終わり (ドル記号 $ で表現されます) かどうかを判断します。
温度の正規表現の中で、行の先頭を示す演算子と行の末尾を示す演算子は、ゼロ幅アサーション、つまりリテラルの突き合わせではなく位置の突き合わせの 2 つの例です。括弧も、リテラルではありません。逆に、あるパターンを括弧の中に埋め込むことによって、そのパターンにマッチするテキストをキャプチャーすることができます。従って、もしテキストがパターン全体とマッチしたら、最初の 1 対の括弧によって、正または負の整数を表すストリング (+49 など) が出力されます。また 2 番目の 1 対の括弧によって、C または F という文字のいずれかが出力されます。
第 1 回では正規表現の概念を紹介し、またテキストとパターンの比較やマッチしたものの抽出に利用できる PHP 関数を紹介しました。今度は正規表現をさらに深く掘り下げ、いくつかの高度な演算子とその使い方について調べます。
括弧が (またも) 救いに登場
ほとんどの場合は、1 対の括弧を使ってサブパターンを定義し、そのサブパターンにマッチするテキストをキャプチャーします。しかし、必ずしも括弧がサブパターンをキャプチャーするわけではありません。複雑な数学の公式の場合と同じように、単に文字列をグループ化するためにも括弧を使えるのです。
下記がその一例です。これがどんなデータと突き合わせを行うか、おわかりでしょうか。
/[-a-z0-9]+(?:\.[-a-z0-9]+)*\.(?:com|edu|info)/i
|
この正規表現は、ご想像のとおり、ホスト名と突き合わせを行います (ただし .com、.edu、.info というドメイン内に限ります)。変わっているのは、?: が追加されていることです。このサブパターン修飾子 ?: によってキャプチャーが無効になり、括弧の後に演算が続くを明確に示すことになります。例えばここでは、(?:\.[-a-z0-9]+)* という句は、あるストリング (例えば「.ibm」など) のゼロ個以上のインスタンスとマッチします。同様に、\.(?:com|edu|info) という句は、リテラルのピリオドと、その後に .com、.edu、.info のいずれかのストリングが続くことを表現しています。
キャプチャーを無効にすることは的外れに思えるかもしれませんが、キャプチャーには余分な処理が必要であることを理解すれば納得できるはずです。大量のデータを処理するコードの場合、キャプチャーを省略することを検討する価値があるかもしれません。また、正規表現が特に複雑な場合には、一部のサブパターンのキャプチャーを無効にすることによって、真の関心対象であるサブパターンの抽出を容易にすることができます。
注意: この正規表現の最後にある i 修飾子によって、このパターン内のすべての突き合わせで大文字小文字が区別されなくなります。従ってサブセット a-z は、大文字小文字に関係なく、すべての文字と突き合わせを行います。
PHP には他にもサブパターン修飾子が用意されています。第 1 回で紹介した正規表現テスト用のユーティリティー (今回のリスト 1 に再掲しています) を使うと、候補ストリングである「EDU」、「edu」、そして「Edu」と、正規表現 ((?i)edu) とを突き合わせることができます。もしサブパターンを修飾子 (?i) で始めと、サブパターンでの突き合わせは大文字小文字を区別しなくなります。大文字小文字を区別する動作は、このサブパターンが終了すると同時に再度有効になります。(これを、パターン全体に適用される上記の / ... /i 修飾子と比較してみてください)。
リスト 1. 正規表現用の簡単なテスト・ユーティリティー
<?php
//
// divide the comma-separated list into individual words
// the third parameter, -1, permits a limitless number of matches
// the fourth parameter, PREG_SPLIT_NO_EMPTY, ignores empty matches
//
$words = preg_split( '/,/', $_REQUEST[ 'words' ], -1, PREG_SPLIT_NO_EMPTY );
//
// remove the leading and trailing spaces from each element
//
foreach ( $words as $key => $value ) {
$words[ $key ] = trim( $value );
}
//
// find the words that match the regular expression
//
$matches = preg_grep( "/${_REQUEST[ 'regex' ]}/", $words );
print_r( $_REQUEST['regex' ] );
echo( '<br /><br />' );
print_r( $words );
echo( '<br /><br />' );
print_r( $matches );
exit;
?>
|
もう一つ、便利なサブパターン修飾子が (?x) です。この修飾子を使うとサブパターンに空白を埋め込むことができるため、正規表現が読みやすくなります。従ってサブパターン ((?x) edu | com | info) は (edu|com|info) と同じになります (読みやすくするために追加した、選択演算子の間の空白に注目してください)。グローバル修飾子 / ... /x を使うと、正規表現全体の中に空白とコメントを埋め込むことができます (下記)。
リスト 2. 空白とコメントを埋め込む
$matches = preg_grep(
"/
[- a-z 0-9]+ # machine name
(?: \. [- a-z 0-9]+)* # subdomains
\. (?: com | edu | info)# domain
/xi", $words );
|
これを見るとわかるように、必要に応じて修飾子を組み合わせることもできます。また、例えば (?x) を使うのと同時にリテラルの空白との突き合わせを行いたい場合には、メタ文字 \s を使って任意の空白文字と突き合わせるか、あるいは \ (バックスラッシュと、その後に続く空白) を使って 1 つの空白と突き合わせることができます (例えば ((?x) hello \ there) など)。
他の使い方を探る
正規表現を使う場合のほとんどが、入力を検証するためか、あるいは入力を細かく分解して、リポジトリーにデータとして保存するため、またはアプリケーションですぐに使用するためです。フォームのフィールドの処理や XML コードの構文解析、プロトコルの解釈などは標準的な使い方です。
正規表現のもう 1 つの使い方が、整形、つまりデータを正規化したりデータの読みやすさを改善したりするための使い方です。整形では、テキストを見つけて抽出するために正規表現を使うのではなく、適切な位置でテキストを見つけ、また適切な位置にテキストを挿入するために正規表現を使います。
ここで、整形の応用として便利な例を挙げましょう。ある Web フォームが、ドルで表される給料の額をアプリケーションに送信するとします。給料の額は整数として保存されるため、このアプリケーションはポストされたデータから区切り文字を除いてデータを保存する必要があります。しかしリポジトリーからデータを取得したら、カンマを使ってそのデータを読みやすく整形しなおしたいものです。ドルで表された金額を数字に変換するための単純な PHP 関数コールを以下に示します。
リスト 3. ドルで表された金額を数字に変換する
$salary = preg_replace( "/[\$\s,]/", '', $_REQUEST[ 'salary' ] );
if ( is_numeric( $salary ) ) {
// persist the data
}
else {
// error
}
|
preg_replace() 関数を呼び出すことによって、ドル記号やすべての空白、すべてのカンマが空のストリングで置き換えられ、整数という条件に合うものが生成されます。さらに is_numeric() を呼び出すことによって入力が検証されると、そのデータを保存することができます。
次に、この操作を行い、通貨記号と、千、百万の単位を区切るためのカンマを付けて数字を出力しましょう。これらの単位を見つけるためのコードを作成することもできますが、それをしなくても、先読みと後読みを使うことで適切な位置にカンマを挿入することができます。サブパターン修飾子 ?<= は、後読み、つまり現在の位置の左を見ることを表します。修飾子 ?= は先読み、つまり現在の位置の右を見ることを表します。
では、適切な位置はどこなのでしょう。ストリング中のどの位置であっても (小数点とセントの数字を除いて)、左側に少なくとも 1 桁あり、右側に 3 桁の数字が 1 組以上ある場所であれば適切な位置です。このルールと、前後読みをする 2 つの修飾子 (どちらもゼロ幅アサーションです) を使うと、以下の文により、この動作を実現することができます。
$pretty_print = preg_replace( "/(?<=\d)(?=\d\d\d)+$)/", ',', $salary );
|
後者の正規表現はどのように動作するのでしょう。この正規表現は、ストリングの先頭から開始し、各位置を進んでいきながら、「左側に少なくとも 1 桁あり、右側に 1 組以上の 3 桁があるか」アサーションを行います。この条件に一致する場合、カンマによってゼロ幅アサーションが「置き換え」られます。
上記のような方法を使うと、多くの複雑な突き合わせの省略を容易に行うことができます。例えば、以下に示すのは先読みの別の使い方で、一般的なジレンマを容易に解決することができます。
リスト 4. 先読みの例
$tab_data = preg_replace( '/
, # look for a comma
(?= # then look ahead for
(?:[^"]*$) # a string with no quotes and eol
| # -or-
(?:[^"]*"[^"]*"[^"]*)*$ # a string with balanced quotes
) #
/x', "\t", $csv_data );
|
この preg_replace() 関数は、カンマ区切りデータの行をタブ区切りデータの行に変換します。この関数は賢明なことに、引用されたストリング内にあるカンマは置き換えません。
この正規表現は、カンマ (正規表現の先頭のカンマ) があるごとに、「この先に引用符はないか、あるいはこの先に偶数個の引用符があるか」アサーションを行います。このアサーションが真であれば、そのカンマはタブ (\t) で置き換えられます。
前後読みの演算子を使うのを好まない場合や、このような演算子を持たない言語で作業している場合には、従来の正規表現を使って数字の中にカンマを埋め込むことができますが、それを実現するためには繰り返しを数多く行う必要があります。以下に示すのは考えられる 1 つのソリューションです。
リスト 5. カンマを埋め込む
$pretty_print = preg_replace( "/[\$\s,]/", '', $_REQUEST[ 'salary' ] );
do {
$old = $pretty_print;
$pretty_print = preg_replace( "/(\d)(\d\d\d\b)/", "$1,$2", $pretty_print );
} while ( $old != $pretty_print );
|
このコードのステップをたどってみましょう。まず、データベースからの整数の読み取りをシミュレートするために、給料 (salary) パラメーターから区切り文字が除去されます。次にループが繰り返され、1 桁 (\d) の数字の後に 3 桁 (\d\d\d\) の数字が続き、単語境界 (\b で指定されます) で即座に終了している位置を見つけます。単語境界は、もう 1 つのゼロ幅アサーションであり、以下のように定義されます。
- ストリングの最初の文字が単語文字である場合には、その文字の前
- ストリングの最後の文字が単語文字である場合には、その文字の後
- 単語文字と、その直後の非単語文字との間
- 非単語文字と、その直後の単語文字との間
従って、空白、ピリオド、そしてカンマはそれぞれ有効な単語境界です。
外側のループがあるため、この正規表現は基本的に、1 桁の数字の後に 3 桁の数字と単語境界が続く部分を探して右から左に進みます。マッチするものが見つかると、2 つのサブパターンの間にカンマが挿入されます。preg_replace() がマッチするものを見つける限り、$old != $pretty_print という条件を示すループを継続するはずです。
貪欲と怠惰
正規表現は非常に強力です。時に、少し強力すぎます。例えば、「The author of 'Wicked' also wrote 'Mirror, Mirror'」というストリングに正規表現「.*」を適用すると何が起こるかを考えてみてください。preg_match() によって、マッチするものが 2 つ返されると思うかもしれませんが、1 つの結果「'Wicked' also wrote 'Mirror, Mirror'」しか返されないことを知ると驚く人がいるかもしれません。
その原因は何でしょう。他の指定をしない場合には、* (0 個以上) や + (1 個以上) などの演算子は貪欲なのです。もしパターンのマッチが続く可能性があれば、パターン・マッチングは続けられ、可能な限りの最長マッチの結果が生成されます。マッチを最小限にとどめるためには、一部の演算子を強制的に怠惰にする必要があります。怠惰な演算子は最短マッチを見つけて止まります。演算子を怠惰にするためには、接尾辞として疑問符を追加します。リスト 6 はその一例です。
リスト 6. 接尾辞としての疑問符
$text = 'The author of "Wicked" also wrote "Mirror, Mirror."';
if ( preg_match_all( '/".*?"/', $text, $matches ) ) {
print_r( $matches[0] );
}
|
上記のスニペットによって、以下の結果が出力されます。
Array ( [0] => "Wicked" [1] => "Mirror, Mirror." )
|
正規表現「.*?」は「引用符と突き合わせ、次に必要最小限の文字と突き合わせ、次に引用符と突き合わせる」という意味です。
しかし場合によると、* 演算子は怠惰すぎることがあります。例えば以下に示すコード・スニペットを考えてみてください。これによって何が出力されるでしょう。
リスト 7. 簡単な正規表現のテスト・ユーティリティー
if (preg_match( "/([0-9]*)/", "-123", $matches ) ) {
print_r( $matches );
}
|
皆さんは何が出力されると思いましたか? 「123」でしょうか。「1」でしょうか。それとも「何も出力なし」でしょうか。実は、出力は Array ( [0] => [1] => ) です。つまり突き合わせは行われましたが、何もキャプチャーされなかったのです。なぜでしょう。演算子 * は 0 個以上のマッチを検出する、ということを思い出してください。この場合の [0-9]* という式は、ストリングの先頭に対して 0 個のマッチが検出され、そして処理が停止するのです。
この問題を修正するためには、ゼロ幅アサーションを追加してマッチが検出される位置を固定します。そうすることによって、正規表現エンジンに強制的に突き合わせを継続させます。そのためには /([0-9]*\b/ で十分です。
その他のヒントと手法
正規表現によって、単純な、あるいは複雑なテキスト処理の問題を解決することができます。最初はいくつかの演算子から始め、経験を積みながら語彙を増やすことです。以下で紹介するのは、正規表現をうまく使うためのいくつかのヒントと手法です。
文字クラスを使って正規表現を移植できるようにする
これまで、任意の空白文字と突き合わせを行う、\s などのメタ文字を見てきました。また、多くの正規表現の実装では事前定義された文字クラスをサポートしており、これらのクラスは使いやすく、さまざまな言語に移植可能です。例えば文字クラス [:punct:] は、カレント・ロケールのすべての区切り文字を表します。[0-9] の代わりに [:digit:] を使うことができ、また [-a-zA-Z0-9_] の代わりに [:alpha:] を使った方が移植性を高めることができます。例えば、以下の文を使うと、ストリングからすべての区切り文字を削除することができます。
$clean = preg_replace( "/[[:punct:]]/", '', $string );
|
この文字クラスの方が、すべての区切り文字を書き出すよりも簡潔です。文字クラスの完全なリストに関しては、皆さんが使用している PHP のバージョンのドキュメンテーションを参照してください。
検索対象ではないものを除く
カンマ区切りの値 (CSV: comma-separated value) とタブ区切りのデータの例で見たように、突き合わせたくないものをリストアップした方が容易で簡潔な場合があります。キャレット (^) で始まるセットは、そのセットに含まれないすべての文字と突き合わせを行います。例えば、正規表現 /[2-9][0-9]{2}[2-9][0-9]{2}[0-9]{4}/ を使えば米国の電話番号を検証することができます。しかし除外セットを使うと、この正規表現をもっと明示的に /[^01][0-9]{2}[^01][0-9]{2}[0-9]{4}/ と書くことができます。どちらの正規表現も有効ですが、後者の方が明らかに意図を簡潔に表現しています。
改行をスキップする
入力が複数行にわたる場合には、典型的な正規表現では走査が改行で終了してしまうため、十分ではありません (改行は $ で表現されます)。しかし、s 修飾子または m 修飾子を使うと、正規表現エンジンは異なった方法で入力を扱います。前者はストリングを単一行として扱い、ドットと改行をマッチさせます (通常はドットと改行がマッチすることはありません)。後者はストリングを複数行として扱い、^ と $ はそれぞれ行の先頭と最後とマッチします。例えば、$string = "Hello,\nthere" と設定すると、preg_match( "/.*/s", $string, $matches) というステートメントによって $matches[0] は Hello,\nthere に設定されます。(s を除くと Hello が出力されます。)
正規表現を使うことによって、皆さんの想像力と創意工夫次第で何でも実現することができるのです。
参考文献 学ぶために
製品や技術を入手するために
- 皆さんの次期オープン・ソース開発プロジェクトをIBM trial software を使って革新してください。ダウンロード、あるいは DVD で入手することができます。
-
IBM 製品の試用版をダウンロードし、DB2® や Lotus®、Rational®、Tivoli®、WebSphere® などが提供するアプリケーション開発ツールやミドルウェア製品をお試しください。
議論するために
著者について  | |  | Martin Streicherは McClatchy Interactive の最高技術責任者であり、Linux Magazineの編集長であり、Web 開発者であり、また developerWorks への頻繁な寄稿者でもあります。彼は Purdue Universityでコンピューター・サイエンスの修士号を取得しており、1986年以来、UNIX ライクのシステムでプログラミングを行ってきています。 |
記事の評価
|