-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Match Types: implement cantPossiblyMatch #5996
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
Conversation
This commit implements one of the missing aspects of Match Types: an algorithm to determine when it is sound to reduce match types (see discussion in scala#5300). To understand the problem that is being solved, we can look at the motivational example from the [Haskell Role paper](https://www.seas.upenn.edu/~sweirich/papers/popl163af-weirich.pdf) (adapted to Scala). Given this class: ```scala class Foo { type Age type Type[X] = X match { case Age => Char case Int => Boolean } def method[X](x: X): Type[X] = ... } ``` What is the type of `method(1)`? On master, the compiler answers with "it depends", it could be either `Char` or `Boolean`, which is obviously unsound: ```scala val foo = new Foo { type Age = Int } foo.method(1): Char (foo: Foo).method(1): Boolean ``` The current algorithm to reduce match types is as follows: ``` foreach pattern if (scrutinee <:< pattern) return pattern's result type else continue ``` The unsoundness comes from the fact that the answer of `scrutinee <:< pattern` can change depending on the context, which can result in equivalent expressions being typed differently. The proposed solution is to extend the algorithm above with an additional intersection check: ``` foreach pattern if (scrutinee <:< pattern) return pattern's result type if (!intersecting(scrutinee, pattern)) continue else abort ``` Where `intersecting` returns `false` if there is a proof that both of its arguments are not intersecting. In the absence of such proof, the reduction is aborted. This algorithm solves the `type Age` example by preventing the reduction of `Type[X]` (with `X != Age`) when `Age is abstract. I believe this is enough to have sound type functions without the need for adding roles to the type system.
This commit also updates pattern matching exhaustivity tests for the new type inhavitation decision procedure. The diffs in t6420 and t7466 comes from a bug a the previous algorithm that would conclue that given `val a: Int`, `1` and `a.type` are non- intersecting because they are both singleton types. However, we can only get to that conclusion when both types are ConstantTypes. The diff in andtype-opentype-interaction come from the fact that the new decomposition of sealed types is more powerful than the previous one. For instance, we can now rule out `SealedClass & OpenTrait` as both types are provably non intersecting. i3574.scala is pending for now due to the removal of inhabitation check of childrens, it will be enabled back in a subsequent commit.
cb34288
to
c3d23fe
Compare
`Some isSubType List` returns `false`, so use derivesFrom instead.
instantiateParams(instances)(body) | ||
var result: Type = NoType | ||
var remainingCases = cases | ||
while (!remainingCases.isEmpty) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not use a tail-recursive function for matchCases?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't want constraints created while trying to evaluate one case to be carried over to the following cases, but I'm not sure if having these extra constraints could change anything...
@@ -516,101 +514,7 @@ class SpaceEngine(implicit ctx: Context) extends SpaceLogic { | |||
|
|||
val childTp = if (child.isTerm) child.termRef else child.typeRef | |||
|
|||
val resTp = instantiate(childTp, parent)(ctx.fresh.setNewTyperState()) | |||
|
|||
if (!resTp.exists || !inhabited(resTp)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The first part returning NoType is no longer present. Is that intentional? EDIT: I see that seems to be handled by the next commit "Check inhabitation of children" now. Correct?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, I don't understand SpaceEngines inhabited
enough so I have to ask: Does SpaceEngine follow the same logic that an abstract type / type parameter and a class type can potentially overlap? /cc @liufengyun
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, it takes the bounds & variance of abstract type & type parameters into consideration.
So it reports non-exhaustive warnings for the following code (it is possible that T = Int | Boolean
):
object O {
sealed trait Expr[+T]
case class IExpr(x: Int) extends Expr[Int]
case class BExpr(b: Boolean) extends Expr[Boolean]
def foo[T](x: Expr[T], y: Expr[T]) = (x, y) match {
case (IExpr(_), IExpr(_)) => true
case (BExpr(_), BExpr(_)) => false
}
}
It does not report warning for the following code, due to nonvariance of the type parameter:
object O {
sealed trait Expr[T]
case class BExpr(bool: Boolean) extends Expr[Boolean]
case class IExpr(int: Int) extends Expr[Int]
def join[T](e1: Expr[T], e2: Expr[T]): Expr[T] = (e1, e2) match {
case (IExpr(i1), IExpr(i2)) => IExpr(i1 + i2)
case (BExpr(b1), BExpr(b2)) => BExpr(b1 & b2)
}
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes I did that in a separate commit because it requires some extra logic: this check from space takes a type as input and looks for unrealizable intersections inside, while the other check started from two types and checks if their intersection is unrealizable. The commits move a test back and forth in /pending
to document what is handled by that code.
} | ||
} | ||
!(fullyInstantiated.apply(true, arg1) && | ||
fullyInstantiated.apply(true, arg2)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this still misses the case where we have an abstract TypeRef that is not a type parameter. Also, it is a bit unsatisfactory that for toplevel TypeRefs and TypeParamRefs we go to their supertypes but here we do not. So if I understand correctly in the case where we have a TypeParamRef A <: Int and a TypeParamRef B <: String, we would diagnose a possible overlap for
NonVariant[A], NonVariant[B]
but no such overlap for
CoVariant[A], Covariant[B]
(because for the latter we do a recursive call of intersecting
).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this still misses the case where we have an abstract TypeRef that is not a type parameter.
I changed the test from tp.symbol.is(TypeParam)
to tp.symbol.isAbstractOrParamType
and added a test.
Also, it is a bit unsatisfactory that for toplevel TypeRefs and TypeParamRefs we go to their supertypes but here we do not. [...]
Indeed, I guess I can also run the covariant test in the invariant case, invariance should give us more ways to prove non overlap, not less!
It's great that this unifies essential logic for match types and exhaustivity checking! |
} | ||
} | ||
if (inhabited.apply(true, refined)) refined | ||
else NoType |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems that a nested non-inhabitable intersection type does not necessarily mean the outer type is non-inhabited, just as Nothing
does not imply F[Nothing]
is non-inhabited..
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point, I've updated that check to be more conservative while traversing refined
.
To be more conservative during the traversal. As pointed out in the review "nested non-inhabitable intersection type does not necessarily mean the outer type is non-inhabited".
This PR implements one of the missing aspects of Match Types: an algorithm to determine when it is sound to reduce match types (see discussion in #5300).
To understand the problem that is being solved, we can look at the motivational example from the Haskell Role paper (adapted to Scala).
Given this class:
What is the type of
method(1)
?On master, the compiler answers with "it depends", it could be either
Char
orBoolean
, which is obviously unsound:The current algorithm to reduce match types is as follows:
The unsoundness comes from the fact that the answer of
scrutinee <:< pattern
can change depending on the context, which can result in equivalent expressions being typed differently.The proposed solution is to extend the algorithm above with an additional intersection check:
Where
intersecting
returnsfalse
if there is a proof that both of its arguments are not intersecting. In the absence of such proof, the reduction is aborted. This algorithm solves thetype Age
example by preventing the reduction ofType[X]
whenAge
is abstract (andX != Age
) . I believe this is enough to have sound type functions without the need for adding roles to the type system.This PR is best reviewed commit by commit as it contains quite a bit refactoring, all the interesting work is condescended in the commit entitled
Implement cantPossiblyMatch
.