language icon indicating copy to clipboard operation
language copied to clipboard

Add an await pattern?

Open leafpetersen opened this issue 1 year ago • 8 comments

Should we add a pattern form which expects a Future and awaits it? e.g.

void test(Map<String, Future<int>>? e) async {
  switch (await e) {
    case {"first" : await x, "second" : await y}?: ...
    case null: ...
    default : ...
}

This seems very useful for destructuring data structures with asynchronous components.

cc @munificent @eernstg @lrhn @natebosch @jakemac53 @stereotype441

leafpetersen avatar Aug 06 '22 01:08 leafpetersen

I haven't thought through this deeply, but I think I would propose the semantics that await p matches if the value being matched is a Future, and the result that you get from awaiting it matches p.

We could instead though remove the check for Future, to make it more consistent with normal await : that is, await p matches v if v is a Future and the result of awaiting it matches p or of v is not a Future and v matches p.

Thoughts?

leafpetersen avatar Aug 06 '22 01:08 leafpetersen

We could instead though remove the check for Future, to make it more consistent with normal await : that is, await p matches v if v is a Future and the result of awaiting it matches p or of v is not a Future and v matches p.

I like where you're going with this. Making the semantics more consistent with normal await seems right to me, partly because I think it's more likely to be consistent with user expectations, and partly because it makes the lowering simpler. For example, for any type T, I would like for the lowering of:

case await (T t): ...

to be equivalent to:

var tmp = await x;
if (tmp is T) {
  ...
}

But this doesn't actually work for the above definition, because if the runtime value of v is a Future yielding an int, and T is Future<dynamic>, then by the above definition, the match should succeed, but by this desugaring it fails.

I would rather just specify:

await p matches v if the result of evaluating await v matches p.

(Maybe with some non-normative accompanying text explaining that, except in wacky corner cases, this implies that the match will succeed if v matches p).

That way it's unambiguously clear that desugaring into a simple await is correct.

And of course we'll want to remember to write language tests that exercise the wacky corner cases 😄

stereotype441 avatar Aug 06 '22 13:08 stereotype441

I like this idea, though I don't know if it's important enough to slot into the initial release.

I would make it consistent with normal await: an await pattern matches if its subpattern matches. This is value because it means that the await pattern is irrefutable if the subpattern is, which lets you use it in irrefutable contexts:

void test((Future<int>, Future<String>) futures) async {
  var (await age, await name) = futures;
}

The more interesting question is whether all the await patterns in run in parallel or sequentially. Parallel would be really nice but since the other patterns can invoke user-defined code, specifying the behavior precisely might be tricky.

munificent avatar Aug 23 '22 23:08 munificent

The problem with an await pattern is that it must await a future before knowing whether the pattern matches. If I do switch (someObject as Object?) { case await int x: .. }, what would happen if the value is:

  • an integer? Perform an await on the integer, wait a microtask, the accept.
  • a string? Perform an await on the string, wait a microtask, reject and move on
  • a Future<Object> completing with an int? Wait for future to complete, then accept.
  • a Future<Object> completing with a String? Wait for future to complete, then reject.
  • a Future<Object> completing with an error? Wait for future to complete, then throw.

Or should await p reject if the matchee is not a Future?

If I do

  case await int x: ...
  case await String x: ...

will the future be awaited twice (two microtasks) if it's not an int, or will we cache the result of awaiting, like we cache the result of calling getters? (We should, there should only ever be one call to then of each matchee in the entire switch.)

lrhn avatar Aug 24 '22 09:08 lrhn

f I do switch (someObject as Object?) { case await int x: .. }, what would happen if the value is:

  • an integer? Perform an await on the integer, wait a microtask, the accept.

Yes, same as an await expression.

  • a string? Perform an await on the string, wait a microtask, reject and move on

Yes, same as an await expression.

  • a Future<Object> completing with an int? Wait for future to complete, then accept.

Yes, same as an await expression.

  • a Future<Object> completing with a String? Wait for future to complete, then reject.

Yes, same as an await expression and then apply that value to the inner subpattern, which fails to match.

  • a Future<Object> completing with an error? Wait for future to complete, then throw.

Yes, same as an await expression.

Or should await p reject if the matchee is not a Future?

I think it should match since await expressions permit non-Future values.

If I do

  case await int x: ...
  case await String x: ...

will the future be awaited twice (two microtasks) if it's not an int, or will we cache the result of awaiting, like we cache the result of calling getters? (We should, there should only ever be one call to then of each matchee in the entire switch.)

It will cache. Basically any "operation" (getter call, field access, [] operator, await, etc.) performed on a "known object" (the matched value, or the result of any previous operation stemming from a known object) is cached.

munificent avatar Aug 26 '22 23:08 munificent

Doing the await only if a pattern has not been rejected yet means that optimizations can change timing:

  case (await var x, int y) = pair;

can be optimized to check the second field first, and if that's not an int, the first field's future is not awaited.

We should be very clear about whether such optimizations are allowed (they should be), especially if a prior case had been case (String x, num y) and that had failed due to y not being a num, then a good compiler can skip right past any check that is impossible due to prior checks. (And as usual, where behavior is unspecified, I'd like for our dev-mode compilers to add some randomness, so people don't start assuming behavior is consistent and dependable.)

lrhn avatar Aug 27 '22 10:08 lrhn

In general, pattern matching can have user visible side effects, since many kinds of patterns end up invoking methods that could be user-defined. And, yes, if we want to allow the compiler to reorder stuff, then it means we have to explicitly say that the order is unspecified and users can't rely on it.

munificent avatar Aug 30 '22 19:08 munificent

I'm interested in this but given how big the pattern matching feature is already, I'm going to move it to "patterns-later".

munificent avatar Sep 15 '22 22:09 munificent