Repository navigation
Add optional_index and optional_property lenses - #39
Merged
Merged
Conversation
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().
This was referenced Oct 14, 2026
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()).
…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
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.
This was referenced Oct 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
optional(index($key))andoptional(property($name))resolve a miss by throwing an exception inside the inner lens and catching it inoptional(). 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)andoptional_property($propertyName)inVeeWee\Reflecta\Lens. They return the same values as the optional compositions, also for a null subject or a subject of the wrong type, but theirgetgets to null without throwing.setdelegates to the optional composition, because only reads are on the hot path.Both lenses are stateless, like
property(),index()andproperty_get(). One instance can be reused across any mix of subjects.optional_indexreads 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_propertyresolves the name on everygetthe 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 withproperty_exists()and builds one nativeReflectionPropertyfor 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 aReflectedProperty, 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}ifproperty_exists($subject, $name). I tried returning aReflectionPropertyfor 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__getfor a declared property that was unset, and the composition returns that value today. Anything the read throws returns null, asoptional()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, becauseReflectionObject, 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:
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 casegetValue()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 perget(). The 4703efd column is the previous head of this branch, which built aReflectionObjecton every read.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 throughReflectionClass::hasProperty()per class in the chain. It could use the sameproperty_exists()walk, but I left it alone here because it also serves class names without an object.Dependency
#37 is merged and
optional_propertyno longer calls itsReflectedClass::findProperty(), so the base of this PR can move to main.