Skip to content

Commit e4af70b

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 5337ac5 commit e4af70b

File tree

11 files changed

+740
-2
lines changed

11 files changed

+740
-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
@@ -10,6 +10,7 @@ import transform.ExplicitOuter._
1010
import transform.ValueClasses._
1111
import transform.TypeUtils._
1212
import transform.ContextFunctionResults._
13+
import unpickleScala2.Scala2Erasure
1314
import Decorators._
1415
import Definitions.MaxImplementedFunctionArity
1516
import scala.annotation.tailrec
@@ -184,6 +185,10 @@ object TypeErasure {
184185
def valueErasure(tp: Type)(using Context): Type =
185186
erasureFn(sourceLanguage = SourceLanguage.Scala3, semiEraseVCs = true, isConstructor = false, wildcardOK = false)(tp)(using preErasureCtx)
186187

188+
/** The erasure that Scala 2 would use for this type. */
189+
def scala2Erasure(tp: Type)(using Context): Type =
190+
erasureFn(sourceLanguage = SourceLanguage.Scala2, semiEraseVCs = true, isConstructor = false, wildcardOK = false)(tp)(using preErasureCtx)
191+
187192
/** Like value class erasure, but value classes erase to their underlying type erasure */
188193
def fullErasure(tp: Type)(using Context): Type =
189194
valueErasure(tp) match
@@ -502,8 +507,11 @@ class TypeErasure(sourceLanguage: SourceLanguage, semiEraseVCs: Boolean, isConst
502507
this(defn.FunctionType(paramss.head.length, isContextual = res.isImplicitMethod, isErased = res.isErasedMethod))
503508
case tp: TypeProxy =>
504509
this(tp.underlying)
505-
case AndType(tp1, tp2) =>
506-
erasedGlb(this(tp1), this(tp2), sourceLanguage.isJava)
510+
case tp @ AndType(tp1, tp2) =>
511+
if sourceLanguage.isScala2 then
512+
this(Scala2Erasure.intersectionDominator(Scala2Erasure.flattenedParents(tp)))
513+
else
514+
erasedGlb(this(tp1), this(tp2), isJava = sourceLanguage.isJava)
507515
case OrType(tp1, tp2) =>
508516
TypeComparer.orType(this(tp1), this(tp2), isErased = true)
509517
case tp: MethodType =>
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
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 checkSupported(tp: Type)(using Context): Unit = tp match
37+
case AndType(tp1, tp2) =>
38+
checkSupported(tp1)
39+
checkSupported(tp2)
40+
case RefinedType(parent, _, _) =>
41+
checkSupported(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 as 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+
checkSupported(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 sym: Symbol =>
126+
sym.isClass
127+
case _: Scala2RefinedType =>
128+
true
129+
case _ =>
130+
false
131+
132+
/** Is this a trait symbol? */
133+
def isTrait: Boolean = psym match
134+
case sym: Symbol =>
135+
sym.is(Trait)
136+
case _ =>
137+
false
138+
139+
/** An emulation of `Symbol#isNonBottomSubClass` from Scala 2.
140+
*
141+
* The documentation of the original method is:
142+
*
143+
* > Is this class symbol a subclass of that symbol,
144+
* > and is this class symbol also different from Null or Nothing?
145+
*
146+
* Which sounds fine, except that it is also used with non-class symbols,
147+
* so what does it do then? Its implementation delegates to `Type#baseTypeSeq`
148+
* whose documentation states:
149+
*
150+
* > The base type sequence of T is the smallest set of [...] class types Ti, so that [...]
151+
*
152+
* But this is also wrong: the sequence returned by `baseTypeSeq` can
153+
* contain non-class symbols.
154+
*
155+
* Given that we cannot rely on the documentation and that the
156+
* implementation is extremely complex, this reimplementation is mostly
157+
* based on reverse-engineering rules derived from the observed behavior of
158+
* the original method.
159+
*/
160+
def isNonBottomSubClass(that: PseudoSymbol): Boolean =
161+
/** Recurse on the upper-bound of `psym`: an abstract type is a sub of a
162+
* pseudo-symbol, if its upper-bound is a sub of that pseudo-symbol.
163+
*/
164+
def goUpperBound(psym: Symbol | StructuralRef): Boolean =
165+
val info = psym match
166+
case sym: Symbol => sym.info
167+
case tp: StructuralRef => tp.info
168+
info match
169+
case info: TypeBounds =>
170+
go(pseudoSymbol(info.hi))
171+
case _ =>
172+
false
173+
174+
def go(psym: PseudoSymbol): Boolean =
175+
psym.sameSymbol(that) ||
176+
// As mentioned in the documentation of `Scala2RefinedType`, in Scala 2
177+
// these types get their own unique synthetic class symbol, therefore they
178+
// don't have any sub-class Note that we must return false even if the lhs
179+
// is an abstract type upper-bounded by this refinement, since each
180+
// textual appearance of a refinement will have its own class symbol.
181+
!that.isInstanceOf[Scala2RefinedType] &&
182+
psym.match
183+
case sym1: Symbol => that match
184+
case sym2: Symbol =>
185+
if sym1.isClass && sym2.isClass then
186+
sym1.derivesFrom(sym2)
187+
else if !sym1.isClass then
188+
goUpperBound(sym1)
189+
else
190+
// sym2 is an abstract type, return false because
191+
// `isNonBottomSubClass` in Scala 2 never considers a class C to
192+
// be a a sub of an abstract type T, even if it was declared as
193+
// `type T >: C`.
194+
false
195+
case _ =>
196+
goUpperBound(sym1)
197+
case tp1: StructuralRef =>
198+
goUpperBound(tp1)
199+
case tp1: RefinedType =>
200+
go(pseudoSymbol(tp1.parent))
201+
case AndType(tp11, tp12) =>
202+
go(pseudoSymbol(tp11)) || go(pseudoSymbol(tp12))
203+
end go
204+
205+
go(psym)
206+
end isNonBottomSubClass
207+
end extension
208+
209+
/** An emulation of `Erasure#intersectionDominator` from Scala 2.
210+
*
211+
* Accurately reproducing the behavior of this method is extremely difficult
212+
* because it operates on the symbols of the _non-erased_ parent types, an
213+
* implementation detail of the compiler. Furthermore, these non-class
214+
* symbols are passed to methods such as `isNonBottomSubClass` whose behavior
215+
* is only specified for class symbols. Therefore, the accuracy of this
216+
* method cannot be guaranteed, the best we can do is make sure it works on
217+
* as many test cases as possible which can be run from sbt using:
218+
* > sbt-dotty/scripted scala2-compat/erasure
219+
*
220+
* The body of this method is made to look as much as the Scala 2 version as
221+
* possible to make them easier to compare, cf:
222+
* https://github.com/scala/scala/blob/v2.13.5/src/reflect/scala/reflect/internal/transform/Erasure.scala#L356-L389
223+
*/
224+
def intersectionDominator(parents: List[Type])(using Context): Type =
225+
val psyms = parents.map(pseudoSymbol)
226+
if (psyms.contains(defn.ArrayClass)) {
227+
defn.ArrayOf(
228+
intersectionDominator(parents.collect { case defn.ArrayOf(arg) => arg }))
229+
} else {
230+
def isUnshadowed(psym: PseudoSymbol) =
231+
!(psyms.exists(qsym => !psym.sameSymbol(qsym) && qsym.isNonBottomSubClass(psym)))
232+
val cs = parents.iterator.filter { p =>
233+
val psym = pseudoSymbol(p)
234+
psym.isClass && !psym.isTrait && isUnshadowed(psym)
235+
}
236+
(if (cs.hasNext) cs else parents.iterator.filter(p => isUnshadowed(pseudoSymbol(p)))).next()
237+
}
238+
239+
/** A flattened list of parents of this intersection.
240+
*
241+
* Mimic what Scala 2 does: intersections like `A with (B with C)` are
242+
* flattened to three parents.
243+
*/
244+
def flattenedParents(tp: AndType)(using Context): List[Type] =
245+
val parents = ListBuffer[Type]()
246+
247+
def collect(parent: Type, parents: ListBuffer[Type]): Unit = parent.dealiasKeepAnnots match
248+
case AndType(tp1, tp2) =>
249+
collect(tp1, parents)
250+
collect(tp2, parents)
251+
case _ =>
252+
checkSupported(parent)
253+
parents += parent
254+
255+
collect(tp, parents)
256+
parents.toList
257+
end flattenedParents
258+
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)