Closure::bindでクロージャの$thisとスコープを差し替える
Closure::bind() は、クロージャの $this と「クラススコープ」を差し替えて複製する機能です。スコープを渡すと、そのクラスの private にも触れます。
クロージャは「その場で書ける無名関数」くらいの認識で止まりがちですが、PHPのクロージャには $this とは別に「どのクラスの一員として振る舞うか」という情報がくっついています。これを外から差し替えられる、というのが bind の正体です。
テストで private を覗きたいとき、ライブラリのマクロ機構を自作するとき、そして「なぜか private にアクセスできない」とハマるとき。だいたいこの話が絡んできます。
クロージャは$thisとスコープの2つを持っている
ここを分けて理解しないと、bind は一生ハマり続けます。$this は「誰のプロパティを読むか」、スコープは「どこまで見えるか(private/protectedの可視性)」です。別物です。
シグネチャを確認しておきます。第2引数が省略できるせいで、後述の事故が起きます。
// インスタンスメソッド版
public function Closure::bindTo(?object $newThis, object|string|null $newScope = "static"): ?Closure
// 静的メソッド版(やることは同じ)
public static function Closure::bind(Closure $closure, ?object $newThis, object|string|null $newScope = "static"): ?Closure
第2引数のデフォルトは文字列の "static"、つまり「今のスコープを維持する」という意味です。null でも「スコープなし」でもありません。ここが読み違えポイントです。
なぜprivateにアクセスできないのか
いちばん多いハマりどころがこれです。クラスの外で定義したクロージャを bindTo でオブジェクトに結びつけたのに、private が読めない。
<?php
class Counter
{
private int $count = 3;
}
$peek = function () {
return $this->count;
};
$c = new Counter();
// スコープを渡していない → 「今のスコープ」= クラス外のまま
$bad = $peek->bindTo($c);
$bad(); // Error: Cannot access private property Counter::$count
// スコープを渡すと、Counterのメソッドとして振る舞う
$good = $peek->bindTo($c, Counter::class);
echo $good(); // 3
$this は確かに $c になっています。それでも落ちるのは、可視性を決めているのがスコープのほうだからです。マニュアルにも、スコープを持たないクロージャにインスタンスだけ与えた場合は「特定されないクラス」にスコープされる、と書かれています。要するにどのクラスの内側でもない、という扱いですね。
スコープにはクラス名の文字列でも、オブジェクトそのものでも渡せます。オブジェクトを渡した場合はその型が使われます。$peek->bindTo($c, $c) と書くのが実務では一番よく見る形かもしれません。
クラス内で作ったクロージャは最初からスコープを持っている
逆に、メソッドの中で生成したクロージャは生まれつきそのクラスのスコープを持っています。だから $this だけ差し替えれば、別インスタンスの private も読めます。
<?php
class A
{
private $val;
public function __construct($val)
{
$this->val = $val;
}
public function getClosure(): Closure
{
// このクロージャのスコープは A
return function () {
return $this->val;
};
}
}
$ob1 = new A(1);
$ob2 = new A(2);
$cl = $ob1->getClosure();
echo $cl(); // 1
echo $cl->bindTo($ob2)(); // 2
ここでは第2引数を省略しても動きます。「今のスコープ(=A)を維持する」が期待どおりに働くからです。同じ bindTo($obj) という書き方が、定義場所によって動いたり落ちたりする。この非対称さが、bind を分かりにくくしている一番の原因だと思います。
使い捨てならClosure::callのほうが手軽
一度呼ぶだけなら、クロージャを複製する必要はありません。PHP 7.0 で入った Closure::call() は、一時的に束縛して即座に呼びます。しかもスコープは自動的に「渡したオブジェクトのクラス」になります。
public function Closure::call(object $newThis, mixed ...$args): mixed
テストで内部状態を確認したいとき、これが地味に効きます。ゲッターをテストのためだけに生やさずに済みます。
<?php
final class Order
{
private array $lines = [];
public function add(string $sku, int $qty): void
{
$this->lines[] = ['sku' => $sku, 'qty' => $qty];
}
}
$order = new Order();
$order->add('A-100', 2);
// private $lines をそのまま取り出す
$lines = (function () {
return $this->lines;
})->call($order);
var_dump(count($lines)); // int(1)
bindTo と違ってスコープの指定を忘れようがないので、事故が減ります。引数もそのまま後ろに並べて渡せます。
もっとも、これはテストコードでの話です。本番コードで他クラスの private をこじ開け始めたら、それは設計のほうを疑ったほうがいいと思います。
インスタンス無しでスコープだけ変える
$newThis に null を渡すと、$this を持たないクロージャになります。これでも private static には触れます。可視性はスコープが決めている、という原則がそのまま効いてくるわけですね。
<?php
class Registry
{
private static array $map = ['a' => 1];
}
$dump = Closure::bind(
static function () {
return Registry::$map;
},
null,
Registry::class
);
var_dump($dump()['a']); // int(1)
逆に bindTo(null, null) のように両方 null にすると、スコープも $this も持たない素のクロージャに戻ります。「外から見える範囲だけ」を扱いたいときに使えます。
static宣言したクロージャは結び付けられない
これも踏みやすい地雷です。static function () {} と書いたクロージャは $this を持てないので、インスタンスを結びつけようとすると警告が出て失敗します。戻り値は null になるので、そのまま呼び出せば「null に対する呼び出し」で落ちます。
<?php
$fn = static function () {
return 1;
};
$bound = $fn->bindTo(new stdClass()); // 警告: Cannot bind an instance to a static closure
var_dump($bound); // NULL
この挙動は将来のPHPではエラーに格上げされる予定になっています。今のうちに、static を外すか $newThis に null を渡すか、どちらかに直しておくのが無難です。なお static クロージャでもスコープの変更だけは可能です。
もうひとつ。スコープに組み込みクラス(stdClass、Closure など)は指定できません。bindTo($obj, $obj) と機械的に書いていて、たまたま $obj が stdClass だった、というパターンで引っかかります。
戻り値がnullになりうることを忘れない
bind も bindTo も失敗時は例外ではなく null を返します。?Closure です。動的にクロージャを組み立てるようなコードで、いきなり「null を呼び出した」というエラーが出たら、まずここを疑ってください。
<?php
$bound = $callback->bindTo($target, $target);
if ($bound === null) {
throw new LogicException('クロージャの束縛に失敗しました');
}
$bound();
静的解析ツールを入れていれば大抵は指摘してくれますが、素で書いているときは見落とします。私も一度、原因究明に無駄な30分を使いました。
まとめ
クロージャは $this とスコープという2つの情報を持っていて、bind はその両方を差し替える機能です。private が読めないときは、たいてい第2引数を省略してスコープが変わっていないだけです。一度きりの呼び出しなら Closure::call() のほうが短く、しかもスコープを間違えません。テストや小さなDSLを書くときには気持ちよく効く道具ですが、本番の業務ロジックで多用し始めたら、それはカプセル化のほうに無理が出ているサインだという気がしています。
よくある質問
Q. bindとbindToはどちらを使うべきですか?
A. 機能は同じで、静的メソッドかインスタンスメソッドかの違いだけです。変数に入ったクロージャを繋げて書くなら bindTo、その場で定義したクロージャを渡すなら Closure::bind が読みやすいと思います。
Q. bindToの第2引数を省略するとどうなりますか?
A. デフォルト値は文字列 “static” で、「現在のスコープを維持する」という意味になります。クラス外で定義したクロージャの場合はスコープが無いままなので、private や protected にはアクセスできません。
Q. private を読むのに Reflection とどちらがいいですか?
A. 1つの値を読むだけなら ReflectionProperty でも十分です。複数のプロパティやメソッドをまとめて触りたい、あるいは通常のPHPの構文のまま書きたい場合は、クロージャを bind するほうが読みやすくなります。