How to do the 'right' thing with this refutable pattern: val (_ :: _ :: Nil) = List(1,2)?

On the contributors forum a discussion is taking place about replacing @unchecked and turning refutable patterns into static errors. But what would be the correct way for the user to circumvent this warning (which will become an error on later Scala versions) in the first place? My question comes from the use of this pattern in my code which issued no warnings before:

def work(file: File, tests: File => Boolean *): List[Boolean]

The idea is that the method opens a file, performs a fixed number of tests at once, and then returns the same number of answers. So if you do:

val (result1 :: result2 :: Nil) = work(file,test1,test2) 

you will know that it will issue no runtime errors, but the compile cannot know this of course. But what is surprising, at least to me, is that the warning seems only indirectly related to the underlying problem, namely that the number of items must be equal. This code gives the same compile time warning on all lines:

val (_ :: _ :: Nil) = List(1)      // compile time warning, runtime error
val (_ :: _ :: Nil) = List(1,2)    // compile time warning
val (_ :: _ :: Nil) = List(1,2,3)  // compile time warning, runtime error

It is only at runtime the first and third line fail.

Now, if I try to define my own list class (called Series, with :|: instead of :: and Empty instead of Nil) with a compile time fixed number of items, this still gives the same warning, albeit that the wrong number of items is now an error at compile time:

val (_ :|: _ :|: Empty) = Series(1)      // compile time error
val (_ :|: _ :|: Empty) = Series(1,2)    // compile time warning
val (_ :|: _ :|: Empty) = Series(1,2,3)  // compile time error

What would be the correct way to program this, so that no warning is issued at compile time when the number of items is correct, and a compile time error when it is not?

The current way to do it would be

val result1 :: result2 :: Nil = work(file,test1,test2): @unchecked

but I find this not ideal. In Pre SIP: Replace non-sensical @unchecked annotations - Scala Improvement Process - Scala Contributors I proposed one of the two following alternatives instead:

val result1 :: result2 :: Nil = work(file,test1,test2).checkedCast
val result1 :: result2 :: Nil = work(file,test1,test2).checked
1 Like

you could try this:

type IsVarArg[Ts <: Tuple, T] = Ts <:< Tuple.Map[Ts, [_] =>> T]
def work[Ts <: Tuple]
  (file: File, tests: Ts)(using ev: IsVarArg[Ts, File => Boolean]): Tuple.Map[Ts, [_] =>> Boolean] = ???

scala> def result3 = work(File(), ((_: File) => true, (_: File) => false, (_: File) => true))
def result3: (Boolean, Boolean, Boolean)

scala> lazy val (r1, r2, r3) = work(File(), ((_: File) => true, (_: File) => false, (_: File) => true))
def r1: Boolean
def r2: Boolean
def r3: Boolean

the benefit then is that the results are type safe

2 Likes

This solution is far more than ā€œnot idealā€, it is disastrous because although @unchecked removes the warning on:

val result1 :: result2 :: Nil = work(file,test1,test2): @unchecked

it also removes the warning on:

val result1 :: Nil = work(file,test1,test2): @unchecked

which then still leads to a run time error. You could of course argo, this is the responsibility of the programmer, but i do not see it that way. For suppressing a warning where the programmer takes responsibility we have @nowarn, which loose tells the compiler: ā€œI heard you, there is nothing wrong hereā€. If it the fails after all, the compiler is not to blame, for ā€œhe told you so, you choose to ignore itā€. Btw: @nowarn does effectively the same here

val result1 :: result2 :: Nil = work(file,test1,test2): @nowarn
val result1 :: Nil = work(file,test1,test2): @nowarn

both compile without warning.

But this post was not meant to find a good replacement for @unchecked, that can best be ā€˜solvedā€™ in the contributors thread. Although i think it would be better if non was needed.

My post was a question about, how to program this without the need of annotations or ā€œmagicā€ methods, in such a way that it either fails at compile time or does not warn at compile time and works at runtime.

with the method I posted above you also get accurate warnings when the pattern doesnt match:

scala> val (r1, r3) = work(File(), ((_: File) => true, (_: File) => false, (_: File) => true))
1 warning found
-- Warning: --------------------------------------------------------------------
1 |val (r1, r3) = work(File(), ((_: File) => true, (_: File) => false, (_: File) => true))
  |               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |pattern's type (Any, Any) does not match the right hand side expression's type (Boolean, Boolean, Boolean)
  |
  |If the narrowing is intentional, this can be communicated by adding `: @unchecked` after the expression,
  |which may result in a MatchError at runtime.
scala.MatchError: (true,false,true) (of class scala.Tuple3)
  ... 30 elided
2 Likes

Yes, your idea is very good, to use the length information that is already in the tuples and preserve that with a Map on the Tuple. Thank you!

I have some second thoughts @bishabosha about your solution, for i am not able to implement this without type check warnings and the use of ā€œasInstanceOfā€, but this could be due to my limited knowledge about this: First attempt:

type IsVarArg[Ts <: Tuple, T] = Ts <:< Tuple.Map[Ts, [_] =>> T]

def work1[Ts <: Tuple](file: File, tests: Ts)(using ev: IsVarArg[Ts, File => Boolean]): Tuple.Map[Ts, [_] =>> Boolean] =
  @annotation.tailrec
  def applyTestsTo[U <: Tuple](fs: U, acc: Tuple): Tuple = fs match
    case EmptyTuple => acc
    case ((f: (File => Boolean)) *: tail) => applyTestsTo(tail, acc ++ Tuple(f(file)))
  applyTestsTo(tests, EmptyTuple).asInstanceOf[Tuple.Map[Ts, [_] =>> Boolean]]

or made somewhat simpler:

type FileTests[T <: Tuple] = Tuple.Map[T, [File] =>> Boolean]

def work2[Ts <: Tuple](file: File, tests: Ts): FileTests[Ts] =
  @annotation.tailrec
  def applyTestsTo[U <: Tuple](fs: U, acc: Tuple): Tuple = fs match
    case EmptyTuple => acc
    case ((f: (File => Boolean)) *: tail) => applyTestsTo(tail, acc ++ Tuple(f(file)))
  applyTestsTo(tests, EmptyTuple).asInstanceOf[FileTests[Ts]]

but both give

[warn] -- [E029] Pattern Match Exhaustivity Warning: /Volumes/DATA/Sense2Act/Software/ws-scala/scala3/src/main/scala/typestest.scala:20:61 
[warn]    |    def applyTestsTo[U <: Tuple](fs: U, acc: Tuple): Tuple = fs match
[warn]    |                                                             ^^
[warn]    |                                 match may not be exhaustive.
[warn]    |                                 It would fail on pattern case: *:(_, _)

and

[warn] -- Unchecked Warning: /Volumes/DATA/Sense2Act/Software/ws-scala/scala3/src/main/scala/typestest.scala:22:13 
[warn]    |      case ((f: (File => Boolean)) *: tail) => applyTestsTo(tail, acc ++ Tuple(f(file)))
[warn]    |             ^
[warn]    |the type test for Main.File => Boolean cannot be checked at runtime because its type arguments can't be determined from Any

So, although this solution works, and moves warnings from usage to implementation (which is an improvement), this still does not feel right. Is this solvable?

Just to partially answer my own question here, i found a solution that removes the warnings and gives the same level of type protection:

type Result[T <: Tuple] <: Tuple = T match
  case EmptyTuple => EmptyTuple
  case h *: t => ([File] =>> Boolean)[h] *: Result[t]

inline def work3[T <: Tuple](file: File, tests : T): Result[T] = inline tests match
  case x: EmptyTuple => x.asInstanceOf[Result[T]]
  case elms: ((File => Boolean) *: rest) => elms match
    case test *: tail => (test(file) *: work3[rest](file,tail)).asInstanceOf[Result[T]]

It is also simpler, but, the asInstanceOf is still needed, which feels awkward somehow.

Finally, just for the record and for people passing by with the same challenge, i decided for an even simpler approach, which gives a decent error when the match is not possible, based on the idea to capture an array inside a class with a singleton type for the size and specialised constructors, the signature (see Scastie for the complete code):

class Few[T: ClassTag, N <: Int] private (array: Array[T])(using ValueOf[N]) :
  final val size: N
  /** Returns the M'th element of this sequence at compile time */
  inline def apply[M <: Int](using ValueOf[M]): T
  /** Returns the m'th element of this sequence at run time (may cause exception) */
  def apply(m: Int): T
  /** Returns the m'th element of this sequence at run time if present, otherwise None. */
  def get(m: Int): Option[T]
  /* Returns a new instance of Few where all elements are function applied. */
  def map[U: ClassTag](f: T => U)

object Few :
  def apply[T: ClassTag]()
  def apply[T: ClassTag](a:T)
  def apply[T: ClassTag](a:T, b:T)
  /* Extend when needed ... */
  def unapply[T](f: Few[T,0]) 
  def unapply[T](f: Few[T,1]) 
  def unapply[T](f: Few[T,2])

use like:

def work[N <: Int](file: File, tests: Few[File => Boolean,N]): Few[Boolean,N] = tests.map(_(file))
val Few(res1,res2) = work(file, Few((_: File) => false, (_: File) => true)) //works, no warning
val Few(res1) = work(file, Few((_: File) => false, (_: File) => true)) //compile time error.