SLC: Optional traits to simplify creation of extractor objects

This is an SLC

Sketch implementation:

trait Extractor[-In, +Out]:
  def unapply(scrutinee: In): Option[Out]

trait ExtractorSeq[-In, +Out]:
  def unapplySeq(scrutinee: In): Option[Seq[Out]]

The idea is to allow simpler creation of extractor objects, notably through SAM-conversion[1]
In particular it would make it simpler to create custom string interpolation extractors:

case class Point(x: Double, y: Double)

abstract class Extractor[-In, +Out]:
  def unapply(scrutinee: In): Option[Out]

extension (sc: StringContext)
  def p: Extractor[Point, (Double, Double)] = point =>
      sc.parts match

        // checks if the pattern is p"$a,$b" or p"$a, $b"
        case Seq("", "," | ", ", "") =>
          Some((point.x, point.y))

        case _ =>
          throw IllegalArgumentException("The pattern was not well-formed")

Point(2, 3) match
//  case p"$x$y" => x + y // IllegalArgumentException: The pattern was not well-formed
  case p"$x,$y" => x + y

It would also allow extractor objects to stand out more:

class MyClass(x: Int):
  // a bunch of things, but also an `unapply` method to avoid the wrapping into option (efficiency gain)

// vs

class MyClass(x: Int) extends Extractor[MyClass, Int]:
  // a bunch of things, but also an `unapply` method to avoid the wrapping into option (efficiency gain)

It should probably have a smarter type than my proposal, since:

  1. It does not handle Boolean return types
  2. It does not handle Some return types
  3. It does not infer the Out type from the expression

Furthermore there is some discrepancy in the return type depending on the number of objects extracted:
0 objects: Boolean
1 object: Option[T]
2 objects: Option[(T, U)]
So we might want to make Out always take a tuple

Eventually, we could even make Extractor and ExtractorSeq (required) marker traits for extractor objects

This is definitely not my most polished proposal, but I hope we can refine it together !


  1. I was not able to find a better source for SAM-conversion, if you know of one, please let me know ↩︎

Try to put PartialFunction in place of the Extractor trait. Isn’t sufficient?
Like here alpaca/src/alpaca/internal/parser/ParserExtractors.scala at master · halotukozak/alpaca · GitHub

It does work for the specific example I gave:

But it has several drawbacks:

  1. Does not work for unapplySeq
  2. Only works for extractors of arity 1 or more (always returns Option[...], never Boolean)
  3. Does not allow for irrefutable patterns (return type Some[...]/true)
    1. For this point, we could allow an unapply that returns Some[...] on Function
  4. It’s not very clear what is going on, I for one didn’t even know PartialFunction defined an unapply method
  5. This cannot be used as a marker trait to say “I have an extractor”
1 Like

Maybe this is entering SIP territory, but I think we should rework the way extractor objects work entirely

Current Situation

We have:

  1. Two different methods: unapply vs unapplySeq

  2. return type bool vs Option[T] vs Option[(U, V, ...)] vs Option[Seq[T]] (for unapplySeq)

  3. Extractor does not know the amount of expected slots, so the scrutinee defines the number of valid sub-patterns

For 2. for example this means when you write Extractor(a, b) you could have written Extractor((a, b)) or even Extractor(tuple) (Where T =:= (U, V)), but only if Extractor uses unapply !

For 3. this means you cannot create an opposite unapply to the following:

class Rectangle private(x: Int, y: Int)

object Rectangle:
  def apply(side: Int) = new Rectangle(side, side)
  def apply(x: Int, y: Int) = new Rectangle(x, y)

For example:

class Rectangle private(val x: Int, val y: Int)

object Rectangle:
  def apply(side: Int) = new Rectangle(side, side)
  def apply(x: Int, y: Int) = new Rectangle(x, y)

  def unapplySeq(r: Rectangle): Some[Seq[Int]] =
    if r.x == r.y then
      return Some(Seq(r.x))
    else
      return Some(Seq(r.x, r.y))

Rectangle(1) match
  case Rectangle(x) => "a"
  case Rectangle(x, y) => "b"

Rectangle(1, 1) match
  case Rectangle(x, y) => "b" // MatchError

Rectangle(1, 2) match
  case Rectangle(x) => "a"
  case Rectangle(x, y) => "b"

(I think you can get around this with macros, but should we need macros for an example as simple as this one ?)

Proposal

Therefore we should have something like (name TBD)


def unapplyV2(scrutinee: S, numSlots: Int): Opt[T]

Where

  • S is the type being extracted
  • numSlots is either the number of sub-patterns, or -1 in the case MyExtractor(elems*)
  • Opt is either Option or Some (or None.type with no T ?)
  • T is a subtype of Tuple

A warning is emitted if:

  1. There is an unapplyV2 and either an unapply or unapplySeq on a class
  2. unapplyV2’s numSlots is not compatible with T
    Example def unapplyV2(scrutinee: S, numSlots: 2): Option[(Int, Int, Int)]

For parity with unapplySeq we would need a TupleOf[A] <: Tuple where all elements are subtypes of A: (A, A) <: TupleOf[A]

Examples:

def unapply(o: SomeObject): Boolean
// becomes
def unapplyV2(o: SomeObject, numSlots: 0): Option[(,)]

def unapply(p: Point): Some[(Int, Int)]
// becomes
def unapplyV2(p: Point, numSlots: 2): Some[(Int, Int)]

def unapplySeq(r: Rectangle): Some[Seq[Int]]
// becomes
def unapplyV2(r: Rectangle, numSlots: 1 | 2): Option[(Int,) | (Int, Int)]
// or, to be more precise
def unapplyV2(r: Rectangle, numSlots: 1 | 2): Option[(Int,)] | Some[(Int, Int)]

def unapplySeq(s: List): Option[Seq[Int]]
// becomes
def unapplyV2(s: List, numSlots: Int): Option[TupleOf[T]]

Since the most common case is the one with an output where the tuple is of known size, we should allow a variant without numSlots

Counter proposal

Have unapplyForN methods which extract N elements:


def unapplyForN(scrutinee: S): Opt[T]

where T is a subtype of TupleN

// Example
object Rectangle:
  def unapplyFor1(r: Rectangle): Option[(Int,)] = Option.when(r.x == r.y)(r.x)
  def unapplyFor2(r: Rectangle): Some[(Int, Int)] = Some(r.x, r.y)

And unapplyForSeq in the special case of MyExtractor(elems*)

In this scheme, unapplySeq is harder to do, as you need to go through Dynamic

Notable deviation

Currently, only a single number of sub-pattern can match for any scrutinee, but this is no longer the case, therefore the meaning of MyExtractor(elems*) is muddier, for example:

Rectangle(1) match
  case Rectangle(elems*) =>

Rectangle(1, 1) match
  case Rectangle(elems*) =>

Could be reasonably expected do one of four things:

  1. Be invalid, if -1 is not a subtype of numSlots’s type (for example numSlots: 1 | 2)
  2. Return Seq(1) for both cases
  3. Return Seq(1, 1) for both cases
  4. Rectangle having extra logic which allows it to remember how it was initialized, and return Seq(1) for the first and Seq(1, 1) for the second (but I would highly discourage this !)

An other option would be some sort of type class like:

trait Extractable[-Scrutinee]:
  type Out
  def unapply(scrutinee: Scrutinee): Option[Out]

trait ExtractableSeq[-Scrutinee]:
  type Out
  def unapply(scrutinee: Scrutinee): Option[Seq[Out]]

(But this would also be cleaner with def unapplyV2)