Skip to content

Commit f322b7b

Browse files
committed
Fix overcompilation due to unstable context bound desugaring
Context bounds are desugared into term parameters `evidence$N` and before this commit, the `N` was chosen to be unique in the current compilation unit. This isn't great because it means that adding a new definition with a context bound in the middle of a file would change the desugaring of subsequent definitions in the same file. Even worse, when using incremental compilation we could end up with the same context bound desugared with a different value of `N` on different compilation runs because the order in which a compilation unit is traversed during Typer is not fixed but depends on the how the units that are jointly compiled depend on each other (as demonstrated by the `stable-ctx-bounds` test). This issue affects all fresh names generated during Typer, but it is especially problematic for context bounds because they're part of the API and renaming a method parameter forces the recompilation of all files calling that method. To fix this, we now only require context bounds parameters to have unique names among all the parameters of the method. This matches how we already desugar `def foo(using A, B)` into `def foo(using x$1: A, x$2: B)` regardless of the context. Note that fresh names used in other situations are still problematic for deterministic compilation. Most of the time they're not part of the API checked by Zinc, but they can still lead to overcompilation if they appear in an `inline def` since the entire body of the `inline def` constitutes its API. In the future, we should follow Scala 2's lead and only require names to be fresh at the method level: scala/scala#6300 (The Scala 2 logic is slightly more complex to handle macros, but I don't think that applies to Scala 3 macros), see #7661. Fixes #18080.
1 parent 77be114 commit f322b7b

File tree

10 files changed

+133
-10
lines changed

10 files changed

+133
-10
lines changed

