Skip to content

Commit 5ccc481

Browse files
committed
Fix #16018: Existential widening for wildcard arguments
The Scala 3 typer was rejecting subtypings of the form F[? >: lo <: hi] <: F[X] for covariant or contravariant `F`, even when `X = hi` (covariant) or `X = lo` (contravariant). This is a regression against Scala 2 and surfaced as the akka-derived report in #16018: def f[M](xs: java.lang.Iterable[? <: Container[Any, ? <: M]]) : Seq[Container[Any, M]] = seqOf(xs).collect { case g: SubContainer[Any, M] @unchecked => g case other => other } Once `Inferencing.captureWildcards` lifted the Java wildcard to a `TypeBox.CAP` skolem and pattern matching narrowed the first case body to `pat ∩ ?N.CAP`, the inferred result reduced to a chain of subtype checks that ultimately required `F[? <: M] <:< F[M]` for covariant `F`. That check went through `TypeComparer.compareCaptured` and was answered in the negative. Type-theoretic justification ---------------------------- For a covariant type constructor `F` and an existential `∃X. X <: hi`, ⨆{F[X] | X <: hi} = F[hi] (covariant supremum) — the supremum is attained at `X = hi`, by covariance. The compiler must admit that supremum as a subtype of `F[hi]`. Dually for contravariance with the lower bound: ⨅{G[X] | X >: lo} = G[lo] (contravariant infimum) This is the standard existential-elimination rule for variant occurrences. Bug --- `compareCaptured` was checking v > 0: isSubType(paramBounds(tparam).hi, arg2) v < 0: isSubType(arg2, paramBounds(tparam).lo) i.e. it asked whether the *declared parameter bound* (which collapses to `Any` / `Nothing` for unconstrained type parameters) conforms to `arg2`, instead of asking whether the *wildcard's own bound* (`arg1.hi` / `arg1.lo`) does. That rejected every interesting case. Fix --- In `TypeComparer.compareCaptured` use the wildcard's own hi/lo, intersected (resp. unioned) with the declared parameter bounds to preserve soundness in the corner case where a wildcard is wider than its parameter permits: v > 0: isSubType(arg1.hi & paramBounds(tparam).hi, arg2) v < 0: isSubType(arg2, arg1.lo | paramBounds(tparam).lo) Tests (directional matrix) -------------------------- - `tests/pos/i16018.scala` — mbovel's minimization plus a pure-Scala matrix that exercises subtype patterns + fallback `case other` over wildcards. - `tests/pos/i16018b.scala` — the akka-style shapes that motivated the ticket: `java.lang.Iterable[? <: G[? <: M]]` / `java.util.List[…]` routed through `seqOf`, then `collect` / `map` with subtype + fallback patterns. - `tests/pos/i16018c.scala` — positive half of the variance × bound matrix for the fix itself: covariant + upper, contravariant + lower, nested, via method type params, with parameter-bound tightening. - `tests/neg/i16018c.scala` — negative half of the matrix: invariant containers, covariant + lower, contravariant + upper, and the soundness guard against wildcards wider than the required type. These must remain rejected after the fix. Verified locally ---------------- - All four `i16018*` tests: 16/16 pass via `sbt 'scala3-compiler-bootstrapped/testOnly … CompilationTests'` with `-Ddotty.tests.filter=i16018`. - `wildcard` / `match` / `variance` filter subsets: 16/16 each. - Full `CompilationTests` run: the remaining 4 failures (`tests/run/i13358.scala`, `tests/run/lazy-*.scala`, `tests/run/t5552.scala`, `tests/run/t7406.scala`, `tests/run/isInstanceOf-eval.scala`, `tests/run/i24553.scala`, `tests/pos-custom-args/captures/fill-cbn.scala`, and the capture-checking neg suite) reproduce on `main` without this change and are caused by JDK 25 environment drift (`sun.misc.Unsafe` deprecation messages on stdout, the introduction of `java.lang.IO`, and removal of `native` from `Object.wait`).
1 parent b6e5747 commit 5ccc481

5 files changed

Lines changed: 220 additions & 2 deletions

File tree

compiler/src/dotty/tools/dotc/core/TypeComparer.scala

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1890,10 +1890,25 @@ class TypeComparer(@constructorOnly initctx: Context) extends ConstraintHandling
18901890
// The captured reference could be illegal and cause a
18911891
// TypeError to be thrown in argDenot
18921892
false
1893+
// Existential widening for wildcard arguments (issue #16018):
1894+
// `arg1` is the bounds carried by a `? >: lo <: hi` wildcard. For
1895+
// `arg2` to be a (covariant / contravariant) supertype of `arg1`,
1896+
// we only need the *wildcard's own* hi/lo to conform — the
1897+
// declared parameter bounds (`paramBounds(tparam)`) only ever
1898+
// *constrain* the wildcard further, never relax it. The earlier
1899+
// formulation used `paramBounds(tparam).hi`, which collapses to
1900+
// `Any` for unconstrained type parameters and rejects valid
1901+
// subtypings such as `F[? <: M] <: F[M]` for covariant `F`
1902+
// (mathematically: ⨆{F[X] | X <: M} = F[M] by covariance, since
1903+
// the supremum is attained at X = M). Intersecting with
1904+
// `paramBounds(tparam)` keeps soundness in the rare case where
1905+
// the wildcard's bound is *wider* than what `tparam` permits.
18931906
else if (v > 0)
1894-
isSubType(paramBounds(tparam).hi, arg2)
1907+
val effectiveHi = arg1.hi & paramBounds(tparam).hi
1908+
isSubType(effectiveHi, arg2)
18951909
else if (v < 0)
1896-
isSubType(arg2, paramBounds(tparam).lo)
1910+
val effectiveLo = arg1.lo | paramBounds(tparam).lo
1911+
isSubType(arg2, effectiveLo)
18971912
else
18981913
false
18991914
case _ =>

tests/neg/i16018c.scala

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Negative half of the directional matrix for the fix to
2+
// `TypeComparer.compareCaptured` (#16018 / Existential widening for wildcard
3+
// arguments).
4+
//
5+
// These shapes must remain *rejected* after the fix: the fix only adds
6+
// widening that is justified by variance — covariant + upper bound,
7+
// contravariant + lower bound. Any other combination is unsound and must
8+
// keep the same diagnostic it had before. If any of these `//<space>error`
9+
// markers stops firing, the fix has overreached.
10+
11+
object Test:
12+
13+
class Co[+T]
14+
class Contra[-T]
15+
class Inv[T]
16+
17+
// ---- A. Invariant containers never widen the wildcard ---------------
18+
def inv_upper_bad[M](xs: Inv[? <: M]): Inv[M] = xs // error
19+
def inv_lower_bad[M](xs: Inv[? >: M]): Inv[M] = xs // error
20+
21+
// ---- B. Covariant with a *lower*-bounded wildcard does not widen ----
22+
//
23+
// For covariant F, `F[? >: lo]` is the type of F[X] for some X >: lo.
24+
// That has no useful relationship to F[lo] — picking X = Any is
25+
// permitted yet gives F[Any], not F[lo].
26+
def co_lower_bad[M](xs: Co[? >: M]): Co[M] = xs // error
27+
28+
// ---- C. Contravariant with an *upper*-bounded wildcard does not widen
29+
//
30+
// Dual to (B): for contravariant G, `G[? <: hi]` makes no commitment
31+
// that the picked X is `hi` itself; it could be `Nothing`.
32+
def contra_upper_bad[M](xs: Contra[? <: M]): Contra[M] = xs // error
33+
34+
// ---- D. The required type's hi is strictly tighter than the wildcard
35+
//
36+
// `Co[? <: Any]` is the trivial widening shape (any T). It must not be
37+
// accepted at `Co[Holder]` because the wildcard's hi (`Any`) is wider
38+
// than the target.
39+
trait Holder
40+
def co_too_wide(xs: Co[? <: Any]): Co[Holder] = xs // error

tests/pos/i16018.scala

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Regression test for #16018: type inference with `_ <: T` wildcards in
2+
// pattern matching over capture-converted scrutinees.
3+
//
4+
// Coverage axes for the positive matrix:
5+
// 1. The mbovel minimization (single wildcard via List.map) — was fixed
6+
// since 3.2.0, kept here as a pin.
7+
// 2. Pure-Scala collections — never trigger the capture-conversion path.
8+
// 3. Identity / generic Scala helpers over the wildcard-bearing collection.
9+
//
10+
// The dual cases reached through Java-generic interop (java.lang.Iterable /
11+
// java.util.List), which were the original akka regression, are in
12+
// tests/pos/i16018b.scala.
13+
14+
object Test:
15+
16+
class Box[T](val value: T)
17+
class Container[+S, +M]
18+
class SubContainer[+S, +M] extends Container[S, M]
19+
20+
// ---- 1. mbovel's minimized case from #16018 ------------------------
21+
def f1[T](l: List[Box[? <: T]]): List[T] = l.map(_.value)
22+
23+
// 1b. Variant: returning the wildcard-bearing element preserved.
24+
def f1b[T](l: List[Box[? <: T]]): List[Box[? <: T]] = l.map(identity)
25+
26+
// ---- 2. Pure-Scala scrutinee — never goes through capture conversion -
27+
def f2[M](xs: scala.collection.immutable.Seq[Container[Any, ? <: M]])
28+
: scala.collection.immutable.Seq[Container[Any, M]] =
29+
xs.collect {
30+
case g: SubContainer[Any, M] @unchecked => g
31+
case other => other
32+
}
33+
34+
// 2b. Variant: `.map` instead of `.collect`.
35+
def f2b[M](xs: scala.collection.immutable.Seq[Container[Any, ? <: M]])
36+
: scala.collection.immutable.Seq[Container[Any, M]] =
37+
xs.map {
38+
case g: SubContainer[Any, M] @unchecked => g
39+
case other => other
40+
}
41+
42+
// 2c. Variant: List instead of Seq.
43+
def f2c[M](xs: List[Container[Any, ? <: M]])
44+
: List[Container[Any, M]] =
45+
xs.collect {
46+
case g: SubContainer[Any, M] @unchecked => g
47+
case other => other
48+
}
49+
50+
// ---- 3. Generic helper that stays pure-Scala does not skolemize ----
51+
def idSeq[T](it: Iterable[T]): scala.collection.immutable.Seq[T] = ???
52+
53+
def f3[M](xs: List[Container[Any, ? <: M]])
54+
: scala.collection.immutable.Seq[Container[Any, M]] =
55+
idSeq(xs).collect {
56+
case g: SubContainer[Any, M] @unchecked => g
57+
case other => other
58+
}

tests/pos/i16018b.scala

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Regression test for #16018: the akka-derived shapes.
2+
//
3+
// These exercise the path that motivated the original ticket: a value of
4+
// type `J[? <: G[? <: M]]` (J a Java generic) flows through a method that
5+
// re-projects it as a Scala collection, and is then `collect`/`map`-ed with
6+
// a subtype pattern + a fallback `case other`. The combination triggers
7+
// (i) `Inferencing.captureWildcards` — the Java-generic argument becomes
8+
// a TypeBox.CAP skolem `?N.CAP`, and
9+
// (ii) `Match` typing — pattern narrowing turns the first case body into
10+
// `pat ∩ ?N.CAP`, while `case other` keeps the raw `?N.CAP`.
11+
//
12+
// Until the fix in `TypeComparer.compareCaptured` for wildcard-bound
13+
// existential widening, the inferred result type could not escape the
14+
// skolem cleanly. See:
15+
// - tests/pos/i16018.scala — companion pos matrix (pure-Scala)
16+
// - scala/scala3 #16018 — original bug report
17+
18+
object Test:
19+
20+
class Container[+S, +M]
21+
class SubContainer[+S, +M] extends Container[S, M]
22+
23+
def seqOf[T](it: java.lang.Iterable[T]): scala.collection.immutable.Seq[T] = ???
24+
25+
// ---- f1: trigger via java.lang.Iterable ----------------------------
26+
def f1[M](xs: java.lang.Iterable[? <: Container[Any, ? <: M]])
27+
: scala.collection.immutable.Seq[Container[Any, M]] =
28+
seqOf(xs).collect {
29+
case g: SubContainer[Any, M] @unchecked => g
30+
case other => other
31+
}
32+
33+
// ---- f2: same trigger via java.util.List ---------------------------
34+
def f2[M](xs: java.util.List[? <: Container[Any, ? <: M]])
35+
: scala.collection.immutable.Seq[Container[Any, M]] =
36+
seqOf(xs).collect {
37+
case g: SubContainer[Any, M] @unchecked => g
38+
case other => other
39+
}
40+
41+
// ---- f3: same trigger via .map instead of .collect -----------------
42+
def f3[M](xs: java.lang.Iterable[? <: Container[Any, ? <: M]])
43+
: scala.collection.immutable.Seq[Container[Any, M]] =
44+
seqOf(xs).map {
45+
case g: SubContainer[Any, M] @unchecked => g
46+
case other => other
47+
}

