Skip to content

Latest commit

 

History

History
272 lines (221 loc) · 12.7 KB

File metadata and controls

272 lines (221 loc) · 12.7 KB

Variance

Variance markers tell the compiler how subtyping flows through a generic parameter. out T declares the parameter covariant; in T declares it contravariant; unmarked is invariant. With markers in place, specializations get real extends chains and PHP's native LSP carries the subtype relationship.

Example

<?php
declare(strict_types=1);

namespace App;

// Covariant: T appears in return positions (and in a private property).
// A *private* `T` property is variance-exempt — PHP doesn't type-check private
// slots across the `extends` chain — so the backing field keeps its real type
// (a public/protected `T` property would be rejected; see the rules below).
class Producer<out T> {
    public function __construct(private T $item) {}
    public function get(): T { return $this->item; }
}

// Contravariant: T appears in parameter positions only
class Consumer<in T> {
    public function accept(T $x): void { /* ... */ }
}

// Unmarked T is invariant -- no subtype edge between specializations
class Box<T> {
    public function __construct(public T $item) {}
    public function set(T $item): void { $this->item = $item; }
}

class Fruit {}
class Banana extends Fruit {}

// With out T, this is now a valid downcast at the type-system level:
function eat(Producer<Fruit> $p): Fruit {
    return $p->get();
}
eat(new Producer::<Banana>(new Banana()));

What gets emitted

For each subtype relationship Banana extends Fruit in your source, the compiler emits a real extends edge between the corresponding specializations:

namespace XPHP\Generated\App\Producer;

class T_<hash-of-fruit> implements \App\Producer {
    public function __construct(private \App\Fruit $item) { ... }   // real type, runtime-checked
    public function get(): \App\Fruit { ... }
}

class T_<hash-of-banana> extends T_<hash-of-fruit> implements \App\Producer {
    public function __construct(private \App\Banana $item) { ... }  // narrowed; `__construct` is LSP-exempt and the private slot isn't checked across the edge
    public function get(): \App\Banana { ... }
}

PHP's native LSP handles the relationship from there — passing a Producer<Banana> where a Producer<Fruit> is required Just Works, including reflection and instanceof.

For contravariant in T, the edge flips: Consumer<Fruit> extends Consumer<Banana>.

Nested type-arguments (a generic as a type-argument)

A covariant slot can hold another generic as its argument, and the edge composes through it — including when the inner argument is a generic of a different but related template. A covariant Tuple<out A, out B> holding a covariant container relates by its element relationship:

interface Collection<out E> { /* … */ }
class ImmutableList<out E> implements Collection<E> { /* … */ }
interface Tuple<out A, out B> { /* … */ }
class Couple<out A, out B> implements Tuple<A, B> { /* … */ }

// Book <: Product, ImmutableList implements Collection, all covariant ⇒
//   Tuple<ImmutableList<Book>, Tag> ⊑ Tuple<Collection<Product>, Tag>

The compiler proves the argument relationship ImmutableList<Book> ⊑ Collection<Product> by threading the subtype's element up its implements/extends chain to the supertype's template (ImmutableList<Book>Collection<Book>) and comparing under the inner template's variance (Book ⊑ Product under Collection's covariant E), then emits the Tuple edge. So a Couple<ImmutableList<Book>, Tag> is usable where a Tuple<Collection<Product>, Tag> is required, with the covariance honoured at runtime (instanceof, type hints), not only at check. The edge is emitted only when the relationship is positively provable — an unrelated or unprovable inner pair stays conservative (no edge), never a bogus one that would PHP-fatal at autoload.

Unprovable variance edges

An extends edge only emits when the compiler can prove the element relationship from the source set. If an element type is not in the compiled .xphp source set and isn't a recognized PHP built-in — typically a plain-.php domain class — the compiler can't prove it, so the edge is silently skipped. The specializations still work in isolation, but they aren't linked: passing a Producer<Banana> where a Producer<Fruit> is expected then fails at runtime with a TypeError, because the covariant edge never formed.

xphp check reports this as a non-failing warning at the instantiation site (xphp.variance_edge_unprovable), naming the type and pointing at "add it to the source set." It mirrors the bounds "cannot prove satisfaction" check for the same condition (see type bounds); the fix is the same — include the element type in the source set the compiler builds its hierarchy from, so the edge can be proven and emitted.

Rules

Position rules, enforced at compile time over the collected definitions (Registry::validateVariancePositions):

Position out T allowed? in T allowed?
Method return type
Method parameter
By-reference parameter (T &$x)
Constructor parameter (plain)
Private property (mutable/readonly)
Private promoted constructor property
Public/protected property
Public/protected promoted property
Bound expression
Default expression

The strict-invariance rule on public/protected properties (mutable, readonly, and promoted-constructor) is forced by the runtime model: xphp emits real extends chains between specialised classes, and PHP enforces invariant property types across those chains for visible members regardless of readonly — a covariant one would PHP-fatal at autoload when the variance edge lands.

