With this post I’m trying to give some use cases, in context, of where unions could be more ergonomic. (Apologies for a rather long post…)
To set the use cases in context of real code I will use the domain of music and try to extract “minimal” parts of a library I’m working on while learning about the shiny new Scala 3 goodies.
First I wanted to try to use unions for working with empty values (in the context of music, I have used it for silence, and non-available notes etc. coming later), esp. when the heavier monadic and parameterized Option feels a bit bulky, and also without resorting to null and Null, byt simply:
sealed abstract class Empty
object Empty extends Empty:
override def toString = "Empty"
… and then by extension of T | Empty
providing some useful goodies and also a shim to Option, like so:
extension [T](x: T | Empty)
def isEmpty: Boolean = x.isInstanceOf[Empty]
def nonEmpty: Boolean = !isEmpty
def get: T = x match
case Empty => throw new NoSuchElementException("Empty.get")
case _ => x.asInstanceOf[T]
def getOrElse(default: T): T = x match
case Empty => default
case _ => x.asInstanceOf[T]
def toOption: Option[T] = x match
case Empty => None
case _ => Option(x.asInstanceOf[T])
end extension
Enhancement 1: more precise matching
A first observation is the use of asInstanceOf[T]
, that would have been nice to avoid. I think (in my possibly naive assumption) that if case Empty
has not fired then the compiler should be able to see that the rest what is left of T | Empty
is T
, but the compiler stops at Matchable
:
scala> extension [T](x: T | Empty)
| def toOption: Option[T] = x match
| case Empty => None
| case t: T => Option(t)
|
4 | case t: T => Option(t)
| ^
| pattern selector should be an instance of Matchable,
| but it has unmatchable type T | Empty instead
Enhancement 2: more precise inference
A second observation is that the compiler can infer something else than matchable if there are only two members of my union, but if more it doesn’t:
scala> val ie: Int | Empty = 42
val ie: Int | Empty = 42
scala> ie.get
val res3: Int = 42 // This is a nice and precise type :)
scala> val ise: Int | String | Empty = 42
val ise: Int | String | Empty = 42
scala> ise.get
val res4: Matchable = 42 // Would have been nice if it was inferred as Int | String
I have reported this here: Make type inference preserve user-written union types with more than two members to infer a more precise type than Matchable · Issue #11449 · lampepfl/dotty · GitHub
Now to the use case of union instead of using a hierarchy in a musical context.
First some basic integer boxings before we go on, including the notions of Pitch
(modeled as a positive integer not more than 127, just as in the MIDI standard) and the notion of PitchClass
that represents harmonic equivalence over octaves using modulus 12, and finally the representation of relative distance between pitches, called an Interval
:
final case class Pitch private (toInt: Int)
object Pitch:
def apply(i: Int): Pitch =
new Pitch(if i < 0 then 0 else if i > 127 then 127 else i)
final case class PitchClass private (toInt: Int)
object PitchClass:
def apply(i: Int): PitchClass = new PitchClass(math.floorMod(i, 12))
final case class Interval private (toInt: Int)
object Interval:
def apply(i: Int): Interval =
new Interval(if i < -127 then -127 else if i > 127 then 127 else i)
With these definitions we can now model a sequence of relative intervals (Steps
) and a musical Scale
and a Chord
, that later will be used to form a union.
case class Steps(toVector: Vector[Interval])
case class Scale(root: PitchClass, steps: Steps):
def pitchClasses: Set[PitchClass] =
var sum = 0
val xs = collection.mutable.ListBuffer(Interval(0))
for (step <- steps.toVector) do
sum += step.toInt
xs.append(Interval(sum))
xs.dropRight(1).map(iv => PitchClass(root.toInt + iv.toInt)).toSet
case class Chord(root: PitchClass, pitchSet: Set[Pitch]):
def pitchClasses: Set[PitchClass] = pitchSet.map(p => PitchClass(p.toInt))
case class Tuning(strings: Vector[Pitch]):
def apply(string: Int, pos: Int): Pitch = Pitch(strings(string).toInt + pos)
Enhancement 3: automatic matching on common members
For some fretted string instruments such as a guitar, we can model a fret board called Fret
with strings that have a Tuning
. The purpose of Fret
is to (in terminal with ASCII graphics) show a visualization of a fret board that highlights selected pitches, based on a musical context, including a scale or a chord or just a set of pitches, given a certain tuning.
Here a union Filter
is used to determine which pitches are supposed to be included in a fret board visualization (look at the type aliases in object Fret
):
case class Fret(tuning: Tuning, selected: Fret.Filter = Empty):
val pitchesOnString: Fret.Matrix = selected match
case Empty => Fret.pitchMatrix(tuning)
case pcs: Set[PitchClass] => Fret.filter(Fret.pitchMatrix(tuning), pcs)
case s: Scale => Fret.filter(Fret.pitchMatrix(tuning), s.pitchClasses)
case c: Chord => Fret.filter(Fret.pitchMatrix(tuning), c.pitchClasses)
object Fret:
val MaxNbrOfFrets = 18
type Matrix = Vector[Vector[Pitch | Empty]]
type Filter = Empty | Set[PitchClass] | Scale | Chord
def pitchMatrix(t: Tuning): Matrix =
Vector.tabulate(t.strings.size)(s => Vector.tabulate(MaxNbrOfFrets)(f => t(s,f)))
def filter(m: Matrix, pcs: Set[PitchClass]): Matrix = m.map { string =>
string.map(pitchOrEmpty =>
if pitchOrEmpty.nonEmpty && pcs.contains(PitchClass(pitchOrEmpty.get.toInt))
then pitchOrEmpty else Empty
)
}
So what is the point of this example? The point is that Scale
and Chord
and Set[PitchClass]
are not, from a domain perspective, related in a way that seems to motivate a base type and a hierarchy. Of course we could have constructed one, but a union seems better IMHO.
What is the problem with this? If you look at the match used when constructing pitchesOnString
in case class Fret
you see code duplication. Could we get rid of it? At least not currently this way:
scala>
case class Fret(tuning: Tuning, selected: Fret.Filter = Empty):
val pitchesOnString: Fret.Matrix = selected match
case Empty => Fret.pitchMatrix(tuning)
case pcs: Set[PitchClass] => Fret.filter(Fret.pitchMatrix(tuning), pcs)
case x: (Scale | Chord) => Fret.filter(Fret.pitchMatrix(tuning), x.pitchClasses)
5 | case x: (Scale | Chord) => Fret.filter(Fret.pitchMatrix(tuning), x.pitchClasses)
| ^^^^^^^^^^^^^^
| value pitchClasses is not a member of Scale | Chord
And that is the motivation for the initial example in the first post: it would be great if the above would “just work”
In summary:
-
There are domain modelling cases where a union fits better than a hierarchy, but the ergonomics could be improved to make the modelling with unions more pleasant.
-
When matching, the set of union members possible in a case could be trimmed as cases are subsequently proven not to match (see enhancement 1 above)
-
Type inference of unions with more than 2 members could be more precise (see enhancement 2 above)
-
Structural members could be found to reduce code duplication and avoid clunky matches (see the initial code snippet in the first post in this thread, and enhancement 3 above).
Hope this helps in further discussions of union use cases. (I’m also very open to scrutiny of my modelling choices above )