-
Notifications
You must be signed in to change notification settings - Fork 207
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
Comments
Flutter sometimes uses required nullable named parameters purposely. eg. the EDIT: IIRC this parameter was required to improve developper experience (avoiding to forget this parameter) What would you recommend to migrate such code? |
Yes, I would. If passing 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 Instead I'd make the parameter non-nullable and have a designated and recognizable "null object" to pass instead, like The |
The example of a nullable but required parameter is imo a very valid use of 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:
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. |
Not liking how long that looks, nor that it's bound to a particular type. 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
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 |
There are two problems in this. One is something like The sentinel comes in when you have an optional and nullable parameter with a non- The other problem with required nullable parameters is having a top type, like |
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 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 Note that sentinel values for core types are also not always possible, since you can't create your own implementations. |
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 " 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 |
Given that the conversation seems to shift from "we need more null-like values like 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 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 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? |
Shouldn't the signature of |
Well, it would definitely not be |
If we allow subclasses of You can then call a function with an explicit Is 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 |
Does it mean all values with a default have to be nullable? Eg this is invalid :
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.
Here you have to check 2 things, the type and the default value to figure out the actual type, which is |
Without the optional feature mentioned above, yes. That feature would allow you to write it as 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 |
That would be the idea, yes. All subtypes of null would be
If "no", then that means you can't forward an
A class like that would introduce a new meaning of 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). |
I think this would connect better with overloaded methods. If passing |
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 |
Yes, it should be exactly as though you had omitted it in the call. So
I would stop to clarify right there.
Yes, but only as a syntax sugar.
Actually, stepping through the code, the end value is
|
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 |
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 |
As I see it, it might actually help on some platforms. On the web, you'd no longer have to distinguish 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. |
I don't think it helps is the way you suggest. Many (perhaps most) optional arguments are named, so 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 There are several advantages of this scheme
The main disadvantages are
To be concrete, a collection's 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 We might try to use the same scheme with 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 JavaScript has its own defaulting, but works only when you pass 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.
|
One related idea to having multiple If each |
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. |
Really great proposal! It seems like a missing piece for Null-Safety in Dart and greatly simplifies parameters in general. |
@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 |
No progress at all. It's not something that's actively being worked on at the current time. |
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 typeT
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 passingnull
to the first case.or something else entirely?
There are some combinations which are just weird, but not prohibited:
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:
[...]
to mark optional positional parameters.required
to mark required named parameters.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, passingnull
explicitly also triggers parameter getting the value of the default value (and being promoted to non-nullable if the default value is notnull
).The static type of the local variable of a parameter like
foo(int? x = 42)
isint?
but it is always initially promoted toint
.Migration
Migration is a trivial transformation:
[
and]
.required
?
to the type.dynamic
, change it toObject?
.[
and]
.required
?
to the type.dynamic
, change it toObject?
. Change occurrences in the body wherebeing 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:
That means that you can omit the argument on a
Completer<int?>
, but not on aCompleter<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.The "weird combinations" are no longer possible.
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, includingnull
, but do not want their parameters to be optional.Examples:
bool identical(dynamic object1, dynamic object2)
orvoid print(dynamic object)
.Those look weird if you do
if (identical(a))
(meaningif (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 acceptsdynamic
orObject?
. It matters inside the function, and forcing people to usedynamic
instead ofObject?
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})
, iffR1
<:R2
(covariant return)P2,i
<:P1,i
(shared parameters are contravariant)Null
<:P1,i
(remaining parameters are nullable and therefore optional)Q2,i
<:Q1,i
(shared named parameters are contravariant)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:
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:
Since both old function types transform into the same new function type,
NullableOne
<:NewOne
andNewOne
<:OptionalOne
, but we don't haveNullableOne
<: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
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 passingnull
, 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 ofx
insidefoo
is actuallyint
instead ofint?
-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 tofoo(1, null, 2)
. (A trailing comma will not mean passingnull
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 writeprint()
which meansprint(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'sidentical(null, null)
. We want to change that to keep the parameters required, which is why we have thedynamic
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 passingnull
mean exactly the same, the last chance of having a function which distinguishesfoo.copyWith(x: 42)
andfoo.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 writecopyWith
today either, this change would just cement that.EDIT: Added exception for
dynamic
to make it work as a "required nullable parameter".The text was updated successfully, but these errors were encountered: