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

Unsound conformance from selection of member MatchType #19949

Closed
EugeneFlesselle opened this issue Mar 14, 2024 · 7 comments · Fixed by #19954
Closed

Unsound conformance from selection of member MatchType #19949

EugeneFlesselle opened this issue Mar 14, 2024 · 7 comments · Fixed by #19954
Assignees
Labels
area:match-types itype:bug itype:soundness Soundness bug (it lets us compile code that crashes at runtime with a ClassCastException) regression This worked in a previous version but doesn't anymore
Milestone

Comments

@EugeneFlesselle
Copy link
Contributor

EugeneFlesselle commented Mar 14, 2024

Match type with no cases reduces to unreported ErrorType

Compiler version

3.4.2-RC1

Minimized code

trait T[N]:
  type M = N match
    case 0 => Any // or anything else

val t: T[Double] = new T[Double] {}
val x: t.M = "hello"
val y: Boolean = x // compiles + ClassCastException

We also have the same issue when using a projection:

val z: T[Double]#M = "hello"
val _: Boolean = z // compiles + ClassCastException

Minimised from @Gedochao's #19936 (comment)

@EugeneFlesselle EugeneFlesselle added itype:bug stat:needs triage Every issue needs to have an "area" and "itype" label area:match-types itype:soundness Soundness bug (it lets us compile code that crashes at runtime with a ClassCastException) and removed stat:needs triage Every issue needs to have an "area" and "itype" label labels Mar 14, 2024
@EugeneFlesselle EugeneFlesselle changed the title Unsound conformance from selection of MatchType member Unsound conformance from selection of member MatchType Mar 14, 2024
@Gedochao Gedochao added the regression This worked in a previous version but doesn't anymore label Mar 15, 2024
@Gedochao
Copy link
Contributor

Doesn't compile on 3.3.3, so it's also a regression...

@EugeneFlesselle
Copy link
Contributor Author

Doesn't compile on 3.3.3, so it's also a regression...

It was introduced by #18016

@dwijnand
Copy link
Member

Doesn't compile on 3.3.3, so it's also a regression...

It looks like it shouldn't compile. So not a regression, "just" a soundness bug.

@EugeneFlesselle
Copy link
Contributor Author

@sjrd @dwijnand @odersky There are 3 possible solutions:

  1. Handling the case in TypeComparer
  2. Reverting to throwing a TypeError
  3. Reverting to returning NoType

Handling the case in TypeComparer #19961

This patch is the simplest option but there are probably other cases we are not aware of which don't handle these ErrorTypes correctly either.

A quick test trying to detect them by adding check in subtype test:

case tp2: FlexType =>  
  tp2 match  
    case errTp: ErrorType if  
      tp2.show.contains("<error The match type contains an illegal case") ||  
      tp2.show.contains("<error Match type reduction failed since selector") => false  
    case _ => true

causes several test cases to fail. I haven't investigated further yet but it looks a bit suspicious.

Reverting to throwing a TypeError #19953

This option still has a failure in pos/constvalue-of-failed-match-type.scala. The test case specifically wants to allow match types with no matching cases when they are the result of an inlineCall but will be eliminated by further inlining (e.g. in a branch of an inline match from an inline def).

I haven't figure out yet how to catch/handle the error in the InliningTreeMap, but I expect it should be possible.

Reverting to returning NoType #19954

The last option is leaving match types with no matching cases unreduced without reporting a type error.

The alternative seems inconsistent with the other kinds of unreducible match types which are simply left as is.
I also understand why an error might be desirable (e.g see neg/12974.scala) but as the previous example shows it isn't always preferable either.

#19954 (comment) might also be interesting to look at.


In any of the options, we should make sure to keep MatchTypeLegacyPattern consistent with MatchTypeNoCases.

@odersky
Copy link
Contributor

odersky commented Mar 16, 2024

Logically, a match type with no matching cases should simply not reduce (or return NoType, and then upstream we'd not reduce when we see that). When that causes follow-on erorrs (e.g. TypeMismatch) we show the failing reduction in ShowMatchTrace.

@EugeneFlesselle
Copy link
Contributor Author

Logically, a match type with no matching cases should simply not reduce (or return NoType, and then upstream we'd not reduce when we see that). When that causes follow-on erorrs (e.g. TypeMismatch) we show the failing reduction in ShowMatchTrace.

Okay, that is exactly what #19954 implements. @sjrd mentioned this requiring a SIP #19954 (comment). If so, should I merge it with covariance improvements ?

@dwijnand
Copy link
Member

There are 3 possible solutions:

  1. Handling the case in TypeComparer
  2. Reverting to throwing a TypeError
  3. Reverting to returning NoType

My preference is 1 over 3 over 2, fwiw.

odersky added a commit that referenced this issue Mar 27, 2024
Match type reduction can fail for any of the following reasons:
- EmptyScrutinee: would be unsound to reduce
- Stuck: selector does not match a case and is not provably disjoint
from it either
- NoInstance: selector does not uniquely determine params captures in
pattern
- NoMatches: selector matches none of the cases
- LegacyPattern: match type contains an illegal case and sourceVersion
>= 3.4

Out of those, only Stuck and NoInstance, *could* get reduced in a
refined context.

## Status quo

The match reducer returns:
- `ErrorType` for NoMatches and LegacyPattern,
- `NoType`, which implies the match type is left unreduced, in all other
cases.

In addition, the implementation has an issue where the `ErrorType`s can
be left unreported, then entering the flexible type logic, thereby
conforming to anything.

## Proposed changes

In addition to fixing the aforementioned bug, this PR proposes to leave
all unreducible match types as unreduced.
Of course the reduction may be needed at a later point for conformance,
in which case the error message will still contain the same explanations
from the `MatchTypeTrace`.

Fixes #19949 
Fixes #19950 

## Discussion

All cases of failed match type reductions which we know will never
reduce, even with refined scrutinee, should have a consistent behaviour.
So NoMatches and EmptyScrutinee should either both be an error or both
be left unreduced.

The current implementation attempts to do the former approach (but only
for NoMatches), which has some limitations as discussed below (I'm not
saying I can do better, hence the latter approach).

### Undesirable errors

We dont always want an error for a NoMatches failed reduction, for
example if we just need `Nothing` to conform to it:
```scala 3
trait TupleWrap[T <: Tuple]:  
  def head: Tuple.Head[T]  
  
object EmptyTupleWrap extends TupleWrap[EmptyTuple]:  
  def head = throw NoSuchElementException() // Error:
// |      ^  
// |      Match type reduction failed since selector EmptyTuple  
// |      matches none of the cases
```
But we could do `def head: Nothing = ...` to avoid the error here. 

Generally speaking, places where the bounds of the match type suffice
can still get a reduction error, and adding an ascription to avoid an
inferred match type doesn't always do the trick.

Another refused example could be:
```scala 3
type Default[N <: Int] = N match  
  case 0 => 'a' | 'c'
  case 1 => 'b' | 'd'
  
def default(n: Int): Option[Default[n.type]] = n match  
  case _: (0 | 1) => Some[Default[n.type]]:  
    n match  
      case _: 0 => 'a' 
      case _: 1 => 'b'
  case _ => None  
  
default(2): Option[Char] // Error  
// |   ^  
// |   Match type reduction failed since selector (2 : Int)  
// |   matches none of the cases
```
even though the function looks reasonable and type-checking would be
sound.

### Missed errors

Also note in the `EmptyTupleWrap` example, we get a reduction error from
a match type application which does not appear in the source code. A
valid question might be when and for what exactly these conditions are
checked ?

The goal is to report a type error early on for a NoMatches application
right, but we are actually only doing so if we happen to do
`tryNormalize` and end up in the `MatchReducer`.

Here is an example where were a match type with NoMatches is accepted
```scala 3
trait A:
  type X
  type R = X match
    case 0 => 'a'
    case 1 => 'b'

trait B extends A:
  type S = 2

type R1 = B#R // no error
```

Generally speaking, the NoMatches error can be circumvented with:
```scala 3
type AllowNoMatchesM[X] = {
  type X1 = X
  type R = X1 match
    case 0 => 'a'
    case 1 => 'b'
}#R
type R2 = AllowNoMatchesM[2] // no error
```

Also note the projections are used in the examples for simplicity but
are not necessary, `R` *can be* used within `B` as unreduced without a
reported error.

See #19799 for another example of inconsistent errors
@Kordyjan Kordyjan added this to the 3.5.0 milestone May 10, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment