Skip to content

Instantly share code, notes, and snippets.

@lrhn
Created April 2, 2019 11:48
Show Gist options
  • Save lrhn/47da5eb9f30d21e9228beca6e9414291 to your computer and use it in GitHub Desktop.
Save lrhn/47da5eb9f30d21e9228beca6e9414291 to your computer and use it in GitHub Desktop.
Nullable Parameters are Optional

Required Parameters

Dart has positional and named parameters, and positional parameters can be required or optional. Named parameters are always optional, but users want to have required named parameters too.

With NNBD, in optional non-nullable parameter with no default value does not make sense. If you call the function without a corresponding argument, there is no natural value to ascribe to the parameter. The parameter is, effectively, required. We'd like to disallow invalid calls statically, so this is important at the static type level as well.

This document attempts to solve these two issues (adding required named parameters, handling optional parameters with a non-nullable type and no default value in some way), and do so in a way that is consistent between named and positional parameters, and which uses a minimum of extra syntax.

Proposal

At the function type level:

  • All parameters with nullable types are optional.
  • All parameters with potentially non-nullable types are required.

This is consistent with function subtyping, a function type with a nullable type on a parameter is a subtype of one with the non-nullable version of the type on the corresponding parameter. We also always allowed function subtypes to have more optional parameters, either by adding more optional parameters, or by making existing required parameters into optional. This choice combines the contravariant type-based subtyping and the optional-required subtyping into one rule.

Default values are not part of function types.

At function declarations, the same thing applies, a nullable parameter is optional. It may have a default value, which must still be a constant value. If the default value is non-null, this promotes the parameter variable to the non-nullable version of its type in the body of the function.

  • It is an error to have a default value on a non-optional (non-nullable) parameter.

There is no requirement that a method override has the same default value as the method it overrides.

At function invocations:

  • Omitting an optional argument from an invocation is exactly the same as explicitly passing null.
  • A parameter assumes the default value if the argument is omitted or if null is explicitly passed as an argument.

It might be possible to use the above definition of optionality, and retain the behavior that explicitly passing null will not give you the default value.

Function Subtyping

A non-generic function type, F1 is a subtype of another function type F2 iff

  • For each positional parameter of F2, with type T, F1 has a positional parameter at the same position with a type S so that T is a subtype of S.
  • For each positional parameter of F1 which has no corresponding parameter in F2, the type of that parameter is nullable (the parameter is optional).
  • For each named parameter of F2, with type T, F1 has a named parameter of the same name with a type S so that T is a subtype of S.
  • For each named parameter of F1 which has no corresponding parameter in F2, the type of that parameter is nullable (the parameter is optional).
  • The return type of F1 is a subtype of the return type of F2.

and for generic function types, we use the usual invariant type bound requirement on type parameters and then substitution into the function types and compare for subtyping.

In short: A subtype of a function type may add more optional parameters, and it may vary the existing parameters contravariantly, including making them nullable and, thereby, optional. The retains the "can be used in all the same invocations" property of subtyping.

Consequences

A single function declaration/expression can vary in optionality

A function declaration like foo below:

void bar<T>() {
    foo(T x) { ... }
}

can have either an optional parameter or a required parameter depending on the value of T. This differs from the current behavior where the "optionality" of a function declaration/expression is manifest in its parameter declaration syntax, and does not depend on type variables.

This may require change to the function implementation. A function no longer needs to record optionality separate from types, it just has to check whether null is assignable to a particular parameter to see if it can be omitted (and if it is omitted, pass null as the value). Any typed invocation would need to supply the known arguments from the static type, and then the calling protocol may have to add any further optional arguments to the activation record before entering the function body.

Both kinds of optionals

Functions may have both nullable positional parameters and nullable named parameters. We can't prevent anyone from writing foo(int? x, {int? y}) if they really want to.

This translates to both kind of optional parameters on the same function, which Dart currently doesn't allow. A call-point may only know part of the function type (a function may accept more optional parameters than the function type it's assigned to), so calling convention need to account for missing parameters of both kinds.

No need for [...]

Optional positional parameters no longer need to be grouped by [ and ]. Merely being nullable makes the parameter optional.

This also means that there might be non-trailing optional parameters, like f(int x, int? y, int z) => x + z + (y ?? 0). There is still no syntax for omitting non-trailing arguments, though, so effectively you have to give three arguments here. (We could consider allowing elisions in argument lists, like f(1,,2), but it's not necessary. As long as we make omitted arguments be equivalent to explicitly passing null, this is a simple feature, if we don't, it might require a new calling convention too).

We can keep allowing [...] syntax for parameters, automatically making all such parameters optional (making their type nullable if necessary), but it's probably not worth it. Just automatically convert during the migration conversion.

Can't use null as significant value

It's not possible to have a function with an optional parameter with a default value, and passing null explicitly means getting null instead of the default value. That is, treating null as a significant value, not the absence of a value to be filled with the default. You cannot treat omitting a parameter as different from explicitly passing null. This will affect existing API, but only in a few places because it is already a troublesome design.

There is no way to make a required parameter which accepts null.

This is the only real compatibility issue.

Forwarding is easy(er)

Forwarding parameters from one function to another has always been an issue for optional parameters.

Imagine a function foo({int x = 42, int y = 37, int z = 87}) => ... and wanting to write a function which forwards to foo. You can either copy the default values or try to guess which arguments you were yourself given and pass those.

In current Dart, you could write:

fooEx1({int x = 42, int y = 37, int z = 87}) => foo(x: x, y: y, z: z);
fooEx2({int x, int y, int z}) {
  if (x == null) {
    if (y == null) {
      if (z == null) return foo();
      return foo(z: z);
    }
    if (z == null) return foo(y: y);
    return foo(y: y, z: z);
  }        
  if (y == null) {
    if (z == null) return foo(x: x);
    return foo(x: x, z: z);
  }
  if (z == null) return foo(x: x, y: y);
  return foo(x: x, y: y, z: z);
}

Both of these approaches have issues.

Copying the default values means code duplication and being able to get out of sync with the original function.

Trying to detect whether a parameter was passed is fragile. Using null as the marker means that you cannot distinguish an explicitly passed null from an omitted parameter. Instead you can use a sentinel value as default value, but that requires the parameter type to allow the sentinel class. That's not possible for the example here where the type is int.

With null meaning an omitted value, forwarding becomes just:

fooEx({int? x, int? y, int? z}) => foo(x: x, y: y, z: z);

Dynamic/void parameters are optional

Since both dynamic and void are nullable (because they are aliases of Object? which is nullable), any parameter of those types is optional. This may be surprising to people who just want to write a little dynamic code, and it makes it harder to catch invocations with the wrong arity on those functions.

Variants

Implicit optional if default value

With the definition above, it's an error to write (int x = 2)=>... because the variable is not optional, and you can only use default values with optional parameters.

So, as a variant, we can allow the above so that a parameter with a default value is automatically made optional (and hence the type is made nullable in the function type), even if the type was potentially non-nullable.

The only difference is that you don't need to write the ? on the type if you also write a default value. It is purely a syntactic convenience, and you don't have to use it.

That is:

int foo(int x = 42) => x;

is equivalent to

int foo(int? x = 42) => x;

Since all explicit default values are non-null (the default default-value is null, so there is no need to write it), the type of the default value will be a non-nullable type, and the type variable is promoted to the actual written non-nullable type inside the body.

(Or, we could actually make the variable be typed at the non-nullable type in the body of the function, so you can't even assign ? to it inside the body. Assigning to parameters is rare enough that this is probably not worth the effort.)

I believe users can start thinking of optional parameters as optional rather than nullable. Then they won't be confused by (int x = 42)=>x and (int? x)=>x ?? 42 having the same type, because the type is "optional int to int" (aka int Function(int?)). So, users saying that this is confusing now are thinking of it in the frame of the current language, not the future language, where ? means "optional".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment