Nullable arguments for non-nullable parameters with default values


#1

I recently encountered a scenario where I wanted to be able to provide a nullable type argument to a function where the corresponding parameter was non-nullable with a default value. (My specific case was to do with processing an optional parameter when handling a HTTP request)

Consider this as an example:

fun foo(x: Long = 1) { ... }
fun bar(y: Long?): {
    bar(y) // Error: "Required: Long \nFound: Long?"
}

My thought is that in the case where there is a provided default value, there should be a concise/succinct way of calling this function without writing a bunch of control flow.

The ‘need’ for a way to do this becomes more obvious when there is a number of these parameters (especially with non obvious defaults):

fun baz(a: Long = 1, b: Long = 200, c: Long = 40) { ... }

fun bar(y: Long?): {
    val a: Long? = ...
    val b: Long? = ...
    val c: Long? = ...
    // baz(a,b,c) doesn't compile so...
    when {
      a != null && b != null && c != null -> baz(a,b,c)
      a != null && b != null -> baz(a = a, b = b)
      a != null && c != null -> baz(a = a, c = c)
      b != null && c != null -> baz(b = b, c = c)
      a != null -> baz(a = a)
      b != null -> baz(b = b)
      c != null -> baz(c = c)
      else -> baz()
    }

}

Which as you can see, with even three parameters is rife with potential for making mistakes. (I think a chain of elvis operators would be just as bad)

The workaround I’m aware of is to do something like what follows below, but we lose the niceness of the defaults in the function signature, and is kinda verbose/ugly

fun foo(X: Long?) { // Or X: Long? = null as suggested by fatjoe79 below
    val x = X? : 1
    // The rest of the original function
}
fun bar(y: Long?): {
    bar(y)
}

My proposal is a mechanism for allowing nullable arguments to be used for non-nullable parameters that have default values. I can’t think of a scenario where this would cause problems (I’m no expert though).

Some proposed mechanisms/ideas:

  • Have the compiler perform/implement the above workaround automatically when default arguments are present (Edit: Perhaps a different symbol for this behaviour when when defining the default parameter such as =?)

  • Allow Unit() to be passed instead null via an operator similar to the safe call (?.) or elvis operator (?: ) and do the same thing (My uninformed understanding is that Unit is kinda reserved for use by the language, this might be a case for that)

  • Only allow this functionality when ‘named arguments’ are used

I’m really interested in what people think of this so please point out any pitfalls you see or alternative mechanisms. Kotlin is very good for minimising overly verbose code, I think that there needs to be a way to provide arguments to a function when the data decides whether a parameter is available or not to take this all the way.


#2

Saw this on Reddit: https://www.reddit.com/r/Kotlin/comments/aejoo9/providing_null_as_the_argument_to_a_nonnullable/

Basically, sound like you want to “pass val if not-null or use default value if null”. So instead of:

fun foo(x: Long = 1) { ... }
fun bar(y: Long?): {
    bar(y) // Error: "Required: Long \nFound: Long?"
}

try (untested, just typed in this forum):

fun foo(x: Long = 1) { ... }
fun bar(y: Long?): {
    if (y == null) foo() else foo(y)
}

I understand your example is trivial, but simply put you cannot reasonably extract a default parameter value which means you can’t do a “something or default param” without doing it at the call site. I don’t believe the language needs to support this use case in any way and if the library author wanted to extract the default to something meaningful, they could make it available separately, e.g.

const val FOO_DEFAULT = 1
fun foo(x: Long = FOO_DEFAULT) { ... }
fun bar(y: Long?): {
    foo(y ?: FOO_DEFAULT)
}

#3

Another way of solving this is a slightly adjusted pattern:

fun foo(x: Long? = null) {
    val x = x ?: 1
    ....
}

This way a null argument is handled in the same way as an omitted argument.


#4

Basically, sound like you want to “pass val if not-null or use default value if null”

Yes, that’s it.

if (y == null) foo() else foo(y)

That will work, but it’s the kind of control flow that I was hoping could be avoided, it also grows very fast for several nullable parameters (see the original ‘when’ clause)

const val FOO_DEFAULT = 1
fun foo(x: Long = FOO_DEFAULT) { ... }
fun bar(y: Long?): {
    foo(y ?: FOO_DEFAULT)
}

While the default may be defined in just one place, we now have to check for nulls (via the elvis operator or otherwise) for every invocation of bar with a nullable parameter. This is exactly what I was hoping the language could help us with.

For example imagine a simple ‘$’ operator (replace it with whatever symbol you like) that allows replaces the a null with the default value of the parameter when present, allowing the following:

fun foo(x: Long = 1) { ... }
fun bar(y: Long?): {
    bar(y$)
}

The mechanism for this could be replacing null with Unit for example. I’m just trying to highlight the use of such a feature. The language doesn’t “need” to do it, it would just make the code a lot more concise.


#5

That’s actually what I’m using at the moment as a workaround, but now the default in the signature isn’t the actual one. Perhaps the compiler could allow us to do this with another way of defining default arguments.

Imagine a =? operator when defining default arguments. Code such as this

fun foo(x: Long =? 1) {
    ...
}

Could be ‘treated as /transformed into’ the functional equivalent of this, except that we keep the types we want

fun foo(x: Long? = null) {
val x = x ?: 1
....
}

The benefit is that we get to define our defaults in one place, keep the type of the parameter as non-nullable in the function, and we can provide nullable arguments to the function which means we don’t have to reference the defaults outside the function it is meant for.


#6

Default arguments are currently a compiler feature. There is no implicit check at runtime that checks for the absence of the argument. It is all checked and resolved by the compiler.

Altering the behavior of default arguments regarding null values would mean that this is not anymore a compile-time feature. An implicit check at runtime needs to be added. As such I would not like to have the existing feature be changed to this.

This is the most feasible suggestion so far, as the original feature can remain unchanged.


#7

Another option is to designate some syntax, a placeholder like _, to pass the default value to a parameter, so you call a function specifying its parameter but omitting its value:

foo(_)
// or
foo(x = _)
// equivalent to just
foo()

Then this placeholder could be used as a right-hand side of the elvis operator to substitute null with a default value:

foo(y ?: _)

See the related proposals KT-18695 and KT-27627.


#8

I like the change you proposed the most. When considering the other ideas so far I can only get behind the first one and only if it would not introduce a new syntax. Otherwise we would have 2 different default argument solutions which would just make the language confusing. Also =? would just be superior (except maybe for some really high performance situations).
Shifting the change from the declaration side to the call side would not only remove this problem, but it might also be useful in other cases to be able to access the default value.

Then there is this rare case (and I don’t know if it ever is used) but I guess people could have a code like this

val f = Foo()
fun foo(v: Foo? = f)
foo(null)

If we just replace null with the default value this would no longer work. So we would have to use =? and have 2 different default variations (which I already explained I don’t like)