dart-lang / language

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

FutureOr, access value synchronously if possible. #3625

Open elkSal opened 6 months ago

elkSal commented 6 months ago

As per this request, I'm opening a new issue. In Dart, the keywords async and await will convert any function in an asynchronous one. It would be useful if for cases like FutureOr, it would be possible to access the value synchronously first if possible, and if not, the asynchronously. I saw on this topic this and this but I haven't found a resolution yet.

Ex:

FutureOr<int> get valueA => 45;

FutureOr<int> get valueB => 5;

FutureOr<int> get differenceAB async{
//if I use the keywords async or await, below code will be run asynchronously even though it can be run synchronously
return await valueA - await valueB;
}

Solution A: Introduce a new term for awaiting a FutureOr value or adapt async and await to allow to run code synchronously if possible

Solution B: Why doesn't FutureOr allow a then method as Future? It won't be maybe the best solution out there as it would be requiring nesting in the above differenceAB function but at least it works :)

eernstg commented 6 months ago

There's no way you can make a function run synchronously if it has a body which is marked async. When the body is async the function will always return a future, and there's no way to obtain the object that was used to complete that future other than awaiting it (which can be done using an actual await expression, or via Future members like then).

So you need to write code that allows for a purely synchronous execution, and you may use the type FutureOr to indicate that it might also initiate an asynchronous computation.

import 'dart:async';

FutureOr<int> get valueA => 45;
FutureOr<int> get valueB => 5;

FutureOr<int> get differenceAB {
  var a = valueA, b = valueB;
  switch ((a, b)) {
    case (int aInt, int bInt): return aInt - bInt;
  }
  return (() async => await valueA - await valueB)();
}

void main() {
  print("Start");
  var difference = differenceAB;
  if (difference is int) {
    print("The difference is $difference");
  } else {
    print("Oh, we'll have to wait for that!");
    difference.then((difference) => print("Here it comes: $difference"));
  }
  print("Done");
}

You can add async here and there, and see that you will then have to wait for the result. So the point is that you need to keep the computation completely free from async function bodies in order to preserve the possibility that the computation proceeds synchronously throughout.

jakemac53 commented 6 months ago

FutureOr is just a special union type, there is no actual class associated with it, it has no instance methods other than what are on Object.

However, you can make a then extension on it, https://dartpad.dev/?id=de531414b6bac53863806691ae0918b6 (in reality you would want to do something a bit more complicated than this to support the other typical then arguments).

The main issue is this won't work with async/await.

rrousselGit commented 6 months ago

There's no way you can make a function run synchronously if it has a body which is marked async. When the body is async the function will always return a future, and there's no way to obtain the object that was used to complete that future other than awaiting it (which can be done using an actual await expression, or via Future members like then).

One thing that was requested in a similar issue is to have either:

rrousselGit commented 6 months ago

Ultimately the problem with FutureOr is that we lose the ability to use await unless we treat the FutureOr as a Future, and ignore the "value synchronously available" bit.

jakemac53 commented 6 months ago

Fwiw, I like the idea of asyncOr, which returns a FutureOr by default, doesn't implicitly convert return values to futures, and where await continues synchronously when awaiting non-Futures. I am not sure about the feasibility/complexity of implementation though.

lrhn commented 6 months ago

The asyncOr idea is https://github.com/dart-lang/language/issues/2033 The arguments made there apply here to.

I can't see how this request differs from that, other than possibly syntax. It is "sync evaluation if possible, including await" + "return future only if necessary from async".

If we only make await pass values through synchronously, which is an option, then it would still get wrapped in a future at the end of the async function.

If we make an async function able to return a FutureOr, returning a synchronous result as a non future, then it's #2033. And not an option I'm particularly keen on. It would mean doing an extra type test at everything at every await of such a FutureOr, but on the other hand it feels like it should make some code possibly faster, so I'll expect authors to make every async function return FutureOr, because they can. (Because with this, even if the function always does an await before return, that might now be a synchronous await, if the function it's awaiting has begun returning FutureOr.)

So everybody uses the feature everywhere, but the hoped-for performance improvement gets lost to the extra type checks, and it also increases code size.

jakemac53 commented 6 months ago

So everybody uses the feature everywhere, but the hoped-for performance improvement gets lost to the extra type checks, and it also increases code size.

"Just" add then to Object and rely on dynamic dispatch? 🤣

rrousselGit commented 6 months ago

I don't think this is an issue of performance. To me this is an issue of timing.
I don't care about how hard it is on a CPU. It's about making the UI immediately show a value when it is available, instead of delaying its rendering by a few frames. This avoids flickers

jakemac53 commented 6 months ago

Fwiw, the utility I have written in the past to handle this does incur a type check but still showed some appreciable speedup. I think in the case where you have a FutureOr already, await is probably already doing a similar check?

lrhn commented 6 months ago

I'd consider making my own future wrapper, like:

extension type EagerFuture<T>._(FutureOr<T> _) {
  static final _cachedResults = Expando<_FutureResult>();
  EagerFuture(FutureOr<T> futureOr) : _ = futureOr {
    if (futureOr is Future<T>) {
      _cachedResults[futureOr] ??= _FutureResult<T>.future(futureOr);
    }
  }
  Future<T>? asFuture() => _ is Future<T> ? _ : null;
  T? asValue() => _ is Future<T> ? null : _;
  bool get hasResult =>
      _ is Future<T> ? _cachedResults[_]?.result != null : true;
  T get result => _ is Future<T>
      ? (_cachedResults[_]?.result?.value ?? (throw StateError("No result")))
      : _;
  T? get tryResult => _ is Future<T> ? (_cachedResults[_]?.result?.value) : _;
}

class _FutureResult<T> {
  _FutureResult.future(Future<T> f) {
    f.then((v) {
      result = Result.value(v);
    }, onError: (Object e, StackTrace s) {
      result = Result.error(e, s);
    });
  }
  Result<T>? result;
}

