-
Notifications
You must be signed in to change notification settings - Fork 213
Conditional instance members and constructors #2313
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
Comments
It is worth noting that many use cases of this feature (but not all) could be implemented via extensions, for instance the original example: class A<X> {
final X x;
A(this.x);
}
extension IsEven on A<int> {
bool get isEven => x.isEven;
}
void main() {
A<int> a1 = A(1);
print(a1.isEven); // 'false'.
var a2 = A('Hello'); // Inferred type `A<String>`.
print(a2.isEven); // compile time error, `isEven` does not exist
} I think the main thing this approach doesn't support is the ability to override these methods? And you can't have conditional fields either. |
That's definitely a good approximation. However, there is also the ability to have optional parameters with a default value which is only type correct when some additional constraints are satisfied (those parameters will simply be required otherwise). class C<X> ... {
final X x;
if <[String extends X]>
C([this.x = 'Default x!']);
}
void main() {
C<Object>(); // OK, `String <: Object`, can use the default.
C<int>(1); // OK, but the actual argument cannot be omitted.
} Another thing we can't do with extensions is to introduce constructors of the target class (I forgot to mention constructors in the first version of the proposal in several different locations in the text, that's been fixed now). The ability to invoke conditional methods dynamically (and have the type variable constraints checked at run time) is another thing that extensions won't support. Note that the constraints are expressed and checked in terms of the actual value of each type variable, not in terms of the type arguments in the statically known receiver type. Yet another thing is lower bounds (like |
The constructor case is interesting... would it be totally crazy to also add an class C<X> ... {
final X x;
if <[String extends X]>
C([this.x = 'Default x!']);
else if <[num extends X]>
C([this.x = 1.0]);
} |
Haven't thought about that! 😃 I'm usually somewhat worried about static overloading (e.g., because it breaks tear-offs), but it is certainly interesting to consider. |
I do think in general this is a fair bit easier to reason about than regular overloads - the selection process for methods is straightforward since you just select the first one that matches, you could desugar as well to something that can select the right function at runtime. If you want to be able to statically choose the right function when a tearoff is invoked, that would involve probably making it a part of the static function type though. In general tear-offs probably need to be talked about for this proposal anyways, but maybe you end up with a tearoff that has a generic with a bound that matches the condition? So |
I think we have to consider static overloading to be a more complex beast than that. I'd expect static overloading to rely on a notion of specificity, which would be a partial order on member signatures. For any given invocation Note that specificity gets gnarly quite easily. For instance, is Similarly for We do have rules to answer some questions that are similar to these specificity questions when we are determining the interface of a class (for example, using the function If we're trying to redo static overloading at run time then we'd need to know the static type of the receiver at the given call site, because otherwise we'd get a different set of applicable callees. Next, we'd have to keep track of the scoping environment (such that we can find exactly the set My conclusion is that static overloading is inherently a compile-time mechanism, and the work which is needed in order to replicate the compile-time decision faithfully at run time is so costly that we'd never want to do it, and it's not even a useful way to think about it. The proposal here seems to enable some notion of static overloading, but I'd prefer to look at it from a different angle: When an instance member is conditional, it is accessible if and only if the associated constraints on the type variables of the enclosing class are satisfied. This means that the implementation which is torn off in case of a tear-off operation is well defined (even if done dynamically), it just happens to be an error (compile-time if typed, run-time if done dynamically) to tear off a conditional member in the case where the constraints are not satisfied. Constructors are different: When a constructor is conditional, the condition is on the actual type arguments which are passed to the constructor in the instance creation expression. This means that we can obtain exactly the same guarantees regarding upper bounds by declaring those upper bounds as bounds of the type variables of a torn-off constructor which is a generic function: class C<X> {
final X x;
C(this.x);
if <X extends int>
C.even(X x): this.x = x.isEven ? x : x + 1;
}
void main() {
print(C.even.runtimeType); // `C<X> Function<X extends int>(X x)`.
} However, we can't express anything other than an upper bound in the function type, which means that we may have to restrict the tear-off to a safe set of cases: class A<X> {
if <[String extends X]>
A([X x = 'A string default']);
}
void main() {
print(A.new.runtimeType); // `A<X> Function<X>(X x)`: `x` is not optional.
} |
Yeah I definitely agree that true static overloading gets gnarly fast. Moving the selection process more directly into an if/else type structure greatly simplifies it though (all the hard questions are instead handled by the user, which also gives them greater control and flexibility). We no longer have to define specificity rules etc. I do agree tear-offs are hard though, although I guess you could enforce that the user has to provide the generic type arguments up front in order to take a tearoff of one of these functions. |
Right. I do think, however, that there is no direct match with this proposal, because the constraints are on the type parameters of the enclosing class, not on the type parameters of the function itself. Let's try to emulate a simple case: class C {
void m(int i) { print(i.isEven); }
void m(String s) { print(s.length); }
} We can't use class C<X> {
if <X extends int>
void m(X x) { print(x.isEven); }
else if <X extends String>
void m(X x) { print(x.length); }
} That would work, but we can't call both of them on the same receiver: We can't create a I don't know if it could work, but it isn't obvious to me how to do it... |
Right, I think you could extrapolate this same proposal to work for type parameters of the function as well, but it does get weird (can the type parameters also be different for each version??).
I am not sure if I understand exactly what you are getting at here - I think that actually this just ends up enforcing that only a
Can you explain this a bit more? I agree that a |
We could approach this problem from the other direction, then, and add support for constructors (and other static methods) in extensions (#723). I think that's a smaller, simpler feature, and would cover many of the use cases here. You lose the ability to override but given how complex that seems to be, that could arguably be a good thing. In general, I like the relatively simple model that instance methods dispatch on class and extension members dispatch on type. If you have a member that you want to only be callable on certain types (i.e. generics instantiated with certain type arguments), that sounds a lot like an extension method call to me. |
That is true, but it's worth noting that these mechanisms have different expressive power. (As an aside, I think static extensions are a promising feature, but there's a lot of work to do before we have that. For instance, I think we'd need to introduce the notion of 'the static type of a class' in order to make a static extension applicable to more than a single class, we may have to declare that type using Here's an example where the proposed static extensions feature is used to add a constructor (taken from here): static extension X on List<int> {
factory X.n(int count) => List<int>.filled(count, 0);
}
void main() {
... List<int>.n(5) ...
} Now consider introducing a constructor which is applicable when two type arguments are related in some non-trivial way. With a conditional constructor we can do things like this: class Pair<X, Y> {
final X x;
final Y y;
Pair(this.x, this.y);
if <List<X> extends Y>
Pair.withList(this.x) : y = [x];
} The declaration of a static extension (the set of variants that I've seen, at least) seems to rely on something which is similar to the on-type of a (regular, non-static) extension declaration. That is, the static extension is applicable when the class matches the class specification (in the example above that's a denotation of a specific class, I don't see how the static extension mechanism would be able to express a constraint like With conditional constructors, as proposed in this issue, the specification given in |
In response to #2308, this is a proposal to add support for conditional instance members of classes and mixins, and conditional constructors of classes. The intuitive meaning of such members and constructors is that they are accessible when the type variables of the receiver are known to satisfy certain constraints, and they are inaccessible otherwise.
For example, using this feature, a
sort
method with no arguments can be available on aList<T>
whenT <: Comparable
, but when we can not proveT <: Comparable
, it is a compile-time error to invoke the method.What we currently have is a
sort
method that will throw at run time if it runssort()
, and some elements aren'tComparable
. This proposal would improve on the static type safety of such methods.Similarly, a constructor may be valid when certain constraints on the class type variables are satisfied, and it would then be helpful if the constructor is only accessible when those constraints are actually satisfied. For example,
List<E>([int? length])
has been deprecated, but it could actually be allowed in cases whereT
is nullable.Introduction and motivation
Please take a look at the examples in #2308 to see that these situations do occur in practice. In this context we will consider a minimal example which is specifically designed to show what the mechanism can do.
The class
A
describes objects that contain an object of typeX extends num
. This means that the contained object could be anint
, but we cannot know for sure that it is anint
unless we have a receiver of typeA<int>
. In other words, it is safe to executeisEven
on an instance ofA<int>
, but it is unsafe to do it on an instance ofA<T>
for anyT
which isn't a subtype ofint
.Obviously,
A.isEven
may incur a dynamic type error, because it contains a type cast. However, assume that we want to have theisEven
method onA
anyway, because it is very convenient to be able to call it directly (rather than remembering to do(a.x as int).isEven
every time). If we know statically that the type argument to theA
isint
then the method is guaranteed to avoid the dynamic type error, but we can't express that relationship.In this case we're just writing a forwarding method, but the mechanism is applicable much more broadly:
A use case arises whenever there is a container-like object whose behavior includes performing some operations on the contained objects (e.g.,
List.sort
), and some type parameters describe the contained objects in some way (e.g., aList<T>
contains elements of typeT
), and we don't want to constrain the class/mixin type parameters so much that the contained objects are always guaranteed to have the required types (e.g., we do not want to constrainList
such that no list can ever contain an object unless it isComparable
).Another case arises when there is an object, instance of a class
class C<X1..Xk> ..
, which has a reference to some other object(s), and the type of those other objects is described by some of the type parametersX1 .. Xk
. For instance, a graph with nodes of typeNode<X>
referring to other nodes of typeNode<X>
.In any case, here's the proposal put forward in this issue for how to make it statically type safe:
The ability to make an instance member or constructor conditional by adding a prefix similar to
if <X1 extends T1 .. Xk extends Tk>
to the declaration is the main element of this feature. However, the feature also includes the ability to control the requiredness of optional parameters of a conditional method. In particular,if <[Null extends X]> void foo([X x]) ..
specifies thatfoo
takes an optional parameter in the situation where the receiver has a statically known type argumentX
such thatNull extends X
, but that parameter is not optional when this property cannot be guaranteed statically, and similarly for optional named parameters with{}
around some or all of the constraints.The feature proposed here, conditional instance members and constructors, amounts to specifying some constraints on the type parameters of the enclosing class, reporting a compile-time error whenever such members are invoked or torn off unless the constraints are statically known to be satisfied by the receiver, and being able to use those constraints to improve the static analysis of the given method body.
Proposal
Syntax
The Dart grammar is adjusted as follows:
Static analysis
Consider an instance member declaration or constructor declaration of the form
if memberConds D
in a class or mixinC
.Note that a compile-time error will occur if any type that occurs in
memberConds
is not well-bounded, or it contains an identifier which is not in scope, or it contains an identifier which resolves to a declaration of something that isn't a type, just like all other occurrences of a type in a Dart program.If
memberConds
is of the formmemberCond1 .. memberCondK, [memberCondK+1 .. memberCondN]
, it is a compile-time error unlessD
declares a method or constructor that accepts one or more optional positional parameters.If
memberConds
is of the formmemberCond1 .. memberCondK, {memberCondK+1 .. memberCondN}
, it is a compile-time error unlessD
declares a method or constructor that accepts one or more optional named parameters.If
D
declares an instance membername
that overrides a member signatures
in a direct superinterface of the enclosing classC
, a compile-time error occurs unlesss
has a member conditionsMemberCond
such that satisfaction ofsMemberCond
implies satisfaction of the member conditionmemberConds
.(We have simple cases like
Iterable<E>
which is a superinterface ofList<E>
. If a conditional member ofList
overrides a conditional member ofIterable
, and it has a single constraintE extends T
for some specific typeT
, the same condition can be used inList
. In some cases where the type parameters are passed to superinterfaces in non-trivial types, e.g.,class B<X> extends A<List<X>, X Function()> ..
it is possible that the implication cannot be proven even in some cases where it does hold. IN that caseB
just cannot override a conditional member by a conditional member, but it could declare an unconditional one and check the types as needed. So this is an area where some further studies are needed.)Consider a member access
e.m
(that is, a getter invocation likee.myGetter
, a method invocation likee.myMethod(42)
, etc.) and assume that the name of the member isn
. Assume that the member signature namedn
in the interface of the static type ofe
has a conditionif memberConds
.In the case where
memberConds
is of the form< memberCond1 .. memberCondK ...>
, a compile-time error occurs, unless it is statically known that each of the subtype relationships inmemberCond1 .. memberCondK
are satisfied when substituting the statically known actual type arguments of the receiver for the type parameters of the statically known receiver class.In the case where
memberConds
is of the form<... [memberCond1 .. memberCondK]>
, an invocation that omits one or more of the optional positional arguments is a compile-time error unlessmemberCond1 .. memberCondK
are satisfied, in the same way as specified above. When all optional positional parameters are provided in the invocation,memberCond1 .. memberCondK
are ignored. When a default value is specified for an optional positional parameter, each ofmemberCond1 .. memberCondK
can be assumed when checking the type of the default value.In the case where
memberConds
is of the form<... {memberCond1 .. memberCondK}>
, an invocation that omits one or more of the optional named arguments is a compile-time error unlessmemberCond1 .. memberCondK
are satisfied, in the same way as specified above. When all optional named parameters are provided in the invocation,memberCond1 .. memberCondK
are ignored. When a default value is specified for an optional named parameter, each ofmemberCond1 .. memberCondK
can be assumed when checking the type of the default value.The same rules apply when we consider an instance creation expression (like
C<...>()
orC<...>.named()
, whereC
is a class or a type alias that denotes a class).When the static analysis of the body of
D
is performed (if any,D
could also be abstract), each of the member constraints that are not inside[]
or{}
can be assumed.In particular, if a constraint of the form
X extends T
exists whereX
is a type variable declared by the enclosing classC
,X
is treated as if it had been declared by the class with the boundT
. If a constraint of the formNull extends X
exists whereX
is a type variable declared by the enclosing classC
,X
is treated as a nullable type (this is not sound unless we have sound declaration-site variance andX
is declared to be contravariant or invariant, but it will be checked dynamically whenX
is a dynamically checked covariant type variable, that is, the ones we have today).Dynamic semantics
Instance member invocations based on statically known types have the same semantics with conditional instance members as they have without the condition. Similarly, instance creation expressions have the same semantics with conditions as they would have without conditions.
(The use of conditions will prevent certain statically checked invocations or instance creations from happening at compile time, but it will not change the behavior of the invocations/instance creations which are not flagged as errors.)
Dynamic instance member invocations will check dynamically that the constraints in
<>
are satisfied. Furthermore, the constraints in[]
will be checked dynamically if one or more optional positional arguments have been omitted, and the constraints in{}
will be checked dynamically if one or more optional named arguments have been omitted.Examples
The example given in the introduction will work with the feature:
We can allow specific default values in specific cases, and we can (of course) also make constructors conditional:
Conditional constructors may be particularly useful, because they are sound without adding any sound variance mechanisms to the language (because they are always invoked statically, so there is no variance).
The core library examples will work as follows:
The
isEven
example illustrates the straightforward situation where we want to have an instance method that runs safely when some extra bound is known to be satisfied on a particular type argument. This allows the method to use the enhanced interface of objects of typeX
, and it has the same soundness properties as we would have if the type parameter had been declared with the extra bound (but in that case the class as a whole could not be used with any actual type arguments that don't satisfy this extra bound).The
sort
example illustrates that we can make a positional parameter conditionally optional, such that we know it will only be omitted in situations where some extra constraints are known to hold. This allows us to handle the default value of the parameter under the assumption that the extra constraints are satisfied. In particular, we may then use a default value which is not type correct when those extra constraints are not known to hold.The
complete
example shows that we can express that the parameter is optional (with default value null) whenT
is nullable, which also makesFutureOr<T>
nullable, so it's not an error to leave the default value implicit. However, when the constraint is not statically known (module covariance unsoundness) to be satisfied, the parameter is mandatory.NB: If we can change
Completer
to have an invariant type parameter (inout T
) then we can omit the dynamic check thatvalue
can actually have the value null without violating soundness, because that is then statically guaranteed to be the case. This means that this feature will work in synergy with sound variance (just like lots of other features of Dart ;-).Discussion
The treatment of overriding in the case where the type variables of a class are used in non-trivial ways in superinterfaces needs to be clarified. We would certainly need to consider cases like the following:
If it turns out to be non-tractable to prove the required implications then we would presumably have a number of supported situations (the known easy ones), and then it would simply be a compile-time error to try to use conditional instance members with an overriding relation in cases that we can't prove. In any case, an overriding member can soundly be non-conditional, and that would be sufficient to allow the member to exist. It might then have to contain dynamic type checks (just like it would contain dynamic type checks today, where we can't have conditional members at all).
The text was updated successfully, but these errors were encountered: