Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Backport "Avoid generating given definitions that loop" #19477

Merged
merged 11 commits into from
Jan 18, 2024
1 change: 1 addition & 0 deletions compiler/src/dotty/tools/dotc/config/SourceVersion.scala
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ enum SourceVersion:
case `3.2-migration`, `3.2`
case `3.3-migration`, `3.3`
case `3.4-migration`, `3.4`
case `3.5-migration`, `3.5`
// !!! Keep in sync with scala.runtime.stdlibPatches.language !!!
case `future-migration`, `future`

Expand Down
149 changes: 119 additions & 30 deletions compiler/src/dotty/tools/dotc/typer/Implicits.scala
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ import Scopes.newScope
import Typer.BindingPrec, BindingPrec.*
import Hashable.*
import util.{EqHashMap, Stats}
import config.{Config, Feature}
import Feature.migrateTo3
import config.{Config, Feature, SourceVersion}
import Feature.{migrateTo3, sourceVersion}
import config.Printers.{implicits, implicitsDetailed}
import collection.mutable
import reporting.*
Expand Down Expand Up @@ -93,7 +93,7 @@ object Implicits:
if (initctx eq NoContext) initctx else initctx.retractMode(Mode.ImplicitsEnabled)
protected given Context = irefCtx

/** The nesting level of this context. Non-zero only in ContextialImplicits */
/** The nesting level of this context. Non-zero only in ContextualImplicits */
def level: Int = 0

/** The implicit references */
Expand Down Expand Up @@ -324,7 +324,7 @@ object Implicits:
/** Is this the outermost implicits? This is the case if it either the implicits
* of NoContext, or the last one before it.
*/
private def isOuterMost = {
private def isOutermost = {
val finalImplicits = NoContext.implicits
(this eq finalImplicits) || (outerImplicits eqn finalImplicits)
}
Expand Down Expand Up @@ -356,7 +356,7 @@ object Implicits:
Stats.record("uncached eligible")
if monitored then record(s"check uncached eligible refs in irefCtx", refs.length)
val ownEligible = filterMatching(tp)
if isOuterMost then ownEligible
if isOutermost then ownEligible
else combineEligibles(ownEligible, outerImplicits.nn.uncachedEligible(tp))

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

Expand All @@ -392,7 +392,7 @@ object Implicits:

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

/** This context, or a copy, ensuring root import from symbol `root`
Expand All @@ -408,6 +408,13 @@ object Implicits:
}
}

/** Search mode to use for possibly avoiding looping givens */
enum SearchMode:
case Old, // up to 3.3, old mode w/o protection
CompareWarn, // from 3.4, old mode, warn if new mode would change result
CompareErr, // from 3.5, old mode, error if new mode would change result
New // from future, new mode where looping givens are avoided

/** The result of an implicit search */
sealed abstract class SearchResult extends Showable {
def tree: Tree
Expand Down Expand Up @@ -1550,35 +1557,113 @@ trait Implicits:
case _ =>
tp.isAny || tp.isAnyRef

private def searchImplicit(contextual: Boolean): SearchResult =
/** Search implicit in context `ctxImplicits` or else in implicit scope
* of expected type if `ctxImplicits == null`.
*/
private def searchImplicit(ctxImplicits: ContextualImplicits | Null, mode: SearchMode): SearchResult =
if isUnderspecified(wildProto) then
SearchFailure(TooUnspecific(pt), span)
else
val eligible =
if contextual then
val contextual = ctxImplicits != null
val preEligible = // the eligible candidates, ignoring positions
if ctxImplicits != null then
if ctx.gadt.isNarrowing then
withoutMode(Mode.ImplicitsEnabled) {
ctx.implicits.uncachedEligible(wildProto)
ctxImplicits.uncachedEligible(wildProto)
}
else ctx.implicits.eligible(wildProto)
else ctxImplicits.eligible(wildProto)
else implicitScope(wildProto).eligible
searchImplicit(eligible, contextual) match
case result: SearchSuccess =>
result
case failure: SearchFailure =>
failure.reason match
case _: AmbiguousImplicits => failure
case reason =>
if contextual then
searchImplicit(contextual = false).recoverWith {
failure2 => failure2.reason match
case _: AmbiguousImplicits => failure2
case _ =>
reason match
case (_: DivergingImplicit) => failure
case _ => List(failure, failure2).maxBy(_.tree.treeSize)
}
else failure

/** Does candidate `cand` come too late for it to be considered as an
* eligible candidate? This is the case if `cand` appears in the same
* scope as a given definition of the form `given ... = ...` that
* encloses the search point and `cand` comes later in the source or
* coincides with that given definition.
*/
def comesTooLate(cand: Candidate): Boolean =
val candSym = cand.ref.symbol
def candSucceedsGiven(sym: Symbol): Boolean =
val owner = sym.owner
if owner == candSym.owner then
sym.is(GivenVal) && sym.span.exists && sym.span.start <= candSym.span.start
else if owner.isClass then false
else candSucceedsGiven(owner)

ctx.isTyper
&& !candSym.isOneOf(TermParamOrAccessor | Synthetic)
&& candSym.span.exists
&& candSucceedsGiven(ctx.owner)
end comesTooLate

val eligible = // the eligible candidates that come before the search point
if contextual && mode != SearchMode.Old
then preEligible.filterNot(comesTooLate)
else preEligible

def checkResolutionChange(result: SearchResult) =
if (eligible ne preEligible) && mode != SearchMode.New then
searchImplicit(preEligible, contextual) match
case prevResult: SearchSuccess =>
def remedy = pt match
case _: SelectionProto =>
"conversion,\n - use an import to get extension method into scope"
case _: ViewProto =>
"conversion"
case _ =>
"argument"

def showResult(r: SearchResult) = r match
case r: SearchSuccess => ctx.printer.toTextRef(r.ref).show
case r => r.show

result match
case result: SearchSuccess if prevResult.ref frozen_=:= result.ref =>
// OK
case _ =>
val msg =
em"""Result of implicit search for $pt will change.
|Current result ${showResult(prevResult)} will be no longer eligible
| because it is not defined before the search position.
|Result with new rules: ${showResult(result)}.
|To opt into the new rules, compile with `-source future` or use
|the `scala.language.future` language import.
|
|To fix the problem without the language import, you could try one of the following:
| - use a `given ... with` clause as the enclosing given,
| - rearrange definitions so that ${showResult(prevResult)} comes earlier,
| - use an explicit $remedy."""
if mode == SearchMode.CompareErr
then report.error(msg, srcPos)
else report.warning(msg.append("\nThis will be an error in Scala 3.5 and later."), srcPos)
prevResult
case prevResult: SearchFailure => result
else result
end checkResolutionChange

checkResolutionChange:
searchImplicit(eligible, contextual).recoverWith:
case failure: SearchFailure =>
failure.reason match
case _: AmbiguousImplicits => failure
case reason =>
if contextual then
// If we filtered out some candidates for being too late, we should
// do another contextual search further out, since the dropped candidates
// might have shadowed an eligible candidate in an outer level.
// Otherwise, proceed with a search of the implicit scope.
val newCtxImplicits =
if eligible eq preEligible then null
else ctxImplicits.nn.outerImplicits: ContextualImplicits | Null
// !!! Dotty problem: without the ContextualImplicits | Null type ascription
// we get a Ycheck failure after arrayConstructors due to "Types differ"
searchImplicit(newCtxImplicits, SearchMode.New).recoverWith:
failure2 => failure2.reason match
case _: AmbiguousImplicits => failure2
case _ =>
reason match
case (_: DivergingImplicit) => failure
case _ => List(failure, failure2).maxBy(_.tree.treeSize)
else failure
end searchImplicit

/** Find a unique best implicit reference */
Expand All @@ -1595,7 +1680,11 @@ trait Implicits:
case ref: TermRef =>
SearchSuccess(tpd.ref(ref).withSpan(span.startPos), ref, 0)(ctx.typerState, ctx.gadt)
case _ =>
searchImplicit(contextual = true)
searchImplicit(ctx.implicits,
if sourceVersion.isAtLeast(SourceVersion.future) then SearchMode.New
else if sourceVersion.isAtLeast(SourceVersion.`3.5`) then SearchMode.CompareErr
else if sourceVersion.isAtLeast(SourceVersion.`3.4`) then SearchMode.CompareWarn
else SearchMode.Old)
end bestImplicit

def implicitScope(tp: Type): OfTypeImplicits = ctx.run.nn.implicitScope(tp)
Expand Down
30 changes: 29 additions & 1 deletion docs/_docs/reference/changed-features/implicit-resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,36 @@ The new rules are as follows: An implicit `a` defined in `A` is more specific th

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

[//]: # todo: expand with precise rules

**9.** The following change is currently enabled in `-source future`:

Implicit resolution now avoids generating recursive givens that can lead to an infinite loop at runtime. Here is an example:

```scala
object Prices {
opaque type Price = BigDecimal

object Price{
given Ordering[Price] = summon[Ordering[BigDecimal]] // was error, now avoided
}
}
```

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:

- When doing an implicit search while checking the implementation of a `given` definition `G` of the form
```
given ... = ....
```
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`.

The new behavior is currently enabled in `source.future` and will be enabled at the earliest in Scala 3.6. For earlier source versions, the behavior is as
follows:

- Scala 3.3: no change
- Scala 3.4: A warning is issued where the behavior will change in 3.future.
- Scala 3.5: An error is issued where the behavior will change in 3.future.

Old-style implicit definitions are unaffected by this change.

[//]: # todo: expand with precise rules
31 changes: 31 additions & 0 deletions docs/_docs/reference/experimental/given-loop-prevention.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
layout: doc-page
title: Given Loop Prevention
redirectFrom: /docs/reference/other-new-features/into-modifier.html
nightlyOf: https://docs.scala-lang.org/scala3/reference/experimental/into-modifier.html
---

Implicit resolution now avoids generating recursive givens that can lead to an infinite loop at runtime. Here is an example:

```scala
object Prices {
opaque type Price = BigDecimal

object Price{
given Ordering[Price] = summon[Ordering[BigDecimal]] // was error, now avoided
}
}
```

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:

- When doing an implicit search while checking the implementation of a `given` definition `G` of the form
```
given ... = ....
```
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`.

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).

Old-style implicit definitions are unaffected by this change.

31 changes: 31 additions & 0 deletions tests/neg/i15474.check
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
-- Error: tests/neg/i15474.scala:6:39 ----------------------------------------------------------------------------------
6 | given c: Conversion[ String, Int ] = _.toInt // error
| ^
| Result of implicit search for ?{ toInt: ? } will change.
| Current result Test2.c will be no longer eligible
| because it is not defined before the search position.
| Result with new rules: augmentString.
| To opt into the new rules, compile with `-source future` or use
| the `scala.language.future` language import.
|
| To fix the problem without the language import, you could try one of the following:
| - use a `given ... with` clause as the enclosing given,
| - rearrange definitions so that Test2.c comes earlier,
| - use an explicit conversion,
| - use an import to get extension method into scope.
| This will be an error in Scala 3.5 and later.
-- Error: tests/neg/i15474.scala:12:56 ---------------------------------------------------------------------------------
12 | given Ordering[Price] = summon[Ordering[BigDecimal]] // error
| ^
| Result of implicit search for Ordering[BigDecimal] will change.
| Current result Prices.Price.given_Ordering_Price will be no longer eligible
| because it is not defined before the search position.
| Result with new rules: scala.math.Ordering.BigDecimal.
| To opt into the new rules, compile with `-source future` or use
| the `scala.language.future` language import.
|
| To fix the problem without the language import, you could try one of the following:
| - use a `given ... with` clause as the enclosing given,
| - rearrange definitions so that Prices.Price.given_Ordering_Price comes earlier,
| - use an explicit argument.
| This will be an error in Scala 3.5 and later.
10 changes: 4 additions & 6 deletions tests/neg/i15474.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,15 @@

import scala.language.implicitConversions

object Test1:
given c: Conversion[ String, Int ] with
def apply(from: String): Int = from.toInt // error

object Test2:
given c: Conversion[ String, Int ] = _.toInt // loop not detected, could be used as a fallback to avoid the warning.
given c: Conversion[ String, Int ] = _.toInt // error

object Prices {
opaque type Price = BigDecimal

object Price{
given Ordering[Price] = summon[Ordering[BigDecimal]] // error
}
}
}


5 changes: 5 additions & 0 deletions tests/neg/i15474b.check
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Error: tests/neg/i15474b.scala:7:40 ---------------------------------------------------------------------------------
7 | def apply(from: String): Int = from.toInt // error: infinite loop in function body
| ^^^^^^^^^^
| Infinite loop in function body
| Test1.c.apply(from).toInt
8 changes: 8 additions & 0 deletions tests/neg/i15474b.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//> using options -Xfatal-warnings

import scala.language.implicitConversions

object Test1:
given c: Conversion[ String, Int ] with
def apply(from: String): Int = from.toInt // error: infinite loop in function body

15 changes: 15 additions & 0 deletions tests/neg/i6716.check
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- Error: tests/neg/i6716.scala:12:39 ----------------------------------------------------------------------------------
12 | given Monad[Bar] = summon[Monad[Foo]] // error
| ^
| Result of implicit search for Monad[Foo] will change.
| Current result Bar.given_Monad_Bar will be no longer eligible
| because it is not defined before the search position.
| Result with new rules: Foo.given_Monad_Foo.
| To opt into the new rules, compile with `-source future` or use
| the `scala.language.future` language import.
|
| To fix the problem without the language import, you could try one of the following:
| - use a `given ... with` clause as the enclosing given,
| - rearrange definitions so that Bar.given_Monad_Bar comes earlier,
| - use an explicit argument.
| This will be an error in Scala 3.5 and later.
Loading
Loading