extension <T> on Result<T> {
  T get value {
    if (this case ValueResult(:var value)) return value;
    var error = this.asError!;
    Error.throwWithStackTrace(error.error, error.stackTrace);
  }
}

void main() async {
  var f = EagerFuture(Future.delayed(Duration(seconds: 1), () => 42));
  while (!f.hasResult) {
    print("...tick");
    await Future.delayed(Duration(milliseconds: 250));
  }
  print("Result: ${f.result}");
}

(Not tested much.)

I am worried that you're talking frames here. Awaiting a FutureOr which is a value should never introduce more than a microtask delay. It should complete within the same top-level event. The microtask model was designed in the browser so that UI updates are top-level events, so waiting for a microtask should not cause any frames to be drawn.

rrousselGit commented 6 months ago

We've already talked about this before.

Your wrapper isn't representative of how folks want to use FutureOrs.
We're looking for something comparable to await future in terms of usability, but where await 42 does not introduce any delay yet await Future.value(42) does.

And there's the issue of chaining too. To use await, you had to use async. And async returns a Future, not a FutureOr. Meaning we cannot compose FutureOr functions easily using await

The only real solutions out there are:


I am worried that you're talking frames here

Microtasks are way too late already. If scheduled at the start of a frame, they'll trigger at the end of it. Yet we want those values immediately instead of at the end of the frame.

rrousselGit commented 6 months ago

The issue can be represented using the following Flutter app:

import 'package:flutter/material.dart';

void main() {
  runApp(MyWidget());
}

class MyWidget extends StatefulWidget {
  const MyWidget({super.key});

  @override
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  int? a;
  int? b;
  int? c;

  @override
  void initState() {
    super.initState();

    Future<int> fn() async {
      setState(() {
        a = 42;
      });
      return 42;
    }

    Future<int> fn2() async {
      final result = await fn();
      setState(() {
        b = result * 2;
      });
      return result * 2;
    }

    fn2().then((value) {
      setState(() {
        c = value * 2;
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    print('a $a b $b c $c');
    return const Placeholder();
  }
}

This app will print to console the following:

a 42 b null c null
a 42 b 84 c 168

But that a: 42 b: null c: null state is undesired. That's a frame where if we didn't use async/await, we could've gotten the value already.

This has an impact on the rendering. Because when the widget renders, its content appears over multiple frames instead of all at once.

lrhn commented 6 months ago

If we only go with await not introducing a delay for a non-Future, then I'm much more comfortable allowing that, than changing how async functions work.

That change would be:

Evaluation of await e, where e as static type S and flatten(S) = T, proceeds as follows:

  • Evaluate e to value v.
  • If the runtime type of v has Future<U> as super-interface, with U \<: T, then
    • Suspend the current async or async* function (with all that that entails).
    • Invoke the then instance method of v with a type argument of void, a function onValue as positional argument and a function onErroras named argument with nameonError`. The runtime type of onValue is void Function(T), and the runtime type of onError* is void Function(Object, StackTrace).
    • If the onValue function is called with a value w, then as a later microtask event, the surrounding function resumes, and await e evaluates to w.
    • If the onError function is called with an object x and stack trace s, then as a later microtask event, the surrounding function resumes, and await e throws x with stack trace s.
    • If both functions are called, or either function is called more than once, runtime behavior is unspecified.
  • Otherwise, when v is not a Future<T>, eval e evaluates to v.

If we want a short way to introduce a delay, like await null; today, we can just make the expression optional, and make (await) introduce a microtask delay before it evaluates to null at type void.

That's doable. Heck, even preferable IMO. It won't make an async function be able to return a non-Future. That's something I don't want, because that's where all the incentives are wrong.

mraleph commented 6 months ago

That's a frame where if we didn't use async/await, we could've gotten the value already.

That has nothing to do with await though. You will see exactly the same behavior if you simply use built-in Future:

  @override
  void initState() {
    super.initState();

    setState(() {
      a = 42;
    });
    Future.value(42).then((result) {
      setState(() {
        b = result * 2;
      });
      return result * 2;
    }).then((value) {
      setState(() {
        c = value * 2;
      });
    });
  }

There is another invariant: if propagation has started values will propagate through as far as they can go while microtask queue is being drained. You can see this on b and c "arriving" at the same time despite being separate from each other with a level of Future.then.

So I think the fundamental question we should be asking is: are invariants around value propagation guaranteed by Future worth the inconvenience they cause in cases like this?.

TBH I am not really sure if they are worth it: I kinda understand that they are added so that it is easier to reason about interleaving of callbacks (e.g. "then callback never interleaved with other synchronous code" or consequently "async function execution is never interleaved"). But is this invariant really worth maintaining? We even begrudgingly allow developers to violate the invariant around await by implementing Future.then in an incompatible way:

print('before');
(() async {
  print(await SynchronousFuture(10));
});
print('after');

Will actually print

before
10
after

We probably can't break existing Future and async, but what if this worked:

// library dart:async

final class SynchronousFuture<T> {
  // ...
}

extension ToSync on Future<T> {
  external SynchronousFuture<T> get synchronous;
}

// User code

SynchronousFuture<int> foo() async { // Return type serves as marker.
  await bar();  // equivalent of `await bar().synchronous`.
}

If we have a taste for something even more ambitious we could go even further and have a separate modifier and bring our async story closer to Kotlin and Swift with "auto awaiting":

extension on Future<T> {
  /// If the value available on the Future will return its value
  /// synchronously without suspending the current coroutine.
  T get value coro; 
}

int bar() coro { 
  return fut.value + 1;
}

int foo() coro {
  return bar() + 2;   
}
rrousselGit commented 6 months ago

You will see exactly the same behavior if you simply use built-in Future:

Indeed. But that's why this discussion is about FutureOr. I'm only showcasing how a delay impacts Flutter.

The goal here is to be able to replace Futures in my snippet with FutureOrs somehow, keep an "await"-like syntax, while also removing that frame with null values.

Ultimately, this boils down to convenience. There are lots of possible workarounds, but none of them are very convenient.


I've personally been playing around with implementing a custom return keyword, using a callback that returns Never ; combined with an extension on FutureOr to convert values into SynchronousFutures:

extension<T> on FutureOr<T> {
  Future<T> get asSync {
    final that = this;
    if (that is! Future<T>) return SynchronousFuture(that);
    return that;
  }
}

/// A custom `return` keywords that works by throwing an exception, later caught by [asyncOr]
typedef Returns<T> = Never Function(T value);

/// A custom `async` modifier that returns a FutureOr.
FutureOr<T> asyncOr<T>(
  Future<T> Function(Returns<T> returns) cb,
)

Then used as:

FutureOr<int> getValue() => 42;

FutureOr<int> multiplyBy2() => asyncOr((returns) async {
      final value = await getValue().asSync;

      returns(value * 2);
    });

But I think something's broken in Dart at the moment. Even though the return value appears to be correct, I am seemingly unable to do print(await multiplyBy2()) ... even though doing print(multiplyBy2()) works fine.

lrhn commented 6 months ago

We even begrudgingly allow ...[SynchronousFuture]

and

But I think something's broken in Dart at the moment ...

are probably related.

We don't allow SynchronousFuture, begrudgingly or otherwise, and there are certainly some parts of our async implementation that chokes on a synchronous future, because it's not ready to receive a value when it calls then.

It's an accident when it works, even if that is more often than I'd have expected, but SynchronousFuture was never supported. Nothing is broken in Dart, the broken thing is SynchronousFuture itself.

lrhn commented 6 months ago

... a consequence of the invariant which it is built around: values only propagate through Future listener chains when stack is empty.

That's an invariant. That's pretty much the only invariant that we guarantee, and which we also assume ourselves. I'd be very, very worried about loosening up on that, because there is no telling how much code will simply break if calling .then will synchronously call the callbacks if the future is already completed. (But we could check!)

There is another invariant: if propagation has started values will propagate through as far as they can go while microtask queue is being drained.

That's not an invariant, that's just an implementation detail. We do not promise that it is so, and we don't promise not to change it. (We do know that changing timing can break bad code out there, with "bad code" being defined as code which relies on async timing that was never promised.)

It's really just for efficiency and latency - when most futures have exactly one listener, it's efficient to propagate the completion eagerly through any callbacks that return a result synchronously. We can do that in one big loop, instead of scheduling another microtask, which introduces more latency and overhead. If something prevents us from continuing with such a propagation, we just give up, and continue later, with no promises broken.

munificent commented 6 months ago

I'd be very, very worried about loosening up on that, because there is no telling how much code will simply break if calling .then will synchronously call the callbacks if the future is already completed. (But we could check!)

Way way back when @nex3 and I were first writing pub, I remember running into some issues around this. This was before async/await was in Dart so we used manual Futures for everything. We had some code that was traversing a queue (I think? been a while) of arbitrary size. If we did the traversal asynchronously by having then() in the loop, it worked fine because the stack unwound at each step. But if we accidentally moved all of the async out of the loop, we could run into stack overflows.

In short, I think it's possible that there is a lot of code out there implicitly relying on async code essentially doing tail call optimization. Much of that could break if we start synchronously calling then(). Worse, that code probably won't break tests which have fairly small data sizes that fit within the stack size, but then could break in the wild on real-world data sizes.

rrousselGit commented 6 months ago

To be fair, the idea of making then synchronous would be to have it opt-in.

I think we all agree that the current behaviour is a good default. It's just that in some cases, we want the ability to have a function optionally execute synchronously ; and at the moment the syntax for that is horrifying compared to async/await

lrhn commented 6 months ago

Assume we had both async and asyncOr functions, where asyncOr functions returned FutureOr and ran synchronously if possible. Let's even assume that await would be synchronous on any non-Future.

Which new async function you wrote would you not make asyncOr?

(But isn't that reason enough to give them that? Maybe. But see above for why that could end up more computationally expensive than today, because of all the extra is Future checks. But that cost is all at the call site, not where the function is being written, so the incentives are not aligned.)

rrousselGit commented 6 months ago

Which new async function you wrote would you not make asyncOr?

It depends on the tradeoffs between async/asyncOr.
Since the tradeoff list isn't exactly fixed due to talking about an API that doesn't exist yet, we can only speculate.

I can see a few possible tradeoffs from this conversation:

So the balance is favours pretty strongly async for most use-cases, as few functions care about how many ticks of the event loop they take to complete.


I would expect most asyncOr usages to be specifically related to async cache and data transformation for the sake of UI rendering.

This is what triggered me to request a synchronous await/async many times, and seems to be how Flutter uses its SynchronousFuture too. A quick Github Search of the Flutter repo shows that SynchronousFuture is used for:

This effectively always linked to immediately updating the UI the very first frame where a UI element appears/disappears.

In this case, we don't care about performance, we care about the correctness of the UI output.

tatumizer commented 5 months ago

in #25, there's a proposal to introduce a suffix form of await, like

var x = foo.bar().await.baz().await;

This "suffix await" can be defined to optionally take the form of a function call:

var x = foo.bar().await().baz().await();

Which gives a chance to pass extra parameters to await().

FutureOr<int> x = ...
int y = x.await(immediateIfTheValueIsReady: true); 

No need to introduce asyncOr keyword. The behavior is controlled by the caller. The ugliness of this form guarantees that people won't use it without a reason.

rrousselGit commented 5 months ago

Are you saying:

Future<int> fn() async => 42;

Future<void> fn2() async {
 print(fn().await(sync: true));
}

void main() {
  print('a');
  fn2();
  print('b');
}

Would print:

a
42
b

?

tatumizer commented 5 months ago

Yes, this is exactly what I'm saying :-) Except that the returned value of fn should be FutureOr<int> to allow sync:true parameter to take effect.

lrhn commented 5 months ago

This "suffix await" can be defined to optionally take the form of a function call:

That would conflict with Future<String Function()> asyncFactory = ...; print(asyncFactory.await());. Just sayin'. (All the good syntaxes are taken!)

tatumizer commented 5 months ago

How about This "suffix await" can be defined to optionally take the form of a function call.

To me, it looks more natural with the parens anyway. (you can also pass a "timeout" or something, so it's not only good for "sync")

rrousselGit commented 5 months ago

On a different thought: What about exposing Future.value?

Folks could then do:

extension<T> on Future<T> {
  Future<T> get asSync {
    if (value case final T value) return SynchronousFuture(value);
    return this;
  }
}
tatumizer commented 5 months ago

For this, you don't need to expose "value" or write an extension. Assuming that runtime knows how to differentiate between a real Future and a Future wrapped around the value, Future can provide a method like "tryAsSync" out of the box. The details of implementation can remain hidden.

lrhn commented 5 months ago
 if (value case final T value) return SynchronousFuture(value);

Won't work if T is Object?.

If we have something like extension <T> on Future<T> { Result<T>? get tryResult => ... }, then we can distinguishe a value from an error, and both from not being done yet. But you can do that yourself with a wrapper, like EagerFuture above, no changes needed.

The one thing you can't do today is to have an async function produce a value synchronously, which is available when the function returns. (And there are no plans to change that.)

rrousselGit commented 5 months ago

The one thing you can't do today is to have an async function produce a value synchronously, which is available when the function returns. (And there are no plans to change that.)

Yes, but a Future.value or whatever equivalent we have could be immediately available

We could have:

Future<int> fn() async  => 42;

void main() {
  print(fn().value); // 42
}

Since fn returns immediately, the .value should already be set


But you can do that yourself with a wrapper, like EagerFuture above, no changes needed.

No EagerFuture doesn't solve the problem like counter-argued above. It has a too poor usability when compared to async/await.

tatumizer commented 5 months ago

Have you tried to return a tuple (Future, value)? The idea is to NOT mark the function as async.

(Future<int>?, int?) foo(int n) /* NO "async"! */ {
  return n==0? (null, 0) : (Future.value(n), null);
}
var x = switch (foo(42)) {  // passing 0 will return an immediate value
     (Future<int> f, _)=> await f,
     (_, int? v) => v! 
};

(I couldn't find a way to verify it works as expected though).

EDIT: turns out, rust follows essentially the same template. The following code is produced by Gemini

enum DataResult {
  FutureVariant(futures::future::Ready<i32>),
  ValueVariant(i32),
}

fn get_data() -> DataResult {
  if some_condition() {
    DataResult::FutureVariant(futures::future::ready(42))
  } else {
    DataResult::ValueVariant(100)
  }
}
fn main() {
  let result = get_data();

  match result {
    DataResult::FutureVariant(future) => {
      // Need to wait for the future to complete
      let value = future.await;
      println!("Got value from Future: {}", value);
    },
    DataResult::ValueVariant(value) => {
      println!("Got immediate value: {}", value);
    },
  }
}

The problem is that these either-or functions are potentially contagious, leading to a third color. The same is true for "Result or error" functions. And combinations of those. The program turns into a mess where you can't see the forest for the trees.

rrousselGit commented 5 months ago

Have you tried to return a tuple (Future, value)? The idea is to NOT mark the function as async.

That's no different from using FutureOr, which is horrible in terms of usability. It's error prone and verbose, for something we can to many many times.

It's a problem users of my packages can commonly face. I want to offer a proper solution that's usable my many.

tatumizer commented 5 months ago

I agree. On top of that, it doesn't fit in dart's laconic style IMO. f.await(sync:true) looks like a much better syntax candidate.

mraleph commented 2 months ago

I had a discussion about this issue with @lrhn and I have also looked a bit into Flutter sources to get the context.

(@lrhn could you read this and tell me if I wrote something incorrectly?)

Why the current choice is problematic for Flutter?

Flutter intentionally builds the widget tree only once per frame. This is quite clearly stated in documentation for Element.markNeedsBuild:

  /// Marks the element as dirty and adds it to the global list of widgets to
  /// rebuild in the next frame.
  ///
  /// Since it is inefficient to build an element twice in one frame,
  /// applications and widgets should be structured so as to only mark
  /// widgets dirty during event handlers before the frame begins, not during
  /// the build itself.

This decision effectively means that you can only create widget tree from synchronously available data, and any data hidden within Futures will need to wait the whole frame before you can do something with it. Flutter does drain microtasks queue between frames, but by that time the tree is already built and it is too late to do anything.

Flutter attempts to offer a work-around for this problem by providing SynchronousFuture, which has a slightly misleading documentation:

/// This is similar to [Future.value], except that the value is available in
/// the same event-loop iteration.

This documentation is misleading because the result of Future.value is also available in the same event-loop iteration, because propagation of value from Future.value to the callback will happen as a microtask, not an event. What distinguishes Future.value and SynchronousFuture is whether then callback is invoked immediately (synchronously) or deferred to a micro-task.

Could Flutter solve this issue itself?

Yes, it probably could. One approach I could envision is recognizing that some elements have an asynchronous component and requiring microtask flush before finishing their build:

rebuildDirtyElements();
do {
  drainMicrotasks();
  rebuildDirtyAsyncElements();
} while (microtasks.isNotEmpty || delayedAsyncElements.isNotEmpty);

In this model FutureBuilder will not immediately build its child, but wait until microtasks are flushed and data is available (or it is known that the data will not be available this event loop turn).

This model does the same amount of work as the current synchronous build: you don't rebuild the tree multiple times, but simply delay building parts of it until after you drained the microtasks which produces data for these builds. It is more aligned with microtasks based Future resolution model.

The overall structure of widget tree building becomes much more complicated then it is now, and it will probably come with some amount of overhead which might be a strong enough argument against doing that.

What could Dart do?

Lets assume that solving this in Flutter is not going to happen. What could we do on the Dart level? Well, we could completely change the semantics of await :)

  1. We declare that await f does not require a suspension:
    1. if f is a not a Future, then await f completes immediately with value f;
    2. if f is a completed internal implementation of Future, then await f completes immediately with value (or an error) of f.
  2. We specify that await null expression (with literal null as an argument) is a special case: it requires a microtask even though v = null; await v; does not.
  3. We provide extension methods[^1] on Future<T> which allow synchronous access to the underlying value, e.g. Future<T>.isCompleted and Future<T>.value (or maybe just FutureState<T> get value which incapsulates not-completed, completed with value and completed with error states as a sealed class family?).
  4. We deprecate SynchronousFuture<T> and tell people to just migrate away. It can just become a wrapper around core Future. Eventually we delete SynchronousFuture<T> and mark Future<T> final.

We are open to changing semantics of await, but not semantics of Future.then (or any other Future APIs which take callbacks). This means the code which currently uses then (e.g. FutureBuilder) will need to be updated to benefit from synchronous value propagation.

The big open question is whether we will be able to roll such a change out: our previous experiences with touching async timing was that it requires somehow fixing hundreds if not thousands of poorly written tests in the internal monorepo. If breakage is too big and not toolable then we need an alternative rollout strategy.

While I was writing this I realized that maybe we could package this together with some other changes into a new type of async function. Having special keyword (maybeAwait) seems wrong, but an async function that does not require any await might be actually the path forward? Consider for example:

Future<int> foo() async {
  return await bar() + await baz();
}

extension on Future<T> {
  suspending T get value;
}

suspending int foo2() {
  return bar().value + baz().value;
}

I am more or less lifting suspending name from Kotlin, for the lack of better vocabulary.

[^1]: If we change semantics of await we can just as well provide these methods because they can be implemented in terms of await anyway.

    FutureState<T> get state {
      var result = FutureState<T>.incomplete();
      (() async { 
        try {
          result = FutureState<T>.value(await this);
        } catch (e, st) {
          result = FutureState<T>.error(e, st);
        } 
      })();
      return result;
    }
TekExplorer commented 2 months ago

What if we just... added a ?? await? future; (or async? which could return FutureOr or a Future who's .value exists) That way it suspends if it isnt complete, and doesn't if it is complete.

Honestly, having synchronous access to future values could be super useful, as it would become trivial to determine if it is completed without needing to indirectly check though .then((_) => _completed = true) which obviously is less ergonomic.

Hell, this might even make FutureOr almost obsolete, since you can just... access the completed value directly, or await if its not done, or just await? to do both. It's only use would become for function signatures to not need async, but then it could just become a special-cased typedef for just that.

await?/async? would also avoid breaking anything. That being said, it also wouldnt allow existing code to automatically become sync when there's no actual async occurring, but it may be the most logical solution for anything that expects normal await behavior.

That being said, it may limit the usefulness some - so we should see if we can just reuse await itself

This could be really neat for eager initialization of futures while keeping access to the future so that both sync and async tasks can use it in whatever manner makes sense.

We could also choose to have await? change the semantics of await to match it in the function we're calling, such that

Future<bool> get() async => await Future.value(true);
Future<void> doThing() async? {
  final res = await? get(); // as if it were Future<bool> get() async? => await? Future.value(true);
  assert(res.value == true) // or whatever the API is
}
tatumizer commented 2 months ago

The problem is that as soon as await? becomes available, there will be no justification for a raw await except in the rare cases where the distinction is important. It would be more logical to change the legacy await to await! then, and interpret await as await? by default.

TekExplorer commented 2 months ago

The problem is that as soon as await? becomes available, there will be no justification for a raw await except in the rare cases where the distinction is important. It would be more logical to change the legacy await to await! then, and interpret await as await? by default.

Makes sense! perhaps this is a good point to apply versioning to. Basically, just inject an await null; into the font of any async function before this version, and we're probably good.

rrousselGit commented 2 months ago

@mraleph While await is problematic, there is a currently available workaround (SynchronousFuture). It's not ideal, but "works". IMO the fundamental problem is async. We can't have async return a SynchronousFuture if we wanted to.

That's the gist of the issue what what forces users to stop using async/await.

And although arguably less important, async* has similar issues. The function always starts asynchronously instead of immediately when the stream is listened. And yield has the same issue as await afaik.

lrhn commented 2 months ago

The main problem here is Flutter's behavior, where it renders something, and then changes it after a change happening during the same microtask queue.

The Dart microtask queue is modeled after the browser microtask queue, because that's what it needed to be compatible with, in order to work with browser resources that have a lifetime of "one event" ... meaning the recent and the microtasks belonging to that event. The event isn't done until all the microtasks are done. It's like microtasks are synchronous events that happen right now, not arbitrarily later.

In the browser, UI updates happen as top level events. Change events are microtasks. That means that a cascade of changes will have all completed and stabilized before the UI is updated.

It Flutter followed that model, rather than choosing what to render before all microtasks had been run, the problem wouldn't exist here. Using a FutureBuilder with an already completed future would schedule a microtask, that microtask would run, and the model would have its final state before anything got rendered.

mraleph commented 2 months ago

In the browser, UI updates happen as top level events. Change events are microtasks. That means that a cascade of changes will have all completed and stabilized before the UI is updated.

I want to point out that this applies to raw DOM APIs. Frameworks like React behave similar to Flutter (as far as I can tell in my quick experiments). If you update state as a result of microtasks running you will only see updated UI when event loop turns around, because VDOM is not going to be rebuilt immediately.

TekExplorer commented 2 months ago

I've been looking through the Future code, and... as far as I can tell, it looks like the value already exists as a private variable. var _resultOrListeners; which, for some reason, is pulling double/triple duty.

I honestly don't see much reason why we couldn't add a T? get valueOrNull; + (AsyncError? errorOrNull) to put the final resolved value into. after all, if it's completed, there's no reason we shouldn't be able to get the value immediately. hell, an _isComplete already exists too! just un-private it.

alternatively, a T get value; that just throws either the completed error or a "future not completed" error

It's incredibly odd to me that these are already available, and yet arent exposed. the utility of having access to those two alone is incredible

Even if all we got was sync access to Future values when the future is known to be completed, it would be a major step forwards. Changing how async+await works could honestly wait till later (in the - ahem - Future). It would let us freely pass futures around, which would then let the callers handle the data how they please.

Hell, it makes the FutureOr thing easier, as you can just do Future.value(futureOr).valueOrNull; or something like that, (an extension on FutureOr would make this trivial) which would also allow you to synchronously access the value even if its actually a completed future.

Honestly, what are we missing to make this much happen? it seems almost trivial. Frankly, unless I'm missing something, the only issue is that its technically a breaking change for anything that implements Future, but that's hopefully just Flutter's own SynchronousFuture which would either suddenly become unnecessary, or just get updated by the relevant team trivially.

So... to reiterate

  1. Add sync value access for completed futures
    • Expose existing _isCompleted as bool get isCompleted;
    • Add something like T get value; (that throws) or T? get valueOrNull (that doesn't throw, disambiguated by isCompleted)
      • possibly an accompanying error variant, or otherwise make it throw instead
    • Technically a breaking change for subclasses, but does anything aside from SynchronousFuture break?
      • Would this allow SynchronousFuture to be deprecated, in favor of checking the completed value instead of an unusual .then (which could open the possibility for an extension that exposes a .thenSync that does the same, but for any future)
  2. Change await semantics to act sync when the future is already completed
    • if all awaited futures are already completed, then the entire function effectively completes synchronously
    • async+await should effectively cost nothing if we don't actually have to wait.
    • await null; (or a blank await;) could let you manually opt out of that, if your terrible code relies on timing so much
    • Is there anything that actually breaks if this is done?
      • If we apply versioning, and inject an await; to the front of any async function that doesn't support this version, now does anything break?
      • Possibly make use of that comment annotation I vaguely remember exists to opt-out to the old style for any library

What are we actually missing? What roadblocks are there? Is there any reason we cant ship #.1 "now" and get to async+await semantics later?

lrhn commented 2 months ago

It has always been a non-goal to allow synchronous access to future results. You get the result "when it's ready", as a push event, and you have no control over the scheduling of synchronous events.

If you have two ways to access the result, it encourages writing two code paths, where it's hard to ensure that both code paths are tested.

Or writing code that assumes that a future is always already completed, which makes it harder to optimize the asynchronous implementation code in a way that may change timing. For example, the distinction between setting the result on the future and asynchronously scheduling callbacks, or asynchronously scheduling setting the result and then immediately propagating to callbacks, which is an implementation detail today, would become observable.

You should treat microtasks as being "nearly synchronous" and happening as a unit. Listening to a completed future gets you notified in the same microtask queue, that's part of the same synchronous execution of the microtask queue loop, no real asynchronous timing gap will happen. Asking for a result sooner is just changing the order of code that is all going to get executed "synchronously" during the same event.

TekExplorer commented 2 months ago

I'd argue that already completed futures dont need timing at all. There's already ways to extract a value when completed if the future is awaited somewhere, at some point, by setting it to a variable.

But then, why cant futures just have a variable on them that gets set via .then?

That way, timing becomes irrelevant, as its effectively just avoiding a wrapper object.

Something like this:

class Future<T> {
  Future() {
    then((v) {
      isComplete = true;
      valueOrNull = v;
    );
  }
  bool isComplete = false;
  T? valueOrNull;
}

I don't think anything breaks this way. additionally, the future itself decides when that value is set. If its completed, the value is there. But it may not be considered completed until after that microtask loop completes (and the value isn't there until it does)

This effectively removes the need for indirection and wrapper objects in a number of cases, and still trivializes FutureOr usage. It also decreases the burden of needing to remember to go and do that, which can be especially useful if some wrapper object you don't control does await the future, but doesn't expose the resulting value, even though you really should be able to use that value sync by now. after all, the value isn't "in the future" anymore is it? its here. its right there even, but we just cant use it, even though we know its there.

As far as I can tell, this effectively removes the timing concern doesn't it? It just makes Future more usable. .then still acts the same, its just that it might have already happened.

-- we might want a lint that prefers await over valueOrNull in an async method though.

lrhn commented 2 months ago

It's definitely possible to wrap a future in something that stores the value when the future calls its callbacks. That wrapper can even implement Future.

By all means do that, if you really want to. It's effectively the same as caching the result and the future together. But it's caching that you choose to do when you know that you want to.

If someone gives you a plain future, you shouldn't be wondering whether it may already be completed. You should assume it's not, because futures are rarely shared, and should be listened to immediately when they are created, to ensure that errors are handled in time.

Adding the caching to the future type itself is changing its intended use-cases, from being the eventual result of a, most likely ongoing, computation, to being a way to store that result in perpetuity. That is not its intent today, you want to store the result, you do that in the callback. Needing to store the result is a specific use-case that you can program for, but it shouldn't be baked into the Future type,ø.

Or just await the future. If it comes back within the same microtask queue, during the same event, that should be soon enough.

I'll throw in a wrapper, for good measure:

Copyright 2024 Google LLC.
SPDX-License-Identifier: BSD-3-Clause

import 'dart:async';

/// A future and its eventual result.
///
/// Contains a [future] and, when that future has completed,
/// the future's result.
///
/// The result can be accessed synchronously using [result], which must
/// only be used when the result is available as specified by [hasResult].
/// Do notice that reading [result] will throw an error if the future
/// completed with that error.
///
/// The result can be accessed optimistically using [tryResult] which provides
/// the synchronous result if it's available, and otherwise the [future] which
/// is still the most precise representation of the eventual result.
final class FutureCache<T> {
  Future<T>? _future;
  _Result<T>? _result;

  /// Creates cache with existing result.
  FutureCache._result(this._result);

  /// Creates a cache for the eventual result of [future].
  ///
  /// The cache will not be filled with a result immediately, even if the
  /// future has already been completed. The result will only be available when
  /// the future has responded to a [Future.then] call, which mush be done
  /// asynchronously.
  FutureCache(Future<T> future) : _future = future {
    future.then<void>((T v) {
      _result = _Value<T>(v);
    }, onError: (Object error, StackTrace stack) {
      _result = _Error(error, stack);
    });
  }

  /// Creates a cache that is pre-filled with the given [value].
  ///
  /// Also creates a [future] which complete with [value].
  FutureCache.value(T value) : this._result(_Value<T>(value));

  /// Creates a cache that is pre-filled with [error] and [stack] trace.
  ///
  /// Also creates a [future] which completes with [error] and [stack]
  /// as an error.
  FutureCache.error(Object error, StackTrace stack)
      : this._result(_Error(error, stack));

  /// A future that completes with [result].
  ///
  /// If no result is available yet, as reported by [hasResult], then
  /// cache is waiting for this future to notify of its completion.
  ///
  /// If a result is available, then the future will provide the same
  /// result if awaited.
  Future<T> get future => _future ??= switch (_result!) {
        _Value<T>(:var value) => Future<T>.value(value),
        _Error(:var error, :var stack) => Future<T>.error(error, stack)
          ..ignore(),
      };

  /// Creates a cache for the value or future of [result].
  ///
  /// If [result] is a `Future<T>`, the created cache waits for that
  /// future to complete before the result is valid.
  ///
  /// Otherwise the created cache is already filled with the provided
  /// value, and its [future] is created as a future completing with that
  /// value if necessary.
  factory FutureCache.of(FutureOr<T> result) {
    if (result is Future<T>) {
      return FutureCache<T>(result);
    }
    return FutureCache<T>.value(result);
  }

  /// Captures the result of a synchronous or asynchronous computation.
  ///
  /// Runs [computation] and returns a cache that is either filled with
  /// an available synchronous value or error results, or that waits
  /// for the result of the future returned by [computation].
  factory FutureCache.sync(FutureOr<T> Function() computation) {
    try {
      return FutureCache<T>.of(computation());
    } catch (e, s) {
      return FutureCache<T>.error(e, s);
    }
  }

  /// Whether a result is available.
  ///
  /// A result is available after the [future] has completed invoked its
  /// listeners, which happens asynchronously at some point after the
  /// future completed.
  ///
  /// Until there is an available result, the [result] getter must not be
  /// used, and the [tryResult] getter will return the [future].
  ///
  /// After a result is available, both [result] and [tryResult]
  /// synchronously provides that result, as a value or by throwing an error.
  bool get hasResult => _result != null;

  /// The result of [future] provided synchronously, when available.
  ///
  /// Must not be used until a result is available, as reported by [hasResult].
  ///
  /// When the result of [future] is available, evaluating `result`
  /// will either return the result value, or (re-)throw an error result.
  T get result => (_result ?? (throw StateError("Not completed"))).value;

  /// The result of [future] provided as synchronously as possible.
  ///
  /// If a result is available, as reported by [hasResult], this getter
  /// provides that result, just like [result].
  /// Until a result is available, it instead evaluates to [future].
  /// Notice that in the former case it may synchronously throw an error.
  ///
  /// Awaiting the returned value is equivalent to awaiting [future],
  /// but can also be checked for whether the value is available synchronously.
  FutureOr<T> get tryResult {
    var result = _result;
    if (result != null) return result.value;
    return _future!;
  }
}

/// The result of a computation.
sealed class _Result<T> {
  /// Read to reify the result.
  T get value;
}

/// A value result.
final class _Value<T> implements _Result<T> {
  final T value;
  _Value(this.value);
}

/// An error result.
final class _Error implements _Result<Never> {
  final Object error;
  final StackTrace stack;
  _Error(this.error, this.stack);
  Never get value {
    Error.throwWithStackTrace(error, stack);
  }
}
rrousselGit commented 2 months ago

Being able to synchronously access already resolved futures doesn't really help anyway. await and async would still add introduce a delay regardless.

lrhn commented 2 months ago

@rrousselGit Correct. That's a tangent for this issue's actual requests: A synchronous await of FutureOr which is not a future, and an ability to return a FutureOr synchronous value from an async-like function.

I'm very positive towards allowing an await of a call to an async function to return a result synchronously, if the result is available synchronously.

In practice, an await asyncFunction(args) can already be implemented to continue synchronously (and run on the same stack if it wants to) if the runtime recognizes that the function being called is declared as async. It doesn't even have to allocate a Future, it can just return directly to the caller if it manages to complete synchronously, and if not, then it can create a future (or some other internal signal) and suspend the caller until it's actually done. The behavior of await and async-function invocation are deliberately designed so that this optimization is possible, because all await promises is that it won't continue until the asynchronous computation is done. (For example, an await invocation of an async function could pass in onValue and onError functions to the async function, and if it has those and completes synchronously, it can just call one of those functions instead of creating a future, and if it's async, create a future and add those as listeners. A normal (non-await) call of the function wouldn't pass those functions, and would get a future back in all cases.)

So it's allowed by the spec, it's just an optimization.

I'm possibly willing to allow await to continue "immediately" (synchronously or as the very next microtask, which should be indistinguishable if all code is well-behaved) on an already completed system future. It has to be a system future, we don't have a way to see if user futures are already completed, because they only expose the Future interface. As @mraleph points out, if we have this feature, we can detect whether the result is available (and get it if it is). Not super happy about it, but it's not completely outrageous.

I don't consider that breaking the API of Future because any magic happens at a point where the code would otherwise be suspended at the await. It's not detectable what happens between the await and the resume, and we don't make any promises about how soon it will resume. (We probably do promise to pause await for stream subscriptions before the await, even if it continues synchronously.) That is, it's still just a matter of which interleaving we give asynchronous callbacks. (Except that's not entirely true, because calling an async function without await in front starts code running synchronously, and ends at an await future, which doesn't suspend the current execution thread. Performing that await synchronously runs it not as a new microtask, but in the middle of other synchronous computation. (I guess I'd want to only allow this optimization if the current execution thread is actually suspended at the await.)

I'm not at all positive about allowing synchronous interospective access to a completed future's result from just any code. The Future is designed to make that impossible, because it's a clear abstraction boundary, and because it makes it clear that there is only one thing to do with a future: Wait for it.

TekExplorer commented 2 months ago

That is not its intent today, you want to store the result, you do that in the callback. Needing to store the result is a specific use-case that you can program for, but it shouldn't be baked into the Future type,ø.

I get that, but the problem is that; depending on the use case, we can end up forced to "stutter" with a loading value even though the value may already exist.

Going from nothing to a loading state to a value state over the period of a microtask is jarring

Especially since we may have "lost the opportunity" to extract the value "synchronously" by the time we're in a sync function.

Hell, it's why SynchronousFuture exists at all, and it's not necessarily limited to Flutter use cases.

That wrapper object would always throw if you tried to use it right away, even if the future completed, unless the inner implementation happened to be a SynchronousFuture that was already processed.

I'm almost certain that people would be using FutureOr way more if it were first class, but then that still doesn't handle synchronizing the future at any point.

After all, at some point, we aren't waiting anymore right?

It doesn't make sense to me to wait for a value that's already here.

Could we alternatively say that .then does act immediately if the future is completed instead, SynchronousFuture-style? Would that break anything?

Maybe add a named parameter like then(sync: true) for that behavior? Doesn't break anything and let's us use it as we please

Hell, depending on how you possibly handle synchronous await like you're describing, it could effectively be the same solution.

lrhn commented 2 months ago

Going from nothing to a loading state to a value state over the period of a microtask is jarring

Again, that wouldn't be visible if the UI didn't update in the middle of a microtask queue execution. If it just waits to the end, after all change events and their future propagations have stabilized, nobody would see the flash of loading.

Could we alternatively say that .then does act immediately if the future is completed instead, SynchronousFuture-style? Would that break anything?

Definitely. A lot of code has been written under the assumption that you can call then, and do more initialization (synchronously) after that, possibly using the future returned by then.

The initial implementation of Future and Stream actually did call immediately if a value was available, and the feedback of the first users were that that was confusing and error prone, because it was unpredictable. It was very easy to write code that assumed a later callback, and what then broke on the occasional immediate callback. You had to write more convoluted code to be able to defensively handle two different behaviors in the same code path.

Maybe add a named parameter like then(sync: true) for that behavior? Doesn't break anything and let's us use it as we please.

That would be a better API. You have to opt in to the otherwise surprising behavior, so you wouldn't/shouldn't be surprised.

It's hard to change the API of Future, though. There are many classes that implement Future, and which would be broken by any chance to the interface signature. (Giver me "Interface default methods" please! Then we could add thenSync or something. If we had created Future today, it would probably have been final.)

Those implementations would also have to understand the parameter, but I guess ignoring it is still valid behavior, the value might not have been available anyway.

TekExplorer commented 2 months ago

Sounds like we have a viable idea.

It's hard to change the API of Future, though. There are many classes that implement Future, and which would be broken by any chance to the interface signature. (Giver me "Interface default methods" please! Then we could add thenSync or something. If we had created Future today, it would probably have been final.)

I wonder if there's some extension BS we could do? The most straightforward option is to extension type it such that only _Future gets the benefit

The fact that it was designated an interface instead of a mixin or something unfortunately does make it tricky.

Its possible we could do some funky shit like allowing subtypes to not include {bool sync} in their signatures (effectively a noop) or doing funkier shit like injecting a thenSync => then into everything...

the other option then would be to just... break it. then maybe force people to mix it in instead of implementing (or otherwise some compiler BS to special-case transform it...)

eh.

imo it seems like the most useful option is to permit "subtypes" of functions to exclude parameters they don't use (which, as an aside, can make the (x, y, _) thing a bit cleaner)

currently, subtypes of Functions can only add optional parameters. Maybe we can permit them to exclude named parameters they don't need? The signature and type would still be the same - it would just effectively be syntax sugar for having the parameter, and just... not using it.

effectively:

typedef Fn = Function(int, String, {double percent})
final Fn fn = (i, n, {percent}) {}; // normal
final Fn fnPlus = (i, n, {percent, String additional = ''}) {}; // normal subtyping
final Fn fn2 = (i, n) {} // special syntax. effectively identical to (i, n, {percent}) {}

abstract interface class Future<F> {
  T then<T>(T Function(F) cb, {bool sync = false});
}

class CustomFuture<T> implements Future<T> {
  T then<T>(T Function(F) cb) {...} // lint warning: specify all parameters in function signature. still compiles.
}

this would require language support in some way, but it could cleanly resolve it, and we could even add thenSync as an extension method that just does then(sync: true) we could easily then just have a lint warning you that you arent using a parameter, which should nicely wrap up any remaining concerns

It could also make adding parameters to an interface no longer a breaking change, allowing for stagnant classes (like Future) that simply cannot change (there's extension methods yes, but we've just demonstrated why that's not always viable) to do more

Just to be clear, I'm not necessarily asking for new subtyping rules - effectively the method could just have the parameter injected or something.

Is that worth an issue? thoughts?

lrhn commented 1 month ago

Having "optional optional parameters" that an implementation doesn't have to understand, can be done in two ways:

The former is fairly simple to implement, but invasive and error prone. It makes a superclass and to change the implementation of a subclass method, and if actually acting on the argument is necessary to satisfy the method semantics, then the subclass silently gets a wrong implementation. (Don't use the feature then?)

The latter is a big change to the type system. It introduces a supertype to fx void Function() and void Function(int), which must be called with one argument. That supertype should probably never be the result of an upper bound operation, it must be something you opt in to. Then a subclass method override of a method with such a function type signature can omit a parameter, which makes it not a breaking change to add such a parameter in the superclass. (Again, unless the method must react to the parameter to behave correctly.)

(Languages with overloading and interface default methods have it easier. Looking at you Java.)