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

Type matches are unsound. #6314

Closed
sir-wabbit opened this issue Apr 16, 2019 · 10 comments
Closed

Type matches are unsound. #6314

sir-wabbit opened this issue Apr 16, 2019 · 10 comments
Assignees

Comments

@sir-wabbit
Copy link

sir-wabbit commented Apr 16, 2019

object G {
    final class X
    final class Y

    opaque type Foo = X & Y
    object Foo {
        def apply[F[_]](fa: F[X & Y]): F[Y & Foo] = fa
    }

    type Bar[A] = A match {
        case X & Y => String
        case Y => Int
    }

    def main(args: Array[String]): Unit = {
        val a: Bar[X & Y] = "hello"
        val i: Bar[Y & Foo] = Foo.apply[Bar](a)
        val b: Int = i
        println(b + 1)
    }
}
Exception in thread "main" java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer
    at scala.runtime.BoxesRunTime.unboxToInt(BoxesRunTime.java:103)
    at G$.main(test.scala:30)
    at G.main(test.scala)
@sir-wabbit
Copy link
Author

In fact, you do not need the new opaque types:

object G {
    final class X
    final class Y

    trait FooSig {
        type Type
        def apply[F[_]](fa: F[X & Y]): F[Y & Type]
    }
    val Foo: FooSig = new FooSig {
        type Type = X & Y
        def apply[F[_]](fa: F[X & Y]): F[Y & Type] = fa
    }
    type Foo = Foo.Type

    type Bar[A] = A match {
        case X & Y => String
        case Y => Int
    }

    def main(args: Array[String]): Unit = {
        val a: Bar[X & Y] = "hello"
        val i: Bar[Y & Foo] = Foo.apply[Bar](a)
        val b: Int = i
        println(b + 1)
    }
}

@sir-wabbit sir-wabbit changed the title Opaque types combined with type matches are unsound Type matches are unsound. Apr 16, 2019
@OlivierBlanvillain OlivierBlanvillain self-assigned this Apr 16, 2019
OlivierBlanvillain added a commit to dotty-staging/dotty that referenced this issue Apr 16, 2019
@sir-wabbit
Copy link
Author

sir-wabbit commented Apr 17, 2019

Here is a cleaner example:

object G {
    final class X
    final class Y

    opaque type Foo = Nothing // or X & Y
    object Foo {
        def apply[F[_]](fa: F[X & Foo]): F[Y & Foo] = fa
    }

    type Bar[A] = A match {
        case X => String
        case Y => Int
    }

    val a: Bar[X & Foo] = "hello"
    val b: Bar[Y & Foo] = 1

    def main(args: Array[String]): Unit = {
        val a: Bar[X & Foo] = "hello"
        val i: Bar[Y & Foo] = Foo.apply[Bar](a)
        val b: Int = i
        println(b + 1)
    }
}

EDIT: this example still compiles with the fix in dotty-staging@74168ac

@OlivierBlanvillain
Copy link
Contributor

OlivierBlanvillain commented Apr 17, 2019

Thanks for the bug reports, it's very helpful!

That's clearly a bug, Bar[Y & Foo] shouldn't reduce to Int in main: Foo should be treated as an abstract type outside of its companion.

@sir-wabbit
Copy link
Author

sir-wabbit commented Apr 17, 2019

I don't think it's just intersections,

object G {
    trait Wizzle[L <: Int with Singleton] {
        type Bar[A] = A match {
            case 0 => String
            case L => Int
        }

        def left(fa: String): Bar[0] = fa
        def right(fa: Bar[L]): Int = fa

        def center[F[_]](fa: F[0]): F[L]

        def run: String => Int = left andThen center[Bar] andThen right
    }

    class Wozzle extends Wizzle[0] {
        def center[F[_]](fa: F[0]): F[0] = fa
    }

    def main(args: Array[String]): Unit = {
        val coerce: String => Int = (new Wozzle).run
        println(coerce("hello") + 1)
    }
}

Singletons are suspect too

object G {
    trait Wizzle {
        type X <: Int with Singleton
        type Y <: Int with Singleton

        type Bar[A] = A match {
            case X => String
            case Y => Int
        }

        def left(fa: String): Bar[X] = fa
        def center[F[_]](fa: F[X]): F[Y]
        def right(fa: Bar[Y]): Int = fa

        def run: String => Int = left andThen center[Bar] andThen right
    }

    class Wozzle extends Wizzle {
        type X = 0
        type Y = 0
        def center[F[_]](fa: F[X]): F[Y] = fa
    }

    def main(args: Array[String]): Unit = {
        val coerce: String => Int = (new Wozzle).run
        println(coerce("hello") + 1)
    }
}

@sir-wabbit
Copy link
Author

sir-wabbit commented Apr 17, 2019

Conceptually,

  • X = Y => F[X] = F[Y] always holds
  • Suppose we somehow make F[X] != F[Y] and X = Y, contradiction.
  • Since type equalities are potentially non-local (i.e. X and Y might be imported as type parameters), we can never assume that X != Y. In particular, type Bar[X] = X match { case 0 => String; case L => Int} can not assume that L != 0, so Bar[L] can not be reduced to Int in any context where we don't have a proof of L != 0.

All of the examples above attack compiler's over-eagerness to reduce Bar[X] to some type even though it does not have enough information about X to rule out all but one case.

type Foo

type Bar[A] = A match {
   case X => String
   case Y => Int
}

Bar[Y & Foo] should NOT reduce to Int because the first case can not be ruled out, Foo could be equal to X, in which case Bar[Y & Foo] = Bar[Y & X] = String.

type X <: Int with Singleton
type Y <: Int with Singleton

type Bar[A] = A match {
   case X => String
   case Y => Int
}

Bar[Y] should NOT reduce to Int since the first case can not be ruled out, X might be equal to Y, in which case Bar[Y] = Bar[X] = String.

@OlivierBlanvillain
Copy link
Contributor

Makes perfect sense, this is exactly what the implementation is supposed to do: never "reduce past" a case unless there is proof that the two types (the scrutinee type and the pattern type) are non intersecting.

@sir-wabbit
Copy link
Author

sir-wabbit commented Apr 17, 2019

After snooping around the code for a bit, I found another example:

object G {
    type Void <: Nothing
    trait Wizzle {
        type Razzle[+X >: Void]
        type X = 0
        type Y = 1

        type Bar[A] = A match {
            case Razzle[X] => String
            case Razzle[Y] => Int
        }

        def left(fa: String): Bar[Razzle[X]] = fa
        def center[F[_]](fa: F[Razzle[X]]): F[Razzle[Y]]
        def right(fa: Bar[Razzle[Y]]): Int = fa

        def run: String => Int = left andThen center[Bar] andThen right
    }

    class Wozzle extends Wizzle {
        type Razzle[+X >: Void] = Int
        def center[F[_]](fa: F[Razzle[X]]): F[Razzle[Y]] = fa
    }

    def main(args: Array[String]): Unit = {
        val coerce: String => Int = (new Wozzle).run
        println(coerce("hello") + 1)
    }
}

This one attacks line https://github.com/dotty-staging/dotty/blob/74168ac13746ef43cc3f626cf183b9d452bdc4da/compiler/src/dotty/tools/dotc/core/TypeComparer.scala#L1959 and the surrounding case for AppliedTypes.

@sir-wabbit
Copy link
Author

sir-wabbit commented Apr 17, 2019

I think the code might be mixing the inhabitance of intersections (∃ x: A & B) and subtyping (A <: B). A <: B does not imply ∃ x: A & B (nor vice-versa in general) and ¬(∃ x: A & B) does not imply ¬(A <: B).

final class A
final class B

type Select[X] = X match {
    case A => Int
    case B => String
}

type Type

val a : Select[A & Type] = 1
val b : Select[B & Type] = "hey"

attacks https://github.com/dotty-staging/dotty/blob/74168ac13746ef43cc3f626cf183b9d452bdc4da/compiler/src/dotty/tools/dotc/core/TypeComparer.scala#L1931 - A & (B & Type) is not inhabited, but that doesn't mean that we can "reduce past" the first case, since we are not really interested in inhabitance, but in a subtyping relation. We want to check whether B & Type <: A is possible (which it is), which is not directly related to whether their intersection is inhabited.

EDIT: clarified the first statement

@OlivierBlanvillain
Copy link
Contributor

You nailed it with this last example. I'm now convinced that there is no way to soundly reduce any match types when abstract type are involved.

@sir-wabbit
Copy link
Author

sir-wabbit commented Apr 18, 2019

There are some cases where it is possible.

final case class X()
type Y
type Select[X] = X match {
    case List[X] => Int
    case Option[Y] => String
}
final class X
final class Y
type Select[X] = X match {
    case X => Int
    case Y => String
}
type Type <: X

type Foo = Select[X | Type] // can not be a subtype of Y, subtype of X, hence reduces to Int
final class X
final class Y
type Select[X] = X match {
    case X => Int
    case Y => String
}
type Type <: X

type Foo = Select[X & Type] // can not be a subtype of Y, subtype of X, hence reduces to Int

If the compiler could use implicit evidence (probably not any time soon):

final class X
final class Y
type Select[X] = X match {
    case X => Int
    case Y => String
}

type Type
implicit val p: (Type <:< X) => Nothing // a proof that Type is not a subtype of X
type Foo = Select[Y & Type] // safe to reduce to String

But otherwise yes, things get really tricky when you are dealing with abstract types in type matches.

OlivierBlanvillain added a commit to dotty-staging/dotty that referenced this issue Apr 29, 2019
OlivierBlanvillain added a commit to dotty-staging/dotty that referenced this issue Apr 29, 2019
OlivierBlanvillain added a commit to dotty-staging/dotty that referenced this issue Apr 29, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants