[PHP-DEV] [RFC] Lazy Objects

Dear all,

Arnaud and I are pleased to share with you the RFC we’ve been shaping for over a year to add native support for lazy objects to PHP.

Please find all the details here:
https://wiki.php.net/rfc/lazy-objects

We look forward to your thoughts and feedback.

Cheers,
Nicolas and Arnaud

Hi, Nicolas and Arnaud!

I like the idea, thank you for the RFC!

Here are some initial thoughts and questions:

  1. It doesn’t seem right that calling ReflectionLazyObject::makeLazyGhost has an implicit side effect on $instance and returns reflection. It does 2 things and thus breaks the SRP. Having smth like $lazyGhost = new ReflectionClass(MyClass)->newLazyGhost($initializer) and/or ReflectionLazyObject::makeLazy($object, $initializer): void seems better.

  2. If ReflectionLazyObject extends ReflectionObject, then how new ReflectionLazyObject($object) will work for non-lazy objects? Will it throw?

  3. Is extending ReflectionObject really necessary? What about creating ReflectionLazyObject as a standalone class without abusing inheritance? Or simply adding methods to ReflectionObject / ReflectionClass?

  4. The RFC says that Virtual state-proxies are necessary because of circular references. It’s difficult to accept this reasoning, because using circular references is a bad practice and the given example is something I try to avoid by all means in my code.

···

Best regards,
Valentin

Hello Valentin,

Thanks for having a look.

Arnaud and I are pleased to share with you the RFC we’ve been shaping for over a year to add native support for lazy objects to PHP.

Please find all the details here:
https://wiki.php.net/rfc/lazy-objects

We look forward to your thoughts and feedback.

Cheers,
Nicolas and Arnaud

Hi, Nicolas and Arnaud!

I like the idea, thank you for the RFC!

Here are some initial thoughts and questions:

  1. It doesn’t seem right that calling ReflectionLazyObject::makeLazyGhost has an implicit side effect on $instance and returns reflection. It does 2 things and thus breaks the SRP. Having smth like $lazyGhost = new ReflectionClass(MyClass)->newLazyGhost($initializer) and/or ReflectionLazyObject::makeLazy($object, $initializer): void seems better.

About “new ReflectionClass(MyClass)->newLazyGhost”, we could add this but it would provide a duplicate way to achieve what can be done with “makeLazy($object)” variants. As you might have read in the RFC, being able to make a pre-existing instance lazy is needed to cover all use cases.

Then about “ReflectionLazyObject::makeLazy($object, $initializer): void”, is the difference only to return void and not a ReflectionLazyObject instance? Then this might provide an underperforming API: creating a lazy object and immediately after setting some of its properties is an use case that can happen on the hot path (of e.g. Doctrine entities creation steps). Returning “void” would force an extra call to ReflectionLazyObject::fromInstance() that the proposed API prevents.

  1. If ReflectionLazyObject extends ReflectionObject, then how new ReflectionLazyObject($object) will work for non-lazy objects? Will it throw?

The constructor is private so this is not allowed (I should add this to the RFC).

  1. Is extending ReflectionObject really necessary? What about creating ReflectionLazyObject as a standalone class without abusing inheritance? Or simply adding methods to ReflectionObject / ReflectionClass?

I don’t think extending ReflectionObject is necessary. I don’t know if doing so is “abusing” inheritance. It might make sense either way. For the use cases I identified, it wouldn’t harm to not extend anything. Does anyone else see a reason to go in one or the other direction? To me it just makes sense to have ReflectionLazyObject extend ReflectionObject.

About adding methods to ReflectionObject / ReflectionClass, you mention SRP in your message; ReflectionClass/ReflectionObject is already crowded, and this lazy object topic is better separated to me.

  1. The RFC says that Virtual state-proxies are necessary because of circular references. It’s difficult to accept this reasoning, because using circular references is a bad practice and the given example is something I try to avoid by all means in my code.

Yet circular references happen all the time in any non-trivial app, so this has to be supported.

Nicolas

Hi

On 6/4/24 14:28, Nicolas Grekas wrote:

Please find all the details here:
PHP: rfc:lazy-objects

We look forward to your thoughts and feedback.

I've gave the RFC three or four passes and I'm not quite sure if I follow everything, here's a list of some questions / remarks that came to mind, roughly ordered by the order of things appearing in the RFC.

- "been tested successfully on the Doctrine and on the Symfony projects"

Is there a PoC patch showcasing how the code would change / be simplified for those pre-existing codebases?

- int $options = 0

Not a fan of flag parameters that take a bitset, those provide for a terrible DX due to magic numbers. Perhaps make this a regular (named) parameter, or an list of enum LazyObjectOptions { case SkipInitOnSerialize; }?

- skipProperty()

Not a fan of the method name, because it doesn't really say what it does, without consulting the docs. Perhaps `skipInitializationFor()` or similar?

- setProperty()

Not a fan of the method name, because it is not a direct counterpart to `getProperty()`. Unfortunately I don't have a better suggestion.

- The examples should be expanded and clarified, especially the one for makeLazyProxy():

My understanding is that the $object that is passed to the first parameter of makeLazyProxy() is completely replaced. Is this understanding correct? What does that mean for spl_object_hash(), spl_object_id()? What does this mean for WeakMap and WeakReference? What does this mean for objects that are only referenced from within $object?

Consider this example:

     class Foo {
       public function __destruct() { echo __METHOD__; }
     }

     class Bar {
       public string $s;
       public ?Foo $foo;

      public function __destruct() { echo __METHOD__; }
     }

     $bar = new Bar();
     $bar->foo = new Foo();

     ReflectionLazyObject::makeLazyProxy($bar, function (Bar $bar) {
       $result = new Bar();
       $result->foo = null;
       $result->s = 'init';
       return $result;
     });

     var_dump($bar->s);

My understanding is that this will dump `string(4) "init"`. Will the destructor of Foo be called? Will the destructor of Bar be called?

- What happens if I make an object lazy that already has all properties initialized? Will that be a noop? Will that throw? Will that create a lazy object that will never automatically be initialized?

- Cloning, unless __clone() is implemented and accesses a property.

The semantics of cloning a lazy object should be explicitly spelled out in the RFC, ideally with an example of the various edge cases (should any exist).

- Before calling the initializer, properties that were not initialized with ReflectionLazyObject::skipProperty(), ReflectionLazyObject::setProperty(), ReflectionLazyObject::setRawProperty() are initialized to their default value.

Should skipProperty() also skip the initialization to the default value? My understanding is that it allows skipping the initialization on access, but when initialization actually happens it should probably be set to a well-defined value, no?

Am I also correct in my understanding that this should read "initialized to their default value (if any)", meaning that properties without a default value are left uninitialized?

- If an exception is thrown while calling the initializer, the object is reverted to its pre-initialization state and is still considered lazy.

Does this mean that the initializer will be called once again when accessing another property? Will the "revert to its pre-initialization" work properly when you have nested lazy objects? An example would probably help.

- The initializer is called with the object as first parameter.

What is the behavior of accessing the object properties, while the initializer is active? Based on the examples, I assume it will not be recursively called, similarly to how the hooks work?

- The object is marked as non-lazy and the initializer is released.

What does it mean for the initializer to be released? Consider the following example:

     ReflectionLazyObject::makeLazyGhost($o, $init = function ($o) use (&$init) {
       $o->init = $init;
     });

- The return value of the initializer has to be an instance of a parent or a child class of the lazy-object and it must have the same properties.

Would returning a parent class not violate the LSP? Consider the following example:

     class A { public string $s; }
     class B extends A { public function foo() { } }

     $o = new B();
     ReflectionLazyObject::makeLazyProxy($o, function (B $o) {
       return new A();
     });

     $o->foo(); // works
     $o->s = 'init';
     $o->foo(); // breaks

- The destructor of lazy non-initialized objects is not called.

That sounds unsafe. Consider the following example:

     class Mutex {
       public string $s;
       public function __construct() {
         // take lock
       }

       public function __destruct() {
         // release lock
       }
     }

     $m = new Mutex();
     ReflectionLazyObject::makeLazyGhost($m, function ($m) {
     });
     unset($m); // will not release the lock.

- Using the Manager::createManager() factory is not compatible with ghost objects because their initializer requires initializing the ghost object in place,

I don't understand that example, because it doesn't actually use lazy objects and thus I don't understand if Manager, Dispatcher, or both are intended to be lazily initialized. It would help to rewrite the example to use `makeLazyGhost()` and indicate with a comment in which cases the problem would arise.

- Backward Incompatible Changes

There a technicality: The `ReflectionLazyObject` class name will no longer be available to userland.

Best regards
Tim Düsterhus

On Tue, Jun 4, 2024 at 10:23 PM Tim Düsterhus <tim@bastelstu.be> wrote:

Hi

On 6/4/24 14:28, Nicolas Grekas wrote:
> Please find all the details here:
> PHP: rfc:lazy-objects
>
> We look forward to your thoughts and feedback.

I've gave the RFC three or four passes and I'm not quite sure if I
follow everything, here's a list of some questions / remarks that came
to mind, roughly ordered by the order of things appearing in the RFC.

- "been tested successfully on the Doctrine and on the Symfony projects"

Is there a PoC patch showcasing how the code would change / be
simplified for those pre-existing codebases?

- int $options = 0

Not a fan of flag parameters that take a bitset, those provide for a
terrible DX due to magic numbers. Perhaps make this a regular (named)
parameter, or an list of enum LazyObjectOptions { case
SkipInitOnSerialize; }?

- skipProperty()

Not a fan of the method name, because it doesn't really say what it
does, without consulting the docs. Perhaps `skipInitializationFor()` or
similar?

- setProperty()

Not a fan of the method name, because it is not a direct counterpart to
`getProperty()`. Unfortunately I don't have a better suggestion.

- The examples should be expanded and clarified, especially the one for
makeLazyProxy():

My understanding is that the $object that is passed to the first
parameter of makeLazyProxy() is completely replaced. Is this
understanding correct? What does that mean for spl_object_hash(),
spl_object_id()? What does this mean for WeakMap and WeakReference? What
does this mean for objects that are only referenced from within $object?

Consider this example:

     class Foo {
       public function __destruct() { echo __METHOD__; }
     }

     class Bar {
       public string $s;
       public ?Foo $foo;

      public function __destruct() { echo __METHOD__; }
     }

     $bar = new Bar();
     $bar->foo = new Foo();

     ReflectionLazyObject::makeLazyProxy($bar, function (Bar $bar) {
       $result = new Bar();
       $result->foo = null;
       $result->s = 'init';
       return $result;
     });

     var_dump($bar->s);

My understanding is that this will dump `string(4) "init"`. Will the
destructor of Foo be called? Will the destructor of Bar be called?

- What happens if I make an object lazy that already has all properties
initialized? Will that be a noop? Will that throw? Will that create a
lazy object that will never automatically be initialized?

- Cloning, unless __clone() is implemented and accesses a property.

The semantics of cloning a lazy object should be explicitly spelled out
in the RFC, ideally with an example of the various edge cases (should
any exist).

- Before calling the initializer, properties that were not initialized
with ReflectionLazyObject::skipProperty(),
ReflectionLazyObject::setProperty(),
ReflectionLazyObject::setRawProperty() are initialized to their default
value.

Should skipProperty() also skip the initialization to the default value?
My understanding is that it allows skipping the initialization on
access, but when initialization actually happens it should probably be
set to a well-defined value, no?

Am I also correct in my understanding that this should read "initialized
to their default value (if any)", meaning that properties without a
default value are left uninitialized?

- If an exception is thrown while calling the initializer, the object is
reverted to its pre-initialization state and is still considered lazy.

Does this mean that the initializer will be called once again when
accessing another property? Will the "revert to its pre-initialization"
work properly when you have nested lazy objects? An example would
probably help.

- The initializer is called with the object as first parameter.

What is the behavior of accessing the object properties, while the
initializer is active? Based on the examples, I assume it will not be
recursively called, similarly to how the hooks work?

- The object is marked as non-lazy and the initializer is released.

What does it mean for the initializer to be released? Consider the
following example:

     ReflectionLazyObject::makeLazyGhost($o, $init = function ($o) use
(&$init) {
       $o->init = $init;
     });

- The return value of the initializer has to be an instance of a parent
or a child class of the lazy-object and it must have the same properties.

Would returning a parent class not violate the LSP? Consider the
following example:

     class A { public string $s; }
     class B extends A { public function foo() { } }

     $o = new B();
     ReflectionLazyObject::makeLazyProxy($o, function (B $o) {
       return new A();
     });

     $o->foo(); // works
     $o->s = 'init';
     $o->foo(); // breaks

- The destructor of lazy non-initialized objects is not called.

That sounds unsafe. Consider the following example:

     class Mutex {
       public string $s;
       public function __construct() {
         // take lock
       }

       public function __destruct() {
         // release lock
       }
     }

     $m = new Mutex();
     ReflectionLazyObject::makeLazyGhost($m, function ($m) {
     });
     unset($m); // will not release the lock.

- Using the Manager::createManager() factory is not compatible with
ghost objects because their initializer requires initializing the ghost
object in place,

I don't understand that example, because it doesn't actually use lazy
objects and thus I don't understand if Manager, Dispatcher, or both are
intended to be lazily initialized. It would help to rewrite the example
to use `makeLazyGhost()` and indicate with a comment in which cases the
problem would arise.

- Backward Incompatible Changes

There a technicality: The `ReflectionLazyObject` class name will no
longer be available to userland.

Best regards
Tim Düsterhus

As someone who has had to maintain these proxies/ghosts before, this
looks quite useful and powerful. I feel it has rather wonky syntax,
but it is clearly better than the alternative of implementing it
yourself. I'm also a huge fan that the syntax allows for
usage/creation far and away from the definition/class itself.

Good luck, and I hope it passes.

Robert Landers
Software Engineer
Utrecht NL

Hi Tim,

That's a lot of interesting feedback. I will try to answer some of
your points, and Nicolas will follow with other points.

On Tue, Jun 4, 2024 at 9:16 PM Tim Düsterhus <tim@bastelstu.be> wrote:

- int $options = 0

Not a fan of flag parameters that take a bitset, those provide for a
terrible DX due to magic numbers. Perhaps make this a regular (named)
parameter, or an list of enum LazyObjectOptions { case
SkipInitOnSerialize; }?

The primary reason for choosing to represent $options as a bitset is
that it's consistent with the rest of the Reflection API (e.g.
ReflectionClass::getProperties() uses a bitset for the $filter
parameter).

I don't get your point about magic numbers since we are using
constants to abstract them.

- skipProperty()

Not a fan of the method name, because it doesn't really say what it
does, without consulting the docs. Perhaps `skipInitializationFor()` or
similar?

We have opted for skipInitializerForProperty()

- setProperty()

Not a fan of the method name, because it is not a direct counterpart to
`getProperty()`. Unfortunately I don't have a better suggestion.

We have renamed setRawProperty() to setRawPropertyValue() in the RFC.
We are open to other suggestions.

We have also removed setProperty(), as we believe that there is no
use-case for it.

- The examples should be expanded and clarified, especially the one for
makeLazyProxy():

Agreed. We will add examples and clarify some behaviors.

My understanding is that the $object that is passed to the first
parameter of makeLazyProxy() is completely replaced. Is this
understanding correct? What does that mean for spl_object_hash(),
spl_object_id()? What does this mean for WeakMap and WeakReference? What
does this mean for objects that are only referenced from within $object?

The object is updated in-place, and retains its identity. It is not
replaced. What makeLazyGhost() and makeLazyProxy() do is equivalent to
calling `unset()` on all properties, and setting a flag on the object
internally. Apart from setting the internal flag, this is achievable
in userland by iterating on all properties via the Reflection API, and
using unset() in the right scope with a Closure.

spl_object_id(), spl_object_hash(), SplObjectStorage, WeakMap,
WeakReference, strict equality, etc are not affected by makeLazy*().

The intended use of makeLazyGhost() and makeLazyProxy() is to call
them either on an object created with
ReflectionClass::newInstanceWithoutConstructor(), or on $this in a
constructor. The latter is the reason why these APIs take an existing
object.

The proposed patch integrates into the object handlers fallback code
path used to manage accesses to undefined properties. We implement
lazy initialization by hooking into undefined property accesses,
without impacting the fast path.

Consider this example:

     class Foo {
       public function __destruct() { echo __METHOD__; }
     }

     class Bar {
       public string $s;
       public ?Foo $foo;

      public function __destruct() { echo __METHOD__; }
     }

     $bar = new Bar();
     $bar->foo = new Foo();

     ReflectionLazyObject::makeLazyProxy($bar, function (Bar $bar) {
       $result = new Bar();
       $result->foo = null;
       $result->s = 'init';
       return $result;
     });

     var_dump($bar->s);

My understanding is that this will dump `string(4) "init"`. Will the
destructor of Foo be called? Will the destructor of Bar be called?

This will print:

    Foo::__destruct (during makeLazyProxy())
    string(4) "init" (during var_dump())

and eventually

    Bar::__destruct (when $bar is released)

- What happens if I make an object lazy that already has all properties
initialized? Will that be a noop? Will that throw? Will that create a
lazy object that will never automatically be initialized?

All properties are unset as described earlier, and the object is
flagged as lazy. The object will automatically initialize when trying
to observe its properties.

However, making a fully initialized object lazy is not the intended use-case.

- Cloning, unless __clone() is implemented and accesses a property.

The semantics of cloning a lazy object should be explicitly spelled out
in the RFC, ideally with an example of the various edge cases (should
any exist).

Agreed. We are working on expanding the RFC about this

- Before calling the initializer, properties that were not initialized
with ReflectionLazyObject::skipProperty(),
ReflectionLazyObject::setProperty(),
ReflectionLazyObject::setRawProperty() are initialized to their default
value.

Should skipProperty() also skip the initialization to the default value?
My understanding is that it allows skipping the initialization on
access, but when initialization actually happens it should probably be
set to a well-defined value, no?

Am I also correct in my understanding that this should read "initialized
to their default value (if any)", meaning that properties without a
default value are left uninitialized?

The primary effect of skipProperty() is to mark a property as
non-lazy, so that accessing it does not trigger the initialization of
the entire object. It also sets the property to its default value if
any, otherwise it is left as undef.

Accessing this property afterwards has exactly the same effect as
doing so on an object created with
ReflectionClass::newInstanceWithoutConstructor() (including triggering
errors when reading an uninitialized property).

- If an exception is thrown while calling the initializer, the object is
reverted to its pre-initialization state and is still considered lazy.

Does this mean that the initializer will be called once again when
accessing another property?

Yes. The goal is to prevent transition from lazy to initialized when
an unexpected error occurred in the initializer.

Will the "revert to its pre-initialization"
work properly when you have nested lazy objects? An example would
probably help.

Only the effects on the object itself are reverted. External side
effects are not reverted.

- The initializer is called with the object as first parameter.

What is the behavior of accessing the object properties, while the
initializer is active? Based on the examples, I assume it will not be
recursively called, similarly to how the hooks work?

For ghost objects, the initializer is supposed to initialize the
object itself like a constructor would do. During initializer
execution, the object has exactly the same state and behavior as it
would have in its constructor during `new`:

- The object is not lazy.
- Properties have their default value (if any) and are accessed
without triggering a nested initialization.
- If setRawPropertyValue() was used, some properties may have a value
different from their default.

For virtual proxies, the initializer is supposed to return another
object. Accessing the proxy object during initialization does not
trigger recursive initializations. Some properties will have a value
if setRawPropertyValue() or skipInitializationForProperty().

- The object is marked as non-lazy and the initializer is released.

What does it mean for the initializer to be released? Consider the
following example:

     ReflectionLazyObject::makeLazyGhost($o, $init = function ($o) use
(&$init) {
       $o->init = $init;
     });

"released" here means that the initializer is not referenced anymore
by this object, and may be freed if it is not referenced anywhere
else.

- The return value of the initializer has to be an instance of a parent
or a child class of the lazy-object and it must have the same properties.

Would returning a parent class not violate the LSP? Consider the
following example:

     class A { public string $s; }
     class B extends A { public function foo() { } }

     $o = new B();
     ReflectionLazyObject::makeLazyProxy($o, function (B $o) {
       return new A();
     });

     $o->foo(); // works
     $o->s = 'init';
     $o->foo(); // breaks

$o->foo() calls B::foo() in both cases here, as $o is always the proxy
object. We need to double check, but we believe that this rule doesn't
break LSP.

- The destructor of lazy non-initialized objects is not called.

That sounds unsafe. Consider the following example:

     class Mutex {
       public string $s;
       public function __construct() {
         // take lock
       }

       public function __destruct() {
         // release lock
       }
     }

     $m = new Mutex();
     ReflectionLazyObject::makeLazyGhost($m, function ($m) {
     });
     unset($m); // will not release the lock.

Good point. The Mutex constructor is called during "new Mutex()", but
the object is made lazy after that, and the destructor is never
called.

We have made the following changes to the RFC:

- makeLazyGhost / makeLazyProxy will call the object destructor
- A new option flag is added, `ReflectionLazyObject::SKIP_DESTRUCTOR`,
that disables this behavior

This is not ideal since the intended use of these methods is to call
them on objects created with newInstanceWithoutConstructor(), or
directly in a constructor, and both of these will need this flag, but
at least it's safe by default.

Thanks again for the feedback.

Best Regards,
Arnaud

Hi Tim,

Thanks for the detailed feedback. Arnaud already answered most of your questions, here is the remaining one:

On 6/4/24 14:28, Nicolas Grekas wrote:

Please find all the details here:
https://wiki.php.net/rfc/lazy-objects

We look forward to your thoughts and feedback.

I’ve gave the RFC three or four passes and I’m not quite sure if I
follow everything, here’s a list of some questions / remarks that came
to mind, roughly ordered by the order of things appearing in the RFC.

  • “been tested successfully on the Doctrine and on the Symfony projects”

Is there a PoC patch showcasing how the code would change / be
simplified for those pre-existing codebases?

Yes!
See https://github.com/nicolas-grekas/symfony/pull/44 for Symfony. All the complex code is gone \o/
And if anyone is wondering: No, we’re not moving this complexity into the engine. As Arnaud wrote somewhere: Implementation in core is simple compared to userland as we are at the right level of abstraction. No code gen, no edge cases with relative type hints, visibility, readonly, hooks, etc. We get more consistent and transparent behavior as well compared to userland impl.

For Doctrine, the URL is https://github.com/nicolas-grekas/doctrine-orm/pull/6 for now, with the most important line being the removal of the symfony/var-exporter dependency.

After yours and Valentin’s feedback, we’re considering an updated API that would provide the same capabilities but that might be more consensual.

The RFC isn’t updated but below is what we have in our drafts. Let me know what you think already if you want (otherwise, let us work on the updated implementation/RFC and we’ll let you know about them ASAP).

Nicolas
PS: I understand that the concepts in the RFC might be difficult to grasp. They were certainly challenging for me to summarize. I would happily accept any help to improve the wording if anyone is willing.

class ReflectionLazyClass extends ReflectionClass
{
public int const SKIP_INITIALIZATION_ON_SERIALIZE = 1;
public int const SKIP_DESTRUCTOR = 2;

public function __construct(object|string $objectOrClass);

public function newLazyGhostInstance(callable $initializer, int $options = 0): object;
public function newLazyProxyInstance(callable $initializer, int $options = 0): object;

public function makeInstanceLazyGhost(object $object, callable $initializer, int $options = 0): void;
public function makeInstanceLazyProxy(object $object, callable $initializer, int $options = 0): void;

public static function isInitialized(object $instance): bool;

/**

  • Initializes a lazy object (no-op if the object is already initialized.)
  • The backing object is returned, which can be another instance than the lazy object when the virtual strategy is used.
    */
    public function initialize(object $object, bool $skipInitializer = false): object;

/**

  • Marks a property as not triggering initialization when being accessed.
  • This method is useful to bypass initialization when setting a property.
    */
    public function skipInitializerForProperty(object $object, string $name, string $class = null): void;

/**

  • Sets a property without triggering initialization while skipping hooks if any.
    */
    public function setRawPropertyValue(object $object, string $name, mixed $value, string $class = null): void;
    }

On Wed, Jun 5, 2024, at 2:50 PM, Arnaud Le Blanc wrote:

Good point. The Mutex constructor is called during "new Mutex()", but
the object is made lazy after that, and the destructor is never
called.

We have made the following changes to the RFC:

- makeLazyGhost / makeLazyProxy will call the object destructor
- A new option flag is added, `ReflectionLazyObject::SKIP_DESTRUCTOR`,
that disables this behavior

This is not ideal since the intended use of these methods is to call
them on objects created with newInstanceWithoutConstructor(), or
directly in a constructor, and both of these will need this flag, but
at least it's safe by default.

Thanks again for the feedback.

Best Regards,
Arnaud

Let me make sure I am following, since I'm still having a hard time with the explanation in the RFC (as I discussed with Nicolas off list).

The two use cases intended here are, essentially, "delay invoking the constructor until first use" (ghost) and "sneak a factory object in the way that is called on first use" (virtual). Right?

The ghost initializer callback is basically an alternate constructor (which will probably just call the real constructor in the typical case), and the virtual initializer callback is basically the body of the factory object.

So the most common ghost implementation for a service would be something like:

$c = the_container();

$object = new ReflectionClass(Foo::class)->newInstanceWithoutConstructor();

$initializer = static function(Foo $foo) => $foo->__construct($c->dep1, $c->dep2, $c->dep3);

$lazyReflector = ReflectionLazyObject::makeLazyGhost($object, $initializer, ReflectionLazyObject::SKIP_DESTRUCTOR);

Am I following? Because if so, the proposed API is extremely clunky. For one thing, using an input/output parameter ($object) is a code smell 99% of the time. It's changing an un-constructed object into a ghost object, and returning... um, I'm not sure what. It also means I need to use both reflection classes in different ways to achieve the result.

It seems a much cleaner API would be something like:

$object = new ReflectionClass(Foo::class)->newInstanceWithLazyConstructor($initializer);

In which case it becomes a lot more obvious that we are, essentially, "swapping out" the constructor for a lazy one. It also suggests that perhaps the function should be using $this, not $foo, as it's running within the context of the object (I presume? Can it call private methods? I assume so since it can set private properties.)

That would also suggest this API for the other approach:

$initializer = static function() => new Foo($c->dep1, $c->dep2, $c->dep3);

$object = new ReflectionClass(Foo::class)->newInstanceWithLazyFactory($initializer);

In which case $object is the proxy, and gets "swapped out" for the return value of the $initializer on first use.

Am I understanding all this correctly? Because if so, I think the above simplified API would make it much more obvious what is going on, much easier to work with, easier to document/explain, and simple enough that it could conceivably be used in cases outside of DI or ORMs, too.

If I'm way off and don't understand what you're doing, then please explain as I'm clearly very confused. :slight_smile:

--Larry Garfield

On Wed, Jun 5, 2024 at 6:58 PM Nicolas Grekas
<nicolas.grekas+php@gmail.com> wrote:

Hi Tim,

Thanks for the detailed feedback. Arnaud already answered most of your questions, here is the remaining one:

On 6/4/24 14:28, Nicolas Grekas wrote:
> Please find all the details here:
> PHP: rfc:lazy-objects
>
> We look forward to your thoughts and feedback.

I've gave the RFC three or four passes and I'm not quite sure if I
follow everything, here's a list of some questions / remarks that came
to mind, roughly ordered by the order of things appearing in the RFC.

- "been tested successfully on the Doctrine and on the Symfony projects"

Is there a PoC patch showcasing how the code would change / be
simplified for those pre-existing codebases?

Yes!
See [VarExporter] [WIP] Leverage native lazy proxies by nicolas-grekas · Pull Request #44 · nicolas-grekas/symfony · GitHub for Symfony. All the complex code is gone \o/
And if anyone is wondering: No, we're not moving this complexity into the engine. As Arnaud wrote somewhere: Implementation in core is simple compared to userland as we are at the right level of abstraction. No code gen, no edge cases with relative type hints, visibility, readonly, hooks, etc. We get more consistent and transparent behavior as well compared to userland impl.

For Doctrine, the URL is Use native proxies by nicolas-grekas · Pull Request #6 · nicolas-grekas/doctrine-orm · GitHub for now, with the most important line being the removal of the symfony/var-exporter dependency.

After yours and Valentin's feedback, we're considering an updated API that would provide the same capabilities but that might be more consensual.

The RFC isn't updated but below is what we have in our drafts. Let me know what you think already if you want (otherwise, let us work on the updated implementation/RFC and we'll let you know about them ASAP).

Nicolas
PS: I understand that the concepts in the RFC might be difficult to grasp. They were certainly challenging for me to summarize. I would happily accept any help to improve the wording if anyone is willing.

class ReflectionLazyClass extends ReflectionClass
{
public int const SKIP_INITIALIZATION_ON_SERIALIZE = 1;
public int const SKIP_DESTRUCTOR = 2;

public function __construct(object|string $objectOrClass);

public function newLazyGhostInstance(callable $initializer, int $options = 0): object;
public function newLazyProxyInstance(callable $initializer, int $options = 0): object;

public function makeInstanceLazyGhost(object $object, callable $initializer, int $options = 0): void;
public function makeInstanceLazyProxy(object $object, callable $initializer, int $options = 0): void;
public static function isInitialized(object $instance): bool;
/**
* Initializes a lazy object (no-op if the object is already initialized.)
*
* The backing object is returned, which can be another instance than the lazy object when the virtual strategy is used.
*/
public function initialize(object $object, bool $skipInitializer = false): object;
/**
* Marks a property as *not* triggering initialization when being accessed.
*
* This method is useful to bypass initialization when setting a property.
*/
public function skipInitializerForProperty(object $object, string $name, string $class = null): void;
/**
* Sets a property *without* triggering initialization while skipping hooks if any.
*/
public function setRawPropertyValue(object $object, string $name, mixed $value, string $class = null): void;
}

For virtual proxies, it would be nice if the instance returned didn't
have to be the same type or child type.

For example, in Durable PHP, I allow accessing remote entities via a
Spying Proxy:

$ctx->signal($id, fn(MyEntity $obj) => $obj->doSomething($x));

In this example, ->signal() generates/loads the proxy which implements
MyEntity with code that captures the value of $x to be forwarded to a
remote instance. It then calls the passed closure and captures the
method + arguments passed.

Most of the complexity is generating the objects themselves to
implement the methods. It would be much simpler to implement magic
methods that can intercept the method calls and not worry about
generating an entire class every time a dev wants to use this
functionality.

In my case, none of the complexity actually goes away since I still
need to generate concrete types of the correct type. That being said,
I don't exactly have millions of users so if I still need to manually
create this, I will. I just thought I'd toss out the idea of not
needing an object of the correct type, just one that can handle the
expected behavior.

Robert Landers
Software Engineer
Utrecht NL

Hi Larry,

Thank you for the feedback.

I think you got the two strategies right. However, there is a use-case
in which an object manages its own laziness by making itself lazy:

class C {
     public function __construct() {
        ReflectionLazyObject::makeLazyGhost($this, $this->init(...));
    }
}

This one can not be addressed by a newInstance*() method since the
object to be made lazy already exists.

The makeLazyGhost() / makeLazyProxy() methods are the minimal methods
necessary to address all use-cases, but the methods you are suggesting
are a better API most of the time, so we are adding approximately this
to the proposal [1]. We are keeping them in a separate class to not
pollute ReflectionClass.

It also suggests that perhaps the function should be using $this, not $foo, as it's running within the context of the object (I presume? Can it call private methods? I assume so since it can set private properties.)

The function is not running in the context of the object. It can only
access private members via Reflection or if the closure was bound to
the right scope by the user. This should not be an issue when the
initializer just calls a public constructor.

In which case $object is the proxy, and gets "swapped out" for the return value of the $initializer on first use.

Just to be sure: $object continues to be the proxy instance after the
initializer is called, but it forwards all property accesses to the
return value of the $initializer.

[1] php.internals: Re: Lazy Objects

Best Regards,
Arnaud

Hi

On 6/5/24 17:25, Nicolas Grekas wrote:

Yes!
See [VarExporter] [WIP] Leverage native lazy proxies by nicolas-grekas · Pull Request #44 · nicolas-grekas/symfony · GitHub for Symfony. All the
complex code is gone \o/
[...]
For Doctrine, the URL is
Use native proxies by nicolas-grekas · Pull Request #6 · nicolas-grekas/doctrine-orm · GitHub for now, with the
most important line being the removal of the symfony/var-exporter
dependency.

Thank you, I'll have a look at it when I have the time.

The RFC isn't updated but below is what we have in our drafts. Let me know
what you think already if you want (otherwise, let us work on the updated
implementation/RFC and we'll let you know about them ASAP).

I've already mentioned in the sub-thread with Larry that this is also what I had in mind after Arnaud's clarification, so that sounds good to me.

I'll give the RFC another full read once you finished incorporating the existing feedback. Doesn't really make sense to give feedback on something that still is in-flux and that after all might not exist in the updated proposal.

PS: I understand that the concepts in the RFC might be difficult to grasp.
They were certainly challenging for me to summarize. I would happily accept
any help to improve the wording if anyone is willing.

I've already said it in my email to Arnaud: Examples showcasing the usage of the API in a (simplified) real-world use case would definitely help making the RFC easier to understand.

And for me it is important that any interactions with the existing functionality [1] are explicitly spelled out. I'm happier spending the time reading an RFC that is five times as long, but clearly defines the entire behavior, than reading an RFC that leaves many open questions that will end up being "implementation-defined" rather than fully thought-through.

Best regards
Tim Düsterhus

[1] e.g. my WeakReference question, instanceof behavior, whether or not private properties are accessible in the initializer, readonly, etc.

Hi

Thank you Larry for your email. Your suggested API is basically what I also had in mind after Arnaud's clarification.

On 6/5/24 20:06, Arnaud Le Blanc wrote:

I think you got the two strategies right. However, there is a use-case
in which an object manages its own laziness by making itself lazy:

What is the corresponding real-world use case for that? When would I want to create an object that makes itself lazy?

FWIW: If I understand it right, this use case would be supported by Larry's proposed API by writing a named constructor:

     class C {
       private function __construct($foo, $bar) { }
       public static function lazyNew(...$args) {
         $r = new ReflectionClass(self::class);

         return $r->newInstanceWithLazyConstructor(fn ($o) => $o->__construct(...$args));
       }
     }

It also suggests that perhaps the function should be using $this, not $foo, as it's running within the context of the object (I presume? Can it call private methods? I assume so since it can set private properties.)

The function is not running in the context of the object. It can only
access private members via Reflection or if the closure was bound to
the right scope by the user. This should not be an issue when the
initializer just calls a public constructor.

Please clarify the interaction with visibility in the RFC.

Best regards
Tim Düsterhus

Hi

Working through your reply in order of the email, without any backtracking, because the complexity of this topic makes it hard to keep the entire email in mind. This might mean that I am asking follow-up questions that you already answered further down. Please apologize if that is the case :slight_smile:

One general note: Please include the answers to my questions in the RFC text as appropriate for reference of other readers and so that all my questions are answered when re-reading the RFC without needing to refer back to your email.

On 6/5/24 16:50, Arnaud Le Blanc wrote:

Not a fan of flag parameters that take a bitset, those provide for a
terrible DX due to magic numbers. Perhaps make this a regular (named)
parameter, or an list of enum LazyObjectOptions { case
SkipInitOnSerialize; }?

The primary reason for choosing to represent $options as a bitset is
that it's consistent with the rest of the Reflection API (e.g.
ReflectionClass::getProperties() uses a bitset for the $filter
parameter).

I don't get your point about magic numbers since we are using
constants to abstract them.

It's not a magic number in the classic sense, but when trying to observe it, e.g. by means of a debugger and the $options have been passed through some other functions that wrap the lazy object API, it will effectively be an opaque number that one will need to decode manually, whereas a list of enums is immediately clear.

My understanding is that the $object that is passed to the first
parameter of makeLazyProxy() is completely replaced. Is this
understanding correct? What does that mean for spl_object_hash(),
spl_object_id()? What does this mean for WeakMap and WeakReference? What
does this mean for objects that are only referenced from within $object?

The object is updated in-place, and retains its identity. It is not
replaced. What makeLazyGhost() and makeLazyProxy() do is equivalent to
calling `unset()` on all properties, and setting a flag on the object
internally. Apart from setting the internal flag, this is achievable

Oh. It was not clear at all to me that all existing properties will be unset. Did I miss it or is that not written down in the RFC?

Is there any reason to call the makeLazyX() methods on an object that was not just freshly created with ->newInstanceWithoutConstructor() then? Anything I do with the object before the call to makeLazyX() will effectively be reverted, no?

An example showcasing the intended usage, e.g. a simplified ORM example, would really be helpful here.

in userland by iterating on all properties via the Reflection API, and
using unset() in the right scope with a Closure.

spl_object_id(), spl_object_hash(), SplObjectStorage, WeakMap,
WeakReference, strict equality, etc are not affected by makeLazy*().

That is true for *both* makeLazyGhost(), and makeLazyProxy()?

What would the following example output?

     $object = new MyObject();
     var_dump(spl_object_id($object));
     $r = ReflectionLazyObject::makeLazyGhost($object, function (MyObject $object) {
         $object2 = new MyObject();
         var_dump(spl_object_id($object2));
         return $object2;
     });
     var_dump(spl_object_id($object));
     $r->initialize();
     var_dump(spl_object_id($object));

What would happen if I would expose the inner $object2 to the outer world by means of the super globals or by means of `use (&$out)` + `$out = $object2`?

The intended use of makeLazyGhost() and makeLazyProxy() is to call
them either on an object created with
ReflectionClass::newInstanceWithoutConstructor(), or on $this in a
constructor. The latter is the reason why these APIs take an existing
object.

Okay, that answers the question above. Technically being capable of calling it on an object that was not just freshly created sounds like a footgun, though. What is the interaction with readonly objects? My understanding is that it would allow an readonly object with initialized properties to change after-the-fact?

Consider this example:

      class Foo {
        public function __destruct() { echo __METHOD__; }
      }

      class Bar {
        public string $s;
        public ?Foo $foo;

       public function __destruct() { echo __METHOD__; }
      }

      $bar = new Bar();
      $bar->foo = new Foo();

      ReflectionLazyObject::makeLazyProxy($bar, function (Bar $bar) {
        $result = new Bar();
        $result->foo = null;
        $result->s = 'init';
        return $result;
      });

      var_dump($bar->s);

My understanding is that this will dump `string(4) "init"`. Will the
destructor of Foo be called? Will the destructor of Bar be called?

This will print:

     Foo::__destruct (during makeLazyProxy())
     string(4) "init" (during var_dump())

and eventually

     Bar::__destruct (when $bar is released)

Okay, so only one Bar::__destruct(), despite two Bar objects being created. I assume it's the destructor of the second Bar, i.e. if I would dump `$this->foo` within the destructor, it would dump `null`?

- What happens if I make an object lazy that already has all properties
initialized? Will that be a noop? Will that throw? Will that create a
lazy object that will never automatically be initialized?

All properties are unset as described earlier, and the object is
flagged as lazy. The object will automatically initialize when trying
to observe its properties.

However, making a fully initialized object lazy is not the intended use-case.

Understood. See above with my follow-up question then.

- Before calling the initializer, properties that were not initialized
with ReflectionLazyObject::skipProperty(),
ReflectionLazyObject::setProperty(),
ReflectionLazyObject::setRawProperty() are initialized to their default
value.

Should skipProperty() also skip the initialization to the default value?
My understanding is that it allows skipping the initialization on
access, but when initialization actually happens it should probably be
set to a well-defined value, no?

Am I also correct in my understanding that this should read "initialized
to their default value (if any)", meaning that properties without a
default value are left uninitialized?

The primary effect of skipProperty() is to mark a property as
non-lazy, so that accessing it does not trigger the initialization of
the entire object. It also sets the property to its default value if
any, otherwise it is left as undef.

Accessing this property afterwards has exactly the same effect as
doing so on an object created with
ReflectionClass::newInstanceWithoutConstructor() (including triggering
errors when reading an uninitialized property).

I'm rereading my own question and can't make sense of it any more. I probably forgot that skipProperty() is defined to set the default value in the PHPDoc when I got down to the bit that I quoted.

Please just insert the 'if any' after 'default value' for clarity.

Will the "revert to its pre-initialization"
work properly when you have nested lazy objects? An example would
probably help.

Only the effects on the object itself are reverted. External side
effects are not reverted.

Yes, it's obvious that external side effects are not reverted. I was thinking about a situation like:

     $a = new A();
     $b = new B();
     ReflectionLazyObject::makeLazyGhost($b, function ($b) {
         throw new \Exception('xxx');
     });
     ReflectionLazyObject::makeLazyGhost($a, function ($a) use ($b) {
         $a->b = $b->somevalue;
     });
     $a->init = 'please';

The initialization of $a will implicitly attempt to initialize $b, which will fail. Am I correct in my understanding that both $a and $b will be reverted back to a lazy object afterwards? If so, adding that example to the RFC would help to make possible edge cases clear.

- The return value of the initializer has to be an instance of a parent
or a child class of the lazy-object and it must have the same properties.

Would returning a parent class not violate the LSP? Consider the
following example:

      class A { public string $s; }
      class B extends A { public function foo() { } }

      $o = new B();
      ReflectionLazyObject::makeLazyProxy($o, function (B $o) {
        return new A();
      });

      $o->foo(); // works
      $o->s = 'init';
      $o->foo(); // breaks

$o->foo() calls B::foo() in both cases here, as $o is always the proxy
object. We need to double check, but we believe that this rule doesn't
break LSP.

I don't understand what happens with the 'A' object then, but perhaps this will become clearer once you add the requested examples.

Best regards
Tim Düsterhus

Hi Arnaud,

śr., 5 cze 2024 o 20:08 Arnaud Le Blanc <arnaud.lb@gmail.com> napisał(a):

Hi Larry,

Thank you for the feedback.

I think you got the two strategies right. However, there is a use-case
in which an object manages its own laziness by making itself lazy:

class C {
public function __construct() {
ReflectionLazyObject::makeLazyGhost($this, $this->init(...));
}
}

This one can not be addressed by a newInstance*() method since the
object to be made lazy already exists.

Did you consider implementing it using some attribute?

On constructor like:

class C {
#[LazyInitialization]
public function __construct(private readonly string $foo) {
// ... init executes after first use, but all promoted properties are already initialized
}
}

or on specialized initializer:

class C {
public function __construct(private readonly string $foo) {
// do something light
}

#[LazyInitialization]
private function initi(): void
{
// do something heavy
}
}

I don’t know if this is a good example of doing the same thing or if it doesn’t limit functionality,
but for me, it is way more clean and easier to understand.

Cheers,
Michał Marcin Brzuchalski

On Tue, 4 Jun 2024 at 13:29, Nicolas Grekas <nicolas.grekas+php@gmail.com> wrote:

Dear all,

Arnaud and I are pleased to share with you the RFC we’ve been shaping for over a year to add native support for lazy objects to PHP.

Please find all the details here:
https://wiki.php.net/rfc/lazy-objects

We look forward to your thoughts and feedback.

Cheers,
Nicolas and Arnaud

I’m wondering why this has been attached to the existing reflection API instead of being a new thing in and of itself? It doesn’t seem strictly related to reflection other than currently the solutions for this rely on reflection to work.

~C

Hi Michał, Chris,

On Thu, Jun 6, 2024 at 8:53 AM Michał Marcin Brzuchalski
<michal.brzuchalski@gmail.com> wrote:

Did you consider implementing it using some attribute?

On Sun, Jun 9, 2024 at 1:24 AM Chris Riley <t.carnage@gmail.com> wrote:

I'm wondering why this has been attached to the existing reflection API instead of being a new thing in and of itself? It doesn't seem strictly related to reflection other than currently the solutions for this rely on reflection to work.

Thank you for your feedback.

Currently the lazy objects feature is designed as a low-level
technical building block that libraries and frameworks can use. FFI
and Fibers are examples of such features that most users do not use
directly, but can benefit greatly from within libraries they use.

We have considered the proposed syntaxes, such as using annotations,
but these do not match the core use case of creating a lazy instance
of a class without requiring cooperation from the class itself.
Furthermore we believe that designing a higher-level API in the
language would be considerably more difficult and could put the RFC at
risk.

However, it is possible to introduce a higher-level way to create lazy
objects in a future RFC.

Best Regards,
Arnaud

Hi Tim,

We have updated the RFC to address your feedback. Please find
additional answers below.

On Wed, Jun 5, 2024 at 8:25 PM Tim Düsterhus <tim@bastelstu.be> wrote:

Is there any reason to call the makeLazyX() methods on an object that
was not just freshly created with ->newInstanceWithoutConstructor()
then?

There are not many reasons to do that. The only indented use-case that
doesn't involve an object freshly created with
->newInstanceWithoutConstructor() is to let an object manage its own
laziness by making itself lazy in its constructor:

class C {
     public function __construct() {
        new ReflectionLazyObjectFactory(C::class)->makeInstanceLazyGhost($this,
$this->init(...));
    }
}

When drafting this API we figured that makeLazy addressed all
use-cases, and this allowed us to keep the API minimal. However,
following different feedback we have now introduced two separate
newLazyGhostInstance() and newLazyProxyInstance() methods.

Anything I do with the object before the call to makeLazyX() will
effectively be reverted, no?

Yes, after calling makeLazy() the object is in the same state as if it
was made lazy immediately after instantiation.

An example showcasing the intended usage, e.g. a simplified ORM example,
would really be helpful here.

Agreed

> in userland by iterating on all properties via the Reflection API, and
> using unset() in the right scope with a Closure.
>
> spl_object_id(), spl_object_hash(), SplObjectStorage, WeakMap,
> WeakReference, strict equality, etc are not affected by makeLazy*().

That is true for *both* makeLazyGhost(), and makeLazyProxy()?

What would the following example output?

     $object = new MyObject();
     var_dump(spl_object_id($object));
     $r = ReflectionLazyObject::makeLazyGhost($object, function
(MyObject $object) {
         $object2 = new MyObject();
         var_dump(spl_object_id($object2));
         return $object2;
     });
     var_dump(spl_object_id($object));
     $r->initialize();
     var_dump(spl_object_id($object));

What would happen if I would expose the inner $object2 to the outer
world by means of the super globals or by means of `use (&$out)` + `$out
= $object2`?

We have clarified the RFC on these points

> The intended use of makeLazyGhost() and makeLazyProxy() is to call
> them either on an object created with
> ReflectionClass::newInstanceWithoutConstructor(), or on $this in a
> constructor. The latter is the reason why these APIs take an existing
> object.

Okay, that answers the question above. Technically being capable of
calling it on an object that was not just freshly created sounds like a
footgun, though. What is the interaction with readonly objects? My
understanding is that it would allow an readonly object with initialized
properties to change after-the-fact?

Good point about readonly, this is something we had overlooked. We
have updated the RFC to address this.

>> Consider this example:
>>
>> class Foo {
>> public function __destruct() { echo __METHOD__; }
>> }
>>
>> class Bar {
>> public string $s;
>> public ?Foo $foo;
>>
>> public function __destruct() { echo __METHOD__; }
>> }
>>
>> $bar = new Bar();
>> $bar->foo = new Foo();
>>
>> ReflectionLazyObject::makeLazyProxy($bar, function (Bar $bar) {
>> $result = new Bar();
>> $result->foo = null;
>> $result->s = 'init';
>> return $result;
>> });
>>
>> var_dump($bar->s);
>>
>> My understanding is that this will dump `string(4) "init"`. Will the
>> destructor of Foo be called? Will the destructor of Bar be called?
>
> This will print:
>
> Foo::__destruct (during makeLazyProxy())
> string(4) "init" (during var_dump())
>
> and eventually
>
> Bar::__destruct (when $bar is released)

Okay, so only one Bar::__destruct(), despite two Bar objects being
created. I assume it's the destructor of the second Bar, i.e. if I would
dump `$this->foo` within the destructor, it would dump `null`?

Following your feedback we have updated the RFC to include calling
destructors in makeLazy by default. In the updated version,
Bar::__destruct is called twice: Once on the first instance during the
call to makeLazyProxy(), and another time on the second instance (the
one created in the closure) when it's released. It is not called when
the first instance is released, because it's now a proxy.

>> Will the "revert to its pre-initialization"
>> work properly when you have nested lazy objects? An example would
>> probably help.
>
> Only the effects on the object itself are reverted. External side
> effects are not reverted.

Yes, it's obvious that external side effects are not reverted. I was
thinking about a situation like:

     $a = new A();
     $b = new B();
     ReflectionLazyObject::makeLazyGhost($b, function ($b) {
         throw new \Exception('xxx');
     });
     ReflectionLazyObject::makeLazyGhost($a, function ($a) use ($b) {
         $a->b = $b->somevalue;
     });
     $a->init = 'please';

The initialization of $a will implicitly attempt to initialize $b, which
will fail. Am I correct in my understanding that both $a and $b will be
reverted back to a lazy object afterwards? If so, adding that example to
the RFC would help to make possible edge cases clear.

We have added an example showing that.

>> - The return value of the initializer has to be an instance of a parent
>> or a child class of the lazy-object and it must have the same properties.
>>
>> Would returning a parent class not violate the LSP? Consider the
>> following example:
>>
>> class A { public string $s; }
>> class B extends A { public function foo() { } }
>>
>> $o = new B();
>> ReflectionLazyObject::makeLazyProxy($o, function (B $o) {
>> return new A();
>> });
>>
>> $o->foo(); // works
>> $o->s = 'init';
>> $o->foo(); // breaks
>
> $o->foo() calls B::foo() in both cases here, as $o is always the proxy
> object. We need to double check, but we believe that this rule doesn't
> break LSP.

I don't understand what happens with the 'A' object then, but perhaps
this will become clearer once you add the requested examples.

The 'A' object is what is called the "actual instance" in the RFC. $o
acts as a proxy to the actual instance: Any property access on $o is
forwarded to the actual instance A.

Best Regards,
Arnaud

Hi

On 6/14/24 14:13, Arnaud Le Blanc wrote:

We have updated the RFC to address your feedback. Please find
additional answers below.

for some preliminary feedback: I've given the RFC another quick read and it already reads *much* better, thank you. The two examples for the two strategies were pretty illustrative.

- I noticed a small typo "makeInstanceLazyHost".
- The ORM example uses 'setPropertyValue', I believe it should read 'setRawPropertyValue'.
- The LazyConnection example uses 'ReflectionLazyObject', should that read 'ReflectionLazyObjectFactory'?

I plan to give more detailed feedback at a later point, when i have the time to think through your reply and the semantics updated RFC.

Best regards
Tim Düsterhus

PS: Thanks, I hate the example of how readonly properties can already change in existing versions :frowning:

On Fri, Jun 14, 2024, at 12:13 PM, Arnaud Le Blanc wrote:

Hi Tim,

We have updated the RFC to address your feedback. Please find
additional answers below.

The updated RFC looks much better, thank you. Though I still have some thoughts, in no particular order.

The actual instance is allowed to escape the proxy and to create direct references to itself.

How? Is this a "return $this" type of situation? This could use more fleshing out and examples.

The terms "virtual" and "proxy" seem to be used interchangeably in different places, including in the API. Please just use one, and purge the other. It's confusing as is. :slight_smile: (I'd favor "proxy", as it seems more accurate to what is happening.) For that matter, I'd be very tempted to remove the word "lazy" from the API calls. `newGhostInstance()` and `newProxyInstance()` are plenty understandable, and shorter/easier to read. (Similarly, `makeGhostInstance()` and `makeProxyInstance()`. Although since those are more about modifying a provided object to be lazy, perhaps "make" isn't the right verb to use as that often means "create".)

Under Common Behavior, you have an example of calling the constructor directly, using the reflection API, but not of binding the callable, which the text says is also available. Please include an example of that so we can evaluate how clumsy (or not) it would be.

After calling newLazyGhostInstance(), the behavior of the object is the same as an object created by newLazyGhostInstance().

I think the first is supposed be a make* call?

When making an existing object lazy, the makeInstanceLazy*() methods call the destructor unless the SKIP_DESTRUCTOR flag is given.

I don't quite get why this is. Admittedly destructors are rarely used, but why does it need to call the destructor?

I find it interesting that your examples list DICs as a use case for proxies, when I would have expected that to fit ghosts better. The common pattern, I would think, would be:

class Service {
    public function __construct(private ServiceA $a, private ServiceB $b) {}
}

$c = some_container();

$init = fn() => $this->__construct($c->get(ServiceA::class), $c->get(ServiceB::class));

$service = new ReflectionLazyObjectFactory(Service::class, $init);

(Most likely in generated code that can dynamically sort out the container calls to inline.)

Am I missing something?

ReflectionLazyObjectFactory is a terrible name. Sorry, it is. :slight_smile: Especially if it's subclassing ReflectionClass. If it were its own thing, maybe, but it's still too verbose. I know you don't want to put more on the "dumping ground" fo ReflectionClass, but honestly, that feels more ergonomic to me. That way the following are all siblings:

newInstance(...$args)
newInstanceWithoutConstructor(...$args)
newGhostInstance($init)
newProxyInstance($init)

That feels a lot more sensible and ergonomic to me. isInitialized(), initialized(), etc. also feel like they make more sense as methods on ReflectionObject, not as static methods on a random new class.

As I said, definitely improved, and I like where it's going. I think it can improve further, though.

--Larry Garfield

On Fri, Jun 14, 2024, at 8:15 PM, Larry Garfield wrote:

And of course I got the code sample wrong. It should be:

class Service {
    public function __construct(private ServiceA $a, private ServiceB $b) {}
}

$c = some_container();

$init = fn() => $this->__construct($c->get(ServiceA::class),
$c->get(ServiceB::class));

$service = new ReflectionLazyObjectFactory(Service::class)->newGhostInstance(init);

Sorry about that.

--Larry Garfield