Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simplified parameters, nullable means optional #2232

Open
lrhn opened this issue May 11, 2022 · 26 comments
Open

Simplified parameters, nullable means optional #2232

lrhn opened this issue May 11, 2022 · 26 comments
Labels
feature Proposed language feature that solves one or more problems

Comments

@lrhn
Copy link
Member

lrhn commented May 11, 2022

The current Dart parameters can be named or positional, [required or optional, and typed as nullable or non-nullable, and an optional parameter can have a default value or not.

Some combinations do not work (a non-nullable optional parameter without a default value).

The grammar is complex and inconsistent - positional parameters are required by default and requires extra syntax to be optional, named are optional by default and requires extra, different, syntax to be required.

Some pre-Null-Safety code relied on null always being a valid default value, and now requires run-time checks to be valid, like
[Completer.complete([T? value])[(https://github.com//issues/1299) where the value is actually not optional if the type T isn't nullable. But it's a run-time error to misuse it instead of a compiler error.

Forwarding parameters from one function to another, with the same function type, requires copying the default values of optional parameters of the function you forward to.

It's not always clear if there is a canonical way to write a parameter. Is it:

  • [int? x] ... x ??= 42
  • [int x = 42] ...
  • [int? x = 42] ... (x ??= 42) (to be backwards compatible with someone passing null to the first case.

or something else entirely?

There are some combinations which are just weird, but not prohibited:

int foo({required String? value}) { ... }  // Required but nullable?
int bar({String? value = "text"}) { ... } // Can pass `null`, but `null` is not default value.

All in all, things are complicated, powerful, and hard to tangle with, and the connection, yet distinction, between being optional and being nullable is problematic.

Proposal: Make nullable mean optional (and vice-versa)

In a new language version, we change the parameter syntax, function types syntax, and their associated semantics as follows:

  • You can no longer use [...] to mark optional positional parameters.
  • You can no longer use required to mark required named parameters.
  • You can put default values on all nullable parameters.
  • Parameters with declared type dynamic are not considered "nullable" for this. (Reasons below!)

We remove the concept of a parameter being optional or required as a separate concept, and make it be a consequence of being (definitly) nullable or (potentially) non-nullable. If you can definitely pass null as an argument, you can also omit the argument instead.

Only trailing positional arguments can be omitted (no shifting positional argument positions up or down).

Omitting an argument means that null will be the value of the parameter, unless it has a default value, then the default value is used instead.

We also make it so that passing null explicitly means exactly the same as omitting the argument. That is, if the parameter has a default value, passing null explicitly also triggers parameter getting the value of the default value (and being promoted to non-nullable if the default value is not null).

The static type of the local variable of a parameter like foo(int? x = 42) is int? but it is always initially promoted to int.

Migration

Migration is a trivial transformation:

  • For each function type:
    • remove [ and ].
    • remove required
    • For each parameter which used to be optional, if its type is not nullable, add ? to the type.
      • If the type is dynamic, change it to Object?.
  • For each function declaration:
    • remove [ and ].
    • remove required
    • For each parameter which used to be optional, if its type is not nullable, add ? to the type.
      • If the type is dynamic, change it to Object?. Change occurrences in the body where
        being dynamic is used to (variableName as dynamic).

That is it.

Examples

This doesn't immediately help Completer.complete. It needs to be rewritten (because it's currently optional and nullable and unsafe by necessity).
Its new declaration will instead be:

  Completer.complete(T value) ...

That means that you can omit the argument on a Completer<int?>, but not on a Completer<int>. That's precisely the desired behavior, and now its supported by the static type system.

Forwarding parameters become much easier, you can copy the parameter list and forward the values explicitly, since the forwarding function fan just omit a default value on its (per definition) nullable optional parameters, then forward the null value it gets if an argument is omitted directly to the other function.

  int foo(int x, String? y = "test", {int? z = 42}) => ...

  int logFoo(int x, String? y, {int? z}) {
    _log("Foo", x, y, z);
    return foo(x, y, z: z); // Preserves behavior of omitting an argument.
  }

The "weird combinations" are no longer possible.

int foo({String? value}) { ... }  // Cannot make nullable parameter required any more.
int bar({String? value = "text"}) { ... } // Can pass `null`, but it still gets default value if you do.

The first is one of the two combinations of required/optional vs nullable/non-nullable which are no longer possible:
The other one, optional + non-nullable, used to be possible by having a default value, and the migrated version
simply has a nullable parameter type and allows you to pass null to explicitly request the default value, instead of only allowing you to get the default value by textually omitting an argument from the argument list.

All in all, nullable means optional, null consistently means "no value", and "no value" triggers getting the default value instead.

The reason for omitting dynamic as counting as nullable is to reserve it for functions that accept all objects, including null, but do not want their parameters to be optional.
Examples: bool identical(dynamic object1, dynamic object2) or void print(dynamic object).
Those look weird if you do if (identical(a)) (meaning if (identical(a, null))), and are most likely hiding a bug.
We could instead have introduced (yet) another new top type which includes everything but is not considered nullable.
Instead we reuse dynamic, because in parameter position, it currently doesn't matter for users whether a function accepts dynamic or Object?. It matters inside the function, and forcing people to use dynamic instead of Object? might make them miss incorrect uses. So, not perfect.

Subtyping

Function subtyping falls out of this easily, because a nullable type is already a supertype of its non-nullable version.

A function type F1, R1 Function(P1,1, ..., P1,n, {Q1,1 N1,1, ...., Q1,m N1,m}), is a subtype of F2,
R2 Function(P2,1, ..., P2,h, {Q2,1 N2,1, ...., Q2,k N2,k}), iff

  • R1 <: R2 (covariant return)
  • nk (F1 accepts at least as many arguments as F2)
  • for all positions i in 1..k: P2,i <: P1,i (shared parameters are contravariant)
  • for all positions i in k+1..n: Null <: P1,i (remaining parameters are nullable and therefore optional)
  • {N2,1,...,N2,k} ⊆ {N1,1, ..., N1,m} (accepts at least the same named parameters)
  • For each i in 1..k, let j be the value in 1..m such that N1,j = N2,i (exist per previous item), then Q2,i <: Q1,i (shared named parameters are contravariant)
  • For each j in 1..m s.t. N1,j ∉ {N2,1,...,N2,k}, Null <: Q1,j (remaining parameters are optional/nullable).

Preserving correct behavior

If a program was correct before doing the migration, it will be correct afterwards too.
The migration transformation preserves subtype relations (can be shown, won't do it here).

The transformation may introduces new subtype relations, which previously didn't exist, say between the two types defined by:

typedef NullableOne = void Function(int?);
typedef OptionalOne = void Function([int]);  // Valid, implementation would need a default value.

Prior to migration, neither function was a subtype of the other, afterwards, they're both migrated to
void Function(int?).

Backwards compatibility.

This is where the dragons lie.
If we just migrated the entire world, this would work from day one, all but a few very special cases of code would just work.

However, we don't actually migrate the world, so like the Null Safety language change, we will need to have code running in the same program both which is written with the new parameters ("new code") and with the old parameters ("old code").

The solution, like for Null Safety, is to have both old types and new types in the static and runtime type systems, and to describe what happens when new code sees old types or old objects, and vice-versa.

TODO: Do that. 😉

We can see the migration transformation on function type declarations as a transformation on types as well.
We can then say that an old function type and a new function type are compared by first transforming the old function type to a new one, then using the new subtyping.
However, that's a lossful transformation, and doing that means we loses transitivity.
Example:

// old code
typedef NullableOne = void Function(int?);
typedef OptionalOne = void Function([int]);  // Valid, implementation would need a default value.
// new code
typedef NewOne = void Function(int?);

Since both old function types transform into the same new function type, NullableOne <: NewOne and NewOne <: OptionalOne, but we don't have NullableOne <: OptionalOne.

We can probably survive that non-transitivity in the static type system, if we choose to have all runtime functions and function types use the new type system. That is, being more permissive than required at runtime. (Which risks hiding an error that will then show up in older SDKs).

In a mixed mode program, a function still need to be able check whether an argument was passed or not, so that

 // old code
 void foo([int? x = 42]) => ...

will still be able to distinguish passing null from passing nothing. So, the mixed-mode runtime system will still treat omitted arguments differently from explicitly passing null, and then handle it inside the function instead, with new-code functions treating both the same, and old-code functions making a distinction.
(Potentially see that if new code calls an old-code function with a non-nullable optional parameter using an explicit null, make it an omitted argument instead. Likely not worth the effort though.)

Other options.

Non-nullable local parameter variables

We could allow you to write a non-nullable parameter type with a default value, foo(int x = 42), which would mean that the function type still has a nullable argument (it's optional, therefore nullable), but the type of x inside foo is actually int instead of int?-promoted-to-int.

Omitting non-trailing positional arguments

The current call syntax only allows omitting trailing positional arguments.
We can allow a syntax like foo(1,, 2) to be equivalent to foo(1, null, 2). (A trailing comma will not mean passing null after the comma.)
Completely non-essential, you can always write null instead.

Consequences

Some existing functions, like Completer.complete, can now be statically safe. (There are others with the same pattern).

More functions are compatible with each other, since being nullable and being optional is now the same, compatible, thing.

You can always omit a nullable argument (well, if positional, only if it's trailing). The author of a method cannot force you to write something if null is a valid value to write (no required-but-nullable).
That does have consequences for functions accepting any object, and therefore a nullable Object?. For example, you can write print() which means print(null), aka, print("null"). Here we should probably just keep the parameter optional and print a newline instead of "null".
Similarly identical() would be true, because it's identical(null, null). We want to change that to keep the parameters required, which is why we have the dynamic exception.
We could introduce a different nominal top type which doesn't mean "optional" when used for an argument.

You still cannot write copyWith, and you'll never be able to. By making passing nothing and passing null mean exactly the same, the last chance of having a function which distinguishes foo.copyWith(x: 42) and foo.copyWIth(x: 42, color: null) is gone.
Treating passing null and omitting a value the same is not essential to this feature, we could keep treating them differently, and only trigger the default value when not passing an argument. I believe this change is still a net benefit. We can't write copyWith today either, this change would just cement that.

EDIT: Added exception for dynamic to make it work as a "required nullable parameter".

@lrhn lrhn added the feature Proposed language feature that solves one or more problems label May 11, 2022
@a14n
Copy link

a14n commented May 11, 2022

int foo({String? value}) { ... } // Cannot make nullable parameter required any more.

Flutter sometimes uses required nullable named parameters purposely. eg. the onPressed parameter on TextButton constructor to make it disabled when null is passed.

EDIT: IIRC this parameter was required to improve developper experience (avoiding to forget this parameter)

What would you recommend to migrate such code?

@lrhn
Copy link
Member Author

lrhn commented May 11, 2022

What would you recommand to migrate such code?

Yes, I would.

If passing null does not mean the same as passing nothing, I believe that the API is already confusing to users.

I can see that requiring you to be explicit, instead of implicit, about passing nothing is a way to avoid accidentally passing nothing. But it also makes the parameter nullable, risking an accidental null getting passed in where not intended, which is what Null Safety should otherwise guard you against.

Instead I'd make the parameter non-nullable and have a designated and recognizable "null object" to pass instead, like TextButtone.noOnPressed which is a function doing nothing, and which is recognized as as such and which triggers the same behavior that null does today.
That won't allow you to accidentally pass in null where you intended a callback, because you forgot to check for null.

The null value has many meanings. Often too many. Fewer is better.

@jakemac53
Copy link
Contributor

The example of a nullable but required parameter is imo a very valid use of null and should be a pattern that is still allowed.

It is quite a lot of boilerplate and ceremony to have to create a sentinel value, and its also more work on the end user of the function. They have to:

  • Read the docs to understand what the special null value is (why? we already have null). This provides no value imo.
  • Convert from null to the sentinal value. This also provides no value and is just extra boilerplate.

If APIs allow null as a value, I don't see any risk with people accidentally passing a nullable value to the function. There is no explicit handling of those nullable values required, the function allows null.

@Jetz72
Copy link

Jetz72 commented May 11, 2022

Instead I'd make the parameter non-nullable and have a designated and recognizable "null object" to pass instead, like TextButtone.noOnPressed which is a function doing nothing, and which is recognized as as such and which triggers the same behavior that null does today.

Not liking how long that looks, nor that it's bound to a particular type. TextButton.noOnPressed might make sense when defining a TextButton, but what happens when I have a widget that contains a TextButton that gets its onPressed behavior as a parameter? MyCoolWidget(onPressed: TextButton.noOnPressed) means an extra type is involved at the call site. Or I'd have to make MyCoolWidget.noOnPressed to explicitly forward from one to the other. Could maybe just define a single one at the top level in the Flutter widgets library but then it'd be in scope everywhere when it's only relevant to a few specific cases.

Beyond that, having a special instance that triggers unique behavior kinda just relocates the ambiguity in meaning, rather than resolving it. Now instead of a VoidCallback?, which might be null (and the analyzer will scream at you if you forget that), you have a VoidCallback, which might be a special value that means its not there and you shouldn't really use it (and it's on you to remember that it means something different from any other value).

The null value has many meanings. Often too many. Fewer is better.

Totally spitballing here, but I've occasionally wondered "what if there was a way to disambiguate them?" Like maybe you could create a subclass or separate instance of Null that holds extra meaning (e.g. map key not found, parameter not passed, button disabled). And they'd all mean "this data is not here", all be == to the existing keyword null, and all work the same way with NNBD. But you could also test for specific instances, or put out warnings when a variant shows up in a place where it isn't expected to, or print them differently for debug logs.

@lrhn
Copy link
Member Author

lrhn commented May 11, 2022

The example of a nullable but required parameter is imo a very valid use of null and should be a pattern that is still allowed.

There are two problems in this.

One is something like required Widget?, where you force the user to write null.
There is nothing you can do with that, which cannot also be done with an optional parameter, other than try to force users into writing something, and if they want to do that, they can do it anyway (and if not, it's their code!).
There is no need for a sentinel value in that case.
I'm fine with allowing the user to omit a null argument in that case, just embrace that nullable means optional, and let the user not have to write the null.

The sentinel comes in when you have an optional and nullable parameter with a non-null default value, and where passing null means something special, different from passing nothing.
That's extremely rare, and, IMO, very confusing to begin with. I really do consider it a design smell.
That's where I do think you should introduce a "null object" instead of using null, and let explicitly passing null mean "use the default". That's an option you don't have now, passing an argument that means "use the default".

The other problem with required nullable parameters is having a top type, like Object?. That is one of the most worrisome problems I see. Let's call it the identical problem (since that's the example I used, identical() being valid and true).
In that case, the API is intended to be generic (apply to any object, even null), but it's not intended to be optional, because null is not a good default. It's just another object.
Using a sentinel won't do anything to change that.

@jakemac53
Copy link
Contributor

jakemac53 commented May 11, 2022

just embrace that nullable means optional

But there are cases where this isn't the desired API. Fwiw, I would be fine with making nullable optional by default, and non-nullable required by default. But I still want a required keyword in order to make a required, but nullable parameter.

Just because null is an allowed value, does not always mean that you don't want the user to have to make an explicit choice to pass null.

Fwiw most of the time when I have used this pattern, it is within my own codebase, not in a public API. This is particularly useful during refactoring, let's say I add a new nullable parameter to a method, and I want to ensure all uses of that method are migrated to make an explicit choice about what to pass, this is an easy way to enforce that.

A concrete example of this is the Configuration class in package:test https://github.com/dart-lang/test/blob/master/pkgs/test_core/lib/src/runner/configuration.dart#L257. Previously, it was extremely difficult to add new configuration to the package and ensure that all possible ways you can create a configuration object were properly handled. Moving to this pattern has helped tremendously.

Note that sentinel values for core types are also not always possible, since you can't create your own implementations.

@munificent
Copy link
Member

just embrace that nullable means optional

But there are cases where this isn't the desired API.

There are definitely cases where multiple distinct nulls would be useful. The classic one is map key lookup so that you can distinguish "key not present" from "null value".

But with Dart (for a mixture of historical and other reasons) we did decide to go with nullable types instead of ML-like option types. I think doing so makes the language simpler and easier to use in many common cases, though it does mean some patterns are not supported.

With optional parameters right now, we essentially have a "second kind of null" that means "no argument passed". That means when specifying a parameter, you have to independently specify those two bits of information: can it accept null or not, and can it be omitted or not. That adds a good amount of complexity to parameter lists and users have told us over and over again that 90%+ percent of the time, the ability to vary those bits independently is needless and redundant.

You're right that we'd lose some expressiveness for the edge cases, but we do gain a lot of simplicity for the common ones in return. That may be the right trade-off.

At the very least, Dart's current support for a second kind of null for parameters is really annoying because it's half-baked. You can't actually ask if an argument was passed, and there's no way to forward that hidden bit on to another function call. We have all of the complexity (and runtime performance cost!) of having undefined in the runtime semantics and in the syntax for parameter signatures, but we don't actually give users all of the affordances for it.

@Levi-Lesches
Copy link

Levi-Lesches commented May 13, 2022

Given that the conversation seems to shift from "we need more null-like values like undefined" to "but that would be hard to support, and hard to write code for" (eg, exact relations between undefined and null, lack of null-safe syntax for undefined or Optional, etc.), can someone knowledgeable go a bit into the nitty-gritty details on @Jetz72's proposal? This is what I'm imagining:

class Null { } // The Dart Null class 
class ArgumentNotPassed extends Null { }

/// A function that can detect whether null was explicitly passed
///
/// Since [ArgumentNotPassed] is a subtype of [Null], null-safe features
/// should work on it. For example, in the first `if` branch, [x] should 
/// be promoted to [int]. 
void test([int? x]) {
  if (x != null) { /* x is a numerical value, like 42 */ }
  else if (x is ArgumentNotPassed) { /* Caller omitted parameter */ }
  else { /* Caller explicitly passed in `null` */ }
}

Which would finally enable copyWith:

class Null { } // The Dart Null class 
class ArgumentNotPassed extends Null { }

class User {
  final String name;
  final int? age;
  
  const User({required this.name, this.age});
  
  /// A [copyWith] method with no ambiguity. 
  /// 
  /// Notice that [name] can still use `??` since [ArgumentNotPassed] is
  /// a subtype of [Null] and would therefore work with null-safe syntax.
  /// Since [User.name] could never be null, we don't have to check whether
  /// it was passed, but we do have to check for the [age] parameter.
  User copyWith({String? name, int? age}) => User(
    name: name ?? this.name,
    age: age is ArgumentNotPassed ? this.age : age,
  );
} 

Essentially, this would turn Null into the new Exception, which has a try/catch statement to catch any exception, with subtypes to tell you exactly what went wrong. This would also allow devs to define their own null-like classes (like what's currently being done with Optional) and allow users to use null-safe syntax with their classes. For example, check out these two equivalent examples:

class NetworkResult<T> { T value; NetworkResult(this.value); }
class NetworkError extends NetworkResult<void> with Null {
  final String message; 
  NetworkError(this.message) : super(null);
}

Future<NetworkResult<String>?> makeNetworkCall() => NetworkResult(""); 

/// An example of using [NetworkError] with null-aware syntax.
///
/// [result] will never be null, since the API always returns some
/// helpful value. Some users will care about the error message, we
/// do not. Instead of going through the docs to find the "error" class, 
/// the API simply marks [NetworkError] as extending Null so it can be
/// treated as a null value. 
///
/// We can still always get more specific: 
/// `if (result is NetworkError) { print(result.message); }`
Future<String> getNetworkMessage() async => 
  (await makeNetworkCall())?.value ?? "Nice, user-friendly placeholder :)"
class NetworkResult<T> { T value; NetworkResult(this.value); }
class NetworkError implements Exception { }

Future<NetworkResult<String>?> makeNetworkCall() => NetworkResult(""); 

Future<String> getNetworkMessage2() async {
  try { return (await makeNetworkCall()).value; }
  on NetworkError { return "Nice, user-friendly placeholder :)"; }
}

Thoughts?

@Jetz72
Copy link

Jetz72 commented May 13, 2022

class NetworkResult<T> { T value; NetworkResult(this.value); }
class NetworkError extends NetworkResult<void> with Null {
  final String message; 
  NetworkError(this.message) : super(null);
}

Future<NetworkResult<String>> makeNetworkCall() => NetworkResult(""); 

/// An example of using [NetworkError] with null-aware syntax.
///
/// [result] will never be null, since the API always returns some
/// helpful value. Some users will care about the error message, we
/// do not. Instead of going through the docs to find the "error" class, 
/// the API simply marks [NetworkError] as extending Null so it can be
/// treated as a null value. 
///
/// We can still always get more specific: 
/// `if (result is NetworkError) { print(result.message); }`
Future<String> getNetworkMessage() async => 
  (await makeNetworkCall())?.value ?? "Nice, user-friendly placeholder :)"

Shouldn't the signature of makeNetworkCall indicate the nature of the potential NetworkError result? Like maybe returning Future<NetworkResult<String>?> or Future<NetworkResult<String?>>? Because right now it's saying you'll get a non-null NetworkResult<String>, which in turn suggests it will give a non-null String if you access value. The semantics of going a step further and using Null as a mixin seem a bit bizarre but it does sound like it'd be cool if it could work.

@Levi-Lesches
Copy link

Well, it would definitely not be NetworkResult<String?>, since any response would either be <String> or <void> (statically unusable). You're right though, I think NetworkResult<String>? is correct, and I've edited it. As for your other point, I only used with Null since you can't extend two classes and I didn't want to get into the complications of trying to implement Null, but I don't really think it should be a mixin (I'm just a stickler for getting DartPad code to compile).

@lrhn
Copy link
Member Author

lrhn commented May 16, 2022

If we allow subclasses of Null, and reify ArgumentNotPassed as a user-visible value, then we just push the problem further down.

You can then call a function with an explicit ArgumentNotPassed value. Should that trigger the default value of the parameter? What if you want to store that value in an object, and want to that object to have a copyWith, do we need an ArgumentNotPassedNotPassed value to represent that?

Is ArgumentNotPassed() == null? Yes? No? Maybe? But why?

Or as they say about using RegExps, "Now you have two problems."

Take a class like:

class BinaryArguments<S, T> {
  final S value1;
  final T value2;
  BinaryArgument(this.value1, this.value2);
  R applyTo<R>(R Function(S, T) function) => function(value1, value2);
  BinaryArguments<S, T> copyWith(S? value1, T? value2) {
    var new1 = value1 is ArgumentNotPassed ? this.value1 : value1;
    var new2 = value2 is ArgumentNotPassed ? this.value2 : value2;
    return BinaryArguments(new1, new2);
  }
}

BinaryArguments<int?, int?> capturePoint([int? x, int? y) => BinaryArguments(x, y);
var c1 = capturePoint(2);
var c2 = c1.copyWith(ArgumentNotPassed()); // remove first argument!

Same problem, one turtle further down.

That's why I'd advocate that if we want to make it possible to see whether an argument was passed or omitted, I do not want to reify that as a value.

I'd mark the parameter specially (maybe late) to ensure that it's not initialized automatically when arguments are bound to parameters if there is no argument for it, and then you can later check whether the late variable is initialized or not using an operator like ?x. The absence of a value is not a value (because if it is, then we need to represent the absence of that value too.)

@cedvdb
Copy link

cedvdb commented May 16, 2022

Does it mean all values with a default have to be nullable? Eg this is invalid :

void setValue({String value = "text"})

imo it's clearer without the "?" => if it has a default you can pass null / nothing to access that default. Compared to two mental models of incoming types and inside types.

void setValue({String? value = "text"})

Here you have to check 2 things, the type and the default value to figure out the actual type, which is String. This overhead can be eliminated by just making the parameter non nullable but allowing incoming null values because a rules says that null is allowed if there is a default.

@lrhn
Copy link
Member Author

lrhn commented May 16, 2022

Does it mean all values with a default have to be nullable?

Without the optional feature mentioned above, yes. That feature would allow you to write it as {String value = "text"}, and that meaning the parameter type is String? (it's optional, so it's nullable, and you can pass null to it) and the local variable induced by it is typed as String (and a passed in null or missing argument both triggers the default value), rather than just promoted to String like {String? value = "text"} would always be.

I made that an optional feature because I've gotten pushback on it before. I like it, but not everybody does.

Another way to change it would be to make {String? value = "text"} always introduce a non-nullable local variable. That leaves no room if you actually want a nullable local variable. (But if you do, why not accept null as the argument without a default value?)

@Jetz72
Copy link

Jetz72 commented May 16, 2022

Is ArgumentNotPassed() == null? Yes? No? Maybe? But why?

That would be the idea, yes. All subtypes of null would be == null so that that remains an unambiguous way to verify the type of a nullable variable.

You can then call a function with an explicit ArgumentNotPassed value. Should that trigger the default value of the parameter?

If "no", then that means you can't forward an ArgumentNotPassed input from one function to another, which severely limits its usability. So that'd have to be a "yes". I see how that would have its own complications, though.

Take a class like:

class BinaryArguments<S, T> {
  final S value1;
  final T value2;
  BinaryArgument(this.value1, this.value2);
  R applyTo<R>(R Function(S, T) function) => function(value1, value2);
  BinaryArguments<S, T> copyWith(S? value1, T? value2) {
    var new1 = value1 is ArgumentNotPassed ? this.value1 : value1;
    var new2 = value2 is ArgumentNotPassed ? this.value2 : value2;
    return BinaryArguments(new1, new2);
  }
}

BinaryArguments<int?, int?> capturePoint([int? x, int? y) => BinaryArguments(x, y);
var c1 = capturePoint(2);
var c2 = c1.copyWith(ArgumentNotPassed()); // remove first argument!

Same problem, one turtle further down.

A class like that would introduce a new meaning of null ("Pass nothing to whatever function we apply this BinaryArguments to"), and so you'd introduce a new subclass to represent it. Still some problems though -

class PassNothing extends Null{}

class BinaryArguments<S, T>
{
  static N? boxNoArgs<N>(N input) => input is ArgumentNotPassed && PassNothing() is N ? PassNothing() : input;
  static N unboxNoArgs<N>(N? input) => (input is PassNothing ? ArgumentNotPassed() : input) as N;
  
  final S? _value1;
  final T? _value2;
  S get value1 => unboxNoArgs<S>(_value1);
  T get value2 => unboxNoArgs<T>(_value2);
  
  BinaryArguments(S value1, T value2)
    : _value1 = boxNoArgs<S>(value1), _value2 = boxNoArgs<T>(value2);
  
  R applyTo<R>(R Function(S, T) function) => function(value1, value2);
  
  BinaryArguments<S, T> copyWith([S? value1, T? value2]) {
    var new1 = value1 is ArgumentNotPassed ? this.value1 : value1;
    var new2 = value2 is ArgumentNotPassed ? this.value2 : value2;
    return BinaryArguments._raw(new1, new2);
  }
  
  BinaryArguments._raw(S? value1, T? value2) : _value1 = value1, _value2 = value2;
}

BinaryArguments<int?, int?> capturePoint([int? x, int? y]) => BinaryArguments(x, y);
var c1 = capturePoint(2); //(2, ArgumentNotPassed) gets handed to the constructor, applyTo calls a Function(int?, int?) with (2, ArgumentNotPassed)
var c2 = c1.copyWith(ArgumentNotPassed()); // Same as c1.copyWith(), i.e. no change
var c3 = c1.copyWith(null, null); // Function will be called with (null, null).
var c4 = c1.copyWith(ArgumentNotPassed(), 3); //Function called with (2, 3) - lets you skip the first positional parameter of copyWith but define the second
var c5 = c1.copyWith(PassNothing(), 2); //Function called with (ArgumentNotPassed, 2) - clearing the first BinaryArguments value explicitly

var e1 = BinaryArguments<int, int>(3, 1);
var e2 = e1.copyWith(ArgumentNotPassed(), 2); //An (int, int) function is called with (3, 2)
var e3 = e1.copyWith(PassNothing(), 0); //Whoops, even if the function has a default for this parameter (which isn't in the signature anyway), we still have to squeeze a null value through a nonnull type to get it there.
var e4 = e1.copyWith(1, null); //Uh oh. No way for copyWith to make its parameters optional in without letting in illegal null types.

Even without the ambiguity in the null value's meaning, there isn't a way to express that one type of null is permissible in a parameter list but other types aren't. Not sure if there's an elegant resolution that I'm overlooking right now, but it's apparent that a feature like this would need a bit more than the elevator pitch suggests, at least for a use case like that one.

(Oh, and sorry this tangent about a completely separate feature has gone on for so long. Was hoping to gauge whether it's worth splitting off into its own proposal to workshop it or if it was going to be a total non-starter anyway).

@jodinathan
Copy link

I think this would connect better with overloaded methods.

If passing null means something right now then you could have another version of the same method without the parameter to mean the same thing as before.

@a14n
Copy link

a14n commented May 16, 2022

I think this would connect better with overloaded methods.

I'd rather say union types. Thus you could write something like:

  User copyWith({String?|Unset name, int?|Unset age}) => User(
    name: name is Unset ? null : (name ?? this.name),
    age: age is Unset ? null : (age ?? this.age),
  );

class Unset {}
final unset = Unset();

User u = ...:
u = u.copyWith(age: unset); // to set age to null

@Levi-Lesches
Copy link

Levi-Lesches commented May 16, 2022

@lrhn:

You can then call a function with an explicit ArgumentNotPassed value. Should that trigger the default value of the parameter?

Yes, it should be exactly as though you had omitted it in the call. So func(ArgumentNotPassed()) should be exactly equivalent to func().

What if you want to store that value in an object

I would stop to clarify right there. ArgumentNotPassed is a type that says something meta about the program itself and how a function/constructor was called. It's not a useful value that should be tossed around as data. But if for some reason you do decide to use it, say, in a method-forwarding context, then you should respect its original semantics as "no value was passed in the call to this function".

Is ArgumentNotPassed() == null? Yes? No? Maybe? But why?

Yes, but only as a syntax sugar. ArgumentNotPassed would extend Null, so it's a subclass, not actually == null itself. In this way, it's like IndexError, which is a type of ArgumentError, which itself is a type of Error. However, even though it's not actually equal to any generic Error(), you can still try/catch it. So too here, I would propose having all subclasses of Null be considered == null, so that the standard if (value == null) can still work. But maybe that's not helpful, if it leads to too much confusion between the different nulls. I suppose the "correct" check would be if (value is Null).

BinaryArguments<int?, int?> capturePoint([int? x, int? y) => BinaryArguments(x, y);
var c1 = capturePoint(2);
var c2 = c1.copyWith(ArgumentNotPassed()); // remove first argument!

Actually, stepping through the code, the end value is BinaryArguments(2, null). Here's why:

  • c1 = capturePoint(2) is equal to capturePoint(2, ArgumentNotPassed()), which results in BinaryArguments(2, null)
  • c2 = c1.copyWith(ArgumentNotPassed()) is equal to c1.copyWith(), which results in both original values being preserved
    • it's really equal to c1.copyWith(ArgumentNotPassed(), ArgumentNotPassed())
    • Each ArgumentNotPassed() tells .copyWith to use the instance's fields as default values
    • If c1.value2 == ArgumentNotPassed, that's okay. It's like null.
    • And that's okay since c1 is a BinaryArguments<int?, int?> -- its values are nullable.
  • The fact that ArgumentNotPassed is sort of "erased" in the method call is intentional

@jodinathan
Copy link

User copyWith({String?|Unset name, int?|Unset age}) => User(
name: name is Unset ? null : (name ?? this.name),
age: age is Unset ? null : (age ?? this.age),
);

I find overloading better because the path can be calculated at compile time instead of runtime.

It would be nice if the compilers can dig name: name is Unset ? null : (name ?? this.name), and realize that u.copyWith(name: 'foo') can skip the name is Unset ? null : check, but even so I think overloading is still a better choice

@rakudrama
Copy link
Member

We also make it so that #1512. That is, if the parameter has a default value, passing null explicitly also triggers parameter getting the value of the default value (and being promoted to non-nullable if the default value is not null).

This has a consequence for code size. You have two conditions for the default value to be selected - when the argument is omitted and when the argument is passed as null. It is likely that each condition will need a different test, resulting in more generated code.

@lrhn
Copy link
Member Author

lrhn commented May 17, 2022

This has a consequence for code size. You have two conditions for the default value to be selected - when the argument is omitted and when the argument is passed as null. It is likely that each condition will need a different test, resulting in more generated code.

As I see it, it might actually help on some platforms. On the web, you'd no longer have to distinguish null and undefined in the incoming arguments, because an explicitly passed null means the same as no argument being passed. That means that you don't need to worry about whether a null argument at the call point is represented by null or undefined.
(I don't actually know how it's currently implemented. Maybe the implementations use arguments.length < 4 instead of checking whether typeof arguments[4] == "undefined", in which case it'll about the same size to do arguments[4] == null for the test.)

But yes, the VM may need to do two checks: "if (argument not passed || value == null) value = defaultValue", instead of just the first check, unless they know that all the arguments have been provided.

@rakudrama
Copy link
Member

As I see it, it might actually help on some platforms...

I don't think it helps is the way you suggest. Many (perhaps most) optional arguments are named, so arguments.length does not help with them

dart2js handles all optional arguments by encoding the presence / absence of an argument in the selector. The selector is opaque - there is no way to query the encoding, so toList$0 and toList$1$growable can be minified to anything. The selector with fewer arguments reaches a stub that delegates to the selector with all the arguments, passing in the extra constants for the optional arguments that were not provided.

There are several advantages of this scheme

  • there are no allocations of a 'bag' of named arguments in order to do a call
  • there is no testing to do the defaulting. In a sense, the testing has been partially evaluated.
  • the JavaScript arguments and parameters match exactly, so there is no interstitial frame or other mechanism matching them.
  • the call to the main selector is usually monomorphic

The main disadvantages are

  • Sometimes there are many selectors for the same method, but this is a pay-as-you-go cost - no code is generated for a selector unless there is a call site.
  • The stub cost is a call, probably higher than a few tests, but the very simple form of the stub helps the JavaScript VM do inlining.
  • The stubs are large (but no worse that expression like typeof arguments[4] == "undefined" that can't be minified.)
  • The stubs appear on the JavaScript stack - if JavaScript has a compact tail-call, we would use that.

To be concrete, a collection's toList method might simply delegate to List.of:

  List<E> toList({bool growable = true}) => List<E>.of(this, growable: growable)

The compiled code is something like:

toList$1$growable(growable) {
  return X.List_of(this, growable, this.$E);
}
toList$0() {
  return this.toList$1$growable(true);
}

If you provide the optional growable:, there is no cost other than passing the argument.

We might try to use the same scheme with null:

toList$1$growable(growable) {
  if (growable == null) growable = true;  //  JavaScript has `??=` but we can't use it yet.
  return X.List_of(this, growable, this.$E);
}
toList$0() {
  return this.toList$1$growable(true); // or `null` if the constant is 'large'
}

Now can call x.toList(growable: null) with the proposed behaviour, but we have two places that are doing defaulting, and execution always has a test.

JavaScript has its own defaulting, but works only when you pass undefined so this is a reasonable approach to the current semantics but won't work with the proposal

toList$1$growable(growable = true) {
  return X.List_of(this, growable, this.$E);
}
// Everywhere calls the full interface selector, `toList$1$growable`, with or without the argument.
// Defaulting costs whatever it costs in JavaScript

This might be a reasonable choice now that Dart has more rigourous constraints on overriding.
There are two problems, both of which can be solved by adding back forwarding stubs:

  • When calling a method with many named optional arguments, the preceding arguments in the canonical order need to be passed. (a.foo(arg20: x) would need to pass a.foo(/*arg1:*/undefined, /*arg2:*/ undefined, ...) or have a stub)
  • When an overriding method changes the signature in a way that adds optional arguments, stubs might be needed to convert from the superclass method's signature to the new signature.

@lrhn
Copy link
Member Author

lrhn commented May 20, 2022

One related idea to having multiple nulls, instead of allowing users to write their own class extending Null, would be to have typed nulls with a reified type. (Like the TRINE programming language had 😁).

If each null had an associated type, the type that it wasn't a value of, then you could have null<int> which has type int?, and null<int?> which has type ... well, I guess int??.
Nested nullable types and multiple null values was a thing we decided to not do with null safety. It's very convenient for some things, but potentially very expensive too. And since you can always use null<Never> anywhere anyway, it's not particularly safe.

@cedvdb
Copy link

cedvdb commented Jul 8, 2022

the onPressed parameter on TextButton constructor to make it disabled when null is passed.

That one was always weird to me because it's not just the callback but has side effect. I'd have gone with an additional "disabled". Currently passing null to onPressed disables the button, then if you add something to the onLongPress it re-enables it, it's a bit weird.
Also it forces you to work with ternaries in the view.

@n7trd
Copy link

n7trd commented Oct 8, 2023

Really great proposal! It seems like a missing piece for Null-Safety in Dart and greatly simplifies parameters in general.

@mat100payette
Copy link

@lrhn Any idea if there's progress on this or if it's on the back burner since 2022? The acceptance of no-parameter functions being passed when a function type with only void params is required would be really neat. It's ugly having a bunch of myFunction(void _) caused by generics.

@lrhn
Copy link
Member Author

lrhn commented Feb 23, 2024

No progress at all. It's not something that's actively being worked on at the current time.
I hope we'll get to it eventually, but it is a large change, requiring us to migrate the entire world (again!), so it'll have to be a significant benefit.
(I personally think it'd be a great benefit in the long run, if nothing else because it could allow fixing #1076, which still tops my wishlist.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Proposed language feature that solves one or more problems
Projects
None yet
Development

No branches or pull requests