Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Type-argument inference (optional turbofish).** A generic call or `new` whose
type parameters are determined by the argument values no longer needs the `::<>`
turbofish: `identity(5)` infers `identity::<int>`, `new Box($product)` infers
`new Box::<Product>`, `$factory->make($p)` and `$box->put($this->item)` infer
from the argument's type. Works for free functions, static and instance methods,
and class instantiation. An inferred call compiles to exactly the specialization
the turbofish would have selected — the type arguments are unified from the
arguments' static types and dispatched through the identical path, so bounds,
variance, mangling, and check/compile parity are unchanged. Argument types are
read conservatively (literals, `new`, `$this` properties, and non-reassigned
typed parameters); where they don't determine the type — a type parameter only in
the return type, an unknown argument type, or conflicting arguments — the explicit
turbofish is still required and omitting it remains the same `xphp.missing_type_argument`
error. Generic closure calls (`$f($x)`) and `T[]`-typed parameters are not yet
inference sources. See [turbofish → inference](docs/syntax/turbofish.md#type-argument-inference).
- **Method-generic turbofish grounded by an enclosing type parameter.** A turbofish
whose type argument is supplied by the enclosing generic scope now grounds **per
specialization** and runs, instead of being rejected by the emitted-marker backstop:
Expand Down
82 changes: 82 additions & 0 deletions docs/caveats.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,88 @@ each with the underlying reason and the workaround.
Pages in the [syntax tour](syntax/) link back to specific sections
here using anchor links — search this page for the same heading text.

## Type-argument inference is partial

xphp infers a generic call's or `new`'s type arguments from the values you
pass, so the `::<>` turbofish is optional where the arguments determine the
type. But inference reads argument types conservatively — deliberately, so it
never emits a specialization the runtime value can't match — and where it
can't see a concrete type, you still write the turbofish.

### ❌ What isn't inferred

```php
function first<T>(): T { /* ... */ } // T is only in the return type
$x = first(); // ✗ nothing to infer from — needs first::<Foo>()

function pair<T>(T $a, T $b): array { /* ... */ }
$p = pair(1, 'x'); // ✗ int vs string disagree — needs pair::<...>()

$val = $repo->find(); // a scalar-returning call assigned to a local
$b = new Box($val); // ✗ local-from-call isn't tracked — needs new Box::<int>()

function f(Fruit $x): void {
$x = pickAnother(); // $x reassigned...
$b = new Box($x); // ✗ reassigned param isn't trusted — needs the turbofish
}

$g = function<T>(T $x): T { return $x; };
$g(5); // ✗ generic *closure* calls aren't inferred (deferred)
```

### ✅ What is inferred

```php
identity(5); // ✓ T = int, from the literal
wrap(new Plastic()); // ✓ T = Plastic, from the `new`
Factory::make($p); // ✓ from $p's declared (class) type
$box->put($this->item); // ✓ from the declared property type
wrap($factory->make()); // ✓ from make()'s class return type (call path)
new Box(5); // ✓ T = int
new Pair($a, new Plastic()); // ✓ from a typed parameter + a `new`
```

Inference sources differ slightly between the two paths, because a **call**
reuses the monomorphizer's receiver/flow tracking while **`new`** runs a
lighter standalone pass:

- **Calls** infer from: literals, `new X(...)`, `$this->prop` (declared type),
a plain parameter, a local whose type is statically tracked (assigned from a
`new` or a class-returning call), and a call whose declared return type is a
determinable class.
- **`new`** infers from the conservative set only: literals, `new X(...)`,
`$this->prop`, and a non-reassigned typed parameter — not locals or call
returns.

In both, a reassigned parameter, a *scalar*-returning-call value held in a
local, a union-typed value, or a value typed by a still-abstract type parameter
yields no inference.

One conservative edge: inference is skipped when an argument's *simple* type
name coincides with an in-scope type parameter — e.g. a class imported as
`use Other\U as U` (or a same-named `U` in the current namespace) passed inside
`f<U>(...)`. The name is treated as the type parameter (which shadows it), so
the call falls back. It never mis-infers — write the explicit turbofish there.

### Why

Monomorphization needs the *concrete* type to pick a specialization, and an
inferred call must compile to exactly what the turbofish would have. So
inference only fires when it can prove the concrete type from the argument
itself: it derives the type arguments by unifying each parameter's declared
type against the argument's static type, then dispatches through the identical
path an explicit turbofish uses (same bounds, variance, and mangling). A value
whose type it can't prove statically — or can't prove *soundly*, like a
reassigned parameter — is left alone rather than guessed, because a wrong guess
would emit a specialization the runtime value fails to satisfy.

### ✅ Workaround

Write the explicit turbofish (`identity::<int>(5)`, `new Box::<int>($val)`)
wherever inference can't see the type. It's always accepted, and an inferred
call is identical to the turbofished one — so adding a turbofish never changes
behavior, only makes the type explicit.

## `$this`-capturing arrows and closures rejected

### ❌ What doesn't work
Expand Down
2 changes: 1 addition & 1 deletion docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ The `json` and `github` formats tag each diagnostic with a stable code:
|------|---------|
| `xphp.bound_violation` | a concrete type argument doesn't satisfy its parameter's bound |
| `xphp.default_bound_violation` | a parameter's default doesn't satisfy its own bound |
| `xphp.missing_type_argument` | a required type argument was omitted and has no default — including a **turbofish-less call** to a generic method, function, or closure (`$x->pick('a')` instead of `$x->pick::<string>('a')`), and a **bare `new` of a generic without all-defaults** (`new Box(...)` where `Box<T>` has a required parameter, instead of `new Box::<int>(...)`): the type argument takes no inference, so it must be supplied explicitly |
| `xphp.missing_type_argument` | a required type argument was omitted, has no default, and **could not be inferred from the call/constructor arguments** — e.g. a type parameter used only in the return type, an argument whose static type isn't known, or arguments that disagree. A turbofish-less call (`$x->pick('a')`) or bare `new` (`new Box(...)`) is fine when the arguments determine the type; when they don't, supply an explicit turbofish (`$x->pick::<string>('a')`, `new Box::<int>(...)`). See [turbofish → inference](syntax/turbofish.md#type-argument-inference) |
| `xphp.too_many_type_arguments` | more type arguments were supplied than the template declares (e.g. `Box::<int, string>` for a one-parameter `Box`) |
| `xphp.variance_position` | an `out T` / `in T` parameter appears in a position its variance forbids |
| `xphp.inner_variance` | variance is violated through another generic's slot (composition) |
Expand Down
48 changes: 30 additions & 18 deletions docs/guides/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ than erasure can.
| Feature | xphp | RFC | TS | Kotlin | Rust |
|------------------------------------------|-------------------------|------------------|------------------|---------------|-----------------------|
| Generic classes / interfaces / traits | ✅ | ✅ | ✅ | ✅ | ✅ |
| Generic functions / methods | ⚠️ (no inference; can't forward a method-level param, target another generic template, or use `static::`/`parent::`) | ✅ | ✅ | ✅ | ✅ |
| Generic functions / methods | ⚠️ (can't forward a method-level param, target another generic template, or use `static::`/`parent::`) | ✅ | ✅ | ✅ | ✅ |
| Generic closures + arrow functions | ⚠️ (no `$this` capture or `static function` closures; reflection/serializers see the dispatcher rewrite) | ✅ | ✅ | ✅ | ✅ |
| Typed closure signatures (`Closure(int): bool`) | ⚠️ (param/return/property only — not a generic arg or bound; erases to `\Closure`, literal conformance checked) | ❌ (only untyped `callable` / `\Closure`; noted as future work) | ✅ (function types) | ✅ (`(Int) -> Bool`) | ✅ (`Fn(i32) -> bool`) |
| Type-argument inference (call without `::<>`) | ❌ (explicit turbofish required) | ❌ (turbofish optional; omitting runs unvalidated) | ✅ | ✅ | ✅ (turbofish is the fallback) |
| Type-argument inference (call without `::<>`) | ⚠️ (inferred from the arguments for calls and `new` where they determine the type; otherwise the explicit turbofish is still required) | ❌ (turbofish optional; omitting runs unvalidated) | ✅ | ✅ | ✅ (turbofish is the fallback) |
| Upper bounds | ✅ | ✅ | ✅ | ✅ | ✅ |
| Multiple bounds (intersection) | ✅ | ✅ | ✅ | ✅ | ✅ |
| Union bounds + DNF | ✅ | ✅ | ✅ | ❌ (intersection only via `where`) | n/a |
Expand All @@ -54,18 +54,29 @@ mark features that simply can't exist under bound erasure: there are
no specialized classes at runtime, so subtype edges, reified-T
operations, and a wildcard sigil all lose their meaning.

**Type-argument inference.** No xphp generic call infers its type
arguments from the values passed — you always write the turbofish:
`identity::<int>($x)`, `new Box::<int>()`. Omitting it is a compile
error (`xphp.missing_type_argument`), because monomorphization needs
the concrete type to pick a specialization. TypeScript, Kotlin, and
Rust all infer instead. Rust is the closest comparison: xphp borrows
its `::<>` turbofish spelling exactly, but where Rust infers by
default and reaches for the turbofish only to disambiguate, xphp
makes it the only spelling. The bound-erasure RFC has no inference
either, yet diverges from xphp in the other direction — there the
turbofish is *optional*: omit it and the call runs unvalidated with
erased-to-`mixed` semantics rather than failing to compile.
**Type-argument inference.** xphp infers the type arguments from the
values passed when they determine the type, so the turbofish is
optional there: `identity(5)` infers `identity::<int>`, `new Box($product)`
infers `new Box::<Product>`, `$factory->make($p)` infers from `$p`'s
type. An inferred call compiles to exactly the specialization the
turbofish would have selected — inference only writes the turbofish for
you, so bounds, variance, and mangling are unchanged. When the arguments
*don't* determine the type — a type parameter used only in the return
type, an argument whose static type isn't known, or two arguments that
disagree — you still write the turbofish, and omitting it is the same
compile error as before (`xphp.missing_type_argument`). Argument types are
read conservatively (literals, `new`, `$this` properties, and non-reassigned
typed parameters); generic *closure* calls (`$f($x)`) and `T[]`-typed
parameters are not yet inference sources and keep the explicit turbofish.
See [caveats](../caveats.md#type-argument-inference-is-partial).

This is the same `::<>` turbofish Rust uses, and xphp now works like Rust
in spirit: infer by default, reach for the turbofish to disambiguate or
where inference can't see the type. TypeScript and Kotlin infer too. The
bound-erasure RFC has no inference, and diverges in the other direction —
there the turbofish is *optional* in a different sense: omit it and the
call runs unvalidated with erased-to-`mixed` semantics rather than
inferring or failing to compile.

## Where the monomorphic and erasure paths diverge

Expand Down Expand Up @@ -144,10 +155,11 @@ reproduction and workaround) in [caveats](../caveats.md).
parameter can't be defaulted or untyped
([caveat](../caveats.md#closure-signature-types-only-in-parameter-return-and-property-slots)).
- **Generic functions / methods.** The base feature is solid; *composition*
is where the gaps are. There's no type-argument inference — the turbofish
is mandatory (see the grid row). And a turbofish grounded by an enclosing
type parameter can't forward a *method-level* parameter, target a
*different* generic template, or use the `static::`/`parent::` spellings
is where the gaps are. Type arguments are inferred where the call arguments
determine them, and otherwise the turbofish is required (see the grid row).
And a turbofish grounded by an enclosing type parameter can't forward a
*method-level* parameter, target a *different* generic template, or use the
`static::`/`parent::` spellings
([caveat](../caveats.md#generic-turbofish-grounded-by-an-enclosing-type-parameter)).
Receiver-type tracking also gives up across branches that disagree on the
type, and on a local assigned from a free function
Expand Down
55 changes: 45 additions & 10 deletions docs/syntax/turbofish.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,16 +83,51 @@ $id('T_<hash-of-int>', 42);
template is all-defaulted.
- Bare `new Foo;` (no `(` or `::<>`) also works for all-defaulted
class templates — see [defaults](defaults.md).
- **The type argument is not inferred from the call arguments.** A
turbofish-less call (`$x->pick('a')` instead of
`$x->pick::<string>('a')`) is a compile error
(`xphp.missing_type_argument`), not a silent skip — `xphp check`
catches a forgotten turbofish at build time rather than letting it
fatal at runtime. A generic **method** whose type parameters are all
defaulted may still be called bare; a named generic **function** or
**closure** has no bare or empty-turbofish form, so it always needs
an explicit turbofish. (A first-class callable `pick(...)` creates a
closure rather than calling, and is left alone.)
- **The turbofish is optional where the arguments determine the type.**
A bare call or `new` whose type parameters are fixed by the argument
values is inferred — `$x->pick('a')` infers `$x->pick::<string>('a')`,
`new Box(5)` infers `new Box::<int>(5)` — and compiles to exactly the
specialization the turbofish would have selected (see
[inference](#type-argument-inference), below). When the arguments *don't*
determine the type — a type parameter only in the return type, an argument
whose static type isn't known, or arguments that disagree — a turbofish-less
call is a compile error (`xphp.missing_type_argument`), not a silent skip:
`xphp check` catches it at build time rather than letting it fatal at
runtime. A generic **method** whose type parameters are all defaulted may
still be called bare; a generic **closure** call (`$f($x)`) is not inferred
and always needs an explicit turbofish. (A first-class callable `pick(...)`
creates a closure rather than calling, and is left alone.)

## Type-argument inference

Where the arguments determine the type parameters, you can omit the turbofish
and xphp infers it:

```php
$r = identity(5); // identity::<int>
$b = new Box(new Plastic()); // new Box::<Plastic>
$m = Factory::make($product); // from $product's declared type
$d = $bag->put($this->item); // from the declared property type
```

Inference derives the type arguments by matching each parameter's declared
type against the argument's static type, then dispatches through the identical
path an explicit turbofish uses — so an inferred call is byte-for-byte the same
specialization, with the same bound and variance checks. It never *weakens*
anything: adding a turbofish to an inferred call can only make the type
explicit, never change behavior.

Argument types are read from: literals, `new X(...)`, `$this->prop` (declared
type), and a plain parameter with a concrete declared type that isn't reassigned.
A **call** additionally infers from a statically-tracked local (assigned from a
`new` or a class-returning call) and from a call whose declared return type is a
determinable class, because the call path reuses the monomorphizer's receiver/flow
tracking; **`new`** inference is limited to the conservative set (no locals or call
returns). A reassigned parameter, a scalar-returning-call value, a union-typed
value, or a value typed by a still-abstract type parameter is never an inference
source, and such a site keeps the explicit turbofish. Generic **closure** calls
(`$f($x)`) and `T[]`-typed parameters are not yet inferred either. See
[caveats](../caveats.md#type-argument-inference-is-partial).

## Receiver-type analysis (instance methods)

Expand Down
23 changes: 19 additions & 4 deletions src/Transpiler/Monomorphize/Compiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,17 +86,28 @@
// AST in compile-mode, so this must precede it).
UndeclaredTypeParameterValidator::assertMethodLevel($astPerFile, $hierarchy);

$methodCompiler = new GenericMethodCompiler($this->hashLength, $hierarchy);
$methodCompiler->process($astPerFile);

// Phase 1b.i: collect class definitions across every source file. Splitting
// definitions ahead of instantiations gives bare-`new Foo;` synthesis (added
// in 1b.ii) a complete template registry so it can recognize Foo as an
// all-defaulted template regardless of the file-walk order.
// all-defaulted template regardless of the file-walk order. Collected BEFORE the
// method compiler runs so the type-argument inference pass below has the full
// template registry AND sees the original (un-stripped, un-appended) user ASTs —
// the same shape `check()` runs it against, keeping the two modes in parity.
foreach ($astPerFile as $filepath => $ast) {
$collector->collectDefinitions($ast, $filepath);
}

// Optional turbofish on `new`: infer a bare `new Box($x)`'s type arguments from its
// constructor arguments and annotate it, so the instantiation collector + call-site
// rewriter treat it as an explicit turbofish. A `new` it can't resolve is left bare
// (all-defaults synthesis / missing-type-argument error). Runs before `process` so it
// never sees appended specializations (which `check` can't), and before
// collectInstantiations so the annotation is picked up.
(new NewInferencePass($registry, $hierarchy))->run($astPerFile);

$methodCompiler = new GenericMethodCompiler($this->hashLength, $hierarchy);
$methodCompiler->process($astPerFile);

// Phase 1b.ii: validate defaults-against-bounds at the source level (so a
// bad declaration like `class Box<T : Stringable = int>` fails BEFORE any
// padded instantiation is recorded), then collect instantiations -- including
Expand Down Expand Up @@ -314,7 +325,7 @@
/** @var array<string, true> $skip instantiations that could not be specialized (resilient mode only) */
$skip = [];
$closer = new SpecializationCloser($hierarchy, new VarianceSubtyping($hierarchy), $this->specializer, $this->hashLength);
$depth = 0;

Check warning on line 328 in src/Transpiler/Monomorphize/Compiler.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "DecrementInteger": @@ @@ /** @var array<string, true> $skip instantiations that could not be specialized (resilient mode only) */ $skip = []; $closer = new SpecializationCloser($hierarchy, new VarianceSubtyping($hierarchy), $this->specializer, $this->hashLength); - $depth = 0; + $depth = -1; while (true) { $countBefore = count($registry->instantiations()); $newlyProcessed = false;
while (true) {
$countBefore = count($registry->instantiations());
$newlyProcessed = false;
Expand All @@ -328,8 +339,8 @@
$definition = $registry->definition($instantiation->templateFqn);
if ($definition === null) {
if ($resilient) {
$skip[$generatedFqn] = true;

Check warning on line 342 in src/Transpiler/Monomorphize/Compiler.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "TrueValue": @@ @@ $definition = $registry->definition($instantiation->templateFqn); if ($definition === null) { if ($resilient) { - $skip[$generatedFqn] = true; + $skip[$generatedFqn] = false; continue; } throw new RuntimeException(
continue;

Check warning on line 343 in src/Transpiler/Monomorphize/Compiler.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "Continue_": @@ @@ if ($definition === null) { if ($resilient) { $skip[$generatedFqn] = true; - continue; + break; } throw new RuntimeException( Registry::undefinedTemplateMessage($instantiation->templateFqn, $generatedFqn),
}
throw new RuntimeException(
Registry::undefinedTemplateMessage($instantiation->templateFqn, $generatedFqn),
Expand Down Expand Up @@ -501,6 +512,10 @@
foreach ($astPerFile as $filepath => $ast) {
$collector->collectDefinitions($ast, $filepath);
}
// Optional turbofish on `new` (see compile()): infer bare `new` type arguments before
// instantiations are collected. Same pipeline position as compile — after definitions,
// before collectInstantiations — so check and compile infer identically.
(new NewInferencePass($registry, $hierarchy))->run($astPerFile);
$registry->validateVariancePositions();
$registry->validateUndeclaredTypeParameters();
UndeclaredTypeParameterValidator::assertMethodLevel($astPerFile, $hierarchy, $diagnostics);
Expand Down
Loading
Loading