Pre-SIP: Optional parameters by symetry with varargs

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:

  1. Options are really nice to work with, and usually idiomatic to mean “an optional T”, but the call-site is messy (fillValue = Some(0))
  2. Sentinels make the call site seamless (fillValue = 0), but require extra boilerplate, and requires T not 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:

  1. The vararg must be the last parameter
    (def foo(xs: Int*, s: String = "") is not allowed)
  2. If there are optional parameters before the vararg, they must be filled before the vararg
    (def foo(s: String = "", xs: Int*) cannot be called as foo(1) or foo(1, s = "hi"))
  3. => T* is valid, means => (T*), not (=> T)*
  4. () => 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:

  1. Named arguments still wrap: with def foo(x: Int?); foo(4), foo(x = 4), and foo(x = Some(4)?) are equivalent

The declaration side similarly has restrictions in line with default arguments (spec), with the additional restrictions:

  1. Optional parameters cannot have default values: x: Int? = 1 and x: Int? = Some(1) are invalid
  2. => T? is valid, means => (T?), not (=> T)? (same as for varargs)
  3. () => 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:

3 Likes

Hello, I’m taking the liberty of bumping this, since it seems to have been missed, and I think offers valuable contrast to the other T? proposal

perhaps if the other proposal is chosen then the spread part could be added on top?

The way the other proposal has been designed, I don’t really know if the spread would be useful, it doesn’t really go into details

My impression of this proposal was that it results in something very similar to the Converion[A, Option[A]] proposal when considering into. Specifically, it would allow the definition site to opt into not requiring a Some wrapper at use site. The difference would be that this would require additional syntax to pass an option type, while the conversion based variant would just keep accepting the option (by not applying the conversion). But I could not come up with a case where the ambiguity would be a problem.

Even with varargs, I have seen libraries to just add an overload that allows passing a Seq without *, and part of me wonders if that syntax could not also just be removed. It feels like something that was adopted from dynamic languages that need syntax to disambiguate the intended types.

The ? feels like a very different proposal overall, as its really more about thinking about if the representation of error values. Also it does require explicit nulls.

That makes a lot of sense given they try to solve the same problem !


Here are some basic ones:

(SLC: `Conversion[A, Option[A]]` - #11 by Sporarum)

See also SLC: `Conversion[A, Option[A]]` - #24 by Sporarum for a more realistic example


Given all the theoretical problems that overloading cause, it feels like any solutions which has similar properties will probably inherit at least some of those issues
(and see the examples above for ways in which it manifests)


Yes absolutely, it is a much more complex proposal !
Which is why I think this proposal is valuable, even if only as contrast
(although I aspire for it to be more than that)

I mean, I see the weird edgecases for the conversion proposal, but they don’t seem to be problematic, as the semantics is relatively straightforward to reason about based on the types.

Yes, adding syntax to disambiguate would be more explicit, but if the main usecase are default arguments, is that worth the cost of adding additional syntax? Not clear to me. “just one more special case” seems never good with language design.

So the question would be if we could not maybe generalize. Maybe we should require some additional syntax at the call site to try implicit conversions. Or maybe with an into type, we should require that a conversion is applied, and require some additional syntax if not (which would essentially have the same effect as your proposal as far as I can tell).

1 Like