Closure::bind() и bindTo() в PHP

от автора

Привет, Хабр.

Сегодня рассмотрим, как в 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 и мониторингом. Темы уроков:

  • 8 апреля: Продвинутая архитектура приложений на PHP.
    Подробнее

  • 15 апреля: Организация мониторинга с помощью Grafana.
    Подробнее


ссылка на оригинал статьи https://habr.com/ru/articles/898094/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *