dart-lang / language

Design of the Dart language
Other
2.65k stars 202 forks source link

Stop allowing void-to-void data transfer? #3410

Open eernstg opened 11 months ago

eernstg commented 11 months ago

The type void in Dart works as a compile-time only marker indicating that the given value has no meaningful interpretation, and it should simply be discarded.

This works nicely for expressions of type void, e.g., print('Hello!'), which are accepted with no issues when used as an expression statement, but flagged as an error (not just a warning) if we try to use the value:

void main() {
  print('Hello!'); // OK.
  Object? o = print('Hello!'); // Compile-time error.
  print('Hello!').toString(); // Compile-time error.
}

We allow an expression of type void to occur in certain locations which are enumerated in an explicit allowlist.

However, several of the allowed positions are special in that they propagate the value which was obtained by evaluation of an expression of type void, based on the rule, roughly, that the target has type void as well.

void f(void _) => print('Hello!'); // Can return void to void.

void main() async {
  void v = print('Hello!'); // Can initialize void variable.

  v = print('Hello!'); // ... and assign to it.
  f(print('Hello!')); // Can pass void to void parameter.
  for (var x in <void>[]) { // Can iterate over void collection elements.
    // However, `x` has type `void` so we (almost) can't use it.
  }
  return print('Hello!'); // Can return void to void in `{}` function, too.

  // This one was turned into an error with null safety, so that problem is gone.
  await print('Hello!'); // Compile-time error.
}

Some of these situations occur very rarely (for example, not many variables have declared type void, and not many iterables have element type void), but others come up repeatedly (=> functions are widely used, and void is a rather common return type).

This issue is intended to push us in the direction of having fewer situations where such values are propagated.

We could, for example, specify that an => function with return type void returns null, no matter what, and we could eliminate the permission to use the value of a void expression to initialize/assign a new value to a variable, or to return such a value.

We will still have loopholes, of course. For example, void can be the value of a type parameter:

X f<X>(X x) {
  X x2 = x; // Assigns void to void if the value of `X` is `void`.
  return x2; // Returns void to void if the value of `X` is `void`
}

A proposal along these lines would be a breaking change (for instance, return e; might need to be changed to e; return;), but I still think that it will be useful to have an issue where this topic is discussed, and we might just do some of these things at some point. For example, I haven't heard any complaints about await print('Hello!') being an error since Dart 2.12. ;-)

mateusfccp commented 11 months ago

and not many iterables have element type void

I use it sometimes. There are times when I don't care for the type of the element. Instead of using Iterable<Object?> I use Iterable<void> to convey the semantic that the element type is irrelevant.

eernstg commented 11 months ago

Interesting! Do you really never need to access those objects? Then why do you iterate over them? ;-) (If you do need to access them in any way then I'd expect the type void to create some difficulties.)

mateusfccp commented 11 months ago

Then why do you iterate over them?

Well, I don't. In these cases, I usually want the concept of having a list (reference) or to call one of its fields i.e. lenght or isEmpty. Surely, I could do it differently instead of using Iterable<void>, but it's just the way I do.

If wouldn't mind changing the way I program for a greater good, tho.

jakemac53 commented 11 months ago

Switch expression cases are another place we allow this today:

var y = switch(x) {
  _ => print('hello'),
};
eernstg commented 11 months ago

Switch expressions is a good example: I did not mention the conditional expression b ? e1 : e2 because I think it's benign: If e1 or e2 has type void then the expression has type void, and then we'll constrain the usage of the conditional expression. So we accept that void is propagated one step because we will be there to catch it at the next step. Switch is similar.

void main() {
  // Currently: `y` gets inferred type `void`, no error.
  // New rule: Error, using "void value" to initialize variable.
  var y = switch(x) {
    _ => print('hello'),
  };

  // Currently: OK.
  // New rule: OK: Discard the result of  a void expression (after one step, but that's OK).
  (switch(x) {
    _ => print('hello'),
  });
}
eernstg commented 11 months ago

@mateusfccp wrote:

I usually want the concept of having a list (reference) or to call one of its fields

OK! Then I misunderstood you. I was just focusing on the for statement, not on the iterable per se. There is no problem in having an Iterable<T> typed statically as an Iterable<void> in the case where you actually don't want to access the elements, I agree that this is actually a quite natural way to express the intent.

But then there's no problem because this issue doesn't say anything about Iterable<void> being a bad type, it just says that "we shouldn't propagate the value of an expression of type void", and that's actually the same idea as "we shouldn't look at the elements of this list".

munificent commented 11 months ago

We could, for example, specify that an => function with return type void returns null, no matter what,

This would be another implicit conversion, essentially. Those seem to carry a lot of weight in terms of implementation complexity for relatively little value. I'd be loath to add another unless we felt it was particularly compelling.

I'm also disinclined to have code using a concrete type behave differently from the same code where the type is replaced with a type parameter and then instantiated with that same type:

void concrete(void param) => param;
T generic<T>(T param) => param;

main() {
  print(concrete('hi') as Object);
  print(generic<void>('hi') as Object);
}

Currently this (admittedly weird) program prints "hi" twice. I think it would be a little weird if it instead printed null then "hi".

and we could eliminate the permission to use the value of a void expression to initialize/assign a new value to a variable,

Sure, I could see us abolishing this. But it seems relatively low value to do so. We almost certainly have more important things we could be doing. :)

or to return such a value.

I admit that maybe this my old C programming history showing, but I do sometimes indulge in the luxury of being able to do:

void someVoidFunction() { ... }
void someOtherVoidFunction(bool condition) {
  if (condition) return someVoidFunction();
  // ...
}

Instead of:

void someVoidFunction() { ... }
void someOtherVoidFunction(bool condition) {
  if (condition) {
    someVoidFunction();
    return;
  }
  // ...
}

There is some interest in making return and some other control flow statements into expressions so that they can be used in switch expression cases (#2025). If we were to do that, then it becomes more compelling to me to allow void-typed return values in return expressions since switch expression cases can currently only have a single expression. So with the above example, you would be able to write it as:

void someOtherVoidFunction(int x) {
  var y = switch (x) {
    0 => return someVoidFunction(),
    _ => x + 1,
  };
  // ...
}

But there is no way to use a switch expression and expand that out to:

void someOtherVoidFunction(int x) {
  var y = switch (x) {
    0 => { // <-- Error. Can't have a block here.
      someVoidFunction();
      return;
    },
    _ => x + 1,
  };
  // ...
}

(Of course, allowing blocks in switch expression cases would solve this (#3117). But that's a much bigger issue. In the meantime, allowing void expressions here seems to offer some use.)

lrhn commented 11 months ago

This would be another implicit conversion, essentially.

Not necessarily. It would be a static compilation rule that applies to any function with a static return type of void and an => e body, effectively compiling it as a body of {e;}.

There is no runtime coercion of the value, it's just not returned. The void ... => ... combination is considered syntactic sugar for the non-=> body.

Which is only needed because the formatter refuses to allow

void foo(x) { e; }

on one line, even if it would fit. Using void ... => ... is entirely about saving lines, it has no other reason to be allowed.

I'd personally also disallow e as T when e has type void. And I find return someVoidFunction(); confusing, and never accept it silently in a code review. Allowing a return-void in a switch expression isn't reason enough, it's just a sign that you want n statements in a switch expression case, and in that particular case, n == 1 statement happened to be enough. Still won't work for n > 1, so we should fix the general problem, not allow a specific confusing code as a hacky partial solution.

My usual recommendation is that every expression with a static context type of void throws away it's value and evaluates to null instead. That is a coercion, just a particularly simple one. And an expression with static type void can only occur where the value is never inspected or assigned up anything, with no exceptions for as casts or dynamic/void context types. No way out of a statically known void.

munificent commented 10 months ago

This would be another implicit conversion, essentially.

Not necessarily. It would be a static compilation rule that applies to any function with a static return type of void and an => e body, effectively compiling it as a body of {e;}.

There is no runtime coercion of the value, it's just not returned. The void ... => ... combination is considered syntactic sugar for the non-=> body.

Sure, it would be like int-to-double, which is another implicit conversion. :)

Which is only needed because the formatter refuses to allow

void foo(x) { e; }

on one line, even if it would fit. Using void ... => ... is entirely about saving lines, it has no other reason to be allowed.

I feel attacked. :) I have considered allowing { e; }, but the problem is that users don't want that in many cases for single-line functions so figuring out which should and shouldn't be collapsed is hard.

it's just a sign that you want n statements in a switch expression case, and in that particular case, n == 1 statement happened to be enough.

I'd be happy to eliminate statements from the language! :D

lrhn commented 10 months ago

Sure, it would be like int-to-double, which is another implicit conversion. :)

Now you're just messing with me! :stuck_out_tongue_closed_eyes:

(He knows I keep insisting that int-literal-evaluates-to-double-value is not a conversion or coercion, it's just that integer numerals have a context-type dependent semantics. That is, the same syntax have different semantics depending on the context type. But so does the literal [1], which can mean any of <int>[1], <int?>[1], <FutureOr<int>>[1], <num>[1], etc., depending on the context type. Context-type dependent semantics is what downwards type inference is.)

the problem is that users don't want that in many cases for single-line functions so figuring out which should and shouldn't be collapsed is hard.

And there is no good syntax to opt you in or out. Which is probably why we have => e; as opt-in to a single-line void function, even if it's somewhat misleading. (But as an opinionated formatter, I'd just pick the lower-line-count version every time, and users will just have to be happy about it!)