hnwの日記

例えばPHPのpreg_replace関数でe修飾子を避ける


(2015/04/19追記)e修飾子はPHP 5.5からdeprecatedになっています。また、PHP7からは廃止されます。

PHPのpreg_replace関数では、e修飾子が利用できます。これはPerlから輸入された機能で、置換後パターンの文字列をPHP文法に従って評価する(evalする)というものです。Perlを知らないとあまり使わない機能かもしれませんが、Perlを知っているとPHPでも使いたくなるのではないでしょうか。本記事では、preg_replace関数でe修飾子を使う場合の注意点を指摘し、代替案を示します。

preg_replace関数のe修飾子

preg_replace関数のe修飾子は次のような機能です。

e 修飾子を設定すると、preg_replace() は、参照先の対応する置換を行う際に replacement 引数を PHP コードであるとして取り扱います。replacement には有効な PHP コードを記述してください。さもないと、preg_replace() がある行でパースエラーが発生してしまいます。

PHP: preg_replace - Manual

つまり、マッチした場所を置換後パターンに置き換える代わりに、置換後パターンをPHPとして解釈した結果に置き換えます。これを利用すれば正規表現マッチの範囲では不可能な置換を実現できるというわけです。

例題として、「空白文字以外が連続する部分文字列の後ろに、その部分文字列の長さを括弧付きの数字で追記する」ことを考えてみます。つまり、「abc 12345」であれば、「abc(3) 12345(5)」などと置換したいものとします。

通常の正規表現では文字列の長さを取り出すことはできませんので、e修飾子とstrlen関数で実現してみましょう。

<?php
$str = 'abc 12345';
$str = preg_replace('/(\S+)/e', "'$1('.strlen('$1').')'", $str);
echo $str, "\n"; # abc(3) 12345(5)

このコードで期待通りに実現できているように見えますが、実は置換対象文字列にダブルクォートが含まれる場合に正しく動きません。

e修飾子を指定すると後方参照がエスケープされる

preg_replace関数のマニュアルには次のように書いてあります。

e 修飾子を使用する際に、この関数は後方参照を置換する文字列のうちの特定の文字 (具体的には '、"、 \ および NULL) をエスケープします。これは、後方参照をシングルクォートやダブルクォートを共用した場合 (たとえば 'strlen(\'$1\')+strlen("$2")')に構文エラーが発生しないようにするためのものです。PHPの文字列構文を意識し、文字列がどのように解釈されるのかを正確に知っておくようにしましょう。

PHP: preg_replace - Manual

つまり、e修飾子を利用した場合のみ、置換パターン中の後方参照($1や\1など)を文字列展開する際に、勝手にaddslashesされるというわけです。先ほどのコードで試してみましょう。

<?php
$str = 'abc 12345 #",;';
$str = preg_replace('/(\S+)/e', "'$1('.strlen('$1').')'", $str);
echo $str, "\n"; // abc(3) 12345(5) #\",;(5)

置換前は4文字だったものが5文字に置換されてしまいました。どういうことかというと、$1の中の"が文字列展開される際に\"に展開されてしまったのです。これはマニュアルの記載通りの挙動ではありますが、多くの人が予想しない挙動なのではないでしょうか。

このようにe修飾子を使っていて後方参照をPHP文字列として評価したい場合、キャプチャ*1した文字列中にダブルクォートおよびヌル文字が含まれないかどうかに注意する必要があります。これらが含まれる場合、期待と異なる置換結果になる可能性があります。

後方参照をダブルクォートで囲むと別の問題がある

先ほどの例では$1の周りをシングルクォートで囲んでいました。では、ダブルクォートで囲めば問題は解決するでしょうか?確かに、後方参照の文字列展開後に「"\""」となっていれば同じ問題は起きません。しかし、ダブルクォートで囲むとevalするときに変数展開が起こるので、キャプチャした文字列中にドル記号が含まれていると不思議な結果になってしまいます。

<?php
$foo = 'bar';
$str = 'abc 12345 #",; $foo';
$str = preg_replace('/(\S+)/e', '"$1(".strlen("$1").")"', $str);
echo $str, "\n"; // abc(3) 12345(5) #",;(4) bar(3)

例題の仕様としては「$foo」は「$foo(4)」と置換されるべきなのですが、「"$foo(".strlen("$foo").")"」をevalしたせいで「bar(3)」になってしまうのです。もしもeval実行時にプログラムがユーザーに見られたくない変数値を持っていたとしたら、情報流出などの事故の原因になりかねません。ダブルクォートで囲む状況が無いとは言いませんが、シングルクォートで囲むよりも注意が必要なのではないでしょうか。

e修飾子の代わりにpreg_replace_callback関数を使う

preg_replaceとe修飾子を用いて置換後パターン中で後方参照をPHP文字列として使いたい場合、シングルクォートで囲んでもダブルクォートで囲んでも事故の原因になりかねないことを指摘しました。このような状況に対して、preg_replace_callback関数を利用することができます。例えば次のようなコードです。

<?php
$str = 'abc 12345 #",; $foo';
$str = preg_replace_callback('/(\S+)/', function($m){return $m[1].'('.strlen($m[1]).')';}, $str);
echo $str, "\n"; // abc(3) 12345(5) #",;(4) $foo(4)

このようにすれば余計なエスケープはされません。上記コード例ではPHP5.3.x以降の無名関数の機能を利用していますが、置換専用の関数やメソッドを作成したり、create_function関数を利用すればPHP5.2.x以前でも同じことができます。

Perl正規表現との対比

参考までにPerlで同じことをしたい場合のコードを紹介します。

#!/usr/bin/perl -w
use strict;
my $str = 'abc 12345 #",; $foo';
$str =~ s/(\S+)/"$1(".length($1).")"/eg;
print $str, "\n"; # abc(3) 12345(5) #",;(4) $foo(4)

見ての通り、十分シンプルなコードです。Perlでは/eを指定した場合に置換後パターンとして直接コードを書けることに加え、$1なども通常の変数としてセットされるので、他の場所と同じようにコードを書くことができます。

一方、PHPには正規表現リテラルが無いので、e修飾子の対象となるPHPコードは文字列として実現されており、エスケープ地獄*2に悩まされたり、変なコードを実行しないようにaddslashesされたりしてカオスなわけです。

逆に言えば、preg_replace_callback関数と無名関数の組み合わせを使うのは、エスケープ地獄から抜け出せるというメリットもあるわけです。Perlのノリでe修飾子を使うより、preg_replace_callbackを使う方がPHPっぽいということなのかもしれません。

まとめ

  • preg_replaceのe修飾子の挙動がキモい
    • 置換後パターン中の後方参照は勝手にaddslashesされる
    • マッチするパターンによっては利用可能だが、エスケープ地獄の恐れも
  • PHP5.3.0以降ならpreg_replace_callback+無名関数なら比較的読みやすい
  • PHP5.2.x以前ならプライベートメソッドを作ってpreg_replace_callback?
    • create_functionは文字列としてPHPコードを書くので個人的には嫌い

*1:正規表現を括弧で囲むことで、マッチした文字列を捕まえておくこと。技術用語として適切な日本語訳を僕が知らないので、ここでもそう呼んでいます

*2:文字列として評価された後で再度evalで評価されるため、バックスラッシュを書きたい場合などにエスケープしまくる必要があります