PHP Tricks: Lazy public readonly properties
hive-168588·@crell·
0.000 HBDPHP Tricks: Lazy public readonly properties
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!