compiler/src/dotty/tools/dotc/ast/Desugar.scala

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,19 +244,27 @@ object desugar {
244244
val DefDef(_, paramss, tpt, rhs) = meth
245245
val evidenceParamBuf = ListBuffer[ValDef]()
246246

247+
var seenContextBounds: Int = 0
247248
def desugarContextBounds(rhs: Tree): Tree = rhs match
248249
case ContextBounds(tbounds, cxbounds) =>
249250
val iflag = if sourceVersion.isAtLeast(`future`) then Given else Implicit
250251
evidenceParamBuf ++= makeImplicitParameters(
251252
cxbounds, iflag,
252-
mkParamName = () => ContextBoundParamName.fresh(),
253+
// Just like with `makeSyntheticParameter` on nameless parameters of
254+
// using clauses, we only need names that are unique among the
255+
// parameters of the method since shadowing does not affect
256+
// implicit resolution in Scala 3.
257+
mkParamName = () =>
258+
val index = seenContextBounds + 1 // Start at 1 like FreshNameCreator.
259+
val ret = ContextBoundParamName(EmptyTermName, index)
260+
seenContextBounds += 1
261+
ret,
253262
forPrimaryConstructor = isPrimaryConstructor)
254263
tbounds
255264
case LambdaTypeTree(tparams, body) =>
256265
cpy.LambdaTypeTree(rhs)(tparams, desugarContextBounds(body))
257266
case _ =>
258267
rhs
259-
260268
val paramssNoContextBounds =
261269
mapParamss(paramss) {
262270
tparam => cpy.TypeDef(tparam)(rhs = desugarContextBounds(tparam.rhs))

compiler/test/dotty/tools/backend/jvm/DottyBytecodeTests.scala

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -874,7 +874,7 @@ class DottyBytecodeTests extends DottyBytecodeTest {
874874
}
875875
}
876876

877-
@Test def freshNames = {
877+
@Test def stableNames = {
878878
val sourceA =
879879
"""|class A {
880880
| def a1[T: Ordering]: Unit = {}
@@ -902,11 +902,11 @@ class DottyBytecodeTests extends DottyBytecodeTest {
902902
s"Method ${mn.name} has parameter $actualName but expected $expectedName")
903903
}
904904

905-
// The fresh name counter should be reset for every compilation unit
905+
// Each definition should get the same names since there's no possible clashes.
906906
assertParamName(a1, "evidence$1")
907-
assertParamName(a2, "evidence$2")
907+
assertParamName(a2, "evidence$1")
908908
assertParamName(b1, "evidence$1")
909-
assertParamName(b2, "evidence$2")
909+
assertParamName(b2, "evidence$1")
910910
}
911911
}
912912

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package database
2+
3+
object A {
4+
def wrapper: B.Wrapper = ???
5+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package database
2+
3+
object B {
4+
trait GetValue[T]
5+
6+
object GetValue {
7+
implicit def inst[T]: GetValue[T] = ???
8+
}
9+
10+
class ResultSet {
11+
def getV[A: GetValue]: A = ???
12+
}
13+
14+
trait DBParse[T] {
15+
def apply(rs: ResultSet): T
16+
}
17+
18+
class AVG() {
19+
def call: String = "AVG"
20+
}
21+
22+
object ClientOwnerId {
23+
class CompanyId
24+
25+
def parseClientOwnerId[T: DBParse]: Unit = {}
26+
}
27+
28+
class Wrapper(companyId: ClientOwnerId.CompanyId)
29+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package database
2+
3+
object C {
4+
def foo: Unit = {
5+
val rs: B.ResultSet = ???
6+
rs.getV[String]
7+
}
8+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
scalaVersion := sys.props("plugin.scalaVersion")
2+
3+
import sbt.internal.inc.Analysis
4+
import complete.DefaultParsers._
5+
6+
// Reset compiler iterations, necessary because tests run in batch mode
7+
val recordPreviousIterations = taskKey[Unit]("Record previous iterations.")
8+
recordPreviousIterations := {
9+
val log = streams.value.log
10+
CompileState.previousIterations = {
11+
val previousAnalysis = (previousCompile in Compile).value.analysis.asScala
12+
previousAnalysis match {
13+
case None =>
14+
log.info("No previous analysis detected")
15+
0
16+
case Some(a: Analysis) => a.compilations.allCompilations.size
17+
}
18+
}
19+
}
20+
21+
val checkIterations = inputKey[Unit]("Verifies the accumulated number of iterations of incremental compilation.")
22+
23+
checkIterations := {
24+
val expected: Int = (Space ~> NatBasic).parsed
25+
val actual: Int = ((compile in Compile).value match { case a: Analysis => a.compilations.allCompilations.size }) - CompileState.previousIterations
26+
assert(expected == actual, s"Expected $expected compilations, got $actual")
27+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package database
2+
3+
object B {
4+
trait GetValue[T]
5+
6+
object GetValue {
7+
implicit def inst[T]: GetValue[T] = ???
8+
}
9+
10+
class ResultSet {
11+
def getV[A: GetValue]: A = ???
12+
}
13+
14+
trait DBParse[T]
15+
16+
class AVG() {
17+
def call: String = "AVG2"
18+
}
19+
20+
object ClientOwnerId {
21+
class CompanyId
22+
23+
def parseClientOwnerId[T: DBParse]: Unit = {}
24+
}
25+
26+
class Wrapper(companyId: ClientOwnerId.CompanyId)
27+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// This is necessary because tests are run in batch mode
2+
object CompileState {
3+
@volatile var previousIterations: Int = -1
4+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
> compile
2+
> recordPreviousIterations
3+
4+
# change only the body of a method
5+
$ copy-file changes/B.scala B.scala
6+
7+
# Only B.scala should be recompiled. Previously, this lead to a subsequent
8+
# compilation round because context bounds were desugared into names unique to
9+
# the whole compilation unit, and in the first `compile` the two context bounds
10+
# of B.scala were desugared into `evidence$2` and `evidence$1` in this order
11+
# (because the definitions were visited out of order), but in the second call
12+
# to `compile` we traverse them in order as we typecheck B.scala and ended up
13+
# with `evidence$1` and `evidence$2` instead.
14+
> compile
15+
> checkIterations 1

tests/neg/i10901.check

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@
1212
| [T1, T2]
1313
| (x: BugExp4Point2D.ColumnType[T1])
1414
| (y: BugExp4Point2D.ColumnType[T2])
15-
| (implicit evidence$7: Numeric[T1], evidence$8: Numeric[T2]): BugExp4Point2D.Point2D[T1, T2]
15+
| (implicit evidence$1: Numeric[T1], evidence$2: Numeric[T2]): BugExp4Point2D.Point2D[T1, T2]
1616
| [T1, T2]
1717
| (x: T1)
1818
| (y: BugExp4Point2D.ColumnType[T2])
19-
| (implicit evidence$5: Numeric[T1], evidence$6: Numeric[T2]): BugExp4Point2D.Point2D[T1, T2]
19+
| (implicit evidence$1: Numeric[T1], evidence$2: Numeric[T2]): BugExp4Point2D.Point2D[T1, T2]
2020
| both match arguments ((x : BugExp4Point2D.IntT.type))((y : BugExp4Point2D.DoubleT.type))
2121
-- [E008] Not Found Error: tests/neg/i10901.scala:48:38 ----------------------------------------------------------------
2222
48 | val pos4: Point2D[Int,Double] = x º 201.1 // error
@@ -31,8 +31,8 @@
3131
| Ambiguous overload. The overloaded alternatives of method º in object dsl with types
3232
| [T1, T2]
3333
| (x: BugExp4Point2D.ColumnType[T1])
34-
| (y: T2)(implicit evidence$9: Numeric[T1], evidence$10: Numeric[T2]): BugExp4Point2D.Point2D[T1, T2]
35-
| [T1, T2](x: T1)(y: T2)(implicit evidence$3: Numeric[T1], evidence$4: Numeric[T2]): BugExp4Point2D.Point2D[T1, T2]
34+
| (y: T2)(implicit evidence$1: Numeric[T1], evidence$2: Numeric[T2]): BugExp4Point2D.Point2D[T1, T2]
35+
| [T1, T2](x: T1)(y: T2)(implicit evidence$1: Numeric[T1], evidence$2: Numeric[T2]): BugExp4Point2D.Point2D[T1, T2]
3636
| both match arguments ((x : BugExp4Point2D.IntT.type))((201.1d : Double))
3737
-- [E008] Not Found Error: tests/neg/i10901.scala:62:16 ----------------------------------------------------------------
3838
62 | val y = "abc".foo // error

0 commit comments

Comments
 (0)