Skip to content

Add optional_index and optional_property lenses - #39

Merged
veewee merged 9 commits into
mainfrom
feature/optional-lenses
Oct 15, 2026
Merged

veewee merged 9 commits into
mainfrom
feature/optional-lenses

Conversation

@veewee

@veewee veewee commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

optional(index($key)) and optional(property($name)) resolve a miss by throwing an exception inside the inner lens and catching it in optional(). php-soap/encoding uses them for every property of every node it encodes or decodes, and on sparse payloads most of those reads are misses, so the exceptions add up. The measurements behind that are in php-soap/encoding#73.

This PR adds optional_index($index) and optional_property($propertyName) in VeeWee\Reflecta\Lens. They return the same values as the optional compositions, also for a null subject or a subject of the wrong type, but their get gets to null without throwing. set delegates to the optional composition, because only reads are on the hot path.

Both lenses are stateless, like property(), index() and property_get(). One instance can be reused across any mix of subjects.

optional_index reads the key when the subject is an array and returns null otherwise. A key that is present with a null value returns null, the same as an absent key.

optional_property resolves the name on every get the way PHP resolves $object->{$name}: a property the class itself declares, then a dynamic property of that object (read directly), then a property declared by a parent. It walks the class chain with property_exists() and builds one native ReflectionProperty for the first class that declares the name, without ever throwing. That walk stays inside the lens on purpose: ReflectedClass::findProperty() remains the one public way to find a property, returning a ReflectedProperty, and wrapping the native property on this hot path would cost 60 to 120 ns per read.

Asking the class name skips the dynamic properties of the instance, and the parent walk finds a private property the child cannot see. A parent's private property therefore wins over a dynamic property with the same name, the same as in optional(property()). Dynamic properties are handled in the lens itself: when no class declares the name, it reads $subject->{$name} if property_exists($subject, $name). I tried returning a ReflectionProperty for dynamic properties too, and that took a stdClass hit from about 0.1 us to 0.2 us. Dynamic properties are public, unhooked and always initialised, so the reflection adds nothing there.

An uninitialised property returns null, unless the class has __get, because PHP calls __get for a declared property that was unset, and the composition returns that value today. Anything the read throws returns null, as optional() does. That covers a get hook that throws and a lazy object whose initialiser fails. For a name the class does not declare, property_exists() on the object runs that initialiser, so the whole lookup sits inside the same catch.

Parity and throws

The tests compare both lenses against the optional composition on 32 property subjects and 10 index subjects. They cover visibility, a private property on a parent, readonly, nullable, uninitialised, unset, static, undeclared, stdClass, array casts with int keys, #[AllowDynamicProperties], property hooks, __get, and a lazy ghost with a failing initialiser for both a declared and an absent name. A separate test runs one lens instance over two different classes and over stdClass instances with different dynamic properties. Results are identical. A dynamic property that shadows a private parent property is tested against plain property access instead, because ReflectionObject, and so the optional composition, resolves that case differently between PHP 8.4 patch releases (8.4.13 against 8.4.26); plain access returns the dynamic value on both, and so does the lens.

I counted throws per case with Xdebug in develop mode:

Lens optional composition new lens
property 20 4
index 5 0

The four throws that remain come from the subject itself: a get hook that throws, the two lazy ghosts whose initialiser throws, and a typed property that was never initialised on a class with __get. In that last case getValue() has to run in case the property was unset, because only then does PHP call __get.

Benchmark

composer bench, OptionalLensBench, PHP 8.4, Xdebug off, lenses built once and reused, mode per get(). The 4703efd column is the previous head of this branch, which built a ReflectionObject on every read.

Case optional composition new lens at 4703efd new lens now
array hit 0.295 us 0.051 us 0.051 us
array miss 0.853 us 0.046 us 0.045 us
typed class hit 0.585 us 0.377 us 0.151 us
typed class miss 0.993 us 0.171 us 0.090 us
stdClass hit 0.642 us 0.405 us 0.110 us
stdClass miss 1.030 us 0.173 us 0.089 us

The array rows use optional_index, which this change does not touch. In a separate loop of 200,000 reads, a private property on a parent went from about 0.43 us to 0.19 us per read.

On the 3 MB encode in php-soap/encoding (median of 5 runs), this branch brings the sparse payload from 2.90 s on main to 1.66 s with stdClass and from 2.83 s to 1.73 s with a classmap. The dense payload goes from 0.92 s to 0.74 s and 0.77 s.

Follow-up

ReflectedClass::findProperty() still goes through ReflectionClass::hasProperty() per class in the chain. It could use the same property_exists() walk, but I left it alone here because it also serves class names without an object.

Dependency

#37 is merged and optional_property no longer calls its ReflectedClass::findProperty(), so the base of this PR can move to main.

optional(index()) and optional(property()) throw and catch an exception on every miss. These lenses keep the same contract, but their get resolves a miss to null without throwing. Set still goes through the optional composition.
isInitialized() runs the initialiser of a lazy ghost, so it has to sit inside the try like getValue(). The index parameter is typed string|int, matching index_get().
Lenses in reflecta keep no state, so the lens now resolves the property
from the object on every get instead of caching it per class. The lookup
moved inside the catch, because a lazy ghost runs its initialiser while
looking up a name the class does not declare.
Building a ReflectionObject on every read made a property hit cost
about 0.38us. object_declared_property() asks property_exists() on
the class and its parents instead, so the lens builds one
ReflectionProperty for a declared property and reads dynamic
properties directly. A parent's private property still wins over a
dynamic property with the same name, as in optional(property()).
@veewee
veewee changed the base branch from feature/throwless-properties-set to main October 15, 2026 05:55
…roperty

Plain property access returns the dynamic value. ReflectionObject resolves this case differently between PHP patch releases (8.4.13 against 8.4.26), so the test compares against plain access instead of optional(property()).
It only ever looked at the class chain, so it now takes a class name and follows the find convention: null instead of an exception. optional_property keeps PHP's own order for $object->{$name} in one place: the class itself, then a dynamic property, then the parents.
ReflectedClass::findProperty() is the public way to find a property and returns a ReflectedProperty. A second public function returning a native ReflectionProperty would give users two lookups with two return types, and wrapping the native property on this hot path costs 60 to 120 ns per read.
@veewee
veewee merged commit 742b58d into main Oct 15, 2026
13 checks passed
@veewee
veewee deleted the feature/optional-lenses branch October 15, 2026 06:46
veewee added a commit to php-soap/encoding that referenced this pull request Oct 15, 2026
veewee/reflecta#39 adds optional_index() and optional_property(), stacked on #37. Until 1.1.0 is tagged the branch comes in through a VCS repository aliased as 1.1.0, so this cannot be merged before the constraint is back on a release.
veewee added a commit to php-soap/encoding that referenced this pull request Oct 15, 2026
veewee/reflecta#39 adds optional_index() and optional_property(), stacked on #37. Until 1.1.0 is tagged the branch comes in through a VCS repository aliased as 1.1.0, so this cannot be merged before the constraint is back on a release.
veewee added a commit to veewee/encoding that referenced this pull request Oct 15, 2026
veewee/reflecta#39 adds optional_index() and optional_property(), stacked on php-soap#37. Until 1.1.0 is tagged the branch comes in through a VCS repository aliased as 1.1.0, so this cannot be merged before the constraint is back on a release.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant