LazyProperty - Automatic property initialization for PHP
Yesterday, I worked on yet another interesting little experiment, which is called LazyProperty .
The problem with "lazy" properties
The idea is very simple: avoid manually checking object properties to see if they were initialized.
Let's make a simple example:
<?php
class UserService
{
// ...
protected $userRepository;
public function register($user)
{
// ...
$this->getUserRepository()->add($user);
}
// ...
protected function getUserRepository()
{
// we're not using DI here because of performance implications
// developers often use a service locator for RAD here
return $this->userRepository
?: $this->userRepository = new MemoryUserRepository();
}
}
This is a fairly simple approach, and it is perfectly fine to use it when we don't want to be forced
to build the MemoryUserRepository
instance for calls to API methods that don't use
it.
There is a problem though, which is that any direct access to UserService#$userRepository
has to be avoided: all of the class' implementation as well as its subclasses have to rely on the
protected getter in order to access the repository instance.
For instance, the following code works only because of a lucky combination of events:
<?php
class UserService
{
// ...
protected $userValidator;
public function register($user)
{
$this->getUserRepository()->add($user);
}
public function login($userId)
{
// mind this - this is a mistake!
$this->userRepository->find($userId);
// ...
}
}
<?php
$user = build_user_somehow();
$userService->register($user);
$userService->login($user->getId());
This code will run, but only because UserService#register()
was called before
UserService#login()
.
Possible solutions
The code in the examples exposes a silly bug, and it should be fixed by avoiding any property
access to UserService#$userRepository
.
Moreover, implementors of subclasses will also experience the issue if they try accessing the
un-initialized property. The fix for that is to just define
UserService#$userRepository
as private
and being very defensive
about its usage.
These are all valid solutions, but we still don't get around using a protected getter in our class' scope, which I personally consider ugly.
That's where I had this possibly crazy idea: making the property itself "lazy" and avoiding the getter call completely, making the getter an implementation detail.
Let's see how this is done with the library:
<?php
class UserService
{
use \LazyProperty\LazyPropertiesTrait;
protected $userRepository;
public function __construct()
{
// ...
// mind this - we are marking "userRepository" as lazy
$this->initLazyProperties(['userRepository']);
}
public function register($user)
{
// ...
// now use the property directly
$this->userRepository->add($user);
// ...
}
public function login($userId)
{
$this->userRepository->find($userId);
// ...
}
protected function getUserRepository()
{
return $this->userRepository
?: $this->userRepository = new MemoryUserRepository();
}
}
By calling LazyProperty\LazyPropertiesTrait#initLazyProperties()
, we've made sure
that any access to the un-initialized UserService#$userRepository
will actually
trigger a call to UserService#getUserRepository()
, and therefore initialize it.
With that, we don't need to actually worry about calling the getter: both the getter call and property access will work the same way, which is pretty cool!
Under the hood
What is going on? I'm simply exploiting an feature of the PHP language on which I've already blogged at Property Accessors in PHP Userland .
As a reference, here is the annotated body of
LazyProperty\LazyPropertiesTrait#initLazyProperties()
:
<?php
private function initLazyProperties(array $lazyPropertyNames, $checkLazyGetters = true)
{
foreach ($lazyPropertyNames as $lazyProperty) {
// verify that a getter is available for the given lazy property
if ($checkLazyGetters && ! method_exists($this, 'get' . $lazyProperty)) {
throw MissingLazyPropertyGetterException::fromGetter($this, 'get' . $lazyProperty, $lazyProperty);
}
// record the properties that were defined as "lazy"
$this->lazyPropertyAccessors[$lazyProperty] = false;
// if the property is defined, then ignore it (we don't want to sensibly alter object state)
if (! isset($this->$lazyProperty)) {
// unset the property, this allows us to use magic getters
unset($this->$lazyProperty);
}
}
}
Quite simple! Nothing really incredible going on here. When is the property actually initialized?
The other method defined by the trait is the
Magic getter LazyProperty\LazyPropertiesTrait#__get()
:
<?php
// returning a reference is required,
// otherwise (for example) array properties accessed for writes will fail
public function & __get($name)
{
// disallow access to non-existing properties
if (! isset($this->lazyPropertyAccessors[$name])) {
throw InvalidLazyProperty::nonExistingLazyProperty($this, $name);
}
// retrieve the object that is trying to access the lazy property
$caller = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT)[1];
// disallow access from invalid scope
// basically reproduces property visibility in userland
if (! isset($caller['object']) || $caller['object'] !== $this) {
AccessScopeChecker::checkCallerScope($caller, $this, $name);
}
// set the property to `null` (disables notices)
$this->$name = null;
// initialize the property
$this->$name = $this->{'get' . $name}();
return $this->$name;
}
Conclusions
This "hack" is simple, clean, easily tested and doesn't cause any problems. It works on PHP 5.4, 5.5, 5.6 and even on HHVM, which I wasn't expecting.
Can you use it? Yes, why not? Do you need it? Usually not. Being defensive about usage of your APIs can actually avoid sloppy mistakes like the ones that I've described in the examples.
Correctly annotating your properties with /** @var Type|null */
also helps tools
like PHP Analyzer
in discovering such bugs via static analysis.
Clean dependency injection and eventually proxying dependencies also produces better and easier testable results than explicit lazy-loading coded in your classes.
The most interesting result in this experiment is that PHP yet again allows to implement language-level features in userland, which is awesome!
Please let me know if you like this project by giving it a star, or just drop me a tweet if you think it can be enhanced!