Skip to content

Compute ConstantArrayType sealed-shape isSuperTypeOf() reasons lazily via IsSuperTypeOfResult closures - #6003

Merged
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-t7yte7i
Jul 4, 2026
Merged

Compute ConstantArrayType sealed-shape isSuperTypeOf() reasons lazily via IsSuperTypeOfResult closures#6003
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-t7yte7i

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

bug-7581.php (a 33-case switch that builds an array shape key by key inside a foreach
convergence loop) got ~30% slower after the June 2026 "reasons" batch
(57ea87c83, a01080ece, e5caec56f). That batch made ConstantArrayType::isSuperTypeOf()
eagerly build a Sealed array shapes … cannot be intersected explanation — which calls the
expensive describe() on both array shapes — for every sealed-vs-sealed comparison during
union/intersection normalization, even though the reason is almost never rendered. On this
benchmark the reason string was built 22,558 times and displayed 0 times.

This PR keeps the diagnostic but computes it lazily, so the describe() work only happens if
and when the reason is actually turned into an error tip.

Changes

  • src/Type/IsSuperTypeOfResult.php: added an optional list<Closure(): string> $lazyReasons
    constructor argument (BC — defaults to []) and a getReasons() accessor that materializes
    and de-duplicates eager + lazy reasons. Every reason-carrying method (and, or,
    decorateReasons, extremeIdentity, maxMin, lazyMaxMin, negate, toAcceptsResult)
    now threads the lazy reasons through unchanged. createNo() gained a matching
    $lazyReasons parameter and keeps returning the shared No singleton only when both reason
    lists are empty.
  • src/Type/Constant/ConstantArrayType.php: the two sealed-shape no() branches in
    isSuperTypeOf() now pass sealedArrayShapesCannotBeIntersectedReason() as a
    Closure(): string via createNo(lazyReasons: …) instead of building the string eagerly.
  • src/Type/TypeCombinator.php: the two intersect() sites that turn an array intersection
    into a reason-bearing NeverType now read getReasons() so the lazy reason is materialized
    exactly where it is consumed.
  • src/Reflection/InitializerExprTypeResolver.php, src/Rules/Comparison/ImpossibleCheckTypeHelper.php,
    src/Rules/Classes/ImpossibleInstanceOfRule.php: switched the reason reads that feed error
    tips from ->reasons to ->getReasons(), so ===/is_*()/instanceof impossibility tips
    keep showing the sealed-shape explanation.
  • tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php: new regression test.

Probed and intentionally left as-is:

  • The sibling ConstantArrayType::accepts() / AcceptsResult describe-based reasons only run
    on genuine acceptance failures (0 builds on the benchmark), so they are not part of this
    regression.
  • The analogous ObjectType::isSuperTypeOf() per-pair reasons were already handled separately
    (0c88c321a, 4be03a5a0).

Root cause

The "reasons" batch attached human-readable explanations directly to the IsSuperTypeOfResult
returned by hot type comparisons. ConstantArrayType's explanation is expensive because it
describe()s both (potentially large) array shapes, and isSuperTypeOf() is called O(n) times
per intersection/union step while the array shape grows, so the cost is roughly quadratic — all
of it discarded because the reason is only shown when a rule renders it. The fix makes the
reason a Closure(): string that runs only when getReasons() is called at a render site.

Test

  • Added ConstantArrayTypeTest::testSealedArrayShapesCannotBeIntersectedReasonIsLazy: for two
    disjoint sealed shapes it asserts isSuperTypeOf()->reasons === [] (nothing built eagerly)
    while getReasons() still yields the full Sealed array shapes … cannot be intersected
    message. This fails on the pre-fix code (where ->reasons already contained the eagerly
    built string) and passes after the fix.
  • Existing IncompatiblePhpDocTypeRuleTest::testExplainUnresolvable continues to pass,
    proving the tip is still surfaced end-to-end via the NeverType / unresolvable-type path.
  • The reason-consuming rule suites (Comparison, ImpossibleInstanceOf,
    ImpossibleCheckType{Function,Method,StaticMethod}Call) and the full Type / Analyser
    suites remain green.

Fixes phpstan/phpstan#14918

…zily via `IsSuperTypeOfResult` closures

- Add an optional `list<Closure(): string> $lazyReasons` channel to `IsSuperTypeOfResult`
  plus a `getReasons()` accessor that materializes and de-duplicates them; `and()`, `or()`,
  `decorateReasons()`, `extremeIdentity()`, `maxMin()`, `lazyMaxMin()`, `negate()` and
  `toAcceptsResult()` all propagate the lazy reasons untouched.
- `ConstantArrayType::isSuperTypeOf()` now passes the "Sealed array shapes … cannot be
  intersected" explanation as a lazy closure instead of eagerly calling `describe()` on both
  shapes for every sealed-vs-sealed comparison. On `tests/bench/data/bug-7581.php` this
  removed 22.5k wasted `describe()` builds (the reason was never rendered) and cut analysis
  time ~35% (≈693ms → ≈452ms in-process), erasing the regression from the "reasons" batch.
- Materialize the lazy reasons at the render sites that surface them as tips:
  `TypeCombinator::intersect()` (both NeverType-with-reason branches),
  `InitializerExprTypeResolver::resolveIdenticalType()`, `ImpossibleCheckTypeHelper` and
  `ImpossibleInstanceOfRule`, so error output is unchanged.
- Probed the sibling `accepts()` / `AcceptsResult` describe-based reasons in `ConstantArrayType`:
  they only run on genuine acceptance failures (0 builds on the benchmark), so they are not
  part of this regression and were left eager. The parallel `ObjectType` reasons were already
  addressed separately (0c88c32, 4be03a5).
@ondrejmirtes
ondrejmirtes merged commit 411daa4 into phpstan:2.2.x Jul 4, 2026
666 of 671 checks passed
@ondrejmirtes
ondrejmirtes deleted the create-pull-request/patch-t7yte7i branch July 4, 2026 19:34
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.

Performance regression: switch-heavy array-shape code ~30% slower since the type "reasons" commits (bench bug-7581.php)

2 participants