Skip to content
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

Extension types and await. #2710

Closed
lrhn opened this issue Dec 10, 2022 · 27 comments
Closed

Extension types and await. #2710

lrhn opened this issue Dec 10, 2022 · 27 comments
Assignees
Labels
extension-types inline-classes Cf. language/accepted/future-releases/inline-classes/feature-specification.md

Comments

@lrhn
Copy link
Member

lrhn commented Dec 10, 2022

(NOTICE: Written when the feature was called "inline class", is now "extension type", and is not completely the same. New conclusion too, probably, so check comments.)

TL;DR: flatten(N) of an inline class type N should be N.

What happens if you do await e and the static type of e is an inline class, N?

The runtime semantics are pretty clear, so it boils down to defining what flatten returns on an inline class.
(And it's probably going to be trivial, but let's make sure it's documented.)

As currently defined, an inline class can only implement other inline classes, so it cannot implement Future (or FutureOr).
It would seem valid to make flatten(N) = N for an inline-class type N, like we do for other types which do not imply an expected Future type.

At runtime, the inline class type does not exist, it's replaced entirely by the representation type.
(Basically, at runtime the inline class type can be treated as a type alias for the representation type, instead of a separate type. It needs to have all the same cyclic-reference restrictions as a type alias to avoid an infinite expansion when going from the static type to the runtime type.)

If N has representation type R, the static type of await e will be flatten(N), aka N, which is the same as R at runtime.
The await needs to do an is Future<R> check on the value of e to see if it should be, and can soundly be, awaited.
If the representation type is actually Future<int> itself, we won't await that future, because it's not a Future<Future<int>>.

That should work, and is probably what we want.

An alternative could be to looks at the representation type immediately, and see if it mentions a future type, and if so assume that we are awaiting that type.
I don't think it's a good idea. Mainly because it breaks the abstraction of the inline class, but also because it opens up questions about where R being a Future<R> should make the await e switch back to being typed as N or not.
So, let's not do that, the flatten(N) = N is simpler.

If, at a later time, we allow an inline class type to implement interfaces or other types (which must then also be supertypess of the representation type), then it may become possible for an inline class type to implement Future or FutureOr. At that point we should look at the future-behavior of the type itself, just as we do for other types. That should be sound wrt. the representation type, since it requires the representation type itself to have those types.

@lrhn lrhn added the inline-classes Cf. language/accepted/future-releases/inline-classes/feature-specification.md label Dec 10, 2022
@lrhn lrhn assigned lrhn and eernstg Dec 10, 2022
@eernstg
Copy link
Member

eernstg commented Dec 12, 2022

Good question! 😄

Based on the most recent proposal about flatten, flatten(N1) == N1, because N1 does not have a 'future type'. So at await n1 where n1 has static type N1, we would check whether the dynamic type of the value of n1 is Future<N1>, that is Future<Future<int>>. This is never true (because the same object can't implement Future<int> and Future<Future<int>>), so we will not await the future.

inline class N1 { // At run time: `N1` means `Future<int>`.
  final Future<int> it;
  N1(this.it);
}

void main() async {
  N1 n1 = N1(Future.value(1)));
  var x1 = await n1; // `x1` has static type `N1`, the future is not awaited.
}

We should consider at least one more case:

inline class N2 { // At run time: `N2` means `FutureOr<int>`.
  final FutureOr<int> it;
  N2(this.it);
}

void main() async {
  N2 n2 = N2(Future.value(1)));
  var x2 = await n2; // `x2` has static type `N2`, the future is not awaited.
}

It would seem valid to make flatten(N) = N for an inline-class type N

Agreed, basically.

However, it may seem somewhat surprising that we know statically that the representation object has type Future<int>, and it's known that the expression of type N1 evaluates to the representation object, and still we don't compute int as the static type of await n1 and await the future. In contrast, we specify flatten(N1) == N1 and then (somewhat ridiculously) check whether the given object has type Future<N1>, that is Future<Future<int>> (and get no, every single time).

You could say that we're protecting the encapsulation of the representation object by refusing to take it into account during the computation of flatten, but you can certainly also say that the test for is Future<Future<int>> is artificial, and hard to justify.

Maybe we should simply say that await e is a compile-time error when the static type of e is an inline type? Or at least when the static type of e is an inline type whose representation type 'has a future type'? (A type T 'has a future type', basically, if it's a subtype of Future<...>, FutureOr<...>, Future<...>?, or FutureOr<...>?.)

If, at a later time, we allow an inline class type to implement interfaces or other types (which must then also be supertypess of the representation type), then it may become possible for an inline class type to implement Future or FutureOr.

This could be taken as a hint that we'd want to make it an error to await an inline type for now: Some/many of the inline classes whose representation type has a future type might be changed to have Future as a supertype when and if that becomes possible. This might break some programs because some futures will start being awaited, even though they did not do that previously.

@lrhn
Copy link
Member Author

lrhn commented Dec 12, 2022

It's always hard to guess about intent, when the language and code doesn't allow the user to express any.

If I did (as I'm likely to):

// Like `Future`, but with superior typing of `onError` functions!
inline class BetterFuture<T> {
  final Future<T> _realFuture;
  BetterFuture(Future<T> future) : _realFuture = future;

  BetterFuture<R> then<R>(FutureOr<R> Function(T) onValue, 
      {FutureOr<R> Function(Object, StackTrace)? onError}) =>
    BetterFuture<R>(_realFuture.then(onValue, onError));

  BetterFuture<T> catchError<E extends Object>(
      FutureOr<T> Function(E, StackTrace) onError, [bool Function(E)? test]) =>
    BetterFuture<T>(_realFuture.catchError(
        (Object o, StackTrace s) => onError(o as E, s), 
        (Object o) => o is E && (test?.call(o) ?? true));

  BetterFuture<T?> ignoreError<E extends Object>([bool Function(E)? test]) =>
    BetterFuture<T?>(_realFuture.then<T?>((v) => v,
        (Object o, StackTrace s) => (o is E && (test?.call(o) ?? true)) ? null : (throw o);
}

I might actually expect to be able to await it (even if I know it won't call my then method, it should still work).

We cannot see that if the author cannot state that the type should be treated as a Future.

As you suggest, we can just disallow awaiting an inline class type. It's inconsistent with what we otherwise do for non-future types, but if the argument is that we can't decide whether it's a non-future type at all, then failing might be the better option. It does suggest that we'd definitely want the "implement interface" eventually, so that it's possible to implement Future or Iterable and interact with language constructs that depend on those types, without clumsy casts that we'd want to remove later.

(Which might be an argument for allowing the feature to implement interfaces from the start, because without that, the feature doesn't cover all the desired uses out of the box, and there is no real workaround that won't require rewriting later, if/when we do get the feature.)

Nit: As usual

if it's a subtype of Future<...>, FutureOr<...> ...

doesn't work, because everything is a subtype of FutureOr<Object?>.
We really should define a predicate on types which says whether it suggests a future type or not.

@eernstg
Copy link
Member

eernstg commented Dec 12, 2022

We really should define a predicate on types which says whether it suggests a future type or not.

https://github.com/dart-lang/language/pull/2713/files#diff-0e2f05538272f3fb682a361d7738d860125088af3ac0044e5472cac965cfdb61R11279.

eernstg added a commit that referenced this issue Dec 15, 2022
@eernstg
Copy link
Member

eernstg commented Jul 21, 2023

The feature specification of 'extension types' makes it a compile-time error to await an expression whose type is an extension type E, except when that extension type is a subtype of Future<T> for some T. With that, we can use the existing language to conclude that when E implements Future<T> for some T, flatten(E) is T, and in all other cases await e is a compile-time error when the static type of e is an extension type.

@lrhn
Copy link
Member Author

lrhn commented Jul 29, 2023

We probably need to consider union types too.

FutureOr<ExtType> is easy, it's handled before we reach the extension type.
ExtType? is not an extension type, but should probably also be an error, if awaiting ExtType directly is.

It's also a problem that async functions have an implicit await in the return statement. We can't disallow returning an extension type from an async function, so we need to do something reasonable there.

That may be to not introduce an implicit await when returning an extension type on an async function, unless the extension type implements Future.
(Or at all, as a first step to remove that await.)

Again, being an extension type is not enough, have to remove ? union type first

@lrhn
Copy link
Member Author

lrhn commented Aug 4, 2023

I don't think we need to disallow awakening extension types that do not implement future.
They'll just be seen as not having s future type, so flatten of the type returns the type again.
Then at runtime, the await does an is Future<R> where R is the ultimate representation type of the (flatten of the) static type.

@eernstg
Copy link
Member

eernstg commented Aug 7, 2023

The most permissive approach would be to consider await e as await (e as T) in every case where the static type of e is an extension type E and T is the extension type erasure of E. This amounts to an unbounded number of unwrapping steps which will reveal the underlying representation.

This would be sound, and it would yield the best possible typing of await e in the case where there is compile-time evidence for a future (that is, the ultimate representation type has a future type). It is arguably also an implicit, recursive encapsulation violation. In the case where there is no evidence for a future, it's just an encapsulation violation with an extremely dubious justification.

I'd prefer to maintain some amount of encapsulation. So if an extension type E declares that it implements Future<S> for some S then it's OK to have await e where e has static type E, and treat it like a Future<S> (which is what the rules will already do). However, if there is no declared relationship from E to a future type then I'd prefer to make await e an error.

It's a bit like making await 1 an error because it is confusing and not very useful. We do allow that today, but we should be able to allow await e just in the case where e has a type that derives a future type, or e has type Null (to enable the explicit suspension, and we might even get rid of that). An e of type dynamic would still be allowed as well. If we'd like to go in that direction then we might as well report the error for extension types already now.

The underlying rationale for being even more strict with extension types than we are with class types is that extension types are compile-time-only types, and hence it's an unrecoverable loss of information to cast away the extension type. Say, if you have an int which is obtained by a cast from an expression typed as IdNumber or as WeightInGrams, you can't detect at run time whether it was one or the other, and you might then interpret the int in a way which is a logical bug. So we don't want to enable and encourage casting away from extension types in general, and await e is just one example.

I agree that we should include union types as well:

It is a compile-time error if await e occurs, and the union-free type derived from the static type of e is an extension type which is not a subtype of Future<T> for any T.

@lrhn
Copy link
Member Author

lrhn commented Aug 7, 2023

The "union free type" would ignore the Future part of a FutureOr, so that's probably not a good idea. I do want await to work on FutureOr<ExtensionType>, and actually await a Future<ExtensionType> (at its runtime type of Future<RepresentationType>).

I also worry about disallowing await on an extension type, and not on Object. There is no real difference. Either can be a future of the same type. There is no real difference, and no need to treat them differently.
If anything, I'd say that disallowing await on an extension type makes casting away more likely, not less.

@eernstg
Copy link
Member

eernstg commented Nov 6, 2023

Questions for which we don't have clear answers at this time:

  • await e where e has static type E? where E is an extension type that does or does not implement Future for some T.
  • await e where e has static type FutureOr<E>, same E.

We could specify that it is a compile-time error at await e where e has static type T if T ...:

  • Is an extension type that does not implement Future<T> for any T.
  • Is of the form E? where E is an extension type as in the previous item.
  • Is of the form FutureOr<E> where E ...
  • Is of the form X & E where E ...

@eernstg
Copy link
Member

eernstg commented Jan 9, 2024

Noting the status today, Jan 9 2024. Consider the following program:

extension type F<X>(Future<X> it) implements Future<X> {}

void main() async {
  F<int>? fut = F<int>(Future<int>.value(42)) as dynamic;
  await fut;
}

The common front end and the analyzer disagree on the treatment of this program (according to DartPad based on Dart SDK 3.3.0-273.0.dev). The CFE accepts the program, but the analyzer rejects it as follows:

The 'await' expression can't be used for an expression with an extension type that is not a subtype of 'Future'.

The extension type feature specification does indeed say that

It is a compile-time error if await e occurs, and the static type of e is an extension type which is not a subtype of Future<T> for any T.

However, F<int>? is not an extension type as viewed by the specification, it is a nullable type obtained by applying the operator ? to the (extension) type F<int>. In other words, a strict reading of the feature specification says that the CFE is right, and the analyzer is wrong.

However, it seems inconsistent to accept await e when the static type of e is an extension type like F<T> in the example (noting that it implements Future<T>), and rejecting the same expression when the static type of e is F<T>?.

We don't have a type that corresponds to FutureOr<T> which has the meaning F<T> | T, so we don't need to worry about other union types than the nullable one.

@lrhn
Copy link
Member Author

lrhn commented Jan 10, 2024

The flatten function is the gift that keeps giving.
Its goal of flatten(T) is to be the static type of awaiting something of type T. It must be an upper bound on the runtime type of the possible value of the await. We've then fed the result of flatten back into the behavior of await, to ensure that we stay within the expected type, so now it's all nicely cyclic.

Then we try to give rules about when you can and cannot await separately from the algorithm that figures out what happens if you do await, which means missing some of the gnarlier cases.
Maybe we should change flatten to return Type, with a return of ⊥ meaning that you're not allowed to await, and a type being the type you get if you await.
Then we would be certain that the algorithms stay in sync.

The value of flatten(E) where E is an extension type depends on what happens if you await it.

The rule that you cannot await an extension type which does not implement Future should perhaps not be taken that literally (which means it's not the real rule), but rather say that flatten(E) is undefined.
Then flatten(E?) becomes undefined as well, because it's defined in terms of *flatten*(E`).
(I believe I've suggested this before, likely in the PR on the extension type spec which added this restriction.
Probably here: #3473 (comment))

That is, rules in priority order:

  • flatten(FutureOr<T>) = T
  • flatten(T) where T implements Future<S> = S (includes extension types, type variables through their bounds, etc.)
  • flatten(T?) = case flatten(T) of ⊥ => ⊥ | S => S?
  • flatten(X extends T) = flatten(T)
  • flatten(X & T) = flatten(T)
  • flatten(T) where T is an extension type (which doesn't implement Future) = ⊥
  • flatten(T) = T, otherwise.

Then await would behave accordingly:
If await e has context type C, and e with context type FutureOr<C> has static type S,
and F is flatten(S), then:

  • It's a compile-time error if F = ⊥
  • Otherwise then the static type of await e is F.
    (and continue as normal).

@eernstg
Copy link
Member

eernstg commented Jan 10, 2024

This is a very radical change of the definition of flatten, I think it would be a safer bet to start from the current definition and then add rules as needed to handle extension types.

Note that the current rules already handle extension types in the several ways (where E is an extension type, X is a type variable, and S and T are arbitrary types):

  • flatten(T?) == flatten(T)?, where T can be an extension type.
  • flatten(E) == S in the case where E implements Future<S>.
  • flatten(X) == flatten(T) if X is T bounded, where T can be an extension type.
  • and more.

@lrhn
Copy link
Member Author

lrhn commented Jan 11, 2024

The current definition of flatten and derived future type may need a little tweaking.

The definition is:

We say that S is the future type derived from a type T in the following cases, using the first applicable case:

  • T implements S and there is a U such that S is Future<U>.
  • T is S bounded, and there is a U such that S is FutureOr<U>, Future<U>, or FutureOr<U>?.

When none of these cases are applicable, we say that T does not derive a future type.

Note that if T derives a future type F then T <: F,
and F is always of the form G<...> or G<...>?, where G is Future or FutureOr.

We define the auxiliary function flatten(T) as follows, using the first applicable case:

  • If T is S? for some S then flatten(T) = flatten(S)?
  • If T is X & S for some type variable *X*and type S then
    • if S derives a future type U then flatten(T) = flatten(U)
    • otherwise, flatten(T) = flatten(X).
  • If T derives a future type Future<S> or FutureOr<S>
    then flatten(T) = S.
  • If T derives a future type Future<S>? or FutureOr<S>?
    then flatten(T) = S?.
  • Otherwise, flatten(T) = T.

This definition guarantees that for any type T, T <: FutureOr<flatten(T)>.

I think the "future type derived from" should be defined inductively, instead of trying to list cases.
It does not appear to account for types like FutureOr<Object>?? or (X extends Future<Object>)?.

We cannot assume that a Dart type is not of the form T?? until we apply Norm, and we can't apply Norm before deriving the future type, because it would change the result for FutureOr<Object>.
But that won't affect implementations, which always eagerly normalize ?? to ?.

At least X & T cannot nest, so T cannot be another Y & S, so we don't need "derived future type" to account for it.
We only use the derived future type on the second operand of X & T, or after checking for that case.

For (X extends Future<Object>)?, can hit that by code like [this](https://dartpad.dev/?id=bcb03144d866cc97b84e08caffd8605f&channel=master).

We have a discrepancy between CFE and analyzer on this code, where flatten(X & ((F extends Future<int>)?)) is X according to analyzer and int? according to CFE.
(I'll side with the CFE in intent, but the analyzer is implementing what is currently specified.)

Another case that worries me is X extends (F extends Future<int>)?.
Here flatten(X) would look at the derived future type of X, and since X is (F extends Future<int>)? bounded,
but neither implements Future<int> nor is Future<int>? bounded, it has no derived future type.

This is test2 in the DartPad link above.
Seems analyzer infers int as the type of await of this X, which is unsound since the value can be null.
CFE infers int?, consistently with its "derived future type" above.

So I'd consider rewriting the "future type derived from" iteratively as:

We say that S is the future type derived from a type T in the following cases, using the first applicable case:

  • There is a U such that T is FutureOr<U>, in which case *S* is *T*.
  • T implements S and there is a U such that S is Future<U>.
  • There is a U such that T is U? and U derives a future type F, in which case *S* is F?.
  • There is a U, different from T, such that T is U bounded, and U derives the future type *S*.

When none of these cases are applicable, we say that T does not derive a future type.

Note that if T derives a future type F then T <: F,
and F is always of the form Future<...> or FutureOr<...> with a number of ?s after.

(We can also make it normalize the ?s to have at most one. We could also normalize FutureOr<U> to Future<U>, we never use that it was FutureOr for anything. But this is also fine, we just need to iterate on the result to get through the ?s.)

About flatten, I find it hard to read, because it's so indirectly defined in terms of other functions and relations that are hard to remember the details of, but it's probably correct with the above change to "derived future type", and accounting for the derived future type being able to have multiple ?s.

I think we can collapse the two "derived future type" cases into:

  • If T derives a future type S, then flatten(T) = flatten(S).

Maybe it can just be:

  • If T is S? for some S then flatten(T) = flatten(S)?
  • If T is Future<S> or FutureOr<S> for some S
    then flatten(T) = S
  • If T derives a future type F, then flatten(T) = flatten(F).
  • Otherwise, flatten(T) = T.

but that's really more like:

flatten(*T*) is defined as:

  • If T derives a future type F then flatten(T) = flattenaux(F)
  • Otherwise, flatten(T) = T.

where flattenaux(T) is defined as:

  • If T is S? then flattenaux(T) = flattenaux(S)?
  • If T is Future<S> or FutureOr<S> for some S,
    then flattenaux(T) = S

(which accounts for all possible derived-future types.)

@lrhn
Copy link
Member Author

lrhn commented Jan 11, 2024

About a minimal change to specify the "can't await an extension type that doesn't implement Future", we can introduce an external predicate. That won't affect flatten at all. We just have to get it right. I suggest:

A type *T* is incompatible with await if and only if:

  • T is an extension type that does not implement Future.
  • T is S and S is incompatible with await.
  • T is X & S and S is incompatible with await.
  • T is X extends S and *S* is incompatible with await.

and it's a compile time error to await e if the static type of e is incompatible with await.

These are the places (if I remembered them all) where, if S derives a future type, that's the future type that flatten would flatten,
so that's where the user may or may not expect an extension type's representation type to be awaited,
which is what we want them to not assume.

@lrhn lrhn changed the title Inline classes and await. Extension types and await. Jan 11, 2024
@eernstg
Copy link
Member

eernstg commented Jan 15, 2024

About a minimal change ...

That's great! I think we'd want the following adjustment (motivation below):

Consider an expression e of the form await e1 where the static type of e1 is T. A compile-time error occurs if T is incompatible with await.

A type T is incompatible with await if at least one of the following criteria holds:

  • T implements an extension type that does not implement Future.
  • T is S? bounded, where S is incompatible with await.
  • T is X & B, where B does not derive a future type and X is incompatible with await.

For this rule, we'd want the property that flatten will never be applied to a type which is incompatible with await. In order to see that this is indeed true, we need this lemma:

Lemma: If T derives a future type then T is compatible with await.

Proof sketch:

Case: T implements Future<U> for some U. Then T cannot implement an extension type that doesn't implement Future. Also, T cannot be S? bounded. If T is X & B then B implements Future<U>, and then T is compatible with await.

Case: T is FutureOr<U> bounded. Then it cannot be bounded by an extension type. It cannot be S? bounded. And if T is X & B then B is FutureOr<U> bounded, so "do not derive a future type" is false, so T is compatible with await.

Case: T is Future<U>? bounded. Then it cannot implement an extension type. It is S? bounded, but the S is compatible with await. And if T is X & B then B is Future<U>? bounded, so "does not derive a future type" is false, so T is compatible with await.

Case: T is FutureOr<U>? bounded. Then it cannot implement an extension type. It is S? bounded, but the S is compatible with await. And if T is X & B then B is FutureOr<U>? bounded, so "does not derive a future type" is false, so T is compatible with await.

Apart from the need to ensure that flatten is well-refined, another reason why I included the rule about types of the form X & B is that we can have a situation where X on its own does derive a future type, and then we can promote it (from X to X & B). In this case, B might introduce an extension type, but it seems natural to me that we allow await e no matter what B is because we did allow it before the promotion. Here is an example showing this kind of situation:

import 'dart:async';

// --- Standard static type helper.

typedef Exactly<X> = X Function(X);

extension<X> on X {
  X expectStaticType<Y extends Exactly<X>>() => this;
}

// --- Example .

// Does not derive a future type.
extension type E(Future<int> _) implements Object {}

void test<
  X extends FutureOr<Object>, // Derives `FutureOr<Object>`.
  XQ extends FutureOr<Object>? // Derives `FutureOr<Object>?`.
>(
  X x,
  XQ xq,
) async {
  // Without promotion, `x` and `xq` _do_ derive a future type.
  (await x).expectStaticType<Exactly<Object>>;
  (await xq).expectStaticType<Exactly<Object?>>;

  // With promotion, we should still be able to see that future type.
  if (x is E) {
    (await x).expectStaticType<Exactly<Object>>;
  }
  if (xq is E) {
    (await xq).expectStaticType<Exactly<Object?>>;
  }
}

void main() {
  var future = Future.value(1);
  test<FutureOr<Object>, FutureOr<Object>?>(future, future);
}

@eernstg
Copy link
Member

eernstg commented Jan 15, 2024

I created a proposed feature spec update in #3560.

@lrhn
Copy link
Member Author

lrhn commented Jan 15, 2024

Consider an expression e of the form await e1 where the static type of e1 is T. A compile-time error occurs if T is incompatible with await.

A type T is incompatible with await if at least one of the following criteria holds:

  • T implements an extension type that does not implement Future.
  • T is S? bounded, where S is incompatible with await.
  • T is X & B, where B does not derive a future type and X is incompatible with await.

The first line won't work. An extension type which implements Future can also implement a supertype which does not.

So maybe:

  • T implements an extension type, and T does not implement Future.

Here "T implements an extension type" implies "T is an extension type", so we can also write that.

The third line is probably sound. It assumes that we can't have a B which derives a future type and is still incompatible with await. Which is probably a very reasonable assumption, and it links directly into the definition of flatten which will flatten B if it derives a future type, and ignore X.

"T implements S" means:

  • T has S as superinterface, or
  • T is B bounded and B implements S.

T is S bounded means:

  • T is S
  • T is X, X has bound B and B is S bounded
  • T is X&B and B is S bounded

Which means that items 2 and 3 throw away type variables to get the promoted type or bound, except that if the promoted type doesn't derive a future type, we throw that away and continue on the type variable('s bound) instead.
If that type is S?, remove the ? and continue on S.
If reaching a type is not S?, nor a type variable, check if it is an extension type which doesn't implement Future<T>.

Do we need to swap item 2 and 3, otherwise X & ExtTypeNotFuture? will be rejected by item 2,
before getting to item 3 where it would ignore it for not deriving a future, and recurse on X?

Example:

foo<X extends Object?>(X v) {
  if (v is ExtensionTypeNotFuture?) {
    await v;
  }
}

This would promote v to X & ExtensionTypeNotFuture?, match items 2 with S = ExtensionTypeNotFuture, and reject because ExtensionTypeNotFuture is not compatible with await.

If we use item 3, then we ignore &... because it doesn't derive a future type and recurse on X, which is an interface type and not incompatible.

That's different behavior from X & ExtensionTypeNotFuture, which is a problem.
(I think it's actually the X & S? which is what we want. If we don't want users to wonder whether await e will await the representation value or not, we should reject X & ExtensionTypeNotFuture. Not because we can't avoid it, but because the user may or may not expect it to actually await ExtensionTypeNotFuture. So we refuse to await at all.)

  • T is a type variable which has a bound which is S?

We should be a little careful, because for X & T we first check whether T implies a future type. If so, we use flatten(T), and if not, we use flatten(X).

Currently it's not possible to promote a type variable which implies a future type to a subtype which does not (except possibly Never, in which case anything will be trivially sound). However, if we ever change that, we might want to say that X&T is incompatible with await if (T implies a future type and T is incompatible with await) or (X is incompatible with await)

@lrhn
Copy link
Member Author

lrhn commented Jan 16, 2024

So, coming back to it, I think the definition I'd go with is:

A type T is incompatible with await if, and only if, T satisfies one of the following cases:

  • T is an extension type and T does not have Future<R> as super-interface for any type R.
  • T is S? and S is incompatible with await.
  • T is type variable X with bound B and B is incompatible with await
  • T is a promoted type variable X & B and either:
    • B is incompatible with await, or
    • B does not derive a future type, and X is incompatible with await.

This should cover all the cases where a user could be confused if might they think they can await an extension type with a representation type which implements Future, even if it doesn't implement it itself. That is, the places where an extension type if that was true would be the type flattened by flatten.
That's precisely the places where a type containing such an extension type would derive a future type because of the extension type.

So a type is incompatible with await if changing whether awaiting an extension type will await the representation type, would make a difference. (Which excludes the cases where the extension type does implement Future. There was never any confusion about those. Rigth? 😅 )

I find it easier to convince myself that it actually covers all the cases, than when defined using the transitive "implements" and "S-bounded", so I prefer the more direct definition.

@eernstg
Copy link
Member

eernstg commented Jan 16, 2024

Great comments, thanks! ;-)

An extension type which implements Future can also implement a supertype which does not.

Yes, an extension type could implement a lot of other extension types or non-extension types, directly or indirectly.

So maybe:

  • T implements an extension type, and T does not implement Future.

That's better. Using that.

"T implements an extension type" implies "T is an extension type"

That's not quite true, 'implements' includes type variables whose bound (or bound's bound, etc.) has a superinterface path as specified.

we can't have a B which derives a future type and is still incompatible with await.

Right, I gave a proof sketch here. I think this is a property that we want, and it shouldn't be too hard to ensure that it always holds.

Do we need to swap item 2 and 3

The criteria are specified to be used symmetrically ("at least one holds"), so the ordering should not matter. However, it looks like it would be a really good idea to double-check that. ;-)

I changed the rules (#3560) such that 'being S bounded' is handled separately, and none of the cases has an implicit use of boundedness. With this, I think the rules are simpler, more comprehensible, and perhaps faster in a straightforward implementation.

We say that a type T is incompatible with await if at least one of the following criteria holds:

  • T is an extension type, and T does not implement Future.
  • T is S?, and S is incompatible with await.
  • T is X & B, B does not derive a future type, and X is incompatible with await.
  • T is S bounded, and S is incompatible with await.

Consider an expression of the form await e. A compile-time error occurs if the static type of e is incompatible with await.

Assume the following:

extension type N(Future<int> _) {}
extension type N2(Future<int> _) implements N {}
extension type F(Future<int> _) implements Future<int> {}
extension type F2(Future<int> _) implements F {}

void test<
  X extends Object?,
  XN extends N,
  XF extends F,
  XXN extends XN,
  XXF extends XF,
  XNQ extends N?,
  XFQ extends F?, 
  XXNQQ extends XNQ?,
  XXFQQ extends XFQ?
>(X x, XN xn, XF xf, XXN xxn, XXF xxf, XNQ xnq, XFQ xfq, XXNQQ xxnqq, XXFQQ xxfqq) {
  ... // await various expressions.
}

We had a number of examples, they all work out. For example, the following types are compatible with await: F, F2, X, XF, XXF, XFQ, XXFQQ, F?, F2?, X?, XF?, XXF?, XFQ?, XXFQQ?.

Incompatible: N, N2, XN, XXN, XNQ, XXNQQ, N?, N2?, XN?, XXN?, XNQ?, XXNQQ?.

Intersection types were a bit twisted, so let's take them separately:

X & N? where X extends Object? is compatible with await because N? doesn't derive a future type, and X is compatible; it flattens to X. A similar description fits X & N.

X & N2? where X extends N? is incompatible because X is. A similar description fits X & N2 where X extends N or X extends N?.

That's different behavior from X & ExtensionTypeNotFuture, which is a problem.

I believe that's been fixed now.

we should reject X & ExtensionTypeNotFuture.

If we wish to do that then we can just turn the intersection type item into something like this:

  • T is X & B and B is incompatible with await.

We would then make the choice that it is an error to await a promoted type even though the corresponding non-promoted type can be awaited. This is unusual, but it does align kind of nicely with the fact that X could only be compatible with await if it can't derive a future type. (So we're allowed to await X, but that's kind of silly because it isn't a future).

Please note, though, that the current rule has the nice structural property that it is the least strict rule (not in a very strict formal sense, but I think it's a reasonable characterization) which ensures that flatten is never invoked on a type that isn't compatible with await.

@eernstg
Copy link
Member

eernstg commented Jan 16, 2024

(OK, so we commented simultaneously. But we agree on most of it, so that's kind of consistent. ;-)

@eernstg
Copy link
Member

eernstg commented Jan 16, 2024

Right, the only difference between the two proposed rules is that your proposal contains this one:

  • T is a promoted type variable X & B and either:
    • B is incompatible with await, ...

which basically means that we will make it an error to await x when x has been promoted to have a type that we won't await, and then it won't help that it was promoted from a type that does allow awaiting.

That would be fine with me, too.

@eernstg
Copy link
Member

eernstg commented Jan 16, 2024

Updated #3560 accordingly.

@eernstg
Copy link
Member

eernstg commented Jan 17, 2024

Landed #3560.

@eernstg eernstg closed this as completed Jan 17, 2024
@elkSal
Copy link

elkSal commented Feb 23, 2024

I'm sorry for commenting an old post, but I see this can be related to my question: the keywords async and await convert FutureOr into asynchronous even if it can be synchronous code.
Has there been implemented a solution for such issue?
Thanks a lot and have a great weekend!

@eernstg
Copy link
Member

eernstg commented Feb 23, 2024

I think your comment raises an issue which isn't closely related to the topic of this issue. Please just create a new issue. Someone may respond quickly, otherwise I'll take a look when I'm back from lunch. ;-)

@elkSal
Copy link

elkSal commented Feb 23, 2024

Thanks @eernstg , I'm no hurry, I'll wait for your lunch, enjoy it :)
I saw on this topic this and this but no resolution yet.

@eernstg
Copy link
Member

eernstg commented Feb 23, 2024

(@elkSal, will you create that new issue? It's a bit messy if we have substantial discussions about topic B in an issue about topic A; and I do think these topics are different; and this one is closed, anyway. ;-)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
extension-types inline-classes Cf. language/accepted/future-releases/inline-classes/feature-specification.md
Projects
None yet
Development

No branches or pull requests

3 participants