There is a Scala 3 sample which shows that a match case marked with “Unreachable case” warning can still be reached. I am not sure if it is an error (although it seems like one to me), so I decided to start a topic here instead of reporting an issue in GitHub.
Suppose someone wrote this program:
case class A()
case class B()
case class C()
def f(ab: A | B): Unit = ab match
case x: (A | B) => println(s"$x: ok")
case x => println(s"$x: unreachable")
It quite expectedly provokes a warning stating that the second case is unreachable.
However, this code (Scastie link) allows us to reach the case:
f(C().asInstanceOf[A | B])
The same without asInstanceOf
The same can be achieved without asInstanceOf (Scastie link):
def applyIfApplicable[T](f: T => Unit, x: Any): Unit = x match
case t: T => f(t)
applyIfApplicable[A | B](f, C())
It provokes another warning, but we still reach the “unreachable” code. (A proper applyIfApplicable can be done using scala.reflect.Typeable.)
Do you think anything has to be done about this at all? I think something has to be done, since the warning message is inconsistent with reality. The simplest solution would be to change the warning message by adding “maybe”. Also, I guess it is possible and would make sense to throw an exception if an execution reaches an unreachable case in a match. Is there any reason to allow reaching unreachable case?
In the second snippet the warning is perfectly consistent with reality.
In the first one, this is the unavoidable fact of asInstanceOf casts: you are lying to the compiler and forcing it to look the other way. So the entire type system is helpless, including the part that computes reachability.
Perhaps the words used in the message could better distinguish static types from runtime.
The ambiguity is between “unreachable” in the sense of “dead code at runtime” and unreachable in an abstract graph of static cases.
Possible wording: “all applicable patterns have been exhausted” or “the pattern space has been exhausted”.
Maybe there is a word that is both technically precise and in wide usage by users.
The runtime behavior is due to the subversion of the type system.
The usual explanation is that a compiler message is just a shorthand: either the user understands it from previous experience, or the user must become educated, in this instance, about the meaning of “unreachable” in this context and ultimately why asInstanceOf is dangerous, or how other warnings must be heeded.
There is a ticket about braceless syntax which turns on whether a warning is heeded. Probably -Werror should be recommended for the risk-averse.
I believe we should write error messages assuming the type system was not subverted, otherwise the problem becomes untractable
Therefore, I think the current message is perfectly fine
Maybe “the type test for T cannot be checked at runtime because it refers to an abstract type member or type parameter” should be an error instead of a warning
The gravity of the situation is not obvious as it uses quite a lot of advenced terminology
Notably “type test” which is not easy to find the meaning of, as you tend to find TypeTest which is not exactly the same thing
First, I don’t think that the warning “Unreachable case” makes sense as long as we are still able to reach the case (which is what happens also in the second snippet). I also don’t think that the existence of other warnings in the program should justify this, especially since the first part of code, i.e., definition of the classes and f, can be in a very different place than the second part, i.e., the code which uses them.
Second, both snippets are “lying” to the compiler, but in other similar cases, we don’t reach an unreachable case. For example (Scastie link), using List we also get unreachability warning, but it is true — we don’t reach the case:
case class A()
case class B()
def f(l: List[A]): Unit = l match
case x: List[A] => println(s"$x: ok")
case x => println(s"$x: unreachable except for null")
f(List(B()).asInstanceOf[List[A]])
There is still a consequence of the “lie”, i.e., that under A in the list we are actually passing B, but it will result in an exception as soon as we will try to access the element.
The compiler cannot be safe in the presence of asInstanceOf, the whole point of it is to remove compiler checks !
That was actually the reason it has such a long name, it means “Trust me bro, I really know what I’m doing”
Compared to (x: Int) which means “I think x is an Int, could you check ?”
My problem with both asInstanceOf and abstract class / type parameter is that their effect is not local, so the error happens in one place, but the consequences are faced in maybe a very different place.
And speaking about specifically asInstanceOf, it is not all-trusting, and does some checks in the runtime it is all-trusting, but its usage is still usually limited, so this results in an exception:
B().asInstanceOf[A]
I guess the same should be done in case of an unreachable case to really make it unreachable. One particular thing I am worried about is that if the unreachability message is interpreted as completely true, an optimization might rely on this assumption and produce incorrect code.
If you perform a wrong asInstanceOf, it’s on you. The compiler is allowed to report bogus warnings, errors, and or generated code past that point. And yes, it can do so in non local ways.
It is only non-trusting insofar as is necessary for the target VM not to complain that its bytecode is invalid. The JVM requires explicit checks for the monomorphic class, so the compiler emits that. Scala.js does not check anything in fullLink mode, not even the monomorphic class. At the language level, asInstanceOf is all-trusting. The language does not want to check, but some VMs require some checks. It does not mean you can rely on them.
Ok, thanks! So, if I understood it correctly, is it kind of similar to undefined behavior? That is, as long as there is such an error in the program, the program is meaningless (on the language level, I mean), and the compiler therefore is free to do pretty much anything. Is it so?
Yes, it is similar to undefined behavior. Of course undefined behavior is always bounded by the safety guarantees of the underlying VM. (Even C’s undefined behavior is actually bounded by the safety guarantees of the OS wrt. the processes it runs.)
I think the (potentially) surprising thing is that you don’t get a ClassCastException during C().asInstanceOf[A | B]. If you did, the unreachable case would indeed be unreachable – exactly as if you replaced A | B with just A in the definition and type signature of f. There’s an inconsistency introduced by the detail that A | B erases to Object (and thus the cast is successful and the method call is allowed)