Pattern matching with shapeless coproduct

2020-02-03 05:20发布

问题:

Can I use pattern matching with shapeless coproducts?

import shapeless.{CNil, :+:}

type ListOrString = List[Int] :+: String :+: CNil

def f(a: ListOrString): Int = a match {
  case 0 :: second :: Nil => second
  case first :: Nil => first
  case Nil => -1
  case string: String => string.toInt
}

That of course doesn't work, since a is boxed as a Coproduct.

Is there an alternative way to use coproducts and maintain the ability to pattern match?

回答1:

You can use the Inl and Inr constructors in the pattern match:

import shapeless.{ CNil, Inl, Inr, :+: }

type ListOrString = List[Int] :+: String :+: CNil

def f(a: ListOrString): Int = a match {
  case Inl(0 :: second :: Nil) => second
  case Inl(first :: Nil) => first
  case Inl(Nil) => -1
  case Inr(Inl(string)) => string.toInt
}

This approach isn't ideal because you have to handle the CNil case if you want the compiler to be able to tell that the match is exhaustive—we know that it's not possible for that case to match, but the compiler doesn't, so we have to do something like this:

def f(a: ListOrString): Int = a match {
  case Inl(0 :: second :: Nil) => second
  case Inl(first :: Nil) => first
  case Inl(Nil) => -1
  case Inl(other) => other.sum
  case Inr(Inl(string)) => string.toInt
  case Inr(Inr(_)) => sys.error("Impossible")
}

I also personally just find navigating to the appropriate positions in the coproduct with Inr and Inl a little counterintuitive.

In general it's better to fold over the coproduct with a polymorphic function value:

object losToInt extends shapeless.Poly1 {
  implicit val atList: Case.Aux[List[Int], Int] = at {
    case 0 :: second :: Nil => second
    case first :: Nil => first
    case Nil => -1
    case other => other.sum
  }

  implicit val atString: Case.Aux[String, Int] = at(_.toInt)
}

def f(a: ListOrString): Int = a.fold(losToInt)

Now the compiler will verify exhaustivity without you having to handle impossible cases.



回答2:

I just submitted Shapeless a pull request here that may work well for your needs. (Note that it is just a pull request and may undergo revisions or be rejected...but feel free to take the machinery and use it in your own code if you find it useful.)

From the commit message:

[...] a Coproduct c of type Int :+: String :+: Boolean :+: CNil could be folded into a Double as follows:

val result = c.foldCases[Double]
               .atCase(i => math.sqrt(i))
               .atCase(s => s.length.toDouble)
               .atCase(b => if (b) 100.0 else -1.0)

This provides some benefits over existing methods for folding over Coproducts. Unlike the Folder type class, this one does not require a polymorphic function with a stable identifier, so the syntax is somewhat lightweight and better suited to situations where the folding function is not reused (e.g., parser combinator libraries).

Additionally, unlike directly folding over a Coproduct with pattern matching over Inl and Inr injectors, this type class guarantees that the resulting fold is exhaustive. It is also possible to partially fold a Coproduct (as long as cases are handled in the order specified by the Coproduct type signature), which makes it possible to incrementally fold a Coproduct.

For your example, you could do this:

  def f(a: ListOrString): Int = a.foldCases[Int]
    .atCase(list => list match {
      case 0 :: second :: Nil => second
      case first :: Nil => first
      case Nil => -1
      case other => other.sum
    })
    .atCase(s => s.toInt)