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

Apply coercions on implicit getter invocations in pattern declaration/assignment? #2845

Closed
eernstg opened this issue Feb 14, 2023 · 20 comments
Closed
Labels
question Further information is requested

Comments

@eernstg
Copy link
Member

eernstg commented Feb 14, 2023

Coercions (like int-to-double, generic function instantiation, cast from dynamic) may take place in pattern declarations. For example, in var (double d1, double d2) = (41.9, 42); the integer literal 42 is 'an integer literal with static type double', that is, it denotes the same object as 42.0. However, coercions may also happen "late" during pattern declaration execution, that is, they may amount to run-time transformations of objects obtained from implicit getter invocations.

This issue raises the question: Do we want to support late coercions in pattern declarations and pattern assignments?

I'd expect such coercions to occur (cf. this phrase in the patterns feature specification), but the question has just been raised here, so we might as well clarify the situation.

Consider this example from the patterns feature specification (I wrapped the actual example in void main() {} to make it a runnable program):

void main() {
  T id<T>(T t) => t;
  (T Function<T>(T), dynamic) record = (id, 'str');
  var (int Function(int) f, String s) = record;
}

This program is currently rejected by the analyzer and by the CFE (from commit bedcd576560c386e6930573e97ee82596b69fdfe):

n003.dart:4:26: Error: The matched value of type 'T Function<T>(T)' isn't assignable to the required type 'int Function(int)'.
Try changing the required type of the pattern, or the matched value type.
  var (int Function(int) f, String s) = record;
                         ^
Analyzing n003.dart...                 1.8s

  error • n003.dart:4:8 • The matched value of type 'T Function<T>(T)' isn't
          assignable to the required type 'int Function(int)'. Try changing
          the required type of the pattern, or the matched value type. •
          pattern_type_mismatch_in_irrefutable_context
   info • n003.dart:4:26 • The value of the local variable 'f' isn't
          used. Try removing the variable or using it. •
          unused_local_variable
   info • n003.dart:4:36 • The value of the local variable 's' isn't
          used. Try removing the variable or using it. •
          unused_local_variable

3 issues found.

The error messages reveal that no coercion (in particular: no generic function instantiation) takes place during the step where the matched value obtained from record.$1 is used to initialize the pattern declared variable f.

Apparently (cf. dart-lang/sdk#51403 and dart-lang/sdk#51404), coercions are not fully implemented when patterns and records are involved, so perhaps the coercions discussed here will simply start working when those two issues have been addressed.

However, issue dart-lang/sdk#51355 mentions this example and implies that this program should be a compile-time error.

The obvious argument in favor of making it a compile-time error is that coercions at this level are "too much magic", and they will make it difficult to reason about the code (e.g., a cast from dynamic can be hard to discern when it's hidden in the middle of a big record type).

The counter argument would be that these late coercions are a natural consequence of the semantics of pattern declarations:

void main() {
  T id<T>(T t) => t;
  (T Function<T>(T), dynamic) record = (id, 'str');

  // Emulate the following pattern declaration: `var (int Function(int) f, String s) = record;`
  int Function(int) f = record.$1; // A generic function instantiation should be inserted.
  String s = record.$2; // A cast from `dynamic` should be inserted.
}

@scheglov, @dart-lang/language-team, WDYT?

@lrhn
Copy link
Member

lrhn commented Feb 14, 2023

The specification is pretty clear on the coercions happening.

As I remember it, we discussed the issue and choose to allow these coercions on the individual values in destructuring assignments.

We did not make (dynamic, dynamic) assignable to (int, int) at a higher type level, records are not pointwise assignable, because that would require creating a new object for generic instantiation coercions that actually change values. (It would work for just down-casting from dynamic, but so would downcasting from List<dynamic> to List<int>, and we don't do that either.)

Allowing it is built into the type checking algorithm. The required type for a var (int x, int y) = ... pattern is (Object?, Object?), because we don't want to give a type error at the boundary here. The RHS type must be assignable to (Object?, Object?), which means being a two-tuple, Never or dynamic.

And late implicit downcasts from dynamic seem to work in both analyzer and CFE, checked on dartpad.
You can even do var [int x, String y] = <dynamic>[1, "a"]; and it works as intended.

So yes, we do want the coercions.

We don't seem to have implemented implicit generic instantiation at the same points.

  T id<T>(T x) => x;
  var (int Function(int) f, int z) = (id, 1 as dynamic);

This fails to compile in dart2js on DartPad. (No warning from analyzer, though.)

@eernstg
Copy link
Member Author

eernstg commented Feb 14, 2023

The specification is pretty clear on the coercions happening.

The bullet just before the one you link to says that

It is a compile-time error if M is not assignable to T.

where M is the type of the matched value and T is the required type of the pattern, which would actually make it an error to need a generic function instantiation before we reach the case where it says that we will insert a coercion.

Note that 'If p with required type T is in an irrefutable context' is applied recursively for subpatterns. In particular, it is not only applied to the top-level record pattern in the given example, it is also applied to the variable patterns that are subpatterns of the record pattern.

  T id<T>(T x) => x;
  var (int Function(int) f, int z) = (id, 1 as dynamic);

This means that we have a record pattern with required type (Object?, Object?) and a matched value of type (T Function<T>(T), dynamic), which is OK; but then we have two subpattern cases: int Function(int) f with matched value type T Function<T>(T), which is an error according to bullet one (so we don't get to bullet two, so there's no coercion). And finally a variable pattern int z with matched value type dynamic, which is assignable, so we do get the cast from dynamic coercion.

I think we need to come up with a concept which is a bit broader than "assignable", namely "assignable after the application of coercions as needed".

We would also have a funny case if the lack of coercion kicks in because of a variable:

  T id<T>(T x) => x;
  var ((int Function(int) f, int z) && ((int Function(int), int) typedRecord))= (id, 1 as dynamic);

Even if we use the broader assignability concept (such that the variable pattern declaring f isn't an error) then it is still a surprising anomaly that the variable pattern declaring typedRecord is a compile-time error, because (T Function<T>(T), dynamic) isn't assignable-with-coercions to (int Function(int), int).

Unless, of course, we define assignable-with-coercions to be deep and structural. But in that case there's no need to use (Object?, Object?) as the required type for every record pattern with two positional fields.

Etc.

I still think we're introducing a bit more complexity here than we should. We could cut it off and say that late coercions only apply to the initializing expression as a whole, not to each of the parts that we're pulling out using pattern based deconstruction.

(Of course, we'd still have the coercions that are applied to subexpressions of the initializing expression based on the context type schema from the pattern, I'm only worried about the "late" coercions that are applied to objects that are obtained by implicit getter invocations on the value of the initializing expression).

@lrhn
Copy link
Member

lrhn commented Feb 15, 2023

I think we need to come up with a concept which is a bit broader than "assignable", namely "assignable after the application of coercions as needed".

I thought that was what "assignable" meant today. A type is assignable to another type if it is a subtype, or if there is a coercion which creates a subtype.

The (now) two special assignability cases that we have are:

  • dynamic is assignable to any T, with coercion (_ as T).
  • Generic function type T Function<X1,...,Xn>(args) is assignable to non-generic function type T' Function(args') if a specific specified instantiation algorithm finds a substitution σ = [X1 ↦ T1, ... ,Xn ↦ Tn] s.t. σ(T) Function(σ(args)) is a subtype of T' Function(args').

(We used to have "callable class to Function" with coercion "add .call", but we dropped that for Dart 3.0. It still works in 2.X code, but that code won't have patterns.)

In particular T Function<T>(T) is assignable to int Function(int). If that's not actually what's specified, I think we should consider that a spec error.

Our biggest trouble with coercions is that we have not been good enough at stating when they occur, or rather, the implementations had not implemented the specification, so we had to change the specification.
(It used to say that if the the static type of an expression was not a subtype of the context type, but it was assignable, the coercion was performed there, which effectively changed the static type of the expression to the coerced type. For expressions with inherited context types, implementations instead did the coercion as late as possible.)

@eernstg
Copy link
Member Author

eernstg commented Feb 16, 2023

I'll comment on the terminology first. This could be considered less important than the actual semantics, but I think it's useful to mention how things have been named so far.

"A type S is assignable to a type T" used to mean S <: T or T <: S. Starting with Dart 2 it means S <: T or S is dynamic.

The generic function instantiation mechanism is specified in terms of a specific pair of context and expression types: If the expression e has a static type in the empty context which is a generic function type, and the context type is a non-generic function type, then e is subject to generic function instantiation. This means that it is transformed into e<T1, .. Tk> by type inference using the given context type. The type of e<T1, .. Tk> may or may not give rise to a compile-time error, but that's just standard type checking which occurs after the transformation.

So we're changing e before we even ask whether or not there's an assignability relation (there isn't, ever, if generic function instantiation is applicable) or a compile-time error (there might be, but a type mismatch could also be handled as a demotion of a promoted variable).

In particular T Function<T>(T) is assignable to int Function(int). If that's not
actually what's specified, I think we should consider that a spec error.

We can of course change the definition of 'assignable', but it is not obvious to me that there is much to be gained by doing that (but we do need to adjust various documents).

However, I do think we should maintain a clear distinction between relations among existing entities (like subtyping) and mechanisms that involve computations yielding new entities (like coercions). Part of this is that we make sure that coercions aren't bundled with any compositional mechanisms. For instance, we don't want generic function instantiation to try to kick in on an expression of type List<X Function<X>(X)>.

the implementations had not implemented the specification

That's true, generic function instantiation is only applied in some situations. But it shouldn't be hard to change the specification, e.g., the part about generic function instantiation, to say "if e has a generic function type and a context type which is a non-generic function type and e occurs in one of the following locations: ...", which is what the implementation actually does.


However, terminology aside, we just need to make sure that we know which behavior we want.

My impression from the language team meeting is that everybody is happy about using the desugared code of a pattern declaration as an approximation of the desired semantics. If the rules give rise to a significantly different behavior then we'd want to re-scrutiny the rules.

X id<X>(X x) => x;

void main() {
  (X Function<X>(X), dynamic) record = (id, 'foo');
  var (int Function(int) f, String s) = record;

  // The pattern declaration corresponds to the following:
  {
    int Function(int) f = record.$1; // OK, does give rise to generic function instantiation.
    String s = record.$2; // OK, does give rise to a dynamic type check.
  }
}

(OK, the actual semantics includes various kinds of caching such that we, for example, don't evaluate the initializing expression more than once, but that doesn't matter here.)

The main ideas are: (1) Coercions can and will be inserted in situations that are semantically similar to existing variable declarations with initialization. (2) Coercion is not implicitly lifted to composite entities (that is, we aren't going to create a new record like (record.$1<int>, record.$2 as String) — when the record has been taken apart, the coerced parts will not be implicitly combined again into any larger object).

@lrhn
Copy link
Member

lrhn commented Feb 16, 2023

Terminology only:

I think we should change the terminology so that "A is assignable to B" means that an expression of type A is allowed in a context where a B is required. (Because every piece of the specification, and feature specifications, that I have ever written has assumed that to be the case, so I highly question whether we are using the term consistently.)

Then A is assignable to B when:

  • A is a subtype of B,
  • A is dynamic, or
  • A is a generic function type, B is a non-generic function type, and a specific algorithm produces type arguments such that when applied as type arguments to A, the result is subtype of B.

We should then introduce the notion of "coercion from A to B" which takes a value with a statically known type of A, where A is assignable to B, and converts it to a value with a runtime type which is a subtype of B (or throw). We'll define precisely how that coercion works:

  • When A is a subtype of B, the result is the value.
  • If A is dynamic, the coercion throws if the value's runtime type is not a subtype of B, otherwise the result is the value.
  • If A is a generic function type and B is a non-generic function type, the generic function value is instantiated with the runtime instantiation of the type arguments found by the algorithm above.

We should then go through the specification and ensure that we insert the coercion at precisely the points where we want it.
For example:

Let e the assignment expression x = e2 where x denotes a local variable.
Static semantics:

  • Let T be the declared (promoted?) type of the variable x.
  • Let V be the static type of e2 inferred with T as context type.
  • It is a compile time error if V is not assignable to T.
  • The static type of e is V.
    Runtime semantics:
  • Evaluate e2 to a value v.
  • Coerce v from V to T to a value v'.
  • ... actually assign v' to variable x, includes checking that a late final variable is not already assigned ...
  • Then e evaluates to the value v.

We can introduce shorthands if we think it gets too wordy.

That would should preserve the validity of

  int i;
  num n;
  dynamic d = 1;
  i = n = d;

which works today, but would also allow:

  T Function<T>(T) g;
  int Function(int) n;
  T Function<T>(T) d = <T>(T x) => x;
  g = n = d;

which is currently rejected.
I cannot explain why one is accepted and the other rejected.

It seems like n = d still has static type dynamic, the original type, in the first example,
but n = d has type int Function(int), the coerced type, in the second.

I'd like to not have multiple different places and approaches to coercion. One is enough.

@stereotype441
Copy link
Member

To clear up some confusion in the discussion above: in addition to the coercions mentioned by @eernstg (int-to-double, generic function instantiation, and cast from dynamic), the analyzer and CFE also support implicit tearoff of .call. (We have been thinking of removing this from the language, but we haven't removed it yet. Instead we made a lint so that to help pave the way for removing it in the future).

There appear to be a lot of inconsistencies between how these different kinds of coercions behave, and a few differences between the analyzer and CFE. To the best of my understanding, there are three possible times when coercion can happen:

  1. After analyzing a subexpression, we may decide to coerce it based on the type context it appears in. This is when both the analyzer and the CFE do int-to-double conversion; consequently, double d = 0..isEven; is a compile-time error, because the 0 is interpreted as 0.0.
  2. After analyzing the right hand side of an assignment, or some other subexpression that is required to be assignable to a destination type (e.g. a function argument), we may decide to coerce it based on the type it's required to be assignable to. This is when both the analyzer and the CFE do implicit tearoffs of .call. So for instance, if class C contains methods int call(int i) => i; and void m() {}, then both the analyzer and the CFE allow int Function(int) f = C()..m();, because they treat it as equivalent to int Function(int) f = (C()..m()).call;.
  3. As an implicit part of the act of assigning a value, we may decide to coerce it to the destination type. This is when we do downcasts from dynamic. So for instance, dynamic d = 0; int i = d..foo(); is allowed by both the analyzer and CFE (but fails at runtime of course).

The difference between the last two bullets is a subtle one: with the second bullet, the RHS of the assignment is changed, so if the assignment expression is used as the RHS of a further assignment, the further assignment sees the coerced value. With the third bullet, the coercion happens during the assignment, but the RHS of the assignment is unchanged. For example:

class C {
  int call(int i) => i;
}
f(C c) {
  C a;
  int Function(int) b;
  a = b = c; // ERROR: `b = c` has type `int Function(int)`, which can't be assigned to type `C`
}

But:

f(dynamic c) {
  String a;
  int b;
  a = b = c; // OK: `b = c` has type `dynamic`.
}

Now here's where it gets weird: for generic instantiations, the analyzer and the CFE differ. The analyzer coerces generic functions to non-generic ones eagerly according to context type (following bullet 1). The CFE follows bullet 2. So for instance, if f is defined as T Function<T>(T) f() => ...;, then int Function(int) g = f()..call('x'); is rejected by the analyzer (becuse it interprets it as int Function(int) g = (f()<int>)..call('x');). But it's allowed by the CFE, which interprets it as int Function(int) g = (f()..call('x'))<int>;.

BUT! The CFE does coerce generic functions to non-generic ones as part of performing tearoffs, following bullet 1. So for example, if f is defined as T f<T>(T t) => ...;, then both the analyzer and the CFE reject int Function(int) g = f..call('x');. But the CFE allows var f2 = f; int Function(int) g = f2..call('x'); (because the reference to f2 in f2..call('x') isn't a tearoff).

In the long term, I would love for us to settle on one of these three behaviours for all coercions. I'm personally leaning towards behaviour 2 because to me it feels easiest to explain to users: "at the point where an assignability check is made, if the assignability check would fail, but an implicit coercion would fix it, then the code is treated as though the user did the coercion explicitly." This would mean:

test1() {
  double d = 0..isEven; // OK; equivalent to `double d = (0..isEven).toDouble();`
}
test2(int i) {
  double d = i; // OK; equivalent to `double d = i.toDouble();`
}
test3(dynamic d) {
  String a;
  int b;
  a = b = c; // ERROR; equivalent to `a = b = (c as int);`
}
T f<T>(T t) => ...;
test4() {
  int Function(int) g = f..call('x'); // OK; equivalent to `int Function(int) g = (f..call('x'))<int>;`
}

I think what I'm advocating for here is mostly the same as what @lrhn is proposing (though we might differ in our interpretation of how a = b = c should be handled).

Assuming we can come to an agreement that we want to move toward a consistent approach like this in the long term, I think it makes sense to go ahead and add pattern support for all these coercions now (even int-to-double conversions), so that patterns will at least be consistent with what we're trying to shoot for.

@eernstg
Copy link
Member Author

eernstg commented Feb 17, 2023

@lrhn wrote, about assignability:

I highly question whether we are using the term consistently.

We should certainly use it consistently, of course. Just for the record, here is a definition of assignability which matches the one that I mentioned. The proposed updates where null safety is added to the language specification uses the same definition:

A type $T$
\Index{may be assigned}
to a type $S$ in an environment $\Delta$
if{}f $T$ is \DYNAMIC, or \SubtypeStd{T}{S}.
In this case we also say that the type $T$ is \Index{assignable} to $S$.

If you consider the rules about int-to-double coercion then we have the following (in the current version of the language specification):

If \code{double} is assignable to $T$ and \code{int} is not assignable to $T$,
then the static type of $l$ is \code{double};
otherwise the static type of $l$ is \code{int}.

It would at least introduce some extra complexity if we insist that 'assignable to' includes coercions like int-to-double (or we could introduce a new word which has the meaning "S <: T or S is dynamic" ;-). It also seems inconvenient if the relation 'assignable to' cannot rely on types, it needs to say things like "... and if the source expression is an integer literal".

Do you have any specific examples where a specification document uses 'assignable' in such a way that it is (obviously?) intended to include coercions? I'd actually recommend that we change them to use different words to describe the situation where a data transfer is allowed because we have assignability after coercions.

That said, I think it's great that these discussions give rise to a more unified view of coercions! We certainly should treat them in a way that allows for a coherent description of the coercions, rather than describing them as a handful of special cases.

About the repeated assignments (and other situations with a similar structure):

.. would also allow:

  T Function<T>(T) g;
  int Function(int) n;
  T Function<T>(T) d = <T>(T x) => x;
  g = n = d;

I would actually prefer if coercions are considered to be local code transformations. They are applied implicitly (and they could be written explicitly, if we prefer to do that), and the effect should be consistent with this perspective. This would imply that g = n = d above is a compile-time error because it is desugared to g = n = d<int>. I think this yields code which is more readable and less confusing, because there is a way to understand what's going on which is a simple syntactic transformation on the expression which is subject to coercion.

The alternative approach that you described, where g = n = d is allowed, relies on a "late transformation",

Coerce v from V to T to a value v'.

for which there is no corresponding syntax (unless we introduce a let construct or at least some temporary variables). I think there is a comprehensibility tax associated with "from scratch" semantics, compared with the situation where we can describe the mechanism as a simple and local syntactic transformation which is controlled by the static types.

@eernstg
Copy link
Member Author

eernstg commented Feb 17, 2023

@stereotype441 wrote:

the analyzer and CFE also support implicit tearoff of .call

Ah, of course, we only lint it for now. But it will surely go away at some point.

Your subsequent analysis gives excellent support to the argument that there is a need to make this part of the language simpler and more consistent! Agreed, +100!

In the long term, I would love for us to settle on one of these three behaviours for all coercions. I'm personally leaning towards behaviour 2 because to me it feels easiest to explain to users: "at the point where an assignability check is made, if the assignability check would fail, but an implicit coercion would fix it, then the code is treated as though the user did the coercion explicitly."

I think we can achieve this at the specification level by doing the following:

  • Changing the rule about generic function instantiation to include the constraint that the target expression occurs in a location which is considered to be an 'assignment location'.
  • Changing the rule about int-to-double coercion (and the rule about implicit .call tear-offs) in the same way.
  • Changing assignability to be subtyping, and treating cast-from-dynamic as a coercion (which will again only occur at an assignment location).

(We may or may not wish to generalize int-to-double to other expressions than integer literals.)

An assignment location would be something like the right hand side of an assignment, an actual parameter in a function call, each of the two branches in a conditional expression which occurs at an assignment location, etc., whatever we already have, plus whatever we want, which isn't too breaking.

As you mentioned, this makes the following an error, even though it works today:

test3(dynamic d) {
  String a;
  int b;
  a = b = c; // ERROR; equivalent to `a = b = (c as int);`
}

I think this is a good change: (1) It makes cast-from-dynamic a coercion, following common (and hence well-known) rules, (2) it reduces the number of expressions of type dynamic (ever so slightly ;-).

@lrhn
Copy link
Member

lrhn commented Feb 17, 2023

The int to double "coercion" is not a coercion. It doesn't take something of one type and makes it have a different type. There is never a sub-expression of type int which is coerced to double. The expression has type double from the start, and it evaluates directly to a double value.

It's just that the static and dynamic semantics of an integer literal (syntactic construct, not semantic) depends on the context type. In some cases its semantics is an int value with static type int, in others it's a double value with static type double. There is never any other type or value in play for any sub-expression. Nothing is coerced at runtime.

We should call it the "double interpretation of integer literals" and avoid saying "coercion" anywhere near it.

@eernstg
Copy link
Member Author

eernstg commented Feb 17, 2023

The int to double "coercion" is not a coercion.

If we want to make these mechanisms consistent with each other as far as possible then we can make the choice that int-to-double is a coercion.

Given that int-to-double is only applicable to integer literals, it makes no difference whether it's performed at run time or compile time, and we'd of course do it at compile time because we can (this is exactly the treatment we give constant expressions as well). I think it is unproblematic that we optimize away an int object because it can't be observed whether or not it was ever there.

If we do want to generalize it to all int objects, as mentioned by @stereotype441, then we would certainly perform a non-trivial computation at run time, which would make it even more obvious that it should be considered to be a coercion.

We can also

avoid saying "coercion" anywhere near it.

but I don't see how that would help anyone, it just doubles down on the non-uniformity of this part of the language.

@lrhn
Copy link
Member

lrhn commented Feb 17, 2023

Given that int-to-double is only applicable to integer literals, it makes no difference whether it's performed at run time or compile time

It makes every difference, which is why it's imperative that there is no integer value involved.

We cannot make "int-to-double" a coercion, coercing from an int value to because there are integer literals that are valid double values, but not valid integer values. Take:

double d = 18446744073709551616;

This is valid today, and equivalent to double d = 18446744073709551616.0;.

There is no native 64-bit int with the value 18446744073709551616 (aka. 2^64).

We can, hypothetically, decide to make int assignable to double, and insert a coercion which will do .toDouble(), but that'll have a different behavior than interpreting the numeral as a double directly.

Take:

int i = 0x7FFFFFFFFFFFFFFF; // Valid (except on web).
double d = i.toDouble(); // rounded to 0x8000000000000000.
double d2 = 0x7FFFFFFFFFFFFFFF; // Compile-time error.

The non-uniformity here comes from double-interpretation of an integer numeral not being a coercion from one type to another.
The actual coercions are: From dynamic to anything, from generic function to non-generic function, from callable class instance to function value. Each of these take an existing value with a known static type, and does something to ensure a value that is guaranteed to satisfy a different static type.

Nothing like that happens when interpreting certain source numerals as double literals.

@eernstg
Copy link
Member Author

eernstg commented Feb 17, 2023

there are integer literals that are valid double values, but not valid integer values.

Good point!

However, I still tend to think that the value provided by having consistent language mechanisms (as much as possible) is greater than the value of being able to specify a few large number literals of type double without adding that magic .0 at the end.

In the end, int-to-double is probably only ever used in practice with numbers near zero, like 0, 1, 2, -100, 8100, (and 42, obviously), whereas double d = 18446744073709551616; will occur exactly once, in a test. ;-)

double d = 18446744073709551616.0; // Not much worse..

@stereotype441
Copy link
Member

Yeah, I agree with @eernstg on this one. When writing Flutter apps, I often forget that Flutter uses doubles for all its pixel coordinates rather than integers. Which means that if I have some code like Image(..., width: 100, height: 100), and I decide to generalize it to Image(..., width: w, height: h), I'm liable to make w and h integers, and then have to spend some time scratching my head over why I'm getting a compile error. When I'm thinking intuitively about Dart, I'm not thinking about the fact that int-to-double is a special behaviour of integer literals rather than a coercion. I just want it to let me use my integer variable where a double is expected, and I'm frustrated that it doesn't 😃.

So to me, the benefit of changing int-to-double to a coercion like the others is not just a consistency benefit, it's a nontrivial pragmatic coding benefit when writing Flutter apps. Personally I value that a lot higher than being able to write a large double value without the .0.

Also, a mitigating factor here is that if we change the behaviour of int-to-double conversion in the way I'm suggesting, then code like double d = 18446744073709551616; becomes a compile-time error. So we're not going to cause subtle bugs with this change; we're just going to give a very small number of users a very small migration job to do when they upgrade their language version.

@lrhn
Copy link
Member

lrhn commented Feb 17, 2023

If we allow coercing integers to doubles, should we allow coercing doubles to integers too?
Seems just as reasonable.

Doing arbitrary implicit conversion from int to double, or vice versa, is something I really do not want.
Not unless we decide to introduce user-defined implicit coercions in general.
It's a step away from explicitness and towards invisible side-effects. It's an implicit "down"-cast to a less precise type. (In both directions, so really "differently-precise").

I'd rather remove the existing integer numeral double literal semantics, and teach people to write the damn .0.
We have quite enough problems with interaction between integers and doubles without being able to lose track of type and precision implicitly in the middle of a computation.

@eernstg
Copy link
Member Author

eernstg commented Feb 17, 2023

I have a vague feeling that int-to-double is good (because it succeeds without changing the ideal value in all the common cases), whereas double-to-int is bad (because there's a loss of precision with every number that has a fractional part, which is almost all floating point numbers).

We could even make int-to-double a run time error in the case where there is no precise double representation (that is, we could carry the compile-time error that we have today over to run time, in the case where the coercion occurs at run time). More run-time errors is bad, but Ariane 5 came down earlier than planned because the numerical miscalculation wasn't detected at all (IIRC ;-).

@stereotype441
Copy link
Member

Hmm, maybe the right thing to do right now is:

  • ensure that implicit generic function instantiation works in patterns*
    • *When I say "works in patterns" I mean that in all the places where the patterns spec says "A must be assignable to B", we make sure that it works for A to be a generic function type and B to be a non-generic function type, and that at runtime, the corresponding function is instantiated with the appropriate concrete types.
  • ensure that cast from dynamic works in patterns (for a similar definition of "works")
  • ensure that implicit call tearoff doesn't work in patterns (because we plan to get rid of it and there's no sense in permitting it for a short while and creating extra churn for users)
  • ensure that int-to-double conversion doesn't happen in patterns (because that's at least consistent with how int-to-double conversion works today).
  • file an SDK issue to address the analyzer/CFE inconsistencies in the handling of generic instantiations (and if necessary we can have more discussion in that issue about precisely how we want to resolve it)
  • file a separate language issue to discuss whether we want to change how int-to-double conversion works (and we can carry on the debate about it there); if we decide to change this in a future release, we can update the behaviour of patterns at that point.

Does that sound reasonable to folks? If so I'm happy to file the auxiliary issues. Maybe I'll even feel inspired to write some language tests.

@lrhn
Copy link
Member

lrhn commented Feb 17, 2023

SGTM. I'd add:

  • File a separate language issue to discuss whether we should specify (and then implement in analyzer/CFE) the different coercions in a single consistent way.

@munificent
Copy link
Member

Sorry to come this thread late. If it helps, here is how I intend for coercions to behave around records and patterns. I'll use a generic function as an example, but I expect the same behavior with dynamic. int-to-double is maybe different.

T id<T>(T t) => t;

// Wrap `id` in a record without instantiating it.
(T Function<T>(T),) idRec = (id,);

// 1: Instantiate when constructing record. OK:
(int Function(int),) record = (id,);
print(record.runtimeType); // Something like "(int Function(int),)".
print(record.$1.runtimeType); // Something like "int Function(int)".

// 2: Instantiate when constructing record, then destructure. OK:
var (int Function(int) f,) = (id,);
print(f.runtimeType); // Something like "int Function(int)".

// 3: Can't coerce existing record. ERROR:
// (int Function(int),) record = idRec;

// 4: Coerce when destructuring. OK:
var (int Function(int) f,) = idRec;
print(f.runtimeType); // Something like "int Function(int)".

The way I think about them (and hopefully specified it!):

  1. We push a context type of (int Function(int),) over to the record literal expression. That then pushes int Function(int) as the context type into the record literal's first field. The static type of the field expression, id isn't a subtype of that, but is assignable, so we insert a coercion from id before creating the record. It's as if the user wrote:

    (int Function(int),) record = (id<int>,);

    At no point do we have a record object whose first field is an uninstantiated id.

  2. We do the same process as 1 when inferring the record expression. Then we take that and destructure the field. The field's type is int Function(int) since it's already been instantiated. That's the same type as the destructured subpattern, so there's no coercion to do (since it was already done) and we store the destructured field directly in f. It's as if the user wrote:

    (int Function(int),) temp = (id<int>,);
    int Function(int) f = temp.$1;
  3. The record object has already been constructed, so the context type of (int Function(int),) does nothing. The right-hand side's type (T Function<T>(T),) is not a subtype of the variable's type (int Function(int),), nor is assignable. So this is an error.

  4. Again the record has already been constructed, so the context type of the entire pattern doesn't change how the initializer is inferred or executed. But here we aren't assigning the entire initialized value to anything. Instead, the record pattern on the left destructures the first field. Then the assignment happens. In this case, we're assigning a value of type T Function<T>(T) to a variable of type int Function(int). That's not a subtype, but it is assignable. So a coercion is inserted at the point that the field is destructured and we're fine. As if the user wrote:

    (T Function<T>(T),) temp = idRec;
    int Function(int) f = idRec.$1<int>;

Does that sound right?

@lrhn
Copy link
Member

lrhn commented Feb 23, 2023

Sounds absolutely right to me.

The static type of the field expression, id isn't a subtype of that, but is assignable, so we insert a coercion from id before creating the record.

Agree. Creating a record is necessarily a coercion point. We cannot create an (int Function(int), int) from (<T>(T x) => x, 1) without coercing the value first, because if we don't, the runtime type of the created object will not satisfy the static type, and then it's too late to coerce because we don't have any coercion rules for record objects.

That covers 1 and 2, and touches on 3 - we don't have any coercion rules for records, so var r1 = (<T>(T x) => x, 1); (int Function(int), int) r2 = r1; has no way to coerce the value of r1 to the type needed to be assigned to r2.

Ad 4: Yep, destructuring extracts individual values from the object, record here, and then handles them individually.
We do have coercion rules from T Function<T>(T) to int Function(int), so we coerce before assigning to the match variable.

(Also: "That's not a subtype, but it is assignable." - that's what I keep saying 😉, even if the current definition of "assignable" only covers dynamic. We really should fix it, because it's incomprehensible that things can be assignable, but are not "assignable".)

And as usual: "int-to-double" is not a coercion, it's a bad name for the semantics of double literals with no fraction or exponential part. The current behavior cannot be implemented as a coercion. There is never any value which is not a double, or expression whose static type is not double. There is nothing to coerce from or to.

@eernstg
Copy link
Member Author

eernstg commented Mar 23, 2023

The language team decided to take the support for coercions during pattern matching out of the feature.

@eernstg eernstg closed this as completed Mar 23, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Further information is requested
Projects
None yet
Development

No branches or pull requests

4 participants