Skip to content

Yet another proposal for the problem of default values #1492

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

Closed
ghost opened this issue Mar 4, 2021 · 17 comments
Closed

Yet another proposal for the problem of default values #1492

ghost opened this issue Mar 4, 2021 · 17 comments
Labels
feature Proposed language feature that solves one or more problems

Comments

@ghost
Copy link

ghost commented Mar 4, 2021

Problem description (copied from another thread)

In short, the problem is twofold:

  1. you cannot conditionally pass a parameter
    Suppose you have a function foo(int i=42), but in your program, there's a nullable variable x, and you want to say: foo(x !=null ? x : USE_DEFAULT) - but how are you going to say "USE_DEFAULT"? There's no way to do so.
  2. you cannot determine whether the parameter was passed or not. This is especially bad if you are trying to write copyWith: here's a simplified example:
class A {
   final int? x;  
   final String y;
   A({this.x, this.y});
   A copyWith({int? x, String y}) {
      var x1= THE_CALLER_PASSED_X? x : this.x;
      var y1= THE_CALLER_PASSED_Y? y : this.y;
      return A(x: x1, y: y1);
  }
}

How are you going to find out whether the caller passed x or y or not? Well, for y you can change the type to String? and check for null (which, by the way, is against NNBD principles), but what about x? For, x is nullable, it can't be made even more nullable to check the condition.


Proposed solution:

  1. on the caller side, allowing just a single form of the conditional statement: foo(if (cond) expr);. We may, or may not, want to support a special case for foo(if (x != null) x) in the form of foo(?x) or equivalent. This point is not important for the proposal. What is important is that there's SOME way to pass an optional parameter conditionally.

The meaning of if (cond) expr is: if cond is true then pass expr else USE DEFAULT. It's important to avoid formulating it as "if cond is true then pass expr else pass nothing". No, the whole point of it is to avoid using the word "nothing", otherwise "nothing" itself turns into "something" which simply has the label "nothing" attached to it, and we are entering a rabbit hole of increasing degrees of nothingness. We don't want that. But how to find out on the callee side whether the parameter was passed or not without mentioning the word "nothing" directly or indirectly? Here's where the second part comes into play

  1. right now, default values for parameters have to be constant expressions. Making them arbitrary expressions (instead of constants) is impossible for the simple reason that the order of their evaluation can be important (probably, there are other reasons, too, but this one is enough). What we can do instead is to introduce "default" clause where these parameters can be assigned. Syntactically, this is similar to the initializer list of the constructor, but can be applied to any function:
void foo({int bar, String baz}) default bar=expr1, baz=expr2 {
   // body
}

(For a constructor, "default" clause should precede the initializer list).

Note that we never mentioned "nothing"! That was the whole point! :-)

EDIT: Another problem was raised in the discussion (by @rrousselGit)

  1. sometimes you'd like to forward a parameter passed to you to another function as is. This means that if the parameter was not passed to you at all, you'd like to forward "nothing at all" (as opposed to replacing it with what you think is the "default value" and then forwarding - which is certainly not the same thing).

Possible solution: support late declaration for a parameter. Example:

f({int x = 42}) {...}
g({late int x}) => f(x: x);
print(g()); // 42

When you declare your parameter "late", the compiler doesn't complain until you are trying to access the variable inside g, which you never do (other than in forwarding).

@ghost ghost added the feature Proposed language feature that solves one or more problems label Mar 4, 2021
@esDotDev
Copy link

esDotDev commented Mar 6, 2021

Neat! If I'm understanding right, in typical copyWith you would have something like this?

String pageId;
...
copyWith({String? pageId}) default pageId=this.pageId {
   return Page(pageId: pageId);
}

In this case, user can pass in a value, null, or not pass at all, and we get the 3 behaviors we want.

That seems really nice to read, and feels familiar as well due to it's similarities to the constructor.

The one drawback is obviously boilerplate. Take a class with 8 properties, create fields, constructor, defaults and copyWith body, and you end up with quite the boatload of repeated code. I wonder if this could be made more succinct on the callee side somehow. In this example what is really 8 lines of intent, becomes 45 or so lines of derived code. Meta-programming would obviously make this a non-issue though :)

@esDotDev
Copy link

esDotDev commented Mar 6, 2021

A quick example with 6 vars, if I'm understanding right.

Granted I guess this is only ever going to be 25% more lines than the existing approach, so it's no major difference.

class Foo {
  final int foo1;
  final int foo2;
  final int foo3;
  final int foo4;
  final int foo5;
  final int foo6;

  Foo({
    required this.foo1,
    required this.foo2,
    required this.foo3,
    required this.foo4,
    required this.foo5,
    required this.foo6,
  });

  Foo copyWith({
    int? foo1, 
    int? foo2, 
    int? foo3, 
    int? foo4, 
    int? foo5, 
    int? foo6,
  }) 
    default foo1 = this.foo1,
      foo2 = this.foo2,
      foo3 = this.foo3,
      foo4 = this.foo4,
      foo5 = this.foo5,
      foo6 = this.foo6,
  {
    return Foo(
      foo1: foo1,
      foo2: foo2,
      foo3: foo3,
      foo4: foo4,
      foo5: foo5,
      foo6: foo6
    )
  }
}

@rrousselGit
Copy link

I don't like it, because it works only for methods.

The issue with default values applies to static functions and constructors too, especially in the context of "composition".

I'm still convinced that what we want is union types:

class Undefined {
  const Undefined();
}

void fn({int? | Undefined param = const Undefined()}) {
  if (param is Undefined) print("default")
  else print(param) 
}

fn() // default
fn(param: null) // null
fn(param: 42) // 42

@samandmoore
Copy link

I have a strong preference for the union types approach. It feels more natural to me, it's concise, and it's similar to other languages.

@esDotDev
Copy link

esDotDev commented Mar 7, 2021

For me at least union types are not at all natural. Maybe they are more natural if you are used to them from other languages, but in terms of Dart, the default approach feels more "at home", since it mimics much of how constructor assignment and inheritence works.

Also if Undefined is not a standard thing, but instead re-created in every single project, I think the cure might be worse than the disease really.

@rrousselGit
Copy link

Still, this proposal does not solve function/class composition, which is a very important part of the issue with default values.

We need to have a way for anything that can take parameters to differentiate null from no parameter, not just methods.

A common use-case would be for composing TextField for example, which default to a max lines of 1, but has the ability to set the length to infinity (without relying on double)

With unions, we can do:

TextField({int|"infinity" maxLines ??=1})

(??= is just syntax sugar for default value that update the parameter if the parameter is null)

This means doing:

class MyTextField {
  int? | "infinity" maxLines;

  build() => TextField(maxLines: maxLines);
} 

Will behave like:

MyTextField() // 1 line
MyTextField(maxLines: 42) // 42 lines
MyTextField(maxLines: null) // 1 lines
MyTextField(maxLines: "infinity") // infinite lines

This solves the composition Issue. we are now able to compose TextField into a MyTextField, without losing the built-in behavior or "by default, maxLines is 1" or having to replicate the default value of TextField into MyTextField

@rrousselGit
Copy link

sorry, I don't understand your example. How is MyTextField related to TextField? It doesn't even extend it.

Composition. It's a custom-made widget, potentially for theming purposes. Here's a more realistic example

class MyTextField extends StatelessWidget {
  MyTextField({Key key, this.maxLines}): super(key: key);

  final int? | "infinity" maxLines;

  @overrides
  Widget build(context) {
    return TextField(
      style: TextStyle(color: Colors.blue),
      maxLines: maxLines,
    );
  }
}

MyTextField() // 1 line
MyTextField(maxLines: 42) // 42 lines
MyTextField(maxLines: null) // 1 lines
MyTextField(maxLines: "infinity") // infinite lines

In any case, if you want the parameter to be nullable, then the parameter type should be int? | "infinity", not int | "infinity". But in your example, "null" stands for 1. This is a mistake. Null can be passed accidentally, e.g.

No, this is not a mistake. It is a side-effect of the ??= operator I added.
The variable itself is non-nullable, but the parameter is nullable.
And when null is passed, it gets assigned the right operand of the ??=.

That's just syntax sugar for:

void foo({int? count}) {
  count ??= 42;
}

@esDotDev
Copy link

esDotDev commented Mar 7, 2021

This is a very heavily used pattern in Flutter, where you routinely wrap some more complex MaterialWidget, providing some default values of your own, but then letting the rest pass through.

Seems like we already have double.infinity tho, so seems like there is an existing workaround for this, which is fairly readable? maxLInes: double.infinity is pretty clear?

@rrousselGit
Copy link

Compositon is definitely not an edge case.

I would argue that in the context of default values, composition is a more important scenario than copyWith.

To begin with, copyWith could be implemented without default values: a spread operator

If we can have a spread operator like with collections but for classes, that would remove the need for writing copyWith methods, removing the default value issue completely.

@rrousselGit
Copy link

There would be no copyWith

class Person {
  Person({this.name, this.age});
  final String? name;
  final int? age;
}

void main() {
  final john = Person(name: 'John');
  final copy = Person(...john, age: 18);
}

Such spread operator could inject the properties of an object in the named parameters of a constructor/function

@rrousselGit
Copy link

Let's start with the minor one: dart doesn't allow repeated parameters.

Person(age: 20, age:20); // ERROR: the argument for the named parameter 'age' was already specified

Spread operator is different from manually passing named parameters.

With spread operator, the following is valid:

var first = {'key': 42, 'a': 'a'};
var second = {'key': 21, 'b': 'b'};

var merge = {...first, ...second} // {'a': 'a', 'key': 21, 'b': 'b'}

The same logic applies to object spread: the last one takes over.

Does reachedRetirementAge have to be included into ...john?

Yes, because there should not be a difference in behavior between properties and getters, as this would otherwise make refactoring properties into getters a breaking change.

How does the compiler know?

Why would the compiler not know?

If you start making exceptions for a spread operator, then you will have to allow also Person(...john, ...alice, ...victor). I can't see how this is a good thing.

I don't see the issue. This use-case is obviously useless, but the ability to spread multiple objects is definitely useful.

@rrousselGit
Copy link

The spread operator is statically analyzable.
The compiler shouldn't pass properties that are not needed.

So if the constructor doesn't take a parameter, the spread isn't passing it.

@esDotDev
Copy link

esDotDev commented Mar 9, 2021

It does seem like it could get messy really quickly when you look at it like that. It would work, but it has too much implicit behavior. It wouldn't support refactoring well, as you're coupling an implicit field name to an implicit method param, change either and the link quietly severs. The default method seems more focused/explicit and less open to abuse, and no issues with refactoring mysteriously breaking things.

@rockingdice
Copy link

rockingdice commented Mar 10, 2021

Problem description (copied from another thread)

In short, the problem is twofold:

  1. you cannot conditionally pass a parameter
    Suppose you have a function foo(int i=42), but in your program, there's a nullable variable x, and you want to say: foo(x !=null ? x : USE_DEFAULT) - but how are you going to say "USE_DEFAULT"? There's no way to do so.
  2. you cannot determine whether the parameter was passed or not. This is especially bad if you are trying to write copyWith: here's a simplified example:
class A {
   final int? x;  
   final String y;
   A({this.x, this.y});
   A copyWith({int? x, String y}) {
      var x1= THE_CALLER_PASSED_X? x : this.x;
      var y1= THE_CALLER_PASSED_Y? y : this.y;
      return A(x: x1, y: y1);
  }
}

How are you going to find out whether the caller passed x or y or not? Well, for y you can change the type to String? and check for null (which, by the way, is against NNBD principles), but what about x? For, x is nullable, it can't be made even more nullable to check the condition.

