Pattern Matching with path dependent types confusing behavior

I’m trying to understand why the pattern match fails, but the instance check succeeds in the following code:

object Runner extends App {
  trait Y {
    case class X(x: Int)
  }
  class A extends Y {
    val x: X = X(1)
  }
  class B extends Y {
    val x: X = X(1)
    def check(v: Any): Unit = {
      v match {
        case _: X => println("pattern match succeeds, it is the same class")
        case thing if thing.isInstanceOf[X] => println("pattern match failed, but isInstanceOf matched")
        case _ => println("pattern match fails, the class is different")
      }
    }
  }
  val a: A = new A
  val b: B = new B
  println("a.x and b.x have the same runtime class? " + (a.x.getClass eq b.x.getClass))
  b.check(a.x)
}

output:

a.x and b.x have the same runtime class? true
pattern match failed, but isInstanceOf matched

I understand that there are 2 different X classes here, but they share a runtime class. What I don’t understand is why the first case fails but the second case matches. It seems like defining a case class inside a trait is a bad idea because of the possibility for subtle pattern match failures. I discovered this inconsistent behavior when upgrading a code base from Scala 2.11 to 2.12, where a pattern match in this type of scenario started failing suddenly. Fortunately, it was caught by tests. (The book, Programming in Scala, 4th Ed. has an example of defining a case class inside a trait, which seems like setting people up for failure and confusion.)

2 Likes

Accepting and Any and pattern matching for types are already two code smells.
Relying on unsafe behaviour is a recipe for disaster.

What happens if you try with case X(_) =>?

1 Like

That happens because X happens inside an instance of Y not class Y.
Take case class X outside of the trait and it will work.

Think about it. Function check() can only check for X types within an instance of B, but you are passing an instance of A. This means it will never match.

Any was not part of the original code where I encountered this behavior, the original code was much more complicated and I wrote this example to reproduce the behavior in the simplest possible way.

I tried case X(_) => and it does not match.

2 Likes

Yes, I understand why the X type is different (as I explained in my original question), but what I don’t understand is the difference between case _: X => vs case x if x.isInstanceOf[X] =>, aren’t they both evaluated at runtime, how does the first case statement determine that the value being passed in is a different type, if the differences have been erased?

1 Like

I am making an educated guess here…
case _: X
actually means
case _: this.X

but thing.isInstanceOf[X] run a function inside thing. This means that it is using the THIS of thing not the THIS of instance B. thing.isInstanceOf[X] actually means thing.isInstanceOf[thing.X]

A comment on a similar question states that pattern matching uses the erased runtime class, which was my understanding, too, but if it just relied on the runtime class, why does the pattern match work differently on a.x vs b.x when those two objects have an identical runtime class? Help understanding local class definitions

As I guessed in this thread, the compiler seems to inject an additional comparison that uses a synthetic $outer reference to exclusively match on inner class instances belonging to the proper outer instance in the pattern match case.

10: checkcast     #13                 // class Runner$Y$X
13: invokevirtual #40                 // Method Runner$Y$X.Runner$Y$X$$$outer:()LRunner$Y;
16: aload_0
17: if_acmpne     35
20: getstatic     #46                 // Field scala/Predef$.MODULE$:Lscala/Predef$;
23: ldc           #48                 // String pattern match succeeds, it is the same class
25: invokevirtual #51                 // Method scala/Predef$.println:(Ljava/lang/Object;)V

To me it makes sense that the compiler tries to give stronger guarantees for pattern matches than for reflection level comparisons.

2 Likes

Thanks! That definitely explains it the runtime behavior.