Skip to content

[Records] Should pointwise implicit downcasts be allowed in records? #2488

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

Closed
leafpetersen opened this issue Sep 15, 2022 · 17 comments · Fixed by #2541
Closed

[Records] Should pointwise implicit downcasts be allowed in records? #2488

leafpetersen opened this issue Sep 15, 2022 · 17 comments · Fixed by #2541
Assignees
Labels
records Issues related to records.

Comments

@leafpetersen
Copy link
Member

Consider the following example:

  dynamic d = "hello";
  (int, int) r = (d, 4);

There are two possible inferred types for the record literal.

  • We might choose to infer this as (dynamic, int)
    • In this case this code is a static error, since neither type is dynamic, and the RHS type is not a subtype of the LHS type.
  • We might choose to infer this as (int, int)
    • In this case this code is a runtime error, since there must be an implicit cast added to cast d to int before constructing the record.

Which do we intend?

In general, I'm not keen on adding more implicit downcasts. I'd rather the user make the cast explicit here.

We do push casts inwards for list literals however:

  dynamic d = "hello";
  List<int> l = [d];

fails at runtime, not statically (as it must, since we prefer the context type for nominal types, unlike the structural types).

cc @munificent @eernstg @lrhn @jakemac53 @natebosch @stereotype441 @kallentu @chloestefantsova @johnniwinther @scheglov @srawlins

@leafpetersen leafpetersen added the records Issues related to records. label Sep 15, 2022
@lrhn
Copy link
Member

lrhn commented Sep 15, 2022

So the question is whether the context type is propagated into the record, and what that means.

The context type must be propagated into a record, otherwise (double, double) point = (0, 1); won't work, and I think it must work. Records are just parallel operations that otherwise work just as the individual operations would.

For (int, int) r = (d, 4); that means that the context type of d is int and the static type of the d variable is dynamic.

We should downcast d at the same place we would in int _ = d;, which ... I don't actually know where is.

We are not consistent between decimal-literal-to-double promotion and implicit downcast:

extension <T> on T {
  T log() {
     print("$this:$runtimeType @ $T"); 
     return this;
  }
}

void main() {
  double d = 1..log(); // Prints "1.0:double @ double", so static type of `1` is double.
  var d = "x" as dynamic;
  // int x = d..log();  // Does not work because static type of `d` is `dynamic`.
  int x = d..toString().log(); // prints "x:String @ String" before failing the cast, so it's at least outside of the *cascade*.
}

I don't remember what we decided about where to insert the downcasts.
For double-decimal-literals, it's easy, because the conversion can only happen in one place.
For casts, we can add them at any point in the flow up to where they're assigned to the variable providing the context type.

int x = ((d)..something());

could be ant of

int x = ((d as int)..something());
int x = ((d)..something() as int);
int x = ((d)..something()) as int;

It's not the first one.
Another example:

  double y = args.isEmpty ? 1 : 2.5;  // Works.
  var d = "a" as dynamic;
  int x = (args.isEmpty ? d : 4)..log();  // Throws because static type is `dynamic` for the ..log call.

We cast outside the conditional expression.

So, for consistency, I guess

(int, int) r = (d, 4);

should give the RHS the static type (dynamic, int) and then do an implicit downcast to (int, int) at the assignment.

That's also what happens when the RHS is not a literal.

var d = (random ? 'hello" : 2) as dynamic;
(dynamic, int) p1 = (d, 4);
(int, int) p2 = p1;

The (dynamic, int) type is assignable to (int, int) because each field is assignable.
The effect is an implicit downcast like:

(int, int) p2 = p1 as (int, int);

(where an optimizing compiler can omit checking the second field, and only check the ones typed as dynamic).

Same thing should happen for:

var (int x, int y) = p1;

The RHS is implicitly downcast to the LHS's required type before destructuring. If ordering matters, which it shouldn't here, but might for something like;

dynamic d = "a" as dynamic;
Foo foo = Foo(); // has x getter with side effects
var (Foo(x: var x), int y) = (foo, d);

Here the RHS is assignable to the LHS type of (Foo, int), but does not have that type because d is dynamic.
We should downcast the RHS pair (Foo, dynamic) to (Foo, int) before starting on the destructuring.

@eernstg
Copy link
Member

eernstg commented Sep 15, 2022

I saw the feature spec updates before I saw this issue, and commented here.

I agree that there should be an implicit cast (and it's a separate effort, using --strict-something or a lints to avoid having expressions of type dynamic in the first place), but I think it's inconsistent to start having implicit casts from composite types containing dynamic to other composite types (e.g., casting from (dynamic, double) to (int, double)).

I'd prefer if we could specify this mechanism as a transformation that adds a cast to an expression of type dynamic, based on the propagated context type schema.

(So type inference is just one of several transformations, transforming f(42) into f<int>(42) and such; transforming an integer literal into a double-typed integer literal is another one, and adding a cast from dynamic is a third one; adding .call is a fourth & fifth one which may become just a fourth one. ;-)

So I'm suggesting that we transform e to e as T when e has static type dynamic, also when e is a field expression in a record literal, where T is the context type obtained from the context type schema and the result of inference on other parts of the enclosing expression.

The most compelling reason to do this is probably the potential introduction of bottom-up inference on other expressions, namely instance creations where some type arguments use statically checked variance:

class C<out X, inout Y> {
  final X x;
  Y y;
  C(this.x, this.y);
}

void main() {
  var d = 1 as dynamic;
  C<int, int> c = C(d, d); // Error or implicit casts?
}

@lrhn
Copy link
Member

lrhn commented Sep 15, 2022

Erik's question is whether we define "assignability" for records so that a record type is assignable to another record type iff they have the same shape and the field types are pointwise assignable, or just by subtyping.

I think we should define assignability like that, for a couple of reasons.

  • It allows users to think of record operations by their point-wise behavior. If you can do a = c; b = d; you can also do (a, b) = (c, d);, and you can understand the latter in terms of the former.

    I want all of the following to work:

    • (double, double) r = (1, 1);
    • (Function, int) r = (callableObject, 1);
    • (int Function(int), int) r = (id, int); (where id is T id<T>(T value) => value;)
    • (int, int) r = (1, 1 as dynamic);
      because it's so much easier to explain.
  • Also because they all work for arguments in argument lists. Records look like argument lists. I'd hate it if
    foo(1, callableObject, 1 as dynamic) was valid but indirectFoo(args:(1, callabalObject, 1 as dynamic)) is invalid,
    where the args parameter has the same types as the parameters of foo.

  • It makes it less important where we do the implicit downcasts, something which is currently ... complicated. For (int, int) r = (1, d);, having (1, dynamic) being assignable to (int, int) means that the assignment is definitely valid, all we can discuss is where the downcast happens. Is it = (1, d as int) or = (1, d) as (int, int). If assignability betwen records is purely by subtyping, the former implicit downcast, caused by an injected context type reaching the d, would make the assignment valid, but the latter downcast would not happen implicitly. I can do int x = d, but I can't do var (int x,) = (d,);. But I can do var (double x,) = (1,); because some coercions do work.

I have a strong opinion about assignability of records, which implies that an implicit downcast will be made.
For a non-record literal it's a given, it happens just before the assignment;

var p1 = (1, 2 as dynamic);
(int, int) p2 = p1;  // implicit `as (int, int)`.

For a literal, it matters mostly when combined with conditional expressions or cascades.

(int, int) r = someTest ? (1, 2 as dynamic) : (2 as dynamic, 1);
(int, int) q = (1, 2 as dynamic)..$1.arglebargle(); // allowed statically.

What we currently do is to delay the cast as long as possible (for double literals, that's no delay at all, ditto for the instantiated tear-off, because originally we couldn't instantiate after tear-off, for call and downcast, it's later).
Whatever we do, we should do it consistently for records too.
(we can change it, independently of what we do with records, as log as we stay consistent).

@chloestefantsova
Copy link
Contributor

Like Erik, I commented on that topic elsewhere. I copy my comment below.

Maybe another minor argument in defence of casting from dynamic is the potential spreading of records into argument lists. In the following example the spreading will not work, forcing the manual spreading. The manual spreading may appear especially surprising in this case because it will work without the explicit downcast from dynamic, making the reader questioning why the spreading was done manually in the first place.

foo(Funcion(int, String) f, (dynamic, String) a) {
  // This will not work.
  f(...a); // '...a' is a hypothetical syntax for spreading arguments.

  // This will work.
  f(a.$0, a.$1); // The downcast from 'dynamic' will be inserted here.
}

@leafpetersen
Copy link
Member Author

Ok, I think this discussion broadened a bit, so let me try to summarize the issues raised.

First, as I should have pointed out in my initial comment, this is quite connected to the issues raised here by @stereotype441 .

With that in mind, there are three possible directions that have been raised above (only two of which I had intended to introduce). To make it concrete, let's discuss WRT the following example:

  dynamic d = "hello";
  (dynamic, dynamic) dr = (d, d);
  (int, int) r = (d, 4);  // Example 1 
  r = dr; // Example 2

There are three possible interpretations we could take:

  • We could use the specification I have proposed, in which casts are done at the point of assignment (the way they are currently generally done for other types, see the issue I linked above).
    • In this model, the inferred type of (d, 4) would be (dynamic, int) and there would be a static error on the line labelled "Example 1" because that type is not assignable to (int, int)
    • In this model, "Example 2" remains a static error.
  • We could define inference for records specifically to follow the proposed direction from Consider pushing implicit conversions down #2129 , and push the casts in as far as possible into the structure of the term (I had originally specified inference this way in my draft, which led me to file this issue).
    - In this model, the inferred type of (d, 4) would be (int, int) and there would be a runtime cast inserted by the compiler making this the equivalent of (d as int, 4).
    - In this model "Example 2" becomes a static error.
  • We could expand our definition of assignability to treat record types specially. So we would say that a value of record type R is assignable to a location of record type Q if each of the fields of R is pointwise assignable to the corresponding field of Q.
    • In this model, the inferred type of (d, 4) would be (dynamic, int), and there would be a runtime cast inserted by the compiler on the assignment: (d, 4) as (int, int).
    • In this model, "Example 2" also is accepted, with the same runtime cast inserted.

I had not proposed to go the third route, and am somewhat opposed to it - it's highly inconsistent with what we do with all other types. For example, we do not allow List<int> x = <dynamic>[3]. Nor do we special case function types in such a way.

Addressing some specific comments:

@chloestefantsova

Maybe another minor argument in defence of casting from dynamic is the potential spreading of records into argument lists. In the following example the spreading will not work, forcing the manual spreading.

I don't think this follows. Note that we do not make List<dynamic> assignable to List<int>, but we do define the static semantics of spreading to allow <int>[...<dynamic>[3]]. I don't immediately see why we can't/wouldn't just do the same for record spreading.

@lrhn

Erik's question is whether we define "assignability" for records so that a record type is assignable to another record type iff they have the same shape and the field types are pointwise assignable, or just by subtyping.

I think we should define assignability like that, for a couple of reasons.

None of your examples require the pointwise assignability (my option 3 above). They are all covered by option 2, I believe. That said:

@lrhn

  • It allows users to think of record operations by their point-wise behavior. If you can do a = c; b = d; you can also do (a, b) = (c, d);, and you can understand the latter in terms of the former.

It is somewhat compelling that this would work uniformly if we did choose to specify option 3. The fact that the runtime type of a record is computed from its values means that casting any value r of record type to a record type Q will work iff casting every field of r to the corresponding type in Q would also work (assuming they have the same shape).

I'm not sure I find this compelling enough to want to add more implicit downcasts the the language though.

@eernstg
Copy link
Member

eernstg commented Sep 16, 2022

I usually argue in favor of option 2, #2129, exactly because it is a simple and consistent approach. The argument against this approach is usually that it does not work very well for cascades:

class Callable {
  void call(int i) {}
  void foo() {}
}

void main() {
  // Standard example: Works today, breaks if taken to mean `(Callable().call)..foo()`
  void Function(int) f = Callable()..foo();

  // Variant that arises for records and cast-from-dynamic.
  var d = 1 as dynamic;
  (num, num) r = (d, 3.14)..$0.foo(); // Allowed with outer cast, fails with `(d as num, 3.14)`.
}

We surely do want to propagate the context type to cascade receivers (such that C() can receive useful actual type arguments in C<int> c = C()..foo();), and we discussed making it an error to have .call insertion on a cascade receiver (or indeed removing .call insertion entirely). The main problem is that a cascade receiver in a sense has more than one context: In the example above we may wish to change (d, 3.14) to (d as num, 3.14) in order to fit the needs of the enclosing variable declaration, but this change may interfere with the needs of the cascaded member accesses (like $0.foo()).

However, I don't see any additional inconveniencies caused by pushing casts into record literals, so I'll continue to support option 2 as the most consistent approach. That shouldn't prevent any of the solutions to the cascade problem that we might come up with.

On the other hand I'm worried about option 3 (that is, lifting assignability such that not just dynamic is assignable to T for all types T, but also (dynamic, S) is assignable to (T, S) for all T, etc., recursively), in particular because we're very unlikely to introduce a similar kind of lifting for any other kind of type.

void f(int i) {}

void main() {
  void Function(dynamic) g = f; // Will _not_ desugar as `... g = (dynamic d) => f(d);`
}

@lrhn
Copy link
Member

lrhn commented Sep 16, 2022

I don't think comparing records to other kinds of types, mainly collections, is the right choice. They are not like other types.

Records are defined entirely by their content. A triple is not a special object that exists in its own right, it's just three objects in a trenchcoat. The record "object" has no identity, no behavior other than projecting values out of it, and no type other than the combination of the types of the field values. It's reasonable to consider a record object as a boxing of the collection of individual values, rather than records being inherently unboxable objects.

A list is an object with its own behavior, which can include mutability, and its own runtime type. It happens to be containing other objects, but it's not defined entirely by its elements. Because of our reified generics, a List<dynamic> is a different object from a List<int>, even if they contain the same integer elements.
We don't do implicit downcast from List<dynamic> to List<int> because the value might just be a List<dynamic>. And we can't actually check the elements using a single cast.

For a value of type (int, dynamic), we know, with absolute certainty, that the second field is not an object with runtime type dynamic. The runtime type of an (int, dynamic) will be a proper subtype, and a cast to (int, int) will ensure that the one value with static type dynamic has the expected type. It's immutable and entirely covariant.

Another place where it's obvious that a record is just adjacent individual values is in the treatment of Never.
An expression with static type (int, Never) is never going to complete normally. A List<Never> is just an empty list.
Explaining that to users should be simple: A record is just a way to do simple, normal object things in "parallel".

The more we lean into that explanation, the easier things are to explain and understand.

So I believe we should do number 3: Define assignability to include records of the same shape, where the corresponding fields are assignable.
We should do the casts in the same places we do today, which is late (just before the assignment).
That's fine, because any place works if you're just doing assignment.

@leafpetersen
Copy link
Member Author

We don't do implicit downcast from List<dynamic> to List<int> because the value might just be a List<dynamic>. And we can't actually check the elements using a single cast.

For a value of type (int, dynamic), we know, with absolute certainty, that the second field is not an object with runtime type dynamic.

I don't really understand the argument being made here. It's true that the list value might be a List<dynamic>. It's also true that it might not. In other words, the cast might work, it might not. Similarly, for the record type, while the second field is certainly not an object with runtime type dynamic, it could be an object with runtime type Object, or Null or String or anything else, which doesn't seem meaningfully different to me. As with the List case, the cast might work, or it might not.

So what's the principle here? We have a bunch of implicit casts we could do based on the principle "it might work". The principle we've taken as of 2.12 is that we allow a cast from dynamic to anything, but not from composite types that contain dynamic (e.g. FutureOr<dynamic>, void Function(dynamic), etc). Any of these "could work". So why not allow them?

@lrhn
Copy link
Member

lrhn commented Sep 16, 2022

Ok, I'll be honest: I'd be fine with allowing an implicit downcast from List<dynamic> to List<whatever>. We didn't, and as I remember it there were technical reasons for it too, so it's not a completely arbitrary choice. I'm OK with that, but I'd been fine with allowing the downcast too (if we could make the technical issues go away).

The principle here is that records are not like other things, and I want record assignments and operations to behave like they are single-object operations performed in parallel.
Records don't really exist. They have no identity. They have no behavior. It's just a temporary gathering up of independent values, and when you start using them, it should be like using the independent values. I want to push that view throughout the entire record specification, because I believe it's the best model for explaining and understanding what a record is.

If there is a significant difference in how any of these six record assignments work:

dynamic d = 0 as dynamic;
var p = (1, d);

/*1*/ (int, int) r1 = (1, d);
/*2*/ (int, int) r2 = p;
/*3*/ var (int x1, int y1) = (1, d);
/*4*/ var (int x2, int y2) = p;
/*5*/ int x3 = (1, d).$0,  y3 = (1,d).$1;
/*6*/ int x4 = p.$0,       y4 = p.$1;

then I'll have a hard time explaining why.

Numbers 5 and 6 will definitely work.
Numbers 1-4 will all work if we let record assignability be point-wise assignability. If not, ...
Number 1 will work if we do deep implicit downcast, otherwise not.
Number 2 will not work.
Number 3 and 4 - I don't actually know if they get caught in the type checks of pattern declarations.
(If var [int x, int y] = <dynamic>[1, 2]; works, this should too. I don't know if it does.)

One of these solutions is easy to predict and understand, the one where it doesn't matter in which order you do things.
That's not an accident, it's (as I see it) because it's the most natural behavior, the one where we don't try to introduce complications that do not need to be there.

@leafpetersen
Copy link
Member Author

then I'll have a hard time explaining why.

Fair enough. But there's no getting around this in general. For int to double conversions, 2 and 4 will never work. Similarly for .call tearoffs, and for any future direct syntactic conversions we specify. So making this work for implicit casts, while perhaps more internally consistent, makes those conversions less consistent across the language.

In general, this tends to make me lean in the other direction: for downcasts from dynamic (at least), this feels like a good argument for not pushing the context type into things (or at least into records). In that case, 3-6 work, and 1, 2 don't, and it's easy to explain: if you're assigning something of type dynamic to something of a different type, it just works - otherwise it's an error if it's not a subtype.

At a higher level, I think the consistency argument is reasonable, but I think it's not sufficient: doing the wrong thing consistently is still doing the wrong thing. I'd like to see a positive argument here: why is it generally helpful to users to allow these assignments? In general, my experience with implicit assignments from dynamic is that the make it harder to read the code - you can't trust that what you're seeing is what you're getting. I'm reluctant to expand the set of situations in which we do this.

Note too that we do not do this for other composite types (e.g. FutureOr and Function types). So it's not clear to me that this is even a net win in terms of consistency even ignoring the other kinds of conversions.

@eernstg
Copy link
Member

eernstg commented Sep 19, 2022

@leafpetersen wrote:

this feels like a good argument for not pushing the context type into things (or at least into records).

Perhaps we should make a distinction between propagating the context type schema into various constructs, and performing code transformations based on differences between the context type schema and the type of the inferred subexpression?

class C<X> {
  void setup(String s) {...}
}

void main() {
  C<int> c = C()..setup('');
}

Surely we don't want C() to be a compile-time error because it doesn't get the context type C<int>.

I tend to prefer the proposals where a mismatch between the context type schema and the inferred expression type triggers a code transformation initially, but with each propagation of a context type schema into a subexpression, the particular shape of the enclosing expression will or will not propagate the ability to perform the code transformation. When specific code transformations are not enabled, the result of a typing discrepancy is usually a compile-time error.

For instance, we could disable the ability to perform .call insertion for the transition from e..cascadeSection to e, but the same transition would preserve the ability to add missing actual type arguments, and the ability to turn an integer literal into 'an integer literal of type double', etc.

From that perspective, it's an open choice whether or not we'd propagate the ability to cast from dynamic into a record literal: We have some code transformations that are enabled in similar situations, and other code transformations that aren't enabled.

I would tend to enable cast from dynamic in record literals, because of the very direct connection between each field of the record literal and the context type schema for that field.

However, we don't have any other code transformations that are similar to the one that transforms a record-typed expression e into let v = e in (v.$0 as int, v.$1), and hence I don't think we should "cast from dynamic" inside any expression e which is not syntactically a record literal. In other words, /*2*/ and /*4*/ in this comment are completely different from the others.

@lrhn
Copy link
Member

lrhn commented Sep 19, 2022

If we define record assignability as point-wise assignability, it's true that we get into a problem with .call.

It would make (int, CallableClass) assignable to (int, Function), and require an implicit .call tear-off in the assignment (int, Function) p = q; where q has type (int, Function).

We can do that. It means every record assignment can be an implicit destructuring and rebuilding.
It's as if (int, int) r2 = p; is implicitly (int, int) r2 = (p.$0, p.$1); and we apply coercions on every field expression.
That would also apply to .call. It wouldn't apply to "double literals" because those were never runtime coercions to begin with.

So, we can make the number 2 case work. And I'd be in favor of doing that.

Also, number 4, var (int x2, int y2) = p;, is actually format that I would expect to work with .call tear-off. It's a destructuring followed by assignment, so:

(int, CallableClass) p = ...;
var (int x, Function f) = p; 

I'd actually expect the second line to be the same as int x = p.$0; Function f = p.$1;, which is again int x = p.$0; Function f = p.$1.call; because p.$1 is a CallableClass expression assigned to Function.

@munificent
Copy link
Member

There is a lot going on here that I'm struggling to wrap my head around. I don't agree with:

Records don't really exist. They have no identity. They have no behavior. It's just a temporary gathering up of independent values, and when you start using them, it should be like using the independent values.

Records definitely do exist. They are objects—literally subtypes of Object. A record (3,) is not equal (according to ==) to the number 3. You can put single-element records in a list, and when you get them back out, you get records, not the underlying elements.

They are value types in that they don't have any persistent identity and can be allocated on the stack or registers or otherwise optimized away. But they are values.

I've never fully internalized how and where Dart inserts implicit coercions, especially when things like cascades get involved. But as far as Lasse's examples go, here's how I intuitively think they should behave and what I intend the proposal to specify:

/*1*/ (int, int) r1 = (1, d);

This is OK.

  1. We have a context type (int, int) used for downwards inference. When inferring the initializer, we push the corresponding field context type into each field when inferring the fields, so we infer 1 with context type int and then d with context type int.

  2. The latter leads to an implicit cast from dynamic so after type inference, the code is treated as if it had been written:

    /*1*/ (int, int) r1 = (1, d as int);

    I only propose we do this inference for record expressions where type inference can recurse into the fields.

/*2*/ (int, int) r2 = p;

This should be a compile-time error because (int, dynamic) is not assignable to (int, int) in the same way that List<dynamic> is not assignable to List<int>. I don't propose to change assignability for record types.

/*3*/ var (int x1, int y1) = (1, d);

This is OK.

  1. The context type is (int, int). As with example 1, we push that over to the initializer which then recurses into the fields and inserts an implicit cast from dynamic on the second field, like (1, d as int).

  2. Then we type check the pattern against the initializer's inferred type (int, dynamic). The required type of the outer record pattern (int x1, int y1) is (Object?, Object?). (In other words all a record pattern cares about is the shape and it delegates further checking to its subpatterns.) That's fine.

  3. Then we recurse into each field subpattern and check it against the static type of each field. The int x1 requires int which int is assignable to, so that's fine. The int y1 pattern requires int and dynamic is assignable to that, so that's also fine. No static error.

  4. At runtime, the as int succeeds and everything proceeds without error.

/*4*/ var (int x2, int y2) = p;

This is also OK, but through a different mechanism than in 3. Here, since p isn't a record expression, we don't insert the cast during downwards inference.

  1. Again, the context type is (int, int). The initializer doesn't do anything with that context type, so it doesn't matter.

  2. Then we type check the pattern against the initializer's type (int, dynamic). Again, the required type of the outer record pattern (int x1, int y1) is (Object?, Object?). That's fine. Then we recurse into each field subpattern and check it against the static type of each field. The int x1 requires int which int is assignable to, so that's fine. The int y1 pattern requires int and dynamic is assignable to that, so that's also fine. No static error.

  3. At runtime, the record pattern matches since p's runtime type is a subtype of (Object?, Object?). We recurse in. The first field matches, of course. The second field has value 0 which is a subtype of int, so the int y2 pattern matches as well. Had it failed to match, that would become a runtime error since this is in a declaration context. (In a matching context, this would have been a match failure and not a runtime exception.)

/*5*/ int x3 = (1, d).$0,  y3 = (1,d).$1;

This is fine. We insert an implicit cast from dynamic when assigning the result of .$1 to y3, which succeeds at runtime.

/*6*/ int x4 = p.$0,       y4 = p.$1;

Likewise.

var [int x, int y] = <dynamic>[1, 2];

(If [this example] works, [the record examples] should too. I don't know if it does.)

It does. The process is:

  1. Infer a context type of List<int> for the pattern. Push that over to the initializer, which promptly ignores it since the list literal has a type argument already.

  2. Type-check the pattern against the initializer's value type List<dynamic>.

  3. The required type of the list pattern is List<dynamic>. (Since the list pattern has no type argument, we fill it in from the matched value type's type argument.) The matched value type List<dynamic> is assignable to List<dynamic> so no error there.

  4. Extract the element type dynamic from the matched value type and recurse into the element subpatterns using that as the matched value type.

  5. dynamic is assignable to int for both the int x and int y subpatterns, so no static error there.

  6. At runtime, we match the list pattern against [1, 2]. That list matches the pattern's required type List<dynamic> so we extract the two elements and recurse into the subpatterns.

  7. We match 1 against int x. Since 1 is a subtype of int, that succeeds. Likewise matching 2 against int y. Had any of these match failed, it would have become a runtime exception because the pattern occurs in a declaration context.

The way the behavior is specified here is a little different from other places where a value of static type dynamic can be assigned to a value of some other type, because it's defined in terms of "match failure" (which can get turned into a runtime exception) instead of an implicit cast to the expected type, but I think the result is the same.

However, this only works for dynamic. For other coercions, we'll probably have to change the proposal to make that cast explicit. For example:

class Callable {
  void call() {}
}

var (Function f,) = (Callable(),);

According to the current proposal, this would be allowed at compile time since Callable is assignable to Function. It would then throw at runtime since Callable is not a subtype of Function, the match fails, and that becomes an exception. Instead, we'll probably have to tweak the proposal where any time the runtime semantics check "is not a subtype of", we also allow an implicit call tear-off, or generic function instantiation.

@stereotype441
Copy link
Member

stereotype441 commented Sep 28, 2022

Oh hi. At @leafpetersen's encouragement I just read carefully through this thread to catch myself up on the discussion. I don't have a lot to add beyond what's already been said, but I guess I might as well throw my opinion into the ring.

I'm trying to think about this in terms of the two-part question "what would I want to do if I could design the langauge from scratch, without worrying about existing code, and then how can I make incremental steps toward that while breaking as few users as possible?"

Personally my answer to the first part is "do int->double conversions and inference of generic type parameters just as Dart does today. Do dynamic downcasts as early as possible based on context type (as proposed in #2129). Get rid of implicit .call tearoffs entirely."

And my answer to the second part is "since records are new, let's make them behave as consistently with #2129 as we can, without breaking anything else". So from that standpoint, I like @leafpetersen's second bullet (from #2488 (comment)). Namely, define type inference for record literals so that if a field has static type dynamic, an implicit downcast is inserted to coerce it to the corresponding type from the context.

Which means that in @lrhn's example:

dynamic d = 0 as dynamic;
var p = (1, d);

/*1*/ (int, int) r1 = (1, d);
/*2*/ (int, int) r2 = p;
/*3*/ var (int x1, int y1) = (1, d);
/*4*/ var (int x2, int y2) = p;
/*5*/ int x3 = (1, d).$0,  y3 = (1,d).$1;
/*6*/ int x4 = p.$0,       y4 = p.$1;

I'm proposing that 1, 5, and 6 will work (and implicitly downcast d to int, and 2 will be a compile-time error.

This makes sense to me, and I think I can explain it intuitively to users like so: in 1, 5, and 6, there is an expression with type dynamic being used in a place where an int is clearly expected, so there is an implicit downcast. Whereas in 2, there's no expression of type dynamic; just an expression of type (int, dynamic) being assigned to a variable of type (int, int). Sure, we could define assignability so that 2 would work, but I don't see a convincing argument that we should. In my mind, dynamism is a necessary part of Dart and we should support it for the convenience of our users, but we should try to contain it as much as possible to reduce the risk of introducing "surprise" downcasts that a user might not expect. Concretely, what that means to me is that composite types shouldn't "inherit" dynamism from their components; the dynamism should only come into play when you unpack the dynamic component (as we do in 5 and 6).

So what about 3 and 4? According to the current patterns spec (as I read it), 3 and 4 will work (and implicitly downcast d to int). I'm ok with that too. Because I think of both 3 and 4 as "unpacking" the composite type.

I was about to post this comment when I got interrupted for another discussion, and when I returned I saw that @munificent had posted his opinion. It looks like the two of us want the same thing, so we've got that going for us, which is nice.

@lrhn
Copy link
Member

lrhn commented Sep 29, 2022

After further discussion with @munificent and @leafpetersen, I'm OK with what @munificent says above too.
It's a valid approach. It's simple and consistent. As both @munificent and @stereotype441 says, it makes everything except item 2 work.

We could allow item 2 too, but only for implicit downcasts, it won't work for generic instantiation or .call tear-off without having to deconstruct the existing record object, convert the values and build a new one. I accept that as being too magical and performance-unpredictable. And doing it only for one kind of coercion, not for the other one (two, until we remove implicit .call tearoff) is inconsistent.

The way we currently implement implicit downcasts is to do it at "assignments", which is why we don't do it below a cascade, like in Function f = CallableObject()..callableObjectMethod(). Whether that's correct or not is a separate discussion, which we don't have to decide for us to do something reasonable for records.
If we just consider the field initializers of a record literal as assignments, we should get what @munificent describes above.

About:

class Callable {
  void call() {}
}

var (Function f,) = (Callable(),);

I think that should be accepted using the same logic as the other cases:

  • The type schema of (Function f,) is (Function).
  • With context type schmea (Function), (Callable(),) is inferred by inferring Callable() with context type Function.
  • Inferring Callable() with context type Function becomes Callable().call with static type ... either Function or the function type of Callable.call - I don't know what we specify, and I can't actually tell the difference in the language today. Either works. Let's call that type F. It's definitely a subtype of Function.
  • Then the static type of the RHS becomes (F).
  • The required type of the record pattern is (Object?), which accepts (F).
  • Then the field type (F) is checked against the required type of the pattern Function f, which is Function, and F is a subtype of Function.

Success, because we do the coercion on the RHS.
The desugaring would be:

Function f = (Callable().call,).$0;

If we instead look at

var c = (Callable(),);
var (Function f,) = c;

it would still work!

  • Type inference gives (Callable) as static type of RHS (unaffected by LHS type schema of (Function)).
  • Required type of LHS is (Object?), which is satisfied.
  • Callable is assignable to required type Function of the pattern Function f.

Success. Because the value is only assignable, not a subtype, we need to insert a coercion at the assignment to f.

The corresponding desugaring would be:

Function f = c.$0.call;

The implicit coercions to pattern variables only work for assignment, not matching.

if (c case (Function f,)) ...

would start out checking if c is an (Object?), which it is.
It would then check whether c.$0 is a Function, which it isn't. This is a run-time subtype check, it's not an assignability check. We never do those at runtime. (Not even in dynamic invocations!)
So the matching fails and we go to the else branch.

In a pattern match (rather than pattern declaration or assignment), every eventual assignment is either guarded by a type check, which means anything requiring coercion will fail the check, or there is no type check and then there is also no context type, so you can't get a coercion anyway.

@munificent
Copy link
Member

OK, it sounds like we have consensus 🎉 . I believe Leaf will update #2489 to align with this in regards to record static type inference.

I want to update the patterns proposal to make it clearer where these "assignments" (i.e. coercion points) happen. I'll leave this issue open to track that.

@leafpetersen
Copy link
Member Author

Ok, this is good discussion, and nice to see a consensus developing. I have updated #2489 with a proposed specification for this that I believe reflects the intended static semantics as described above, with the exception of the cases that involve destructuring, which will be specified separately in the patterns proposal.

copybara-service bot pushed a commit to dart-lang/sdk that referenced this issue Oct 4, 2022
This CL implements the adjustment of the static semantics for records
as described in
dart-lang/language@d19f6d5. The
adjustment is based on the discussion at
dart-lang/language#2488.

Part of #49713

Change-Id: I7a9d456f702ad0fb14aa3bd121ba9d2bbd104414
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/262202
Reviewed-by: Johnni Winther <[email protected]>
Commit-Queue: Chloe Stefantsova <[email protected]>
munificent added a commit that referenced this issue Nov 3, 2022
* Clarify how implicit conversions happen in patterns.

Fix #2488.

* Remove "first" in "first accesses".

* Fix non-normative example to not claim assignability is involved.

* Respond to review comments.

* Revise some more.

Do use a downwards context type when inferring constant patterns.

But don't insert implicit coercions in refutable patterns.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
records Issues related to records.
Projects
None yet
Development

Successfully merging a pull request may close this issue.

6 participants