Привет, Хабр.
Сегодня рассмотрим, как в PHP управлять контекстом замыканий: подменять $this
, менять область видимости, получать доступ к приватным свойствам, оборачивать методы, реализовывать мини-AOP и использовать замыкания как ленивые фабрики в DI-контейнерах.
bind и bindTo
PHP представляет замыкания через специальный класс Closure
, экземпляры которого можно модифицировать. У этого класса есть два метода:
public Closure::bindTo(?object $newThis, object|string|null $newScope = "static"): ?Closure
public static Closure::bind(Closure $closure, ?object $newThis, object|string|null $newScope = "static"): ?Closure
Оба метода делают одно и то же: создают новое замыкание, которое выполняется с другим значением $this
и другим объёмом видимости (scope), определяющим, к каким приватным/защищённым членам класс имеет доступ.
Если вы путаетесь: bindTo
— это instance-метод, применяется к конкретному Closure. bind
— статический метод, применяется к замыканию, переданному первым аргументом. Отличий по сути нет — дело вкуса.
Контекст $this
По умолчанию, если вы создаёте замыкание вне методов класса, внутри него нет $this
. Но если вы создаёте его в контексте метода, $this
определяется автоматически. Пример:
class Demo {
public function makeClosure(): Closure {
return function () {
return $this;
};
}
}
$demo = new Demo();
$closure = $demo->makeClosure();
var_dump($closure()); // object(Demo)
Теперь попробуем создать closure
вне класса:
$closure = function () {
return $this;
};
$closure(); // Fatal error: Using $this when not in object context
Чтобы в таком случае привязать $this
, мы можем использовать bindTo
:
$bound = $closure->bindTo(new StdClass());
var_dump($bound()); // object(stdClass)
Всё просто: вручную подвязали $this
к замыканию.
Доступ к приватным свойствам через scope binding
Это второй аргумент метода bind
или bindTo
. Он определяет, к каким приватным и защищённым членам классов замыкание получит доступ. Если scope не указан, используется 'static', то есть доступ будет только к публичному API.
Посмотрим:
class Secret {
private string $value = 'classified';
}
$secret = new Secret();
$closure = function () {
return $this->value;
};
$bound = Closure::bind($closure, $secret, 'Secret');
echo $bound(); // classified
Здесь мы получили доступ к приватному полю без всякой рефлексии. Важно: это не баг, это официально поддерживаемое поведение, хотя и потенциально опасное с точки зрения инкапсуляции.
Если передать 'static' как $scope
, то доступ к приватным и защищённым членам будет невозможен:
$bound = Closure::bind($closure, $secret, 'static');
$bound(); // Fatal error
Привязка к null — отключение $this
Можно также привязать замыкание к null
, чтобы убрать привязку к объекту и избавиться от $this вообще:
$closure = function () {
return isset($this);
};
$unbound = $closure->bindTo(null);
var_dump($unbound()); // false
Примеры применения
AOP: обернуть метод до/после
Допустим, есть объект с методом, и хочется повесить на него логгирование:
class Service {
public function execute($arg) {
return "Result: " . $arg;
}
}
$service = new Service();
$originalMethod = function (...$args) {
return call_user_func_array([$this, 'execute'], $args);
};
$wrapper = function (...$args) {
echo "[LOG] before\n";
$result = call_user_func_array($this->original, $args);
echo "[LOG] after\n";
return $result;
};
$boundOriginal = Closure::bind($originalMethod, $service, Service::class);
$proxy = new class($service, $boundOriginal, $wrapper) {
public Closure $original;
public Closure $wrapped;
public function __construct($target, Closure $original, Closure $wrapper) {
$this->original = $original;
$this->wrapped = $wrapper->bindTo($this, self::class);
}
public function __call($name, $args) {
if ($name === 'execute') {
return ($this->wrapped)(...$args);
}
throw new \BadMethodCallException();
}
};
echo $proxy->execute('foo');
// [LOG] before
// [LOG] after
// Result: foo
Создали мини-AOP, не меняя исходный класс и не прибегая к eval или рефлексии. Только Closure::bind()
.
DI-контейнер
Многие контейнеры в PHP (например, Laravel) позволяют регистрировать сервисы в виде замыканий. С bindTo можно удобно управлять контекстом:
class App {
public function makeDatabase() {
return new PDO('sqlite::memory:');
}
}
$app = new App();
$instantiator = function () {
return $this->makeDatabase();
};
$boundInstantiator = $instantiator->bindTo($app, App::class);
$db = $boundInstantiator(); // полноценный lazy-loading
DI-контейнер может хранить $boundInstantiator
и вызывать его по мере надобности.
Нужно знать
Сериализация замыканий
В стандартном PHP замыкания не сериализуемы:
$fn = function () {};
serialize($fn); // Fatal error
С 7.4+ можно использовать сторонние решения, например, opis/closure, которые умеют сериализовывать даже привязанные замыкания. Но есть важный момент: bindTo()
не сериализует scope, только объект $this
. При восстановлении bindTo()-замыкание может потерять доступ к приватным членам, если не использовать специальный сериализатор.
Если важно сохранить контекст и scope — используйте Closure::bind()
+ OpisClosure::serialize()
.
Почему bindTo не может менять scope
Это ограничение встроено в движок PHP. Метод bindTo() специально блокирует смену области видимости по соображениям безопасности. Чтобы изменить scope, необходимо использовать Closure::bind()
, потому что он создаёт полностью новую структуру, а bindTo()
лишь привязывает $this
к существующей:
$fn->bindTo($obj, 'AnotherClass'); // Fatal error
Closure::bind($fn, $obj, 'AnotherClass'); // OK
ZVAL и VM
На уровне движка каждое замыкание — это ZVAL с типом IS_OBJECT
, обёрнутый в zend_closure. Когда вы вызываете bind(), движок создаёт новый объект zend_closure
, копируя handler
, opcodes
, статические переменные и — главное — новую this_ptr
и called_scope
.
Таким образом:
$this
— этоthis_ptr
;scope
— этоcalled_scope
.
Историческая справка
Возможность Closure::bind()
появилась в PHP 5.4 — вместе с анонимными функциями как полноценными объектами.
До этого момента замыкания не были полноценными first-class citizen'ами: можно было передавать функцию как коллбэк, но нельзя было сделать вот это:
$fn = function () {};
$fn = $fn->bindTo($obj, 'Scope');
Тогда же появился класс Closure, возможность создавать замыкания с сохранением контекста и — как побочный эффект — доступ к приватному API через scope.
Когда использовать, а когда нет
Да, если:
вы пишете фреймворк или контейнер;
вам нужно обернуть поведение метода (логгирование, транзакции, AOP);
вы мокаете приватные зависимости в тестах;
вы делаете ленивую инициализацию зависимостей;
вам нужен доступ к внутренностям объекта без рефлексии.
Нет, если:
вы просто хотите сэкономить на геттере;
вы делаете это без нужды — привязка closure стоит ресурсов.
А вы использовали bind()
в проде или тестах? Делитесь опытом в комментах.
Если вы хотите углубить свои знания в современных инструментах разработки и архитектуры приложений, предлагаю обратить внимание на открытые уроки в Otus, которые помогут улучшить навыки и познакомиться с новыми подходами в работе с PHP и мониторингом. Темы уроков: