もう Reflection
なんて要らない。 reveal()
の一撃で終わらせましょう。
<?php
use function Wazly\Revelation\reveal;
$obj = new class {
private function do() {
return 'プライベートメソッドにアクセスできちゃった!';
}
};
echo reveal($obj)->do(); // "プライベートメソッドにアクセスできちゃった!"
ユニットテストのつらみ
PHPUnit を使ってユニットテストを書いていてつらいのが public でないメソッドを単体でテストするときです。テストを実行するオブジェクトのクラススコープとテスト対象オブジェクトのクラススコープが異なるため、 protected あるいは private で定義されたメソッドやプロパティにアクセスできないのです。それを解決する方法は、サンプルコードとともにネット上にたくさん転がっているので、本稿では特に説明しません。
汎用パッケージ化
よく紹介されている Reflection
や Closure
を用いたサンプルコードは醜いです。しかし、それ以外に方法はありません。ただ、もっと簡単かつ汎用的に扱えるモノが欲しかったので冒頭例のように超簡単に non-public を擬似的に public にするツールを作りました。
使い方
ここからは使い方を紹介します。
サポートする PHP バージョン
- 7.1 以上
インストール
Composer を使ってください。テストでしか使わないと思うので require --dev
で。
composer require --dev knj/revelation
Revelation オブジェクトを作る
private や protected をアクセス可能にしたいオブジェクトを Revelation オブジェクト (以下、 Revelation) によってラップします。実にこれだけです。例えば次のような Stuff クラスがあるとしましょう。
<?php
class Stuff
{
private $privateProperty;
// $privateProperty に $a + $b をセット
public function __construct($a, $b)
{
$this->privateProperty = $a + $b;
}
// $privateProperty に $x + $y をセット
// 自分自身を返す
private function privateMethod($x, $y)
{
$this->privateProperty = $x + $y;
return $this;
}
}
次のように reveal()
を使って、 Stuff オブジェクトの Revelation を作ります。
<?php
use function Wazly\Revelation\reveal;
$stuff = new Stuff(1, 2);
$stuff = reveal($stuff);
これで private なメソッドやプロパティにアクセスできます。
echo $stuff->privateProperty; // 3
$stuff->privateMethod(1, 100);
echo $stuff->privateProperty; // 101
Revelation の作り方は他にもいくつかあります。
$stuff = new Stuff(1, 2);
// use function Wazly\Revelation\reveal;
reveal($stuff);
reveal(Stuff::class, 1, 2);
reveal(function ($a, $b) { return new Stuff($a, $b); }, 1, 2);
// use Wazly\Revelation;
Revelation::wrap($stuff);
Revelation::wrap(Stuff::class, 1, 2);
Revelation::wrap(function ($a, $b) { return new Stuff($a, $b); }, 1, 2);
Revelation オブジェクトから素 (もと) のオブジェクトを取り出す
$stuff
は Revelation という全く別のオブジェクトに置き換わっています。 Revelation が参照している素のオブジェクトを取り出したいときは getOriginal
メソッドを使います。
$stuff = reveal($stuff);
echo get_class($stuff); // Wazly\Revelation
echo get_class($stuff->getOriginal()); // Stuff
素のオブジェクトに対する参照
同じオブジェクトから作られた Revelation は同じそのオブジェクトに対する参照を持っています。次の例では、 $rev1
と $rev2
は別々の Revelation オブジェクトですが、参照している元のオブジェクト ( $stuff
) は同じなので、1つの $stuff
を共有している状態になっています。
$rev1 = reveal($stuff);
$rev1->privateMethod(1, 2);
$rev2 = reveal($stuff);
$rev2->privateMethod(3, 4);
echo $rev1->privateProperty; // 7 not 3
echo $rev2->privateProperty; // 7
このようなふるまいを回避したい場合には、 Revelation::clone
を使います1 。
$rev1 = reveal($stuff);
$rev1->privateMethod(1, 2);
$rev2 = Revelation::clone($stuff);
$rev2->privateMethod(3, 4);
echo $rev1->privateProperty; // 3
echo $rev2->privateProperty; // 7
メソッドチェーン
Revelation ラップされたオブジェクトのメソッドが return $this
をするとき、この $this
はラップしている Revelation に上書きされます。これによって public でないメソッドを連鎖させることができます。
reveal($stuff)->privateMethod(1, 2)->privateMethod(3, 4);
static メソッドとプロパティ
public でない static メソッドやプロパティは (public なそれらと異なり) インスタンス化していないとアクセスできないので、インスタンスを Revelation ラップしてから Revelation::getStatic
や Revelation::callStatic
を使ってアクセスします。
class A
{
protected static $staticProperty = 'static';
protected static function className()
{
return __CLASS__;
}
protected static function selfName()
{
return self::className();
}
protected static function staticName()
{
return static::className();
}
}
class B extends A
{
protected static function className()
{
return __CLASS__;
}
}
echo reveal(B::class)->getStatic('staticProperty'); // static
echo reveal(B::class)->callStatic('className'); // B
echo reveal(B::class)->callStatic('selfName'); // A
echo reveal(B::class)->callStatic('staticName'); // B
どういう仕組み?
擬似的な public 化には Closure::bindTo
しか使ってません。Closure::bindTo
とはなんぞや?という人は公式リファレンスへGO!簡単に説明すると、クロージャ内の $this
に任意のオブジェクトを指定し、実行時のクラススコープも変更できてしまうものです。これを使ってメソッドを実行するクロージャを作り、スコープを合わせてしまえば private でも呼び出せてしまうということです。
Revelation によるラップの仕組みは PHPでメソッドの挙動を柔軟に変更するプロキシ設計 を参照してください。
追記
Twitter で空リプをいただいたような気がするので。
クロージャに $this をバインドして可変長呼び出しをすれば万能かと言ったらそんなこともなく、変数リファレンスとマジックメソッドは併用できないんですよね。 https://t.co/Oe5QMmT1tv
— にゃんだーすわん (@tadsan) 2018年12月18日
確かにエラーになってしまいます。
Fatal error: Method Wazly\Revelation::__call() cannot take arguments by reference in ...
ケースとしてはそこまで多くないと思うので、専用のメソッドを用意して対応しようかと思います。ありがとうございました。
追記2
バインドされたオブジェクトを持つクロージャを返すAPI Revelation::bind
を追加しました。これによって、少し遠回りで冗長になりますが、リファレンスを渡すメソッドも扱えるようになります。
class X
{
private function callPassByReferenceMethod($val, &$ref)
{
static::passByReference($val, $ref);
}
private static function passByReference($val, &$ref)
{
$ref = $ref ?? 1;
$ref += $val;
}
}
$closure1 = reveal(X::class)->bind(function ($val, &$ref) {
$this->callPassByReferenceMethod($val, $ref);
});
$closure2 = reveal(X::class)->bind(function ($val, &$ref) {
static::passByReference($val, $ref);
});
$closure1(99, $ref);
echo $ref; // 100
$closure2(100, $ref);
echo $ref; // 200
参考
-
内部で
clone
を使っているので__clone()
がコールされます。 ↩