A private property (declared or promoted, mutable or readonly) is the exception: PHP does not type-check private property types across an extends chain — a private slot is per-declaring-scope and is never inherited — so each specialisation keeps its own real-typed field (private Banana $item / private Fruit $item) with no fatal. A private member is also invisible to the externally-visible variance surface, so it may carry any variance soundly, and xphp emits it with its real substituted type. (Detection is by the private visibility bit: a readonly-only promoted parameter is implicitly public, and an asymmetric public private(set) property is externally readable, so both stay strictly invariant.)

A by-reference parameter (function f(T &$x)) is likewise invariant: the caller's variable is both read and written back through the reference, so it acts as input and output — neither out T nor in T is sound. This holds in method, constructor, and nested closure/arrow signatures.

A plain (non-promoted) constructor parameter is the exception: it may carry out T / in T at any variance, and xphp emits it with its real substituted type. A constructor parameter isn't part of the externally-visible variance surface (a constructor is never reached through an upcast reference — the same reason Kotlin exempts constructor parameters from variance checks), and PHP exempts __construct from LSP signature checks, so the specialisations' constructors may legitimately differ across the edge. That's what lets a covariant immutable collection take type-checked construction input (see below). A promoted constructor parameter is a property, so it follows the property rules above: a public/protected one stays strictly invariant, while a private one is exempt and keeps its real type — which is exactly what makes the covariant single-value Producer<out T> shape at the top of this page work.

Covariant immutable collections (typed construction)

A covariant container can take its element type in its constructor — the backbone of a read-only List<out T>-style collection:

class ImmutableList<out T> {
    private array $items;
    public function __construct(T ...$items) { $this->items = $items; }
    public function get(int $i): T { return $this->items[$i]; }
}

function firstProduct(ImmutableList<Product> $items): Product { return $items->get(0); }

// Covariance: an ImmutableList<Book> is accepted where ImmutableList<Product>
// is expected, because Book extends Product.
$books = new ImmutableList::<Book>(new Book(), new Book());
$p = firstProduct($books);

The constructor parameter keeps its real element type on every specialisation (Book ...$items on ImmutableList<Book>, Product ...$items on ImmutableList<Product>), and ImmutableList<Book> still extends ImmutableList<Product> without a PHP fatal — PHP doesn't signature-check __construct across the chain. (A variant class cannot be declared final: its specializations are linked by extends edges, which a final class can't anchor, so final on a out T/in T class is rejected at compile time.)

Construction is runtime-type-checked. Because the constructor parameter keeps its real type, PHP enforces it at construction: building an ImmutableList<Book> from a non-Book throws a TypeError. You get both covariance and a real construction-time guarantee — nothing is erased. The one property shape that can't carry a real T is a non-private (public or protected) stored property — PHP enforces invariant property types across the edge for visible members. A private stored property can hold a real T (see the single-value Producer<out T> at the top), so a covariant single-value container needs no mixed backing at all. A multi-element collection like ImmutableList is different: many elements live in one private array $items field, which xphp emits without a value-type annotation — so it compiles and runs fine but trips the optional xphp check PHPStan pass at level 6+ (the untyped array property has no iterable value type; see caveats).

Inner-template variance composition

When a generic class uses another generic class in its method signatures, the bounds compose:

// Container's X is invariant, so Container<T> reads as invariant
// regardless of T's outer variance.
class P<out T> {
    public function f(): Container<T> {}     // REJECTED
}

T appears in a covariant outer position (return), but the inner Container<X> has X as invariant — the composed position is invariant, so the outer out T is rejected. The validator walks every generic class's method signatures, bounds, and defaults to apply this composition.

Composition can also permit a position that looks wrong at a glance. A consuming method that takes a contravariant generic is sound on a covariant class:

interface Comparator<in T> { public function compare(T $a, T $b): int; }

class Box<out E> {
    public function pick(Comparator<E> $c): ?E { /* … */ }   // ALLOWED
}

E is in a contravariant slot (Comparator<in T>) inside a contravariant parameter position — contra ∘ contra = covariant, which a covariant out E may occupy. (Under an upcast, a Box<Book> viewed as Box<Product> takes a Comparator<Product>, which by contravariance compares the Book elements — sound.) This is the element-consuming counterpart to the covariant immutable constructor: a mixed-free, fluent sortedWith/minWith/pick on a covariant collection. A direct covariant E in a parameter (pick(E $x)) stays rejected — only the composed position is covariant here, not the bare one.

Caveats

  • ⚠️ Not allowed on closures or arrows — anonymous templates don't have stable identity for an extends chain. See caveats.

  • ⚠️ Trait-imported methods aren't walked for variance — a generic class that uses a trait whose method references T won't catch variance violations inside that trait method. Niche; see caveats.

See also

  • Test fixture: test/fixture/compile/variance_covariant_happy/
  • Test fixture: test/fixture/compile/variance_contravariant_happy/
  • Test fixture: test/fixture/compile/variance_with_defaults_and_bounds/
  • Related: type bounds, runtime semantics