tests/pos/i16018c.scala

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Directional matrix for the fix to `TypeComparer.compareCaptured`
2+
// (#16018 / Existential widening for wildcard arguments).
3+
//
4+
// The fix lets the compiler conclude
5+
//
6+
// F[? <: hi] <: F[hi] when F is covariant in that position
7+
// G[? >: lo] <: G[lo] when G is contravariant in that position
8+
//
9+
// while leaving invariant positions and the opposite-bound directions
10+
// intentionally rigid. The matrix below enumerates each axis so that any
11+
// future refactor sees green/red as soon as the boundary moves.
12+
13+
object Test:
14+
15+
class Co[+T]
16+
class Contra[-T]
17+
class Inv[T]
18+
19+
// ---- 1. Covariant + upper-bounded wildcard widens to its hi --------
20+
21+
// Direct shape
22+
def co_upper_direct[M](xs: Co[? <: M]): Co[M] = xs
23+
24+
// Nested in another covariant container (the akka shape)
25+
def co_upper_in_co[M](xs: Co[Co[? <: M]]): Co[Co[M]] = xs
26+
def co_upper_in_seq[M](xs: scala.collection.immutable.Seq[Co[? <: M]])
27+
: scala.collection.immutable.Seq[Co[M]] = xs
28+
29+
// The wildcard hi is reached via a type parameter
30+
def co_upper_via_tparam[A, M <: A](xs: Co[? <: M]): Co[M] = xs
31+
32+
// ---- 2. Contravariant + lower-bounded wildcard widens to its lo ----
33+
34+
// Direct shape, dual to (1)
35+
def contra_lower_direct[M](xs: Contra[? >: M]): Contra[M] = xs
36+
37+
// Nested in another covariant container (variance composition: Co[+] · Contra[-])
38+
def contra_lower_in_co[M](xs: Co[Contra[? >: M]]): Co[Contra[M]] = xs
39+
40+
// ---- 3. Tightest wildcard against unconstrained param ----------------
41+
//
42+
// `Co[+T]` declares `T` with `paramBounds = [Nothing, Any]`. Pre-fix the
43+
// typer asked `Any <: M`, which is false. Post-fix it asks `M <: M`,
44+
// which is true. This is the most common shape — exercised in many
45+
// places of the matrix above, repeated here as the headline regression.
46+
47+
trait Holder
48+
def headline(xs: Co[? <: Holder]): Co[Holder] = xs
49+
50+
// ---- 4. Wildcard wider than the parameter (soundness guard) ----------
51+
//
52+
// When `T <: Holder` and the wildcard says `? <: Any`, the effective hi
53+
// must remain `Holder` (not `Any`), because the parameter constrains it
54+
// further. The fix uses `arg1.hi & paramBounds(tparam).hi` precisely to
55+
// preserve this. We just witness that this shape still type-checks.
56+
57+
class CoBounded[+T <: Holder]
58+
def soundness_guard(xs: CoBounded[? <: Holder]): CoBounded[Holder] = xs

0 commit comments

Comments
 (0)