Proposed solution:

  1. on the caller side, allowing just a single form of the conditional statement: foo(if (cond) expr);. We may, or may not, want to support a special case for foo(if (x != null) x) in the form of foo(?x) or equivalent. This point is not important for the proposal. What is important is that there's SOME way to pass an optional parameter conditionally.

The meaning of if (cond) expr is: if cond is true then pass expr else USE DEFAULT. It's important to avoid formulating it as "if cond is true then pass expr else pass nothing". No, the whole point of it is to avoid using the word "nothing", otherwise "nothing" itself turns into "something" which simply has the label "nothing" attached to it, and we are entering a rabbit hole of increasing degrees of nothingness. We don't want that. But how to find out on the callee side whether the parameter was passed or not without mentioning the word "nothing" directly or indirectly? Here's where the second part comes into play

  1. right now, default values for parameters have to be constant expressions. Making them arbitrary expressions (instead of constants) is impossible for the simple reason that the order of their evaluation can be important (probably, there are other reasons, too, but this one is enough). What we can do instead is to introduce "default" clause where these parameters can be assigned. Syntactically, this is similar to the initializer list of the constructor, but can be applied to any function:
void foo({int bar, String baz}) default bar=expr1, baz=expr2 {
   // body
}

(For a constructor, "default" clause should precede the initializer list).

Note that we never mentioned "nothing"! That was the whole point! :-)

EDIT: Another problem was raised in the discussion (by @rrousselGit)

  1. sometimes you'd like to forward a parameter passed to you to another function as is. This means that if the parameter was not passed to you at all, you'd like to forward "nothing at all" (as opposed to replacing it with what you think is the "default value" and then forwarding - which is certainly not the same thing).

Possible solution: support late declaration for a parameter. Example:

f({int x = 42}) {...}
g({late int x}) => f(x: x);
print(g()); // 42

When you declare your parameter "late", the compiler doesn't complain until you are trying to access the variable inside g, which you never do (other than in forwarding).

I think it's better to move all possible solutions into the replies and only keep the problems in the first post. People would thumb it without distraction and the dart team will put a higher priority on it.

We could discuss the solutions and rate ideas on different replies.

@rockingdice
Copy link

rockingdice commented Mar 10, 2021

My opinion is to have a value for the absent value condition.
Let's assume it is called undefined(not the exact one from javascript, just borrow some parts of it).

It should work like null but it means the argument's value is undefined (in contrast null is defined).
If a variable is undefined then the variable doesn't have a value. Calling a method on it will throw an exception just like null.

Some examples:

void test({int? arg}) {
  if (arg == null) { print('null'); } // null situation
  else if (arg == undefined) { print('undefined'); } // the arg is undefined, so we could assign default value here.
  else { print('$arg'); }   //do normal stuff
}

void main() {
  test();  // "null", Currently, if no default initializer, the value is null, not undefined.
  test(arg: null);  // "null"
  test(arg: undefined);  // "undefined", a new way to tell the function that the argument's value is undefined.
  test(arg: 1); // "1"
  var a = undefined; //ok, a is dynamic type, and the value is undefined
  var b = a; //ok
  test(arg: b);  //ok, works like test(arg: undefined)
  var c;  // c = null, by default, not undefined.
}

To keep the current default behavior, the undefined value should only be used explicitly. Or it will fall to a null value.

@kasperpeulen
Copy link

@tatumizer The specific problem would be most elegantly solved in the following way:

class A {
   final int? x;  
   final String y;
   A({this.x, this.y});
   A copyWith({int? x = this.x, String y = this.y}) => A(x: x, y: y);
}

This is also how Kotlin allows for a copy method. See this issue:
#140

I agree with @rrousselGit, that if you want more control than just allowing for nonconstant default parameter values, then probably union types are the way to go.

However, I doubt if there are many common problems that could not be solved by allowing non-constant default parameter values. Do you have more examples? Because I can not think of a time where I needed this in Kotlin.

@kasperpeulen
Copy link

See also:
#1541

@ghost ghost closed this as completed Jan 24, 2022
This issue was closed.
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

5 participants