Skip to content

Commit 229d183

Browse files
committed
Correctly erase Scala 2 intersection types
Because our algorithm for erasing intersection types does not exactly match the one used by Scala 2, we could end up emitting calls to Scala 2 methods with the wrong bytecode signature, leading to NoSuchMethodError at runtime. We could try to exactly match what Scala 2 does, but it turns out that the Scala 2 logic heavily relies on implementation details which makes it extremely complex to reliably replicate. Therefore, this commit instead special-cases the erasure of Scala 2 intersections (just like we already special-case the erasure of Java intersections) and limits which Scala 2 intersection types we support to a subset that we can erase without too much complications (but even that still requires ~200 lines of code!). This means that we're now free to change the way we erase intersections in any way we want without introducing more compatibility problems (until 3.0.0 that is), I'll explore this in a follow-up PR. Fixes #4619. Fixes #9175.
1 parent 8dae336 commit 229d183

File tree

11 files changed

+743
-2
lines changed

11 files changed

+743
-2
lines changed

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import transform.ExplicitOuter._
1111
import transform.ValueClasses._
1212
import transform.TypeUtils._
1313
import transform.ContextFunctionResults._
14+
import unpickleScala2.Scala2Erasure
1415
import Decorators._
1516
import Definitions.MaxImplementedFunctionArity
1617
import scala.annotation.tailrec
@@ -186,6 +187,10 @@ object TypeErasure {
186187
def valueErasure(tp: Type)(using Context): Type =
187188
erasureFn(sourceLanguage = SourceLanguage.Scala3, semiEraseVCs = true, isConstructor = false, wildcardOK = false)(tp)(using preErasureCtx)
188189

190+
/** The erasure that Scala 2 would use for this type. */
191+
def scala2Erasure(tp: Type)(using Context): Type =
192+
erasureFn(sourceLanguage = SourceLanguage.Scala2, semiEraseVCs = true, isConstructor = false, wildcardOK = false)(tp)(using preErasureCtx)
193+
189194
/** Like value class erasure, but value classes erase to their underlying type erasure */
190195
def fullErasure(tp: Type)(using Context): Type =
191196
valueErasure(tp) match
@@ -504,8 +509,11 @@ class TypeErasure(sourceLanguage: SourceLanguage, semiEraseVCs: Boolean, isConst
504509
this(defn.FunctionType(paramss.head.length, isContextual = res.isImplicitMethod, isErased = res.isErasedMethod))
505510
case tp: TypeProxy =>
506511
this(tp.underlying)
507-
case AndType(tp1, tp2) =>
508-
erasedGlb(this(tp1), this(tp2), sourceLanguage.isJava)
512+
case tp @ AndType(tp1, tp2) =>
513+
if sourceLanguage.isScala2 then
514+
this(Scala2Erasure.intersectionDominator(Scala2Erasure.flattenedParents(tp)))
515+
else
516+
erasedGlb(this(tp1), this(tp2), isJava = sourceLanguage.isJava)
509517
case OrType(tp1, tp2) =>
510518
if sourceLanguage.isScala2 && ctx.settings.scalajs.value then
511519
JSDefinitions.jsdefn.PseudoUnionType
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
package dotty.tools
2+
package dotc
3+
package core
4+
package unpickleScala2
5+
6+
import Symbols._, Types._, Contexts._, Flags._, Names._, StdNames._, Phases._
7+
import Decorators._
8+
import backend.sjs.JSDefinitions
9+
import scala.collection.mutable.ListBuffer
10+
11+
/** Erasure logic specific to Scala 2 symbols. */
12+
object Scala2Erasure:
13+
/** Is this a supported Scala 2 refinement or parent of such a type?
14+
*
15+
* We do not allow types that look like:
16+
* ((A with B) @foo) with C
17+
* or:
18+
* (A { type X <: ... })#X with C`
19+
*
20+
* as it would make our implementation of Scala 2 intersection erasure
21+
* significantly more complicated. The problem is that each textual
22+
* appearance of an intersection or refinement in a parent corresponds to a
23+
* fresh instance of RefinedType (because Scala 2 does not hash-cons these
24+
* types) with a fresh synthetic class symbol, thus affecting the result of
25+
* `isNonBottomSubClass`. To complicate the matter, the Scala 2 UnCurry phase
26+
* will also recursively dealias parent types, thus creating distinct class
27+
* symbols even in situations where the same type alias is used to refer to a
28+
* given refinement. Note that types like `(A with B) with C` do not run into
29+
* these issues because they get flattened into a single RefinedType with
30+
* three parents, cf `flattenedParents`.
31+
*
32+
* See sbt-dotty/sbt-test/scala2-compat/erasure/changes/Main.scala for examples.
33+
*
34+
* @throws TypeError if this type is unsupported.
35+
*/
36+
def supportedType(tp: Type)(using Context): Unit = tp match
37+
case AndType(tp1, tp2) =>
38+
supportedType(tp1)
39+
supportedType(tp2)
40+
case RefinedType(parent, _, _) =>
41+
supportedType(parent)
42+
case AnnotatedType(parent, _) if parent.dealias.isInstanceOf[Scala2RefinedType] =>
43+
throw new TypeError(i"Unsupported Scala 2 type: Component $parent of intersection is annotated.")
44+
case tp @ TypeRef(prefix, _) if !tp.symbol.exists && prefix.dealias.isInstanceOf[Scala2RefinedType] =>
45+
throw new TypeError(i"Unsupported Scala 2 type: Prefix $prefix of intersection component is an intersection or refinement.")
46+
case _ =>
47+
48+
/** A type that would be represented as a RefinedType in Scala 2.
49+
*
50+
* The `RefinedType` of Scala 2 contains both a list of parents
51+
* and a list of refinements, intersections are represented as a RefinedType
52+
* with no refinements.
53+
*/
54+
type Scala2RefinedType = RefinedType | AndType
55+
56+
/** A TypeRef that is known to represent a member of a structural type. */
57+
type StructuralRef = TypeRef
58+
59+
/** The equivalent of a Scala 2 type symbol.
60+
*
61+
* In some situations, nsc will create a symbol for a type where we wouldn't:
62+
*
63+
* - `A with B with C { ... }` is represented with a RefinedType whose
64+
* symbol is a fresh class symbol whose parents are `A`, `B`, `C`.
65+
* - Structural members also get their own symbols.
66+
*
67+
* To emulate this, we simply use the type itself a stand-in for its symbol.
68+
*
69+
* See also `sameSymbol` which determines if two pseudo-symbols are really the same.
70+
*/
71+
type PseudoSymbol = Symbol | StructuralRef | Scala2RefinedType
72+
73+
/** The pseudo symbol of `tp`, see `PseudoSymbol`.
74+
*
75+
* The pseudo-symbol representation of a given type is chosen such that
76+
* `isNonBottomSubClass` behaves like it would in Scala 2, in particular
77+
* this lets us strip all aliases.
78+
*/
79+
def pseudoSymbol(tp: Type)(using Context): PseudoSymbol = tp.widenDealias match
80+
case tpw: Scala2RefinedType =>
81+
supportedType(tpw)
82+
tpw
83+
case tpw: TypeRef =>
84+
val sym = tpw.symbol
85+
if !sym.exists then
86+
// Since we don't have symbols for structural type members we use the
87+
// type itself and rely on `sameSymbol` to determine whether two
88+
// such types would be represented with the same Scala 2 symbol.
89+
tpw
90+
else
91+
sym
92+
case tpw: TypeProxy =>
93+
pseudoSymbol(tpw.underlying)
94+
case tpw: JavaArrayType =>
95+
defn.ArrayClass
96+
case tpw: OrType =>
97+
pseudoSymbol(TypeErasure.scala2Erasure(tpw))
98+
case tpw: ErrorType =>
99+
defn.ObjectClass
100+
case tpw =>
101+
throw new Error(s"Internal error: unhandled class ${tpw.getClass} for type $tpw in pseudoSymbol($tp)")
102+
103+
extension (psym: PseudoSymbol)(using Context)
104+
/** Would these two pseudo-symbols be represented with the same symbol in Scala 2? */
105+
def sameSymbol(other: PseudoSymbol): Boolean =
106+
// Pattern match on (psym1, psym2) desugared by hand to avoid allocating a tuple
107+
if psym.isInstanceOf[StructuralRef] && other.isInstanceOf[StructuralRef] then
108+
val tp1 = psym.asInstanceOf[StructuralRef]
109+
val tp2 = other.asInstanceOf[StructuralRef]
110+
// Two structural members will have the same Scala 2 symbol if they
111+
// point to the same member. We can't just call `=:=` since different
112+
// prefixes will still have the same symbol.
113+
(tp1.name eq tp2.name) && pseudoSymbol(tp1.prefix).sameSymbol(pseudoSymbol(tp2.prefix))
114+
else
115+
// We intentionally use referential equality here even though we may end
116+
// up comparing two equivalent intersection types, because Scala 2 will
117+
// create fresh symbols for each appearance of an intersection type in
118+
// source code.
119+
psym eq other
120+
121+
/** Is this a class symbol? Also returns true for refinements
122+
* since they get a class symbol in Scala 2.
123+
*/
124+
def isClass: Boolean = psym match
125+
case tp: Scala2RefinedType =>
126+
true
127+
case tp: StructuralRef =>
128+
false
129+
case sym: Symbol =>
130+
sym.isClass
131+
132+
/** Is this a trait symbol? */
133+
def isTrait: Boolean = psym match
134+
case tp: Scala2RefinedType =>
135+
false
136+
case tp: StructuralRef =>
137+
false
138+
case sym: Symbol =>
139+
sym.is(Trait)
140+
141+
/** An emulation of `Symbol#isNonBottomSubClass` from Scala 2.
142+
*
143+
* The documentation of the original method is:
144+
*
145+
* > Is this class symbol a subclass of that symbol,
146+
* > and is this class symbol also different from Null or Nothing?
147+
*
148+
* Which sounds fine, except that it is also used with non-class symbols,
149+
* so what does it do then? Its implementation delegates to `Type#baseTypeSeq`
150+
* whose documentation states:
151+
*
152+
* > The base type sequence of T is the smallest set of [...] class types Ti, so that [...]
153+
*
154+
* But this is also wrong: the sequence returned by `baseTypeSeq` can
155+
* contain non-class symbols.
156+
*
157+
* Given that we cannot rely on the documentation and that the
158+
* implementation is extremely complex, this reimplementation is mostly
159+
* based on reverse-engineering rules derived from the observed behavior of
160+
* the original method.
161+
*/
162+
def isNonBottomSubClass(that: PseudoSymbol): Boolean =
163+
/** Recurse on the upper-bound of `psym`: an abstract type is a sub of a
164+
* pseudo-symbol, if its upper-bound is a sub of that pseudo-symbol.
165+
*/
166+
def goUpperBound(psym: Symbol | StructuralRef): Boolean =
167+
val info = psym match
168+
case sym: Symbol => sym.info
169+
case tp: StructuralRef => tp.info
170+
info match
171+
case info: TypeBounds =>
172+
go(pseudoSymbol(info.hi))
173+
case _ =>
174+
false
175+
176+
def go(psym: PseudoSymbol): Boolean =
177+
psym.sameSymbol(that) ||
178+
// As mentioned in the documentation of `Scala2RefinedType`, in Scala 2
179+
// these types get their own unique synthetic class symbol, therefore they
180+
// don't have any sub-class Note that we must return false even if the lhs
181+
// is an abstract type upper-bounded by this refinement, since each
182+
// textual appearance of a refinement will have its own class symbol.
183+
!that.isInstanceOf[Scala2RefinedType] &&
184+
psym.match
185+
case sym1: Symbol => that match
186+
case sym2: Symbol =>
187+
if sym1.isClass && sym2.isClass then
188+
sym1.derivesFrom(sym2)
189+
else if !sym1.isClass then
190+
goUpperBound(sym1)
191+
else
192+
// sym2 is an abstract type, return false because
193+
// `isNonBottomSubClass` in Scala 2 never considers a class C to
194+
// be a a sub of an abstract type T, even if it was declared as
195+
// `type T >: C`.
196+
false
197+
case _ =>
198+
goUpperBound(sym1)
199+
case tp1: StructuralRef =>
200+
goUpperBound(tp1)
201+
case tp1: RefinedType =>
202+
go(pseudoSymbol(tp1.parent))
203+
case AndType(tp11, tp12) =>
204+
go(pseudoSymbol(tp11)) || go(pseudoSymbol(tp12))
205+
end go
206+
207+
go(psym)
208+
end isNonBottomSubClass
209+
end extension
210+
211+
/** An emulation of `Erasure#intersectionDominator` from Scala 2.
212+
*
213+
* Accurately reproducing the behavior of this method is extremely difficult
214+
* because it operates on the symbols of the _non-erased_ parent types, an
215+
* implementation detail of the compiler. Furthermore, these non-class
216+
* symbols are passed to methods such as `isNonBottomSubClass` whose behavior
217+
* is only specified for class symbols. Therefore, the accuracy of this
218+
* method cannot be guaranteed, the best we can do is make sure it works on
219+
* as many test cases as possible which can be run from sbt using:
220+
* > sbt-dotty/scripted scala2-compat/erasure
221+
*
222+
* The body of this method is made to look as much as the Scala 2 version as
223+
* possible to make them easier to compare, cf:
224+
* https://github.com/scala/scala/blob/v2.13.5/src/reflect/scala/reflect/internal/transform/Erasure.scala#L356-L389
225+
*/
226+
def intersectionDominator(parents: List[Type])(using Context): Type =
227+
val psyms = parents.map(pseudoSymbol)
228+
if (psyms.contains(defn.ArrayClass)) {
229+
defn.ArrayOf(
230+
intersectionDominator(parents.collect { case defn.ArrayOf(arg) => arg }))
231+
} else {
232+
def isUnshadowed(psym: PseudoSymbol) =
233+
!(psyms.exists(qsym => !psym.sameSymbol(qsym) && qsym.isNonBottomSubClass(psym)))
234+
val cs = parents.iterator.filter { p =>
235+
val psym = pseudoSymbol(p)
236+
psym.isClass && !psym.isTrait && isUnshadowed(psym)
237+
}
238+
(if (cs.hasNext) cs else parents.iterator.filter(p => isUnshadowed(pseudoSymbol(p)))).next()
239+
}
240+
241+
/** A flattened list of parents of this intersection.
242+
*
243+
* Mimic what Scala 2 does: intersections like `A with (B with C)` are
244+
* flattened to three parents.
245+
*/
246+
def flattenedParents(tp: AndType)(using Context): List[Type] =
247+
val parents = ListBuffer[Type]()
248+
249+
def collect(parent: Type, parents: ListBuffer[Type]): Unit = parent.dealiasKeepAnnots match
250+
case AndType(tp1, tp2) =>
251+
collect(tp1, parents)
252+
collect(tp2, parents)
253+
case _ =>
254+
supportedType(parent)
255+
parents += parent
256+
257+
collect(tp.tp1, parents)
258+
collect(tp.tp2, parents)
259+
parents.toList
260+
end flattenedParents
261+
end Scala2Erasure
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
lazy val scala2Lib = project.in(file("scala2Lib"))
2+
.settings(
3+
scalaVersion := "2.13.2"
4+
)
5+
6+
lazy val dottyApp = project.in(file("dottyApp"))
7+
.dependsOn(scala2Lib)
8+
.settings(
9+
scalaVersion := sys.props("plugin.scalaVersion"),
10+
// https://github.com/sbt/sbt/issues/5369
11+
projectDependencies := {
12+
projectDependencies.value.map(_.withDottyCompat(scalaVersion.value))
13+
}
14+
)
15+
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
object Main {
2+
def main(args: Array[String]): Unit = {
3+
val z = new scala2Lib.Z
4+
5+
def dummy[T]: T = null.asInstanceOf[T]
6+
7+
// None of these method calls should typecheck, see `Scala2Erasure#supportedType`
8+
z.b_04(dummy)
9+
z.b_04X(dummy)
10+
z.b_05(dummy)
11+
z.a_48(dummy)
12+
z.c_49(dummy)
13+
z.a_51(dummy)
14+
z.a_53(dummy)
15+
z.b_56(dummy)
16+
z.a_57(dummy)
17+
}
18+
}

0 commit comments

Comments
 (0)