Repository navigation
Inline forwarding: a class double runs the real bodies on itself - #157
Merged
Merged
Conversation
A forwarding double of a class no longer proxies the real bodies to the
instance it was given: the state that instance held is copied onto the
double, and an unmatched call runs the doubled class's own body with `$this`
still the double. A self-call inside real code therefore re-enters the
double — it meets stubs, an armed protocol and the call log — which is what a
`makePartial()` port needs and what the standing limitation ("understudy
proxies an object; it does not instrument one") described. An interface
double has no bodies to reach and proxies exactly as before.
The body is entered through `ReflectionMethod::invokeArgs()` on a reflection
of the doubled class, not through a generated `parent::` closure. The closure
variant was built and measured first: handed to the dispatcher as an argument
of every generated method, it cost +10% per call on every class double,
forwarding or not, because the argument is evaluated before dispatch can
decide it is not needed — and `perf/` doubles only interfaces, so the harness
cannot see it. With the reflection the generated code is byte-identical to
master. A generated `__understudyReal_*()` sibling is the third way, and it
is closed by golden rule 3. The language claim — non-virtual entry, virtual
self-calls, protected bodies, by-reference parameters, `$this` identity — is
pinned by spikes/08-inline-forwarding on 8.3, 8.4 and 8.5.
Decisions behind the shape:
- Inline needs the instance to be exactly the doubled class. A donor that is
a subclass carries overrides the doubled class's bodies do not contain, and
running those instead would silently change which code executes, so it
keeps the proxy form.
- `callOriginal()` follows the double's own form, because after the copy the
donor's state is no longer the current one.
- A clone made while a real body runs inherits that double's forwarding:
without it a `: self` wither, or a body merely computing over an internal
copy, would hold a double answering defaults in the middle of real code.
Expectations are still not copied, and a clone the test makes is untouched.
- A `new static` cannot be adopted — its constructor built its own state — so
it is refused by registration rather than by return type: an instance no
context knows is broken for every caller.
- Re-pointing an inline double at another instance is refused. A `readonly`
property cannot be written twice, and a second copy would discard what the
real code has written since.
Measured: mutation 3573 mutants / 93% MSI (gate 92), against 3481 / 93% on
master; the class-double dispatch loop moves -3.4%, -4.8%, +3.4% across three
paired runs, and `perf`/`perf-memory` are unchanged. Suites green on 8.3, 8.4
and 8.5.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configuration
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…on 8.3 `hasHooks()`, `isVirtual()`, `getRawValue()` and `setRawValue()` exist from PHP 8.4, and the state copy reaches them behind a `PHP_VERSION_ID` guard — but Psalm reflects the real `ReflectionProperty` of the PHP it runs on, so naming them directly is an `UndefinedMethod` under 8.3. `composer build` was green on a machine with 8.5 and red in the `PHP 8.3` and `Prefer lowest` matrix jobs, which is where this was caught. They are now asked through two private helpers that take the member name as a parameter, so nothing can resolve it — the idiom `Codegen\PropertyDefaults::isWritableFromOutside()` already uses for the same three members. Verified by running psalm under php:8.3-cli and php:8.4-cli, not only under the 8.5 the build image carries.
Inline forwarding adopted any clone of the running double's class, and one generated class serves every double of a type: a real body handed another double of its own class — `merge(self $other)` — and cloning that one gave the copy bodies to run with no state copy to run them over. Where the class declares a typed property that is PHP's own "must not be accessed before initialization" out of the clone; where every property has a declared default it is a quiet wrong answer computed from the declaration instead of the donor. Both were a regression: proxied, the copy answered defaults. `Runtime::isCopyOf()` now compares the two property tables with `get_mangled_object_vars()` — the only reading that reaches a parent's private properties, answers a hooked property with its backing store rather than running the doubled class's `get` in the middle of `__clone()`, has no entry for a virtual one, and omits what nobody initialized. A copy is property-identical to its source at this moment, because a generated `__clone()` does nothing but call `adoptClone()`. Two doubles of one class in the same state stay indistinguishable, because `__clone()` is handed no reference to its source: such a copy is still adopted and answers for real, over the state it already holds. That residual is documented where the rule is stated — both READMEs, the forwarding guide, the CHANGELOG — rather than called harmless. A `clone $double` written in the test's own `answers()` closure was adopted too, although the documented rule makes it a fresh double: the closure runs inside dispatch, which a real body can reach. `Runtime::$inlineFloor` hides the frames underneath for exactly the closure's span, so only what the closure itself enters — `callOriginal()`, which pushes a frame of its own — is visible to a clone made there. Five of the seven new tests fail or error on the previous commit: both forms of the foreign clone, the test closure under a real body, the comparison reading a hooked property through its backing store, and a plain property declared after a hooked and a virtual one deciding the verdict. The other two pin mechanisms that did not exist before it — the floor coming back after a throwing stub, and the hook that counts its own calls to show neither the state copy nor the comparison runs one. composer build green (Unit 1173, Integration 27, examples 8), full mutation 3586 mutants / 249 escaped / MSI 93% against the gate of 92, rector clean, roave reports only the pre-existing verifyAll(): void -> int from #154, bin/package-audit 0 errors, docs build and claims green. `make docs-vale` needs a vale binary this host has not got; the docs job lints the new prose. Perf was not re-measured.
…below 8.5
The fixtures of the previous commit declared `mergeWith(self $other)`, and
the suite died on PHP 8.3 and 8.4 before reaching a single test:
Declaration of …\Generated\Understudy_c28436a2f78b6ee1::mergeWith(
Understudy_c28436a2f78b6ee1|ArgumentMatcher|Absent $other = …): string
must be compatible with …\Fixture\Fwd\Pipeline::mergeWith(
Pipeline $other): string
The generated override renders the parameter as `self`, which inside the
generated class is the generated class — a subclass of the doubled one, so
the widened union excludes the very type the contract accepts. PHP 8.5 lets
it through, which is why the authoring host and the 8.5 job were green while
every other job was not.
That is a codegen gap of its own, not something this branch introduced: no
fixture had a `self`-typed parameter before. It is filed separately, and the
regression fixtures here name their class explicitly, which is what a user's
class usually does anyway.
Unit suite 1173 green on 8.3, 8.4 and 8.5 (the first two in `php:8.N-cli`
directly, since `composer:2` is 8.5 and cannot see this); `composer build`
and `bin/package-audit` green.
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.
Fixes #150
A forwarding double of a class now runs the real bodies on itself. The state the instance held is copied onto the double when
forwarding()is called, and an unmatched call runs the doubled class's own body with$thisstill the double — so a self-call inside real code re-enters the double, meets stubs, an armed protocol and the call log, and is recorded like the boundary call. An interface double has no bodies to reach and proxies exactly as before. The README limitation paragraph is deleted, which was the acceptance criterion.Mechanism: reflection, not a
parent::closureThe issue proposed what
jasonmccreary/doubledoes — a generated__td_real_*()sibling callingparent::. Golden rule 3 closes that: every member added to a double is a name the doubled type can no longer use.The next candidate, a closure rendered into each generated method and handed to the dispatcher, was built and measured. It cost +10% per call on every class double, forwarding or not, because the argument is evaluated before dispatch can decide it is not needed.
perf/doubles only interfaces, somake perfcannot see this; the figure comes from a loop over a loose class double (1.217 → 1.337 µs/call, three paired runs, no overlap).Runtime::runInline()therefore enters the body throughReflectionMethod::invokeArgs()on a reflection of the doubled class: the same non-virtual entryparent::gives, while$thisstays the double so the body's self-calls dispatch virtually and land back in the override. The generated code is byte-identical tomaster.spikes/08-inline-forwardingpins the language claim (non-virtual entry, virtual self-calls, protected bodies, by-reference parameters,$thisidentity) on 8.3, 8.4 and 8.5.Decisions open to review
callOriginal()follows the double's own form: selfwither, or a body computing over an internal copy, would hold a default-answering double in the middle of real code. Without the second half, a body handed another double of its own class and cloning that one —merge(self $other)— would adopt it with no state copy to run bodies over.Runtime::isCopyOf()compares both property tables throughget_mangled_object_vars(); expectations are still not copiedclonecan see stop at the test's own closureanswers()/returns()closure runs inside dispatch, which a real body can reach, andclone $doublewritten there is the test's clone.Runtime::$inlineFloorhides what is underneath for exactly its span, so only what the closure itself enters (callOriginal()) stays visiblenew staticis refused, by registration rather than return typereadonlyproperty cannot be written twice, and a second copy would discard what the real code has written sinceBehaviour changes for a suite that already forwards class doubles
Documented as
**Changed.**inCHANGELOG.md, one test each:expect(...)->times(n)and can fill a step of an armed protocol;callOriginal()runs on the double, over the state the double holds;static::classinside a real body names the generated class;new staticraisesOriginalReturnTypeViolation;forwarding()ran, and the copy is shallow, so writes through an object-valued property still land on the donor's collaborators.The one thing PHP leaves open
Two doubles of one class in the same state are indistinguishable:
__clone()is handed no reference to its source, so a copy of the other double is
adopted and answers for real, over the state it already holds. It is stated as
a limitation in both READMEs, the forwarding guide and the
CHANGELOG, ratherthan called harmless — proxied, that copy answered defaults.
Verification
composer buildcomposer mutationmaster: 3481 / 234 / 93%. On the changed lines the survivors are equivalents, and the clone-adoption fix added none: cache-key permutations that stay unique perClass::method,??=→=,?->where null is unreachable,>= 80400vs> 80400make perf/perf-memorytry/finallyon every matched-with-action call: six runs of the full harness in one sitting, alternatingb8a8f8eand8befa94, medians of three a side. Ours moved −2.6%, +1.6%, −18.3%, −18.5%, 0.0%, 0.0% across the six tables while Mockery, Prophecy and PHPUnit moved −18.4% to +3.0% in the same runs — the two −18% tables moved for everybody, so the machine moved, not the library, and the two per-call dispatch tables (narrow,wide) are 0.0% for us against ±3.0% for them.RStDevran 6.5–20%, so the ratios are the reading, not the microseconds.perf-memoryidentical to the byte (586 / 571 B)make release-checkverifyAll(): void → intfrom #154bin/package-auditdocs-api,docs-build,docs-claims,docs-cookbook,docs-linksgreen;MIGRATION.mdregenerated; the@throwscompleteness budget lowered 8 → 7.make docs-valeneeds avalebinary the authoring host has not got — the docs job lints the proseThe
@throwsbudget move and the version strings acrossdocs/src/api/**are the ordinary product ofmake docs-apion this branch.Review follow-up (b91073a)
A review of the branch found that adoption decided on the clone's class,
and one generated class serves every double of a type. A body handed another
double of its own class and cloning that one adopted it on the running
double's behalf, with bodies to run and no state copy to run them over: PHP's
own
must not be accessed before initializationwhere the class declares atyped property, and a quiet answer computed from the declaration's default
where it has one. Both were regressions — proxied, the copy answered defaults.
A
clone $doublein the test's ownanswers()closure was adopted too,against the documented rule for it.
The fix is the two table rows above. Seven tests came with it; five fail or
error on the commit before it, and two pin mechanisms that did not exist
there — the floor coming back after a throwing stub, and a hook that counts
its own calls to show that neither the state copy nor the comparison runs one.