I am apparently late in coming to this trick, but PHP 8.1 is going to make it even nicer to use.
A clever trick
There's an interesting intersection of functionality in PHP:
- Declared object properties are more efficient than dynamic ones, because the engine can make assumptions about what data type to expect.
- The magic
__get()
and__set()
methods trigger when there is no property with a given name that has been set. - Fun fact: A property that has been declared but not initialized with a value is still technically "set"... to
uninitialized
. - However, you can
unset()
an uninitialized property.
That means you can do clever tricks like this (and some systems do, internally):
class Person
{
public string $full;
public function __construct(
private string $first,
private string $last,
) {
unset($this->full);
}
public function __get(string $key)
{
print __FUNCTION__ . PHP_EOL;
if ($key === 'full') {
$this->full = "$this->first $this->last";
return $this->full;
}
}
}
$c = new Person('Larry', 'Garfield');
print $c->first . PHP_EOL;
print $c->full . PHP_EOL;
print $c->full . PHP_EOL;
Prints:
Larry
__get
Larry Garfield
Larry Garfield
This is cool! Lazy property creation, on-demand, with built-in caching. The __get()
call is slower than a normal function call, but any subsequent accesses will be faster than a function call as they're just a property access. And because the property is still pre-defined, it will be as memory efficient as any other pre-defined property, and be accessible to static analysis.
The downside is that public properties have a lot of issues, because they are then settable from anywhere, which is rarely a good idea.
Readonly
properties
The above trick isn't new. What's new in PHP 8.1 is the new readonly
keyword. readonly
properties can only be set once, from within the class that declared them. That makes it safe to make them public, as long as they are pre-set, usually in the constructor. Expect to see a lot of code like this in PHP 8.1:
class Point
{
public function __construct(
public readonly int $x,
public readonly int $y,
) {}
}
Combined with named arguments, PHP now has, effectively, immutable structs/record types. Score!
With their powers combined
But here's the fun subtle part: You can unset()
a readonly
property... if and only if it is set to uninitialized
. At that point, the same __get()
trick kicks in. That means we can now do this:
class Person
{
public readonly string $full;
public function __construct(
public readonly string $first,
public readonly string $last,
) {
unset($this->full);
}
public function __get(string $key)
{
print __FUNCTION__ . PHP_EOL;
if ($key === 'full') {
return $this->full = "$this->first $this->last";
}
}
}
$c = new Person('Larry', 'Garfield');
print $c->first . PHP_EOL;
print $c->full . PHP_EOL;
print $c->full . PHP_EOL;
This has the same output as before, but is safe to make a public property because it's readonly. The first time it's accessed, __get()
will be called, the value set, and then returned. On subequent calls, the value is already set and so just read directly. But it can never be written to from outside the class, and since it's only written to inside the class from __get()
it prevents it ever being set to anything else.
Boom. We now have a public, readonly, type safe, lazily-populated, cached property value. And there was much rejoicing!
Still room for improvement
To be fair, this is still not ideal. It requires some manual fiddling to make it work, and if you have multiple such properties then your __get()
method can get ugly fast. For now, one way to make it a bit cleaner is with a match()
statement:
public function __get(string $key)
{
return $this->$key = match($key) {
'full' => "$this->first $this->last",
};
}
That way, any key that is not one one of the properties we hard code will throw an error. It's also nice and compact, and trivial to call a deriving function on the right side instead of inlining the logic. That's probably the best we can do for now; I still like the idea of first class support for this use case, but for now, it's now straightforward to emulate very effectively.
I would not recommend using it everywhere; if you know the property is going to be needed anyway, and you have the necessary inputs as of the constructor, just set it in the constructor. It can still be a public readonly
property and will be a tiny bit faster, as well as less cumbersome. But if lazy-instantiation is really useful in your use case, it's now even better.
Viva la PHP 8.1!