Skip to content

Commit 938d405

Browse files
authored
Avoid generating given definitions that loop (#19282)
Fixes a long-standing footgun of implicit resolution: givens that loop back to themselves.
2 parents beaf7b4 + 87e45fa commit 938d405

File tree

24 files changed

+321
-50
lines changed

24 files changed

+321
-50
lines changed

compiler/src/dotty/tools/dotc/config/Feature.scala

+1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ object Feature:
3333
val pureFunctions = experimental("pureFunctions")
3434
val captureChecking = experimental("captureChecking")
3535
val into = experimental("into")
36+
val givenLoopPrevention = experimental("givenLoopPrevention")
3637

3738
val globalOnlyImports: Set[TermName] = Set(pureFunctions, captureChecking)
3839

compiler/src/dotty/tools/dotc/typer/Implicits.scala

+102-19
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ import Scopes.newScope
2626
import Typer.BindingPrec, BindingPrec.*
2727
import Hashable.*
2828
import util.{EqHashMap, Stats}
29-
import config.{Config, Feature}
30-
import Feature.migrateTo3
29+
import config.{Config, Feature, SourceVersion}
30+
import Feature.{migrateTo3, sourceVersion}
3131
import config.Printers.{implicits, implicitsDetailed}
3232
import collection.mutable
3333
import reporting.*
@@ -324,7 +324,7 @@ object Implicits:
324324
/** Is this the outermost implicits? This is the case if it either the implicits
325325
* of NoContext, or the last one before it.
326326
*/
327-
private def isOuterMost = {
327+
private def isOutermost = {
328328
val finalImplicits = NoContext.implicits
329329
(this eq finalImplicits) || (outerImplicits eqn finalImplicits)
330330
}
@@ -356,7 +356,7 @@ object Implicits:
356356
Stats.record("uncached eligible")
357357
if monitored then record(s"check uncached eligible refs in irefCtx", refs.length)
358358
val ownEligible = filterMatching(tp)
359-
if isOuterMost then ownEligible
359+
if isOutermost then ownEligible
360360
else combineEligibles(ownEligible, outerImplicits.nn.uncachedEligible(tp))
361361

362362
/** The implicit references that are eligible for type `tp`. */
@@ -383,7 +383,7 @@ object Implicits:
383383
private def computeEligible(tp: Type): List[Candidate] = /*>|>*/ trace(i"computeEligible $tp in $refs%, %", implicitsDetailed) /*<|<*/ {
384384
if (monitored) record(s"check eligible refs in irefCtx", refs.length)
385385
val ownEligible = filterMatching(tp)
386-
if isOuterMost then ownEligible
386+
if isOutermost then ownEligible
387387
else combineEligibles(ownEligible, outerImplicits.nn.eligible(tp))
388388
}
389389

@@ -392,7 +392,7 @@ object Implicits:
392392

393393
override def toString: String = {
394394
val own = i"(implicits: $refs%, %)"
395-
if (isOuterMost) own else own + "\n " + outerImplicits
395+
if (isOutermost) own else own + "\n " + outerImplicits
396396
}
397397

398398
/** This context, or a copy, ensuring root import from symbol `root`
@@ -1550,34 +1550,117 @@ trait Implicits:
15501550
case _ =>
15511551
tp.isAny || tp.isAnyRef
15521552

1553-
private def searchImplicit(contextual: Boolean): SearchResult =
1553+
/** Search implicit in context `ctxImplicits` or else in implicit scope
1554+
* of expected type if `ctxImplicits == null`.
1555+
*/
1556+
private def searchImplicit(ctxImplicits: ContextualImplicits | Null): SearchResult =
15541557
if isUnderspecified(wildProto) then
15551558
SearchFailure(TooUnspecific(pt), span)
15561559
else
1557-
val eligible =
1560+
val contextual = ctxImplicits != null
1561+
val preEligible = // the eligible candidates, ignoring positions
15581562
if contextual then
15591563
if ctx.gadt.isNarrowing then
15601564
withoutMode(Mode.ImplicitsEnabled) {
15611565
ctx.implicits.uncachedEligible(wildProto)
15621566
}
15631567
else ctx.implicits.eligible(wildProto)
15641568
else implicitScope(wildProto).eligible
1565-
searchImplicit(eligible, contextual) match
1569+
1570+
/** Does candidate `cand` come too late for it to be considered as an
1571+
* eligible candidate? This is the case if `cand` appears in the same
1572+
* scope as a given definition of the form `given ... = ...` that
1573+
* encloses the search point and `cand` comes later in the source or
1574+
* coincides with that given definition.
1575+
*/
1576+
def comesTooLate(cand: Candidate): Boolean =
1577+
val candSym = cand.ref.symbol
1578+
def candSucceedsGiven(sym: Symbol): Boolean =
1579+
val owner = sym.owner
1580+
if owner == candSym.owner then
1581+
sym.is(GivenVal) && sym.span.exists && sym.span.start <= candSym.span.start
1582+
else if owner.isClass then false
1583+
else candSucceedsGiven(owner)
1584+
1585+
ctx.isTyper
1586+
&& !candSym.isOneOf(TermParamOrAccessor | Synthetic)
1587+
&& candSym.span.exists
1588+
&& candSucceedsGiven(ctx.owner)
1589+
end comesTooLate
1590+
1591+
val eligible = // the eligible candidates that come before the search point
1592+
if contextual && sourceVersion.isAtLeast(SourceVersion.`3.4`)
1593+
then preEligible.filterNot(comesTooLate)
1594+
else preEligible
1595+
1596+
def checkResolutionChange(result: SearchResult) =
1597+
if (eligible ne preEligible)
1598+
&& !Feature.enabled(Feature.givenLoopPrevention)
1599+
then
1600+
val prevResult = searchImplicit(preEligible, contextual)
1601+
prevResult match
1602+
case prevResult: SearchSuccess =>
1603+
def remedy = pt match
1604+
case _: SelectionProto =>
1605+
"conversion,\n - use an import to get extension method into scope"
1606+
case _: ViewProto =>
1607+
"conversion"
1608+
case _ =>
1609+
"argument"
1610+
1611+
def showResult(r: SearchResult) = r match
1612+
case r: SearchSuccess => ctx.printer.toTextRef(r.ref).show
1613+
case r => r.show
1614+
1615+
result match
1616+
case result: SearchSuccess if prevResult.ref frozen_=:= result.ref =>
1617+
// OK
1618+
case _ =>
1619+
val msg =
1620+
em"""Result of implicit search for $pt will change.
1621+
|Current result ${showResult(prevResult)} will be no longer eligible
1622+
| because it is not defined before the search position.
1623+
|Result with new rules: ${showResult(result)}.
1624+
|To opt into the new rules, use the `experimental.givenLoopPrevention` language import.
1625+
|
1626+
|To fix the problem without the language import, you could try one of the following:
1627+
| - use a `given ... with` clause as the enclosing given,
1628+
| - rearrange definitions so that ${showResult(prevResult)} comes earlier,
1629+
| - use an explicit $remedy."""
1630+
if sourceVersion.isAtLeast(SourceVersion.`3.5`)
1631+
then report.error(msg, srcPos)
1632+
else report.warning(msg.append("\nThis will be an error in Scala 3.5 and later."), srcPos)
1633+
case _ =>
1634+
prevResult
1635+
else result
1636+
end checkResolutionChange
1637+
1638+
val result = searchImplicit(eligible, contextual)
1639+
result match
15661640
case result: SearchSuccess =>
1567-
result
1641+
checkResolutionChange(result)
15681642
case failure: SearchFailure =>
15691643
failure.reason match
15701644
case _: AmbiguousImplicits => failure
15711645
case reason =>
15721646
if contextual then
1573-
searchImplicit(contextual = false).recoverWith {
1574-
failure2 => failure2.reason match
1575-
case _: AmbiguousImplicits => failure2
1576-
case _ =>
1577-
reason match
1578-
case (_: DivergingImplicit) => failure
1579-
case _ => List(failure, failure2).maxBy(_.tree.treeSize)
1580-
}
1647+
// If we filtered out some candidates for being too late, we should
1648+
// do another contextual search further out, since the dropped candidates
1649+
// might have shadowed an eligible candidate in an outer level.
1650+
// Otherwise, proceed with a search of the implicit scope.
1651+
val newCtxImplicits =
1652+
if eligible eq preEligible then null
1653+
else ctxImplicits.nn.outerImplicits: ContextualImplicits | Null
1654+
// !!! Dotty problem: without the ContextualImplicits | Null type ascription
1655+
// we get a Ycheck failure after arrayConstructors due to "Types differ"
1656+
checkResolutionChange:
1657+
searchImplicit(newCtxImplicits).recoverWith:
1658+
failure2 => failure2.reason match
1659+
case _: AmbiguousImplicits => failure2
1660+
case _ =>
1661+
reason match
1662+
case (_: DivergingImplicit) => failure
1663+
case _ => List(failure, failure2).maxBy(_.tree.treeSize)
15811664
else failure
15821665
end searchImplicit
15831666

@@ -1595,7 +1678,7 @@ trait Implicits:
15951678
case ref: TermRef =>
15961679
SearchSuccess(tpd.ref(ref).withSpan(span.startPos), ref, 0)(ctx.typerState, ctx.gadt)
15971680
case _ =>
1598-
searchImplicit(contextual = true)
1681+
searchImplicit(ctx.implicits)
15991682
end bestImplicit
16001683

16011684
def implicitScope(tp: Type): OfTypeImplicits = ctx.run.nn.implicitScope(tp)

docs/_docs/reference/changed-features/implicit-resolution.md

-4
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,4 @@ The new rules are as follows: An implicit `a` defined in `A` is more specific th
163163

164164
Condition (*) is new. It is necessary to ensure that the defined relation is transitive.
165165

166-
167-
168-
169-
170166
[//]: # todo: expand with precise rules
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
layout: doc-page
3+
title: Given Loop Prevention
4+
redirectFrom: /docs/reference/other-new-features/into-modifier.html
5+
nightlyOf: https://docs.scala-lang.org/scala3/reference/experimental/into-modifier.html
6+
---
7+
8+
Implicit resolution now avoids generating recursive givens that can lead to an infinite loop at runtime. Here is an example:
9+
10+
```scala
11+
object Prices {
12+
opaque type Price = BigDecimal
13+
14+
object Price{
15+
given Ordering[Price] = summon[Ordering[BigDecimal]] // was error, now avoided
16+
}
17+
}
18+
```
19+
20+
Previously, implicit resolution would resolve the `summon` to the given in `Price`, leading to an infinite loop (a warning was issued in that case). We now use the underlying given in `BigDecimal` instead. We achieve that by adding the following rule for implicit search:
21+
22+
- When doing an implicit search while checking the implementation of a `given` definition `G` of the form
23+
```
24+
given ... = ....
25+
```
26+
discard all search results that lead back to `G` or to a given with the same owner as `G` that comes later in the source than `G`.
27+
28+
The new behavior is enabled with the `experimental.givenLoopPrevention` language import. If no such import or setting is given, a warning is issued where the behavior would change under that import (for source version 3.4 and later).
29+
30+
Old-style implicit definitions are unaffected by this change.
31+

docs/sidebar.yml

+1
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ subsection:
153153
- page: reference/experimental/cc.md
154154
- page: reference/experimental/purefuns.md
155155
- page: reference/experimental/tupled-function.md
156+
- page: reference/experimental/given-loop-prevention.md
156157
- page: reference/syntax.md
157158
- title: Language Versions
158159
index: reference/language-versions/language-versions.md

library/src/scala/runtime/stdLibPatches/language.scala

+9
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,15 @@ object language:
9191
@compileTimeOnly("`into` can only be used at compile time in import statements")
9292
object into
9393

94+
/** Experimental support for new given resolution rules that avoid looping
95+
* givens. By the new rules, a given may not implicitly use itself or givens
96+
* defined after it.
97+
*
98+
* @see [[https://dotty.epfl.ch/docs/reference/experimental/given-loop-prevention]]
99+
*/
100+
@compileTimeOnly("`givenLoopPrevention` can only be used at compile time in import statements")
101+
object givenLoopPrevention
102+
94103
/** Was needed to add support for relaxed imports of extension methods.
95104
* The language import is no longer needed as this is now a standard feature since SIP was accepted.
96105
* @see [[http://dotty.epfl.ch/docs/reference/contextual/extension-methods]]

tests/neg/i15474.check

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
-- Error: tests/neg/i15474.scala:6:39 ----------------------------------------------------------------------------------
2+
6 | given c: Conversion[ String, Int ] = _.toInt // error
3+
| ^
4+
| Result of implicit search for ?{ toInt: ? } will change.
5+
| Current result Test2.c will be no longer eligible
6+
| because it is not defined before the search position.
7+
| Result with new rules: augmentString.
8+
| To opt into the new rules, use the `experimental.givenLoopPrevention` language import.
9+
|
10+
| To fix the problem without the language import, you could try one of the following:
11+
| - use a `given ... with` clause as the enclosing given,
12+
| - rearrange definitions so that Test2.c comes earlier,
13+
| - use an explicit conversion,
14+
| - use an import to get extension method into scope.
15+
| This will be an error in Scala 3.5 and later.
16+
-- Error: tests/neg/i15474.scala:12:56 ---------------------------------------------------------------------------------
17+
12 | given Ordering[Price] = summon[Ordering[BigDecimal]] // error
18+
| ^
19+
| Result of implicit search for Ordering[BigDecimal] will change.
20+
| Current result Prices.Price.given_Ordering_Price will be no longer eligible
21+
| because it is not defined before the search position.
22+
| Result with new rules: scala.math.Ordering.BigDecimal.
23+
| To opt into the new rules, use the `experimental.givenLoopPrevention` language import.
24+
|
25+
| To fix the problem without the language import, you could try one of the following:
26+
| - use a `given ... with` clause as the enclosing given,
27+
| - rearrange definitions so that Prices.Price.given_Ordering_Price comes earlier,
28+
| - use an explicit argument.
29+
| This will be an error in Scala 3.5 and later.

tests/neg/i15474.scala

+4-6
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,15 @@
22

33
import scala.language.implicitConversions
44

5-
object Test1:
6-
given c: Conversion[ String, Int ] with
7-
def apply(from: String): Int = from.toInt // error
8-
95
object Test2:
10-
given c: Conversion[ String, Int ] = _.toInt // loop not detected, could be used as a fallback to avoid the warning.
6+
given c: Conversion[ String, Int ] = _.toInt // error
117

128
object Prices {
139
opaque type Price = BigDecimal
1410

1511
object Price{
1612
given Ordering[Price] = summon[Ordering[BigDecimal]] // error
1713
}
18-
}
14+
}
15+
16+

tests/neg/i15474b.check

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- Error: tests/neg/i15474b.scala:7:40 ---------------------------------------------------------------------------------
2+
7 | def apply(from: String): Int = from.toInt // error: infinite loop in function body
3+
| ^^^^^^^^^^
4+
| Infinite loop in function body
5+
| Test1.c.apply(from).toInt

tests/neg/i15474b.scala

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
//> using options -Xfatal-warnings
2+
3+
import scala.language.implicitConversions
4+
5+
object Test1:
6+
given c: Conversion[ String, Int ] with
7+
def apply(from: String): Int = from.toInt // error: infinite loop in function body
8+

tests/neg/i6716.check

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
-- Error: tests/neg/i6716.scala:12:39 ----------------------------------------------------------------------------------
2+
12 | given Monad[Bar] = summon[Monad[Foo]] // error
3+
| ^
4+
| Result of implicit search for Monad[Foo] will change.
5+
| Current result Bar.given_Monad_Bar will be no longer eligible
6+
| because it is not defined before the search position.
7+
| Result with new rules: Foo.given_Monad_Foo.
8+
| To opt into the new rules, use the `experimental.givenLoopPrevention` language import.
9+
|
10+
| To fix the problem without the language import, you could try one of the following:
11+
| - use a `given ... with` clause as the enclosing given,
12+
| - rearrange definitions so that Bar.given_Monad_Bar comes earlier,
13+
| - use an explicit argument.
14+
| This will be an error in Scala 3.5 and later.

tests/neg/i6716.scala

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
//> using options -Xfatal-warnings
2+
3+
trait Monad[T]:
4+
def id: String
5+
class Foo
6+
object Foo {
7+
given Monad[Foo] with { def id = "Foo" }
8+
}
9+
10+
opaque type Bar = Foo
11+
object Bar {
12+
given Monad[Bar] = summon[Monad[Foo]] // error
13+
}
14+
15+
object Test extends App {
16+
println(summon[Monad[Foo]].id)
17+
println(summon[Monad[Bar]].id)
18+
}

tests/neg/i7294-a.check

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
-- [E007] Type Mismatch Error: tests/neg/i7294-a.scala:10:20 -----------------------------------------------------------
2+
10 | case x: T => x.g(10) // error // error
3+
| ^^^^^^^
4+
| Found: Any
5+
| Required: T
6+
|
7+
| where: T is a type in given instance f with bounds <: foo.Foo
8+
|
9+
| longer explanation available when compiling with `-explain`
10+
-- Error: tests/neg/i7294-a.scala:10:12 --------------------------------------------------------------------------------
11+
10 | case x: T => x.g(10) // error // error
12+
| ^
13+
| Result of implicit search for scala.reflect.TypeTest[Nothing, T] will change.
14+
| Current result foo.Test.f will be no longer eligible
15+
| because it is not defined before the search position.
16+
| Result with new rules: No Matching Implicit.
17+
| To opt into the new rules, use the `experimental.givenLoopPrevention` language import.
18+
|
19+
| To fix the problem without the language import, you could try one of the following:
20+
| - use a `given ... with` clause as the enclosing given,
21+
| - rearrange definitions so that foo.Test.f comes earlier,
22+
| - use an explicit argument.
23+
| This will be an error in Scala 3.5 and later.
24+
|
25+
| where: T is a type in given instance f with bounds <: foo.Foo

0 commit comments

Comments
 (0)