Skip to content

Commit 338daf2

Browse files
feature #46751 [VarExporter] Add trait to help implement lazy loading ghost objects (nicolas-grekas)
This PR was merged into the 6.2 branch. Discussion ---------- [VarExporter] Add trait to help implement lazy loading ghost objects | Q | A | ------------- | --- | Branch? | 6.2 | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | - | License | MIT | Doc PR | - This PR packages an implementation of [lazy loading ghost objects](https://www.martinfowler.com/eaaCatalog/lazyLoad.html) in a single `LazyGhostObjectTrait` (as a reminder, a lazy ghost object is an object that is created empty and that is able to initialize itself when being accessed for the first time.) By using this trait, ppl can easily turn any existing classes into such ghost object implementations. I target two use cases with this feature (but ppl are free to be more creative): 1. lazy proxy generation for service containers; 2. lazy proxy generation for entities. In all cases, the generation itself is trivial using inheritance (sorry `final` classes.) For example, in order to turn a `Foo` class into a lazy ghost object, one just needs to do: ```php class FooGhost extends Foo implements LazyGhostObjectInterface { use LazyGhostObjectTrait; } ``` And then, one can instantiate ghost objects like this: ```php $fooGhost = FooGhost::createLazyGhostObject($initializer); ``` `$initializer` should be a closure that takes the ghost object instance as argument and initializes it. An initializer would typically call the constructor on the instance after resolving its dependencies: ```php $initializer = function ($instance) use ($etc) { // [...] use $etc to compute the $deps $instance->__construct(...$deps); }; ``` Interface `LazyGhostObjectInterface` is optional to get the behavior of a ghost object but gives a contract that allows managing them when needed: ```php public function initializeLazyGhostObject(): void; public function resetLazyGhostObject(): bool; ``` Because initializers are *not* freed when initializing, it's possible to reset a ghost object to its uninitialized state. This comes with one limitation: resetting `readonly` properties is not allowed by the engine so these cannot be reset. The main target use case of this capability is Doctrine's EntityManager of course. To work around the limitation with `readonly` properties, but also to allow creating partially initialized objects, `$initializer` can also accept two more arguments `$propertyName` and `$propertyScope`. When doing so, `$initializer` is going to be called on a property-by-property basis and is expected to return the computed value of the corresponding property. Because lazy-initialization is *not* triggered when (un)setting a property, it's also possible to do partial initialization by calling setters on a just-created ghost object. --- You might wonder why this PR is in the `VarExporter` component? The answer is that it reuses a lot of its existing code infrastructure. Exporting/hydrating/instantiating require using reflection a lot, and ghost objects too. We could consider renaming the component, but honestly, 1. I don't have a good name in mind; 2. changing the name of a component is costly for the community and 3. more importantly this doesn't really matter because this is all low-level stuff usually. You might also wonder why this trait while we already have https://github.com/FriendsOfPHP/proxy-manager-lts and https://github.com/Ocramius/ProxyManager? The reason is that the code infrastructure in ProxyManager is heavy. It comes with a dependency on https://github.com/laminas/laminas-code and it's complex to maintain. While I made the necessary changes to support PHP 8.1 in FriendsOfPHP/proxy-manager-lts (and submitted those changes [upstream](https://github.com/Ocramius/ProxyManager/pulls?q=is%3Apr+is%3Aopen+sort%3Aupdated-desc+author%3Anicolas-grekas)), getting support for new PHP versions is slow and complex. Don't take me wrong, I don't blame maintainers, ProxyManager is complex for a reason. But ghost objects are way simpler than other kind of proxies that ProxyManager can produce: a trait does the job. While the trait itself is no trivial logic, it's at least plain PHP code, compared to convoluted (but needed) code generation logic in ProxyManager. If you need any other kind of proxies that ProxyManager supports, just use ProxyManager. For Symfony, having a simple lazy ghost object implementation will allow services declared as lazy to be actually lazy out of the box (today, you need to install proxy-manager-bridge as an optional dependency.) \o/ Commits ------- 27b4325 [VarExporter] Add trait to help implement lazy loading ghost objects
2 parents fa24df6 + 27b4325 commit 338daf2

15 files changed

+1129
-10
lines changed

.github/expected-missing-return-types.diff

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,6 +1087,17 @@ index f7ef22df5c..9439e9526f 100644
10871087
+ public static function prepare($values, $objectsPool, &$refsPool, &$objectsCount, &$valuesAreStatic): array
10881088
{
10891089
$refs = $values;
1090+
diff --git a/src/Symfony/Component/VarExporter/Internal/GhostObjectState.php b/src/Symfony/Component/VarExporter/Internal/GhostObjectState.php
1091+
index 471c1a6b91..2e19d2ab2d 100644
1092+
--- a/src/Symfony/Component/VarExporter/Internal/GhostObjectState.php
1093+
+++ b/src/Symfony/Component/VarExporter/Internal/GhostObjectState.php
1094+
@@ -54,5 +54,5 @@ class GhostObjectState
1095+
* @return bool Returns true when fully-initializing, false when partial-initializing
1096+
*/
1097+
- public function initialize($instance, $propertyName, $propertyScope)
1098+
+ public function initialize($instance, $propertyName, $propertyScope): bool
1099+
{
1100+
if (!$this->status) {
10901101
diff --git a/src/Symfony/Component/Workflow/Event/Event.php b/src/Symfony/Component/Workflow/Event/Event.php
10911102
index cd7fab7896..b340eba38e 100644
10921103
--- a/src/Symfony/Component/Workflow/Event/Event.php

src/Symfony/Component/VarExporter/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
6.2
55
---
66

7+
* Add `LazyGhostObjectTrait`
78
* Add `Hydrator::hydrate()`
89
* Preserve PHP references also when using `Hydrator::hydrate()` or `Instantiator::instantiate()`
910
* Add support for hydrating from native (array) casts

src/Symfony/Component/VarExporter/Instantiator.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,6 @@ public static function instantiate(string $class, array $properties = [], array
5454
$instance = unserialize('O:'.\strlen($class).':"'.$class.'":0:{}');
5555
}
5656

57-
return Hydrator::hydrate($instance, $properties, $scopedProperties);
57+
return $properties || $scopedProperties ? Hydrator::hydrate($instance, $properties, $scopedProperties) : $instance;
5858
}
5959
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\VarExporter\Internal;
13+
14+
/**
15+
* @internal
16+
*/
17+
enum EmptyScope
18+
{
19+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\VarExporter\Internal;
13+
14+
/**
15+
* Stores the state of lazy ghost objects and caches related reflection information.
16+
*
17+
* As a micro-optimization, this class uses no type declarations.
18+
*
19+
* @internal
20+
*/
21+
class GhostObjectRegistry
22+
{
23+
/**
24+
* @var array<int, GhostObjectState>
25+
*/
26+
public static $states = [];
27+
28+
/**
29+
* @var array<class-string, \ReflectionClass>
30+
*/
31+
public static $classReflectors = [];
32+
33+
/**
34+
* @var array<class-string, array<string, mixed>>
35+
*/
36+
public static $defaultProperties = [];
37+
38+
/**
39+
* @var array<class-string, list<\Closure>>
40+
*/
41+
public static $classResetters = [];
42+
43+
/**
44+
* @var array<class-string, array{get: \Closure, set: \Closure, isset: \Closure, unset: \Closure}>
45+
*/
46+
public static $classAccessors = [];
47+
48+
/**
49+
* @var array<class-string, array{get: int, set: bool, isset: bool, unset: bool, clone: bool, serialize: bool, sleep: bool, destruct: bool}>
50+
*/
51+
public static $parentMethods = [];
52+
53+
public static function getClassResetters($class)
54+
{
55+
$classProperties = [];
56+
$propertyScopes = Hydrator::$propertyScopes[$class] ??= Hydrator::getPropertyScopes($class);
57+
58+
foreach ($propertyScopes as $key => [$scope, $name, $readonlyScope]) {
59+
if ('lazyGhostObjectId' !== $name && null !== ($propertyScopes["\0$scope\0$name"] ?? $propertyScopes["\0*\0$name"] ?? $readonlyScope)) {
60+
$classProperties[$readonlyScope ?? $scope][$name] = $key;
61+
}
62+
}
63+
64+
$resetters = [];
65+
foreach ($classProperties as $scope => $properties) {
66+
$resetters[] = \Closure::bind(static function ($instance, $skippedProperties = []) use ($properties) {
67+
foreach ($properties as $name => $key) {
68+
if (!\array_key_exists($key, $skippedProperties)) {
69+
unset($instance->$name);
70+
}
71+
}
72+
}, null, $scope);
73+
}
74+
75+
$resetters[] = static function ($instance, $skippedProperties = []) {
76+
foreach ((array) $instance as $name => $value) {
77+
if ("\0" !== ($name[0] ?? '') && !\array_key_exists($name, $skippedProperties)) {
78+
unset($instance->$name);
79+
}
80+
}
81+
};
82+
83+
return $resetters;
84+
}
85+
86+
public static function getClassAccessors($class)
87+
{
88+
return \Closure::bind(static function () {
89+
return [
90+
'get' => static function &($instance, $name, $readonly) {
91+
if (!$readonly) {
92+
return $instance->$name;
93+
}
94+
$value = $instance->$name;
95+
96+
return $value;
97+
},
98+
'set' => static function ($instance, $name, $value) {
99+
$instance->$name = $value;
100+
},
101+
'isset' => static function ($instance, $name) {
102+
return isset($instance->$name);
103+
},
104+
'unset' => static function ($instance, $name) {
105+
unset($instance->$name);
106+
},
107+
];
108+
}, null, $class)();
109+
}
110+
111+
public static function getParentMethods($class)
112+
{
113+
$parent = get_parent_class($class);
114+
115+
return [
116+
'get' => $parent && method_exists($parent, '__get') ? ((new \ReflectionMethod($parent, '__get'))->returnsReference() ? 2 : 1) : 0,
117+
'set' => $parent && method_exists($parent, '__set'),
118+
'isset' => $parent && method_exists($parent, '__isset'),
119+
'unset' => $parent && method_exists($parent, '__unset'),
120+
'clone' => $parent && method_exists($parent, '__clone'),
121+
'serialize' => $parent && method_exists($parent, '__serialize'),
122+
'sleep' => $parent && method_exists($parent, '__sleep'),
123+
'destruct' => $parent && method_exists($parent, '__destruct'),
124+
];
125+
}
126+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\VarExporter\Internal;
13+
14+
use Symfony\Component\VarExporter\Hydrator;
15+
16+
/**
17+
* Keeps the state of lazy ghost objects.
18+
*
19+
* As a micro-optimization, this class uses no type declarations.
20+
*
21+
* @internal
22+
*/
23+
class GhostObjectState
24+
{
25+
public const STATUS_INITIALIZED_PARTIAL = 1;
26+
public const STATUS_UNINITIALIZED_FULL = 2;
27+
public const STATUS_INITIALIZED_FULL = 3;
28+
29+
public \Closure $initializer;
30+
31+
/**
32+
* @var array<class-string|'*', array<string, true>>
33+
*/
34+
public $preInitUnsetProperties;
35+
36+
/**
37+
* @var array<string, true>
38+
*/
39+
public $preInitSetProperties = [];
40+
41+
/**
42+
* @var array<class-string|'*', array<string, true>>
43+
*/
44+
public $unsetProperties = [];
45+
46+
/**
47+
* One of self::STATUS_*.
48+
*
49+
* @var int
50+
*/
51+
public $status;
52+
53+
/**
54+
* @return bool Returns true when fully-initializing, false when partial-initializing
55+
*/
56+
public function initialize($instance, $propertyName, $propertyScope)
57+
{
58+
if (!$this->status) {
59+
$this->status = 1 < (new \ReflectionFunction($this->initializer))->getNumberOfRequiredParameters() ? self::STATUS_INITIALIZED_PARTIAL : self::STATUS_UNINITIALIZED_FULL;
60+
$this->preInitUnsetProperties ??= $this->unsetProperties;
61+
}
62+
63+
if (self::STATUS_INITIALIZED_FULL === $this->status) {
64+
return true;
65+
}
66+
67+
if (self::STATUS_UNINITIALIZED_FULL === $this->status) {
68+
if ($defaultProperties = array_diff_key(GhostObjectRegistry::$defaultProperties[\get_class($instance)], (array) $instance)) {
69+
Hydrator::hydrate($instance, $defaultProperties);
70+
}
71+
72+
$this->status = self::STATUS_INITIALIZED_FULL;
73+
($this->initializer)($instance);
74+
75+
return true;
76+
}
77+
78+
$value = ($this->initializer)(...[$instance, $propertyName, $propertyScope]);
79+
80+
$propertyScope ??= \get_class($instance);
81+
$accessor = GhostObjectRegistry::$classAccessors[$propertyScope] ??= GhostObjectRegistry::getClassAccessors($propertyScope);
82+
83+
$accessor['set']($instance, $propertyName, $value);
84+
85+
return false;
86+
}
87+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\VarExporter;
13+
14+
interface LazyGhostObjectInterface
15+
{
16+
/**
17+
* Forces initialization of a lazy ghost object.
18+
*/
19+
public function initializeLazyGhostObject(): void;
20+
21+
/**
22+
* @return bool Returns false when the object cannot be reset, ie when it's not a ghost object
23+
*/
24+
public function resetLazyGhostObject(): bool;
25+
}

0 commit comments

Comments
 (0)