Skip to content

Fix GADT approximation #9322

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

Merged
merged 5 commits into from
Jul 14, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion compiler/src/dotty/tools/dotc/core/TypeComparer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -751,7 +751,7 @@ class TypeComparer(initctx: Context) extends ConstraintHandling[AbsentContext] w
case _ => false
}
case _ => false
comparePaths || isNewSubType(tp1.underlying.widenExpr)
comparePaths || isSubType(tp1.underlying.widenExpr, tp2, approx.addLow)
case tp1: RefinedType =>
isNewSubType(tp1.parent)
case tp1: RecType =>
Expand Down
62 changes: 36 additions & 26 deletions compiler/src/dotty/tools/dotc/typer/Inferencing.scala
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import NameKinds.UniqueName
import util.Spans._
import util.{Stats, SimpleIdentityMap}
import Decorators._
import config.Printers.{gadts, typr}
import config.Printers.{gadts, typr, debug}
import annotation.tailrec
import reporting._
import collection.mutable
Expand Down Expand Up @@ -171,37 +171,47 @@ object Inferencing {
res
}

/** This class is mostly based on IsFullyDefinedAccumulator.
* It tries to approximate the given type based on the available GADT constraints.
*/
/** Approximates a type to get rid of as many GADT-constrained abstract types as possible. */
private class ApproximateGadtAccumulator(using Context) extends TypeMap {

var failed = false

private def instantiate(tvar: TypeVar, fromBelow: Boolean): Type = {
val inst = tvar.instantiate(fromBelow)
typr.println(i"forced instantiation of ${tvar.origin} = $inst")
inst
}

private def instDirection2(sym: Symbol)(using Context): Int = {
val constrained = ctx.gadt.fullBounds(sym)
val original = sym.info.bounds
val cmp = ctx.typeComparer
val approxBelow =
if (!cmp.isSubTypeWhenFrozen(constrained.lo, original.lo)) 1 else 0
val approxAbove =
if (!cmp.isSubTypeWhenFrozen(original.hi, constrained.hi)) 1 else 0
approxAbove - approxBelow
}

private[this] var toMaximize: Boolean = false

/** GADT approximation proceeds differently from type variable approximation.
*
* Essentially, what we're doing is we're inferring a type ascription that
* will remove as many GADT-constrained types as possible. This means that
* we want to approximate type T to type S in such a way that no matter how
* GADT-constrained types are instantiated, T <: S. In other words, the
* relationship _necessarily_ must hold.
*
* We accomplish that by:
* - replacing covariant occurences with upper GADT bound
* - replacing contravariant occurences with lower GADT bound
* - leaving invariant occurences alone
*
* Examples:
* - If we have GADT cstr A <: Int, then for all A <: Int, Option[A] <: Option[Int].
* Therefore, we can approximate Option[A] ~~ Option[Int].
Comment on lines +193 to +194
Copy link
Member

@smarter smarter Jul 10, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still concerned about the stuff I mentioned in #9274 (comment), it seems that if I have a method def foo[T <: X] = ..., then I immediately get a GADT constraint T <: X, and based on this documentation I would guess this means that ApproximateGadtAccumulator will replace Option[T] by Option[X], even though there's no need to do any approximation and nothing GADT related is actually going on?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I now realize that approximateGADT is only called when the type of the tree is not a subtype of the expected type, so it's probably OK technically, but I think it's confusing and not great for performance that we run this stuff in situations which have nothing to do with GADTs. A few suggestions:

  • Add some documentation above the call to approximateGADT in adaptToSubType, describing in what situation this might help (e.g., with a simple example)
  • As far as I can tell, the result of the approximation is ignored unless pt is a SelectjonProto and ctx.gadt.nonEmpty, so we should only run the type map when these conditions are true, this is better for performance and for understanding what's going on.
  • In Incorrect code ends up passing the typechecker due to GADT logic misfiring #9274 you said:

    it's not possible to tell whether a symbol is a type parameter of an enclosing function after descending into its body; therefore, we need to stick those symbols somewhere when we see them and we stick them into GadtConstraint
    Type parameters have the TypeParam flag set, and I think method type parameters should always be TypeRefs where the prefix is NoPrefix, is that enough?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re. documentation: I'll add the doc to code in adaptToSubType. We need to approximate GADTs in adapt in order to look up members that should be there b/c of GADT constraints. We cannot take GADT constraints into account when doing normal member lookup b/c of cache. Sample code is https://github.com/lampepfl/dotty/pull/9322/files#diff-70e7287c36d5e6a572c97c2b51613049R1-R9.

Re. lazily performing the approximation: will do, doing it eagerly is a remainder from the old recover, which also used the result of approx.

Re. type parameters: key issue is telling whether the type parameters are from an enclosing method, or from somewhere else (i.e. a class?) Now that I think about it, there may be some way of doing so. Question is, do we actually get any performance out of it. I'd prefer to know for sure that there's a performance issue with this code before trying to change it.

Final concern: can you take a look @ https://github.com/lampepfl/dotty/pull/9322/files#diff-1b5c8ce1eaca91d793eef217487d5a59R754 and see whether that change makes sense to you? isNewSubType would at most return false early before doing the type comparison, so I cannot really see any way that this change is wrong, but at the same time all the justification I have for it is that no test broke and it fixes my issue.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re. type parameters: key issue is telling whether the type parameters are from an enclosing method, or from somewhere else (i.e. a class?)

Check if the owner is a method, type parameters from methods which are not enclosing should not be in scope at all.

Final concern: can you take a look @ #9322 (files) and see whether that change makes sense to you?

I guess that usage of isNewSubType was incorrect before? Maybe something worth asking Martin about.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re. type parameters: key issue is telling whether the type parameters are from an enclosing method, or from somewhere else (i.e. a class?)

Check if the owner is a method, type parameters from methods which are not enclosing should not be in scope at all.

Yeah, that's what I was thinking about when I said "there may be some way of doing so". I'd still like an indication that we have a performance problem here.

Final concern: can you take a look @ #9322 (files) and see whether that change makes sense to you?

I guess that usage of isNewSubType was incorrect before? Maybe something worth asking Martin about.

@odersky can you take a look at the single line modified in TypeComparer in https://github.com/lampepfl/dotty/pull/9322/files#diff-1b5c8ce1eaca91d793eef217487d5a59R754 and check if the change looks OK to you? The problem that this change fixes is #9322 (comment).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd still like an indication that we have a performance problem here.

Even if it's not a performance problem, I think it's nicer if GADT code doesn't run when nothing GADT related is going on: it makes it easier to know what code can be safely ignored when trying to understand or debug something, and it makes the output of debug printers easier to read (especially the subtyping printer which is hard enough to follow in the best of situations), but this isn't a blocker for this PR, and if you think it would make the resulting GADT logic too complicated then we can keep the current logic of course.

* - If we have A >: S <: T, then for all such A, A => A <: S => T. This
* illustrates that it's fine to differently approximate different
* occurences of same type.
* - If we have A <: Int and F <: [A] => Option[A] (note the invariance),
* then we should approximate F[A] ~~ Option[A]. That is, we should
* respect the invariance of the type constructor.
* - If we have A <: Option[B] and B <: Int, we approximate A ~~
* Option[B]. That is, we don't recurse into already approximated
* types. Since GADT approximation is (for now) only used for member
* selection, this behaviour is expected, as nested types cannot affect
* member selection (note that given/extension lookup doesn't need GADT
* approx, see gadt-approximation-interaction.scala).
*/
def apply(tp: Type): Type = tp.dealias match {
case tp @ TypeRef(qual, nme) if (qual eq NoPrefix) && ctx.gadt.contains(tp.symbol) =>
case tp @ TypeRef(qual, nme) if (qual eq NoPrefix)
&& variance != 0
&& ctx.gadt.contains(tp.symbol)
=>
val sym = tp.symbol
val res =
ctx.gadt.approximation(sym, fromBelow = variance < 0)
val res = ctx.gadt.approximation(sym, fromBelow = variance < 0)
gadts.println(i"approximated $tp ~~ $res")
res

Expand Down
8 changes: 1 addition & 7 deletions compiler/src/dotty/tools/dotc/typer/ProtoTypes.scala
Original file line number Diff line number Diff line change
Expand Up @@ -687,14 +687,8 @@ object ProtoTypes {

/** Dummy tree to be used as an argument of a FunProto or ViewProto type */
object dummyTreeOfType {
/*
* A property indicating that the given tree was created with dummyTreeOfType.
* It is sometimes necessary to detect the dummy trees to avoid unwanted readaptations on them.
*/
val IsDummyTree = new Property.Key[Unit]

def apply(tp: Type)(implicit src: SourceFile): Tree =
(untpd.Literal(Constant(null)) withTypeUnchecked tp).withAttachment(IsDummyTree, ())
untpd.Literal(Constant(null)) withTypeUnchecked tp
def unapply(tree: untpd.Tree): Option[Type] = tree match {
case Literal(Constant(null)) => Some(tree.typeOpt)
case _ => None
Expand Down
35 changes: 14 additions & 21 deletions compiler/src/dotty/tools/dotc/typer/Typer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import collection.mutable
import annotation.tailrec
import Implicits._
import util.Stats.record
import config.Printers.{gadts, typr}
import config.Printers.{gadts, typr, debug}
import config.Feature._
import config.SourceVersion._
import rewrites.Rewrites.patch
Expand Down Expand Up @@ -3407,25 +3407,18 @@ class Typer extends Namer
case _ =>
}

val approximation = Inferencing.approximateGADT(wtp)
gadts.println(
i"""GADT approximation {
approximation = $approximation
pt.isInstanceOf[SelectionProto] = ${pt.isInstanceOf[SelectionProto]}
ctx.gadt.nonEmpty = ${ctx.gadt.nonEmpty}
ctx.gadt = ${ctx.gadt.debugBoundsDescription}
pt.isMatchedBy = ${
if (pt.isInstanceOf[SelectionProto])
pt.asInstanceOf[SelectionProto].isMatchedBy(approximation).toString
else
"<not a SelectionProto>"
}
}
"""
)
// try GADT approximation, but only if we're trying to select a member
// Member lookup cannot take GADTs into account b/c of cache, so we
// approximate types based on GADT constraints instead. For an example,
// see MemberHealing in gadt-approximation-interaction.scala.
pt match {
case pt: SelectionProto if ctx.gadt.nonEmpty && pt.isMatchedBy(approximation) =>
return tpd.Typed(tree, TypeTree(approximation))
case pt: SelectionProto if ctx.gadt.nonEmpty =>
gadts.println(i"Trying to heal member selection by GADT-approximating $wtp")
val gadtApprox = Inferencing.approximateGADT(wtp)
gadts.println(i"GADT-approximated $wtp ~~ $gadtApprox")
if pt.isMatchedBy(gadtApprox) then
gadts.println(i"Member selection healed by GADT approximation")
return tpd.Typed(tree, TypeTree(gadtApprox))
case _ => ;
}

Expand Down Expand Up @@ -3459,6 +3452,7 @@ class Typer extends Namer
if (isFullyDefined(wtp, force = ForceDegree.all) &&
ctx.typerState.constraint.ne(prevConstraint)) readapt(tree)
else err.typeMismatch(tree, pt, failure)

if ctx.mode.is(Mode.ImplicitsEnabled) && tree.typeOpt.isValueType then
if pt.isRef(defn.AnyValClass) || pt.isRef(defn.ObjectClass) then
ctx.error(em"the result of an implicit conversion must be more specific than $pt", tree.sourcePos)
Expand All @@ -3469,14 +3463,13 @@ class Typer extends Namer
checkImplicitConversionUseOK(found.symbol, tree.posd)
readapt(found)(using ctx.retractMode(Mode.ImplicitsEnabled))
case failure: SearchFailure =>
if (pt.isInstanceOf[ProtoType] && !failure.isAmbiguous) {
if (pt.isInstanceOf[ProtoType] && !failure.isAmbiguous) then
// don't report the failure but return the tree unchanged. This
// will cause a failure at the next level out, which usually gives
// a better error message. To compensate, store the encountered failure
// as an attachment, so that it can be reported later as an addendum.
rememberSearchFailure(tree, failure)
tree
}
else recover(failure.reason)
}
else recover(NoMatchingImplicits)
Expand Down
136 changes: 136 additions & 0 deletions tests/neg/gadt-approximation-interaction.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
object MemberHealing {
enum SUB[-A, +B]:
case Refl[S]() extends SUB[S, S]

def foo[T](t: T, ev: T SUB Int) =
ev match { case SUB.Refl() =>
t + 2
}
}

object ImplicitLookup {
enum SUB[-A, +B]:
case Refl[S]() extends SUB[S, S]

class Tag[T]

implicit val ti: Tag[Int] = Tag()

def foo[T](t: T, ev: T SUB Int) =
ev match { case SUB.Refl() =>
implicitly[Tag[Int]]
}
}

object GivenLookup {
enum SUB[-A, +B]:
case Refl[S]() extends SUB[S, S]

class Tag[T]

given ti as Tag[Int]

def foo[T](t: T, ev: T SUB Int) =
ev match { case SUB.Refl() =>
summon[Tag[Int]]
}
}

object ImplicitConversion {
enum SUB[-A, +B]:
case Refl[S]() extends SUB[S, S]

class Pow(self: Int):
def **(other: Int): Int = math.pow(self, other).toInt

implicit def pow(i: Int): Pow = Pow(i)

def foo[T](t: T, ev: T SUB Int) =
ev match { case SUB.Refl() =>
t ** 2 // error // implementation limitation
}

def bar[T](t: T, ev: T SUB Int) =
ev match { case SUB.Refl() =>
(t: Int) ** 2 // sanity check
}
}

object GivenConversion {
enum SUB[-A, +B]:
case Refl[S]() extends SUB[S, S]

class Pow(self: Int):
def **(other: Int): Int = math.pow(self, other).toInt

given as Conversion[Int, Pow] = (i: Int) => Pow(i)

def foo[T](t: T, ev: T SUB Int) =
ev match { case SUB.Refl() =>
t ** 2 // error (implementation limitation)
}

def bar[T](t: T, ev: T SUB Int) =
ev match { case SUB.Refl() =>
(t: Int) ** 2 // sanity check
}
}

object ExtensionMethod {
enum SUB[-A, +B]:
case Refl[S]() extends SUB[S, S]

extension (x: Int):
def **(y: Int) = math.pow(x, y).toInt

def foo[T](t: T, ev: T SUB Int) =
ev match { case SUB.Refl() =>
t ** 2
}
}

object HKFun {
enum SUB[-A, +B]:
case Refl[S]() extends SUB[S, S]

enum HKSUB[-F[_], +G[_]]:
case Refl[H[_]]() extends HKSUB[H, H]

def foo[F[_], T](ft: F[T], hkev: F HKSUB Option, ev: T SUB Int) =
hkev match { case HKSUB.Refl() =>
ev match { case SUB.Refl() =>
// both should typecheck - we should respect invariance of F
// (and not approximate its argument)
// but also T <: Int b/c of ev
val x: T = ft.get
val y: Int = ft.get
}
}

enum COVHKSUB[-F[+_], +G[+_]]:
case Refl[H[_]]() extends COVHKSUB[H, H]

def bar[F[+_], T](ft: F[T], hkev: F COVHKSUB Option, ev: T SUB Int) =
hkev match { case COVHKSUB.Refl() =>
ev match { case SUB.Refl() =>
// Sanity check for `foo`
// this is an error only because we blindly approximate covariant type arguments
// if it stops being an error, `foo` should be re-thought
val x: T = ft.get // error
val y: Int = ft.get
}
}
}

object NestedConstrained {
enum SUB[-A, +B]:
case Refl[S]() extends SUB[S, S]

def foo[A, B](a: A, ev1: A SUB Option[B], ev2: B SUB Int) =
ev1 match { case SUB.Refl() =>
ev2 match { case SUB.Refl() =>
1 + "a"
a.get : Int
}
}
}
File renamed without changes.