+演算子の挙動がJavaScriptっぽくなるPHP拡張モジュールを書いてみた
このエントリは闇PHP Advent Calendar 2015の2日目です。3日目までは僕が書く予定ですが、4日目以降の援軍を絶賛募集中です。
PHPの+演算子で文字列連結できないか?
PHPの+演算子は算術加算の演算子です。つまり、オペランドを整数または浮動小数点数として足し算するわけです。
しかし、JavaScriptを書き慣れているとPHPでも"abc"+"def"
のように書いてしまう事故があるとかないとか聞きました。もしかするとJavaScriptの+演算子をPHPに輸入したら平和が訪れるかもしれません。
そんな発想で、+演算子のオペランドの一方が文字列型なら文字列連結になるようなPHP拡張モジュールjsplusを試作してみました。いまのところPHP7専用ですが、PHP5版も作れると思います。
実際に動かしてみる
この拡張モジュールを組み込んだPHPは、+演算子の右オペランドまたは左オペランドのいずれか一方が文字列型だった場合には文字列連結となり、それ以外の場合は算術加算になります。
<?php $abc="abc"; $twelve=12; var_dump($abc+"def"); // string(6) "abcdef" var_dump($twelve+"34"); // string(4) "1234" var_dump($twelve+34); // int(46)
このように、+の片側または両側が文字列なら文字列連結として動作していることがわかります。
この拡張モジュールの実用性は非常に低いと思います。少なくとも僕は常用したいと思いませんね…。
演算子の挙動を上書きする方法
さて、この拡張モジュールはどういう仕組みなのでしょうか?
そもそも、PHPのプログラムは仮想CPUであるZend VMの命令(opcode)にコンパイルされてから実行されます。+演算子であれば、コンパイル後はZEND_ADD
というopcodeにコンパイルされます。このZEND_ADD
の挙動を変えることができれば+演算子の挙動を変えることができるわけです。
ところで、PHPの拡張モジュールではzend_set_user_opcode_handler
を利用することでopcodeの処理を差し替えることができます。
ZEND_API int zend_set_user_opcode_handler(zend_uchar opcode, user_opcode_handler_t handler)
zend_set_user_opcode_handler
の第一引数がopcodeで、第二引数が処理を差し替えるためのハンドラの指定になります。今回の目的のためには、第一引数でZEND_ADD
を指定した上で、第二引数で指定する独自ハンドラの実装として+の右オペランドと左オペランドのどちらか一方が文字列型だった場合のみZEND_CONCAT
(.演算子に対応)に処理を委ね、それ以外の場合は本来のZEND_ADD
の処理を呼び出すようにすれば完成というわけです。
それにしても、拡張モジュールを1個組み込むとZend VMのopcodeの処理が変わっているかもしれない、というのはPHPの面白い特徴ではないでしょうか。PHPはPHP言語のレイヤでは言語自体に介入する余地があまり無い言語だという印象がありますが、拡張モジュールのレイヤではむしろ自由度が高すぎると言えるかもしれません。
PHP7の定数たたみ込み
実は、上記の処理だけでは+の挙動を完全には上書きできません。opcodeの処理を上書きすることで$abc="abc";echo $abc+"def"
の結果は期待通り"abcdef"
となるのですが、echo "abc"+"def"
は普通のPHPのように0
になってしまいます。
これはPHP7の定数たたみ込み処理が原因です。PHP7ではプログラム中に"abc"+"def"
のような定数同士の演算があるとコンパイル時に先に計算を済ませてしまい、コンパイル後バイナリには計算結果だけを含めるような処理を行います。このコンパイル時の計算処理はVMの実装とは独立に記述されているため、opcodeの挙動を上書きするだけでは定数同士を+演算した計算結果は変わらないのです。
ASTを操作する
定数同士の+の処理を書き換えるには定数たたみ込み処理自体をフックしたいところですが、残念ながら現時点のPHP7には適切なフックポイントが見つかりませんでした。そこで、ASTの書き換えで対応してみることにします。
PHPプログラムのコンパイル処理は下図のように何段階かに分かれます。
この処理中に登場する抽象構文木(AST、Abstract Syntax tree)はプログラムの構造を木構造にしたようなデータ構造で、PHP7から実装されたものです。このASTの木構造を辿りながらopcodeへとコンパイルしていく、というのがPHPコンパイル処理の流れになります。
このASTを読み書きするためのフックポイントとして、PHP7では関数ポインタzend_ast_process
が用意されています。ここに独自の処理を差し込むことで、AST構築直後のタイミングでASTの読み書きを行うことができます。
今回は、+演算子に対応するZEND_AST_BINARY_OP
ノードの子ノード(オペランドに対応)が定数だった場合に、同じ型へのキャスト演算を追加するようにASTの構造を書き換えてみました。これにより定数たたみ込みが抑制され、+演算子の処理がVM実行時に行われるようになりました。PHPのバージョンアップでいつ動かなくなるかわからない程度のクイックハックですが、まずはこれでいいことにしましょう。
課題
ここまで実装してみたものの、ASTを書き換えるというアプローチでは限界があることに気付きました。例えば("1".(2*3))+("1a"."2b")
について考えてみます。ASTの段階では+の子ノードは("1".(2*3))
に対応する複数のノードになっており、定数だと判断するのは難しい状態です。しかし、そのままでは+も含んだ式全体が定数たたみ込み処理されてしまい、結果として17
と評価されてしまいます。
このように複雑な定数たたみ込みへの対応まで考えると、そもそもASTを書き換えるというアプローチがイマイチな気がします。もっと良いフックポイントがないと完璧な対応は厳しいですね。