-
Notifications
You must be signed in to change notification settings - Fork 205
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
Comments
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 Allowing it is built into the type checking algorithm. The required type for a And late implicit downcasts from dynamic seem to work in both analyzer and CFE, checked on dartpad. 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.) |
The bullet just before the one you link to says that
where Note that 'If 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 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 Unless, of course, we define assignable-with-coercions to be deep and structural. But in that case there's no need to use 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). |
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:
(We used to have "callable class to In particular 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. |
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 The generic function instantiation mechanism is specified in terms of a specific pair of context and expression types: If the expression So we're changing
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
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 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 |
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:
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:
We should then go through the specification and ensure that we insert the coercion at precisely the points where we want it.
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. It seems like I'd like to not have multiple different places and approaches to coercion. One is enough. |
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 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:
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 BUT! The CFE does coerce generic functions to non-generic ones as part of performing tearoffs, following bullet 1. So for example, if 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 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. |
@lrhn wrote, about assignability:
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 " 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):
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 The alternative approach that you described, where
for which there is no corresponding syntax (unless we introduce a |
@stereotype441 wrote:
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!
I think we can achieve this at the specification level by doing the following:
(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 |
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 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 We should call it the "double interpretation of integer literals" and avoid saying "coercion" anywhere near it. |
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 If we do want to generalize it to all We can also
but I don't see how that would help anyone, it just doubles down on the non-uniformity of this part of the language. |
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 double d = 18446744073709551616; This is valid today, and equivalent to There is no native 64-bit We can, hypothetically, decide to make 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. Nothing like that happens when interpreting certain source numerals as double literals. |
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 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.0; // Not much worse.. |
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 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 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 |
If we allow coercing integers to doubles, should we allow coercing doubles to integers too? Doing arbitrary implicit conversion from I'd rather remove the existing integer numeral double literal semantics, and teach people to write the damn |
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 |
Hmm, maybe the right thing to do right now is:
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. |
SGTM. I'd add:
|
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 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!):
Does that sound right? |
Sounds absolutely right to me.
Agree. Creating a record is necessarily a coercion point. We cannot create an That covers 1 and 2, and touches on 3 - we don't have any coercion rules for records, so Ad 4: Yep, destructuring extracts individual values from the object, record here, and then handles them individually. (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 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 |
The language team decided to take the support for coercions during pattern matching out of the feature. |
Coercions (like int-to-double, generic function instantiation, cast from
dynamic
) may take place in pattern declarations. For example, invar (double d1, double d2) = (41.9, 42);
the integer literal42
is 'an integer literal with static type double', that is, it denotes the same object as42.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):This program is currently rejected by the analyzer and by the CFE (from commit bedcd576560c386e6930573e97ee82596b69fdfe):
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 variablef
.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:
@scheglov, @dart-lang/language-team, WDYT?
The text was updated successfully, but these errors were encountered: