PHP 8.4: gli oggetti lazy, e quando servono davvero

Hai un oggetto che costa: apre una connessione, legge un file di configurazione, fa una query.
Lo costruisci nel container all’avvio della richiesta, e nove volte su dieci nessuno lo usa.
Fino a PHP 8.3 la soluzione era scriversi un proxy a mano — una classe che ha gli stessi metodi
dell’originale, tiene una closure dentro e la invoca al primo accesso — oppure generare quella
classe con un template. Funziona, ma va mantenuta: ogni metodo nuovo sull’originale va ricopiato
sul proxy, e le proprietà pubbliche non si intercettano affatto. La RFC che ha introdotto la
funzionalità lo dice senza giri di parole: «achieving this kind of laziness in userland is complex,
limited, and can have a significant performance impact».
PHP 8.4 ha spostato il problema nel motore. La definizione sul manuale è una riga:
«A lazy object is an object whose initialization is deferred until its state is observed or
modified».
Lazy ghost: l’oggetto si riempie da solo
La prima delle due strategie. Si crea passando dalla Reflection:
$reflector = new ReflectionClass(Report::class);
$report = $reflector->newLazyGhost(function (Report $report): void {
$report->__construct(loadRowsFromDb());
});
La firma è newLazyGhost(callable $initializer, int $options = 0): object. Quello che torna è già
un Report a tutti gli effetti, ma il costruttore non è stato chiamato e le proprietà non hanno
nemmeno il loro valore di default. Al primo accesso a $report->rows PHP esegue l’initializer, e
da quel momento — dice il manuale — «the object is indistinguishable from an object that was never
lazy».
L’initializer riceve l’oggetto come primo parametro e deve restituire null o niente. Se provi a
restituire l’oggetto, come verrebbe naturale, è un errore.
Se ti serve vederlo, var_dump() non fa scattare l’inizializzazione e te lo dice in chiaro:
lazy ghost object(Example)#3 (0) {
["prop"]=>
uninitialized(int)
}
Lazy proxy: l’oggetto vero arriva da fuori
La seconda strategia serve quando l’istanza non la costruisci tu:
$invoice = $reflector->newLazyProxy(fn (Invoice $proxy): Invoice => $repository->find($id));
Firma newLazyProxy(callable $factory, int $options = 0): object. Qui la callback non riempie
l’oggetto: ne restituisce un altro, «which is then attached to the proxy. After this, all
subsequent interactions with the proxy are forwarded to the real instance».
Il manuale è esplicito su quando scegliere l’una o l’altra. Il ghost va bene «when we control both
the instantiation and initialization of the object, making it unsuitable if either of these is
managed by another party»; il proxy esiste proprio perché «the creation of the real instance can be
delegated to another party».
Cosa fa scattare l’inizializzazione
Leggere una proprietà, scriverla, testarla con isset(), iterare sull’oggetto, serializzarlo,
clonarlo, guardarlo con ReflectionProperty. Non la fa scattare una chiamata a metodo che non
tocca lo stato: «method calls that do not access the object state will not trigger initialization».
Non la fanno scattare nemmeno var_dump(), get_mangled_object_vars(), il cast ad array, e
ReflectionProperty::setRawValueWithoutLazyInitialization(), che scrive una proprietà «without
triggering lazy initialization and without calling hook functions».
Per controllare a mano ci sono initializeLazyObject(), che forza l’inizializzazione, e
isUninitializedLazyObject(), che torna true solo se l’oggetto è lazy e non ancora
inizializzato.
Gli errori che si fanno
Credere di avere un oggetto lazy quando non lo è. Entrambi i metodi dicono la stessa cosa: «if
the object has no properties, or if all its properties are static or virtual, a normal (non-lazy)
instance is returned». Nessun errore, nessun warning: ti torna un oggetto costruito subito. Su una
classe di soli metodi la pigrizia non c’è, e te ne accorgi solo profilando.
Usarlo su una classe interna. «An Error if the class is internal or extends an internal class
except stdClass». Niente PDO lazy, niente DateTimeImmutable lazy.
Dimenticare che l’initializer può girare due volte. Se lancia un’eccezione, «the object state is
reverted to its pre-initialization state and the object is marked as lazy again». L’oggetto torna
pigro, e il prossimo accesso riproverà. Attenzione alla riga dopo: «all effects on the object itself
are reverted. Other side effects, such as effects on other objects, are not reverted». Se
l’initializer incrementa un contatore o scrive una riga di log prima di fallire, quella resta.
Confrontare un proxy con ===. «Care must be taken when their identity is used, as the proxy
and its real instance have different identities». Il proxy inoltra tutto all’istanza vera, ma non
è l’istanza vera. Con i ghost il problema non si pone.
Dare per scontato il distruttore. «For lazy ghosts, the destructor is only called if the object
has been initialized». Se in __destruct() chiudi una risorsa e l’oggetto non è mai stato toccato,
quel codice non gira mai. Il che è corretto — non c’era niente da chiudere — ma sorprende chi ci
mette dentro dell’altro.
E una che costa un debug intero: clonare inizializza. «Cloning a lazy object triggers its
initialization before the clone is created». Un clone messo lì per sicurezza in un setter
immutabile ti annulla tutto il vantaggio, in silenzio.
Quando invece va benissimo un oggetto normale
La RFC dice per chi è stata scritta questa feature, e vale la pena leggerlo: Symfony «uses them in
its dependency injection component to provide lazy services», Doctrine «makes its entities lazy»,
più il caso di un parser JSON che rimanda il parsing. Framework e ORM, cioè codice che istanzia
classi che non ha scritto e non sa se verranno usate.
Nel codice di un’applicazione la situazione di solito è un’altra: sai benissimo se quell’oggetto
ti serve. E allora la domanda prima di newLazyGhost è una sola — l’oggetto viene usato quasi
sempre? Se sì, la pigrizia non ti fa risparmiare niente e ti lascia in mano una ReflectionClass
da portarti dietro, un initializer da testare e cinque comportamenti particolari da ricordare.
Costruirlo e basta è più veloce da leggere e più difficile da sbagliare.
Il criterio pratico: gli oggetti lazy si giustificano quando non puoi sapere se l’oggetto servirà
— perché la decisione è di chi chiama, non tua. Se la decisione è tua, prendila e scrivi il codice
normale. Un fn () => new Report(...) passato in giro risolve la stragrande maggioranza dei casi,
senza Reflection.
Fonti: il manuale PHP, Lazy Objects,
ReflectionClass::newLazyGhost e
ReflectionClass::newLazyProxy, e la
RFC: Lazy Objects (approvata 26 a 5, PHP 8.4).
Se hai un’applicazione PHP che si porta dietro anni di codice e vuoi capire dove sta il tempo
davvero — che quasi mai è dove pensi — scrivici.
