Skip to content

Instantly share code, notes, and snippets.

@johnkary
Last active November 18, 2022 23:58
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save johnkary/904de9171396909c1842 to your computer and use it in GitHub Desktop.
Save johnkary/904de9171396909c1842 to your computer and use it in GitHub Desktop.
Exposing protected and private object methods using \Closure::bindTo(). Code below requires PHP 5.6+ due to argument unpacking (...$args). Could probably be written for PHP 5.4+ but didn't want to reinvent argument unpacking, else could also just use the Reflection implementation.
<?php
class User {
private $firstname;
private $lastname;
public function __construct($f, $l) {
$this->firstname = $f;
$this->lastname = $l;
}
private function buildFullName() {
return sprintf('%s %s', $this->firstname, $this->lastname);
}
private function greeting($say) {
return sprintf('%s, %s', $say, $this->buildFullName());
}
private function greetingToUser($say, User $user) {
return sprintf('%s from %s', $this->greeting($say), $user->buildFullName());
}
}
/**
* Exposes result of a protected/private method call outside the
* object's scope.
*
* @param object $object Object on which to call method
* @param string $method Method to call
* @param array $args Arguments to be unpacked and passed to Method
* @return mixed Result of invoking Method on Object given Arguments
*/
function xray($object, $methodToExpose, array $args = []) {
$fn = function () use ($methodToExpose, $args) {
return $this->$methodToExpose(...$args);
};
$exposed = $fn->bindTo($object, $object);
return $exposed();
};
// The traditional way
function xray_reflection($object, $methodToExpose, array $args = []) {
$m = new \ReflectionMethod($object, $methodToExpose);
$m->setAccessible(true);
return $m->invoke($object, ...$args);
};
$john = new User('John', 'Kary');
$chris = new User('Chris', 'Escalante');
echo implode("\n", [
xray($john, 'greeting', ['Hello']),
xray_reflection($chris, 'greetingToUser', ['Hello', $john]),
]);
// Output:
// Hello, John Kary
// Hello, Chris Escalante from John Kary
@johnkary
Copy link
Author

What the heck am I looking at? You can use $this inside a closure?

Why yes you can! Use bindTo() to change which class scope is to be used when the function is invoked.

From \Closure::bindTo() Description

public Closure Closure::bindTo ( object $newthis [, mixed $newscope = "static" ] )
Create and return a new anonymous function with the same body and bound variables as this one, but possibly with a different bound object and a new class scope.

The “bound object” determines the value $this will have in the function body and the “class scope” represents a class which determines which private and protected members the anonymous function will be able to access. Namely, the members that will be visible are the same as if the anonymous function were a method of the class given as value of the newscope parameter.

Why would you even want to do this?

Well, you wouldn't.

I am in the "don't directly test private methods" camp. Other people who are more stubborn disagree, but an object's public methods are the contract it honors--the inputs it agrees to accept and the output it agrees to return--and by testing its internals you can no longer refactor the internal implementation without the test suite breaking. The test suite failing when refactoring internal methods is a sign you're testing at the wrong abstraction and the tests know too much about the implementation.

I would only suggest writing a test against private methods in a legacy code scenario: write the test exposing the private method, perform an extract class refactor on the private method moving it to a new public method on a new class, tests should still pass so write another test for the new class' public method, once passing again remove the private method test. The private method test should never be committed to source control.

Erm, we have Reflection for this, right?

Yep. But with all the bemoaning about how Reflection is slow I wondered if this might somehow be more efficient. Turns out this closure approach is a tiny tiny bit slower at 100,000 iterations. Unscientific benchmarks comparing Reflection vs Closure::bindTo() are below.

https://blackfire.io/profiles/compare/7c69bb80-e166-4ee6-894d-1407a198fd62/graph

Anyone have other optimization ideas?

So should I start doing it this way?

Naa. If using bindTo() was somehow faster or more memory efficient than ReflectionMethod this might make a good library? Right now it's not, at least in PHP 5.6.17. Maybe I should test against PHP 7.

At least now you know about dynamically binding scope to closures, and that the concept of $this is more fluid than you knew 5 minutes ago.

References

@docteurklein
Copy link

replace:

return $this->$methodToExpose(...$args);

with:

return call_user_func_array([$this, $methodToExpose], $args);

and you have it.

@mnakalay
Copy link

Sorry, I'm late to the party but this actually helped me solve a problem that Reflection didn't.

I have a class extending another and I needed to call a protected method that belongs to the parent's class using the child's object. Reflection only allows you to call methods using the object the method was declared in so it was not working.

Your method works also with the child object which solved my problem.

@zanbaldwin
Copy link

Thanks for this!
I ran into the problem of the (new ReflectionMethod($object, 'method'))->invoke(...) method not respecting declare(strict_types=1) whereas this Closure method does 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment