====== PHP RFC: Readonly hooks ====== * Version: 0.9 * Date: 2024-07-10 * Author: Larry Garfield (larry@garfieldtech.com), Nick Sdot (php@nicksdot.dev) * Status: In Discussion * First Published at: http://wiki.php.net/rfc/readonly_hooks * Implementation: https://github.com/php/php-src/pull/18757 ===== Introduction ===== Support for hooks on ''readonly'' properties was omitted from the original RFC, primarily to minimize complexity as there were questions around when it was safe to do. On further consideration, we believe that hooks on backed properties are sufficiently safe to support readonly, but not on virtual properties. This proposal is split in two parts: One for ''get'' hooks, one for ''set'' hooks. The arguments for them are similar, but sufficiently different that there will be a separate vote for each. ===== Proposal ===== We propose to allow both ''get'' and ''set'' hooks on ''readonly'' properties, if and only if it is a backed property. ==== get hooks ==== The main concern of allowing readonly hooks is that readonly, in theory, implies a property is immutable and idempotent. However, a ''get'' hook supports arbitrary code, so technically a developer could do something like: class Unusual { public readonly int $value { get => $this->value * random_int(1, 100); } } However, the same strange behavior could be implemented using __get: class Test { public readonly int $test; public function __construct() { unset($this->test); } public function __get($prop) { if ($prop === 'test') { return random_int(1, 100); } } } $t = new Test(); // These will print different numbers. var_dump($t->test); var_dump($t->test); Additionally, if a ''readonly'' property is assigned a mutable object, that object may still be altered without violating the ''readonly'' rule. So even a ''readonly'' class is not really immutable to begin with. That means the guarantee that ''readonly'' is idempotent is already not enforceable today, and in fact never has been. ''readonly'' is misleadingly named. It is really "write-once", which is not the same as immutable (as shown above). But there's no reason that "write-once" need be incompatible with hooks. While ''readonly'' technically holds on the zval identity of the property, there are sufficient edge cases already that relying on that in practice is already fraught. With a ''get'' hook permitted, the backing value itself is still write-once. Subsequent ''set'' calls, or ''get'' calls that would modify the backing value a second time, will still be rejected. The concern would only appear if someone is deliberately doing something non-stable (like concatenating a random value, as above), which is a sufficiently bad idea that it should be a non-issue in practice 99% of the time. === Uses for ''readonly'' ''get'' hooks === == ORMs and proxies == Despite the lack of a hard idempotency guarantee, there are valid uses for a readonly get hook, especially for ORMs and proxies. For example: readonly class Product { public function __construct( public string $name, public float $price, public Category $category, ) {} } // Generated code. readonly class LazyProduct extends Product { private DbConnection $dbApi; private string $categoryId; public Category $category { get { return $this->category ??= $this->dbApi->loadCategory($this->categoryId); } } } That is, we feel, an entirely reasonable use of hooks, and would allow for lazy-load behavior per-property on readonly classes. This is subtly different from the Lazy Proxy RFC, which operates on the whole object at once. We believe both use cases are valuable and should be supported. == Inheritance == At present, the presence of even a single hook makes the class incompatible with marking the class readonly, even if it is, in practice, still readonly. Also, a readonly class may only be extended by a readonly class. That creates needless limitations. readonly class Box { public int $topLeft; public int $topRight; public int $bottomLeft; public int $bottomRight; } readonly class DerivedBox { public int $area { get => $this->area ??= ($topRight - $topLeft * $bottomRight - $bottomLeft); } } There's no reason why this code should be invalid, but it is in 8.4. The only way around it would be to make both classes non-readonly, but then mark all four properties readonly manually. ==== set hooks ==== A ''set'' hook, meanwhile, offers no issue for a backed readonly property. As long as it is backed we are able to determine if it is still uninitialized, and so a second set call would correctly fail as it should. For example, one of the recommended uses of hooks is for property validation. Such validation would not in any way impede the readonly-ness of a backed property. readonly class PositivePoint { public function __construct( public int $x { set => $value > 0 ? $value : throw new \Exception(); }, public int $y { set => $value > 0 ? $value : throw new \Exception(); }, ) {} } $p = new PositivePoint(4, 5); $p->x = 1; // This will still fail the readonly check, as today. The above is not legal in 8.4, but it seems entirely safe to do for 8.5. Or, for a more real-world and larger example, PHP 8.4 requires this: final class Entry // can't be readonly { public function __construct( public readonly string $word, // added readonly public readonly string $slug, // added readonly private(set) array $terms { // requires visibility set(array $value) => $this->upcastTerms($value); }, ) {} private function upcastTerms(array $terms): array { $upcast = static fn (Term|array $term): Term => $term instanceof Term ? $term : new Term(...$term); return array_map($upcast, $value) } } Which with this RFC could simplify to this: final readonly class Entry { public function __construct( public string $word, public string $slug, public array $terms { set(array $value) => $this->upcastTerms($value); }, ) {} private function upcastTerms(array $terms): array { $upcast = static fn (Term|array $term): Term => $term instanceof Term ? $term : new Term(...$term); return array_map($upcast, $value) } } Similarly, PHP 8.5 introduces the "clone with" syntax, which allows creating a clone of an object with some properties modified, even if those properties were readonly. However, that means new values would not go through any validation checks in the constructor. // Valid code in PHP 8.4. readonly class PositivePoint { public function __construct( public(set) int $x, public(set) int $y, ) { if ($this->x <= 0) throw new \Exception(); if ($this->y <= 0) throw new \Exception(); } } // New code in 8.5: $p = new PositivePoint(3, 4); $p2 = clone($p, ['x' => -10]); In the above example, $p2 would be invalid. The ''clone()'' syntax temporarily disables the readonly flag, allowing an invalid value to be set. If that check is moved to a set hook, it would be enforced on cloning: readonly class PositivePoint { public function __construct( public(set) int $x { set => $value > 0 ? $value : throw new \Exception(); }, public(set) int $y { set => $value > 0 ? $value : throw new \Exception(); }, ) {} } $p = new PositivePoint(4, 5); $p2 = clone($p, ['x' => -10]); // This will throw from the set hook. In other words, permitting ''set'' hooks can eliminate many ''with*()'' methods, by allowing ''public(set)'' to enable using the new ''clone()'' functionality while still enforcing additional requirements. We believe strongly that ''set'' hooks not only are compatible with readonly, but are now necessary to ensure that invariants are enforced. ===== Backward Incompatible Changes ===== None. No previously-valid code will become invalid. While it will be possible for a readonly property to return different values on subsequent calls, that is already the case as demonstrated above. So no guarantees are softened by this RFC. ===== Proposed PHP Version(s) ===== PHP 8.5 ===== Rejected features ===== ==== Implicit cache ==== An alternate approach that has been suggested is to make a readonly property with a get hook implicitly cache the value after the first call. That would essentially create a "lazy property" feature. However, there are two issues with that. - It breaks the assumption that a get hook is always called. - It would make it non-obvious that the hook is not virtual. On the second point, consider: class P { public string $first; public string $last; // Get hook only runs once. public readonly string $full { get => $this->first . $this->last; } } The code //looks// like ''$full'' should be a virtual property, since $this->full never appears in the hook body. But the readonly would make it implicitly backed. That makes determining when a property is virtual or backed even more involved, and is complexity we do not want to introduce. Alternatively, we could require the user to still make the property backed themselves, but then also cache the value and avoid calling the ''get'' hook a second time. But that implies double caches, and non-obvious behavior. Besides, a cached-on-first-call property is easy enough to do manually already, thanks to the null-assign operator: readonly class P { public string $first; public string $last; // Get hook only runs once. public string $full { get => $this->full ??= $this->first . $this->last; } } And with this RFC becomes compatible with a readonly property. ==== init hooks ==== Another alternative proposal has been adding an ''init'' hook, which operates much like the implicit cache in the previous section but becomes explicitly labeled. While that is an interesting idea that has been floated a few times, it has enough complexities and edge cases of its own to address that we feel it is out of scope. However, this RFC is in no way incompatible with adding an ''init'' hook in the future should it be proposed. ===== Proposed Voting Choices ===== Yes or no vote. The ''get'' and ''set'' hooks have separate votes, each of which require 2/3 to pass. * Yes * No * Yes * No ===== Patches and Tests ===== Link to the PR: https://github.com/php/php-src/pull/18757 ===== Implementation ===== ===== References =====