Meta: The proposal below would benefit from more details (see #future-work), and maybe some polish. I do not have more time to dedicate to it, but still wanted to publish it given other recent proposal also aim to fix that same problem. I hope it will be an helpful addition to the discussion (whether or not it is chosen) !
The problem
It is often useful to have a method where we know whether a value was passed or not, for example:
/**
* Resize a list xs to be of size at most `newSize`
* If fillValue is passed an argument, it will be used to fill the missing indices
* guaranteeing the list is of size `newSize`
* Otherwise, the list will not have elements added
*/
def resize[T](xs: List[T], newSize: Int, fillValue: T = ???): List[T] =
if xs.size <= newSize then
xs.take(newSize)
else
if ???(fillValue) then
xs ++ List.fill(newSize - xs.size)(fillValue)
else
xs
There are currently two ways of doing this:
def resize[T](xs: List[T], newSize: Int, fillValue: Option[T] = None): List[T] =
fillValue match
case Some(defaultV) =>
xs ++ List.fill(newSize - xs.size)(defaultV)
case None =>
xs
resize(List(1, 2), 3) // List(1, 2)
resize(List(1, 2), 3, fillValue = Some(0)) // List(1, 2, 0)
object Sentinel
def resize[T](xs: List[T], newSize: Int, fillValue: T | Sentinel.type = Sentinel): List[T] =
if fillValue != Sentinel then
xs ++ List.fill(newSize - xs.size)(fillValue)
else
xs
resize(List(1, 2), 3) // List(1, 2)
resize(List(1, 2), 3, fillValue = 0) // List(1, 2, 0)
(The sentinel can also be null, but this makes it even harder to ensure point 2 below)
And each has its tradeoffs:
Options are really nice to work with, and usually idiomatic to mean “an optional T”, but the call-site is messy (fillValue = Some(0))- Sentinels make the call site seamless (
fillValue = 0), but require extra boilerplate, and requiresTnot to contain the sentinel value, which is not possible to enforce at compile-time (would require type complements)
It would be better if there was a single solution which made the callsite clean, and enforced the separation, while not requiring extra boilerplate.
This is the goal of this proposal
Some Background
The syntax for varargs is as follows:
def foo(xs: Int*)
From the outside, the arguments are passed as if foo took as many Int parameters as required:
foo()
foo(1)
foo(1, 2)
foo(1, 2, 3)
...
On the inside, the method has a single parameter xs of type Seq[Int]:
def foo(xs: Int*) =
val ys: Seq[Int] = xs // works
summon[xs.type <:< Seq[Int]] // more rigorous proof
And so we get:
def foo[T](xs: T*): String = s"was passed $xs"
foo() // was passed Seq()
foo(1) // was passed Seq(1)
foo(1, 2) // was passed Seq(1, 2)
foo(1, 2, 3) // was passed Seq(1, 2, 3)
(in reality it prints ArraySeq(...) which is an implementation of Seq)
If you already have a Seq, you can splice it like so:
val mySeq = Seq(1, 2, 3)
foo(mySeq*) // was passed Seq(1, 2, 3)
// as opposed to
foo(mySeq) // was passed Seq(Seq(1, 2, 3))
There are some restrictions/edge cases that are important for our disccusion:
- The vararg must be the last parameter
(def foo(xs: Int*, s: String = "")is not allowed) - If there are optional parameters before the vararg, they must be filled before the vararg
(def foo(s: String = "", xs: Int*)cannot be called asfoo(1)orfoo(1, s = "hi")) => T*is valid, means=> (T*), not(=> T)*() => T*is valid, means(() => T)*, not() => (T*)(opposite of above)
The proposal
def resize[T](xs: List[T], newSize: Int, fillValue: T?): List[T] =
fillValue match // acts like `Option[T]`
case Some(defaultV) =>
xs ++ List.fill(newSize - xs.size)(defaultV)
case None =>
xs
resize(List(1, 2), 3) // List(1, 2)
resize(List(1, 2), 3, fillValue = 0) // List(1, 2, 0) // passed like a `x: T = v`
By analogy with varargs, allow parameters of the form x: T? which acts like x: T = default from the outside, but acts as x: Option[T] on the inside:
def foo[T](x: T?): String = s"was passed $x"
foo() // was passed None
foo(1) // was passed Some(1)
foo(Some(1)) // was passed Some(Some(1))
In some cases, it is useful to programmatically decide whether to pass a value or not
For this we again parallel the syntax of varargs (vararg splices to be exact):
val myOption = someList.headOption
foo(myOption?) // x == myOption, not Some(myOption)
foo(None?) // was passed None
foo(Some(1)?) // was passed Some(1)
Another option would be to re-use * for this, I don’t have a preference so I would love your input:
val myOption = someList.headOption
foo(myOption*) // x == myOption, not Some(myOption)
foo(None*) // was passed None
foo(Some(1)*) // was passed Some(1)
As for T*, T? is only allowed as a parameter type. (val x: Int? is not allowed.)
Given x: T? acts very similarly on the outside as x: T = v, it should have the same rules on application (see tour of scala, spec), with the following additional property:
- Named arguments still wrap: with
def foo(x: Int?);foo(4),foo(x = 4), andfoo(x = Some(4)?)are equivalent
The declaration side similarly has restrictions in line with default arguments (spec), with the additional restrictions:
- Optional parameters cannot have default values:
x: Int? = 1andx: Int? = Some(1)are invalid => T?is valid, means=> (T?), not(=> T)?(same as for varargs)() => T*is valid, means(() => T)?, not() => (T?)(same as for varargs, opposite of above)
“Future Work”
Things that I wanted to put in this Pre-SIP, but didn’t have time for:
- Comparison with SLC: `Conversion[A, Option[A]]`
- Comparison with https://contributors.scala-lang.org/t/pre-sip-a-new-type-for-optionals-and-error-handling-2