y_uti のブログ

統計、機械学習、自然言語処理などに興味を持つエンジニアの技術ブログです

nikic/comparable で PHP の比較演算をオーバーライドする

PHP で比較演算子の挙動を変更する拡張モジュール nikic/comparable を試してみました*1PHP ではオブジェクト同士の比較方法は言語仕様として決められており*2、他の言語で見られるように equals や compareTo といったメソッドで挙動を変更することはできません。ですが、この拡張モジュールを利用すると、PHP でも比較演算子の挙動を変更できるようになります。*3
github.com

ただし、作者の nikic 氏が以下のように述べているとおり、この拡張モジュールは PHP の magic interface の実装例として公開されているものです。その点に注意してください。

Note: This is to the most part just code demonstrating the implementation of a magic interface for a tutorial. I do not currently plan on proposing including such an interface for PHP itself.

また、このモジュールは 2013 年に開発されたもので、PHP 7 には対応していません。PHP 7 に移植したものを私のリポジトリで公開していますので、PHP 7 で試したい方はこちらを利用してください。この記事のコード例や実行例も、PHP 7 に移植した実装を使って PHP 7.2.0 で動かしたものです。
https://github.com/y-uti/comparable

拡張モジュールをビルドする

モジュールのビルド方法は、通常の PHP 拡張モジュールと同様です。以下の一連のコマンドを実行すると、modules ディレクトリに comparable.so が出力されます。

$ git clone https://github.com/y-uti/comparable.git
$ cd comparable
$ phpize && configure && make

make install して利用することもできますが、次のようにコマンドラインオプションを指定して PHP を実行すれば、インストールせずにモジュールを有効にできます。-i オプションで phpinfo を表示させており、comparable モジュールが有効になっていることを確認できます。

$ php -n -dextension_dir=modules -dextension=comparable.so -i | grep comparable
comparable

実行例では、-dextension_dir で拡張モジュールのディレクトリを指定して、-dextension=comparable.so でモジュールを有効にしています。指定したディレクトリには comparable.so しか存在しないので、php.ini に書かれている他のモジュールは利用できません。今回は comparable.so だけ利用できれば問題ないので、-n オプションを指定して php.ini を読み込まないように指示します。この方法は make test で拡張モジュールをテストする処理でも用いられています。興味のある方は Makefile を読んでみてください。

Comparable インタフェースを実装する

拡張モジュールを有効にすると、Comparable インタフェースを実装して比較演算子の挙動を変更できます。サンプルプログラムを以下に示します。

<?php
class MyClass implements Comparable
{
    private $value;
    private $ignored;
    public function __construct($value, $ignored)
    {
        $this->value = $value;
        $this->ignored = $ignored;
    }
    public static function compare($a, $b)
    {
        return $a->value <=> $b->value;
    }
}

$one_foo = new MyClass(1, "foo");
$one_bar = new MyClass(1, "bar");
$two_foo = new MyClass(2, "foo");

var_dump($one_foo == $one_bar);   // true
var_dump($one_foo == $two_foo);   // false
var_dump($one_foo < $one_bar);    // false
var_dump($one_foo < $two_foo);    // true

MyClass の compare メソッドが Comparable インタフェースの実装です。二つの引数を取るスタティックメソッドで、第一引数の方が小さければ -1, 等しければ 0, 大きければ 1 を返すように実装します。コード例では、MyClass のプロパティである value と ignored のうち value だけを比較するように定義したので、次の実行例のように $one_foo と $one_bar は等しいという結果になります。

$ php -n -dextension_dir=modules -dextension=comparable.so sample.php
bool(true)
bool(false)
bool(false)
bool(true)

リポジトリのドキュメントには特に説明がないのですが、メソッドから null を返すことで、デフォルトの比較演算に処理を任せることもできます。これは、ソースコードの下記の箇所に実装されている内容による動作です。
https://github.com/nikic/comparable/blob/master/comparable.c#L70-L75

例として、先ほどの compare メソッドを変更して、value が足して 3 になる場合は等しいと判定し、それ以外の場合はデフォルトの比較演算で処理を行うように定義します*4

<?php
    ...
    public static function compare($a, $b)
    {
        if ($a->value + $b->value == 3) {
            return 0;
        }
        return null;
    }
    ...

実行結果は次のようになります。

$ php -n -dextension_dir=comparable/modules -dextension=comparable.so sample2.php
bool(false)   // $one_foo == $one_bar   デフォルトの比較演算が行われ、等価ではないので false
bool(true)    // $one_foo == $two_bar   value の和が 3 になるので等しい (したがって true)
bool(false)   // $one_foo < $one_bar    デフォルトの比較演算行われ、"foo" > "bar" なので false
bool(false)   // $one_foo < $two_bar    value の和が 3 になるので等しい (したがって false)
Comparable の使用上の注意

compare メソッドが受け取る二つの引数は、左辺が第一引数、右辺が第二引数になるとは限りません。このことはリポジトリのドキュメントでも説明されています。

One should not rely on which class the method is invoked on. (Due to technical reasons for $l < $r it will be called on the class of $l, but for $l > $r it will be called on the class of $r.)

これは、PHP の処理系に起因する問題です。そもそも PHP には ">" を処理するバイトコード命令が存在せず、">" は左辺と右辺を入れ替えて "<" として処理されます。次のコード例で確認します。

<?php
$a = 1;
$b = 2;
echo $a > $b;

vld 拡張モジュールを有効にして、このコードのバイトコード命令をダンプした結果は以下のとおりです。IS_SMALLER 命令が出力されていることを確認できます。オペランドは !0 が $a, !1 が $b に対応するので、$b < $a として計算されることがわかります。

$ php -dvld.active=1 -dvld.execute=0 sample3.php
...
compiled vars:  !0 = $a, !1 = $b
line     #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
   3     0  E >   ASSIGN                                                   !0, 1
   4     1        ASSIGN                                                   !1, 2
   5     2        IS_SMALLER                                       ~4      !1, !0
         3        ECHO                                                     ~4
   6     4      > RETURN                                                   1
...

この問題のため、compare メソッドの実装が異なる二つのクラスを正しく比較するのは困難です。比較演算子は必ず "<" を使い ">" の使用を禁止するというような規約が必要になってしまうかもしれません。

一方で、compare メソッドの実装が異なるクラス間の比較という用途自体が一般的ではないとも思います。その必要がなければ、先ほどの null を返す方法と組み合わせて、引数がどちらも MyClass のインスタンスであることを保証するガードを入れるのが無難です。以下のように実装すれば、MyClass のインスタンス同士の比較のみ compare メソッドが利用され、そうでない場合はデフォルトの比較演算が使われるようになります。

<?php
class MyClass implements Comparable
{
    ...
    public static function compare($a, $b)
    {
        if (!$a instanceof MyClass || !$b instanceof MyClass) {
            return null;
        }
        ...
    }
}

もう一つ注意が必要なこととして、compare メソッドは比較される両者がどちらも Comparable インタフェースを実装している場合に限り呼び出されます。compare メソッドは、Comparable を実装しないオブジェクトや、整数、文字列などとの比較には使われません。このことも、リポジトリのドキュメントに記載されています。

When two objects (both implementing the interface) are comapred using <, > or == the compare() method will be invoked.

この挙動も PHP の処理系の実装によるものです。比較される両者がオブジェクトである場合には、まずオブジェクトハンドラの compare_objects が一致するかどうかがチェックされ、一致する場合のみ compare_objects での比較が行われます。ソースコードでは Zend/zend_operators.c の以下の箇所になります。
https://github.com/php/php-src/blob/php-7.2.0/Zend/zend_operators.c#L2009

[2017-12-26 追記]
冒頭の段落の記述は、言語が演算子オーバーロードの仕組みを持つかという話と、言語の標準で提供されるクラス階層が equals 等のメソッドを持つかという話を混同しており、全体としておかしな内容になっていました。この二つは別の問題であり、区別して考える必要があります。

*1:動機は、『テスト駆動開発』 を PHP で写経していたとき、第 10 章の equals をオーバーライドする箇所を実装できなかったためです。実装する方法を調べていたところ、この拡張モジュールを見つけたので、使ってみました。

*2:PHP: オブジェクトの比較 - Manual

*3:[2017-12-26] この部分は内容におかしな点があったと思いますので、取り消します。記事の末尾で補足しました。

*4:return null のところで、うっかり return $a <=> $b と書いてしまうと、それ自体がオーバーライドされているわけですから、再帰呼び出しになり正しく動作しません。私の環境では segmentation fault を起こしました。