-
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
Look up a type variable at a given superinterface #3324
Comments
Although I feel #620 should be solved, I would rather have it solved by a simpler way. First of all, I think Dart should walk in direction of a more consistent type system, not including new "special" types and, if possible, removing the old ones (for dynamic, we have #3192. For Secondly, I think this approach is quite confusing, and it's going to add unnecessary complexity to Dart. I would rather have #170 implemented instead, even if it delays more. |
@eernstg, class B<Z extends A<dynamic, final Y>> {} or ImplementsAt2<Z, A> get g => z.y; and not just treat X in: class A<X> {} the same as X in: class B<Z extends A<X>> {} ? |
@mateusfccp wrote:
This would cause a bigger delay because #170 is more complex than this feature. Basically, if we want to support type patterns (and that was part of the 'patterns' proposal for a while, but it was deemed too complex to be included) then we need to have all the capabilities associated with this proposal at first, and then some. This also means that we could consider this proposal as a stepping stone to type pattern support.
It's about type level computations, and that's a more well-known concept in the functional programming community than in the communities of most other programming paradigms. However, I'll try to present the idea in a way which may be more readable (especially from an object-oriented point of view). This syntax isn't what I would actually recommend as concrete syntax in Dart—the syntax I'm using here will probably break the grammar because of rampant ambiguities. However, I think it could be quite helpful in order to understand what's going on. [Edit Oct 10 2023: This alternative syntax is now mentioned in the proposal, denoted as the 'pseudo syntax' because we don't yet have a set of working grammar rules for it.] So let's think about a type The proposed mechanism is then that we can write So, for instance, we would be able to talk about the element type of a given list: // An example using current Dart.
void f<X extends List<Y>, Y>(X xs, Object? o) {
print('X: $X, Y: $Y'); // 'X: List<double>, Y: dynamic'.
Y y = xs.first; // OK at compile time, and safe.
if (o is Y) xs.add(o); // OK at compile time, throws.
}
// An example using the proposed feature, using the pseudo-syntax mentioned above.
void g<X extends List>(X xs, Object? o) {
print('X: $X, X@List.E: ${X@List.E}'); // 'X: List<double>, X@List.E: double'.
X@List.E anElement = xs.first; // OK at compile time, and safe.
if (o is X@List.E) xs.add(o); // OK at compile time, and does not throw.
}
void main() {
f(<double>[1.5], true);
g(<double>[1.5], true);
} A couple of crucial points:
We still have the property that it is safe to extract the first element of the list and store it in a local variable, but whereas the value of In short, Here is the example from the section 'Usage' again, using the pseudo-syntax: class A<X, Y> {
final Y y;
A(this.y);
}
class B<Z extends A> {
final Z z;
B(this.z);
Z@A.Y get g => z.y;
} Again, the point is that |
@eximius313 wrote:
That wouldn't be possible, because there are lots of situations where that term right after a class B<Z extends A<int>> {} In this case, we can't know whether We could have a rule along the lines of "an identifier which does not resolve to a type declaration in scope is considered to be a fresh type variable", but that would be really error-prone: class B<Z extends List<MaterialInputDefaultvalueAccessor>> {}
// That would be the same thing as this:
class B<Z extends List<X>> {} In any case, support for type patterns is a bigger feature than support for looking up a specific type argument at a specific supertype. |
@eernstg ,
sounds perfectly reasonable (and I believe it's simmilar to what Java does). Why do you think that "that would be really error-prone"? |
The word |
No, I'm afraid I'll have to disagree about that for several reasons. Java generics are not reified, which means that Java could never support a mechanism whereby the actual value of a type argument is queried and the result is bound to a name in the program. So it can't possibly be similar to the mechanism proposed here. In general, the bound of a type parameter is supposed to be a type (not a construct that introduces any new declared names, just a construct that specifies a type using names that are already declared somewhere else). It is possible to use type variables in those types, but they must be defined in the same list of type variables. // Java code, should go into two separate files.
public class A<X extends A<X, Y>, Y extends B<X, Y>> {}
public class B<X extends A<X, Y>, Y extends B<X, Y>> {} This shows that it is possible for a bound like |
@eernstg, thanks for the explanation |
This feature is inspired by the discussion in this issue, where an example with the following structure is given as motivation:
Note that this proposal mentions both a syntax that will definitely work (
ImplementsAtN<AType, ASuperinterface>
) and a pseudo-syntax which is (possibly) more readable (AType@ASuperinterface.TheNthTypeVariable
). The latter is used in commentary in order to provide an additional way to see what's going on.Background, Motivation
We can declare generic classes like this:
However, this may be inconvenient because we'd actually just want to compute the value of
Y
based on the given value ofZ
. We could also pass the actual argument (as we actually do above when passingString
as the second type argument toB
in the declaration ofTestB
), but that seems redundant becauseTestA
already hasA<String, String>
as a superinterface.However, we typically have to pass that 2nd type argument to
B
, because it will otherwise be inferred asdynamic
.So why can't we write those declarations as follows?:
The immediate answer is that we can't do that because it's a compile-time error.
This is because
Y
in the declaration ofB
is an unknown identifier. We really want to have the type variableY
(it would be needed in the body ofB
), but we don't want to ask the "call site" to pass that type argument explicitly. One proposal in response to this request is that we could use type patterns (#170) and then bind a value toY
using a variant of pattern matching:The classes
TestA
andTestB
are unchanged.However, pattern matching on types may be a complex feature. This issue offers a proposal for a simpler mechanism:
Proposal: Compute a type argument at a type
We introduce a built-in generic type
ImplementsAtN
for each natural numberN
(so we'll haveImplementsAt1
,ImplementsAt2
, and so on as needed).This is a "magic" type, just like
FutureOr
anddynamic
are types that we can't express using a Dart declaration. They are a built-in property of the language.Let
t
be a term derived from<type>
of the formImplementsAtN<T, G>
whereT
andG
are derived from<type>
.A compile-time error occurs unless
G
is an identifier or a qualified identifier (likeprefix.MyClass
) that resolves to a generic type declaration. A compile-time error occurs unlessG
hasN
or more type parameters.Finally, a compile-time error occurs unless
T
implementsG
.When no error occurred we know that there exist actual type arguments
S1 .. Sk
such thatT
implementsG<S1 .. Sk>
. In this case,ImplementsAtN<T, G>
denotes the typeSN
. For instance,ImplementsAt2<T, G>
denotesS2
.Using the pseudo syntax we would write
T@G.X
rather thanImplementsAtN<T, G>
, whereX
is the name of the N'th type parameter in the declaration ofG
. You could read this as follows: "Starting from the typeT
, search the superinterface graph to find a typeS
of the formG<...>
. Assume thatX
is the name of the N'th type parameter ofG
. Then this term denotes the N'th type argument ofS
.The compile-time subtype relationships for
ImplementsAtN<T, G>
are determined by computing the statically known value of the type denoted by this term.This is a computation which is already done for member invocations. For example:
In this situation,
ImplementsAt2<C, G>
has the valueString
, which is the result we would also need to find in order to typex.g
wherex
has typeC
. Similarly,ImplementsAt2<Z, G>
is considered to be a fresh type variableY extends num
, andz.g
is known to have return typeY
. On the other hand, we can't know thatImplementsAt2<Z, G>
is a supertype ofdouble
, soy = 1.5
doesn't type check.At run time, the type denoted by
ImplementsAtN<T, G>
is the Nth actual type argument of the actual value ofT
atG
. This determines the dynamic subtype relationships.Optional Type Parameters
We could use this feature together with another new feature: Type parameters could be made optional by ending the type parameter declaration in
= T
whereT
is a type:This would make it possible to use
B<SomeType>
and have the second type argument computed asImplementsAt2<SomeType, A>
, but it would also allowB<SomeType, AnotherType>
, as long as the declared bounds are satisfied (so we'd requireSomeType <: A<dynamic, AnotherType>
).Usage
In order to address the original example, we first expand it slightly such that the type variable originally named
Y
is being used in the body of the class.We can eliminate the type parameter
Y
and still preserve the desired typing as follows:Expressive Power
This feature might appear to subsume an existential open operation. Similarly, it's worth considering whether we have the opposite relationship, namely that this feature might be subsumed by an existential open mechanism. Turns out that neither is true.
Here is the standard example where we assume that we have an existential open operation (using the syntax
xs is List<final X>
) which introduces a new type variable (or several) into a scope (here:X
), and the evaluation of that operation will bind those type variables to the actual values:The point in this example is that we have somehow lost information about the actual type argument of the list and the type of the object
o
(in this case it's just because we've used some overly general parameter types, but the same kind of situation arises in real life for much more complex reasons). Still, we are able to check whether or not it is safe to add that object to that list. In other words,xs.add(o)
is guaranteed to succeed.We could invoke
f
usingf(<num>[1.5], 2)
whereadd
would be invoked, orf(<double>[1.5], true)
where it would be skipped.We could make an attempt to achieve the same level of safety using the mechanism proposed here:
This would be equally safe as
f
in some ways: We could invokeg
asg<num>(<num>[1.5], 2)
(or we could use type inference, which would choose the same type argument) whereadd
would again be invoked, org<double>(<double>[1.5], true)
whereadd
would be skipped.However,
g<List<Object>>(<num>[1.5]. true)
would invokeadd
, and it would throw. The reason for this is that the type argumentList<Object>
causes the list to be typed asList<Object>
, which causes the value ofZ
in the invocation ofh
to beObject
, and theno is Z
is the same thing aso is Object
, so we proceed.The reason why the plain existential open operation is more powerful than this mechanism (for this purpose) is that the existential open operation allows us to denote the actual value of the type argument of
xs
, whereas the mechanism proposed here is able to look up the same kind of information in the type argumentX
, but there is no guarantee thatX
is the most specific type of the formList<S>
such that the run-time type ofxs
is a subtype ofList<S>
. So we "may have the wrongS
", and hence we can't establish a guarantee that theadd
operation will succeed.(OK, even with the existential open the type system can't promise that it will succeed, but the semantics will actually provide the required guarantee as long as we don't assign a new value to
xs
or do other nasty things ;-).Conversely, the existential open also doesn't subsume the mechanism proposed here:
This declares a function which accepts an iterable and returns a pair of the iterable itself and its "first" element. The crucial point is that we're able to use just one type argument (and we will in general be able to get a useful type argument from type inference), and we are also able to preserve the nature of the iterable (whether it's a
List
or aSet
), and finally we're able to give the element a useful type (taken from the type argument ofX
atIterable
).An existential open is an expression, and this means that it wouldn't be able to express signature-level dependencies like this.
So this illustrates that neither of these two mechanisms is subsumed by the other one.
Edits
SomeType@SomeSuperinterface.ATypeVariable
as commentary. This syntax may not fit well into the grammar (so we'll have to fiddle with it if we actually want to use it), but it is arguably more readable.The text was updated successfully, but these errors were encountered: