Description
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 a List<T>
when T <: Comparable
, but when we can not prove T <: 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 runs sort()
, and some elements aren't Comparable
. 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 where T
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 type X extends num
. This means that the contained object could be an int
, but we cannot know for sure that it is an int
unless we have a receiver of type A<int>
. In other words, it is safe to execute isEven
on an instance of A<int>
, but it is unsafe to do it on an instance of A<T>
for any T
which isn't a subtype of int
.
class A<X extends num> {
final X x;
A(this.x);
bool get isEven => (x as int).isEven;
}
void main() {
A<int> a1 = A(1);
print(a1.isEven); // 'false'.
var a2 = A('Hello'); // Inferred type `A<String>`.
print(a2.isEven);
}
Obviously, A.isEven
may incur a dynamic type error, because it contains a type cast. However, assume that we want to have the isEven
method on A
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 the A
is int
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., a List<T>
contains elements of type T
), 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 constrain List
such that no list can ever contain an object unless it is Comparable
).
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 parameters X1 .. Xk
. For instance, a graph with nodes of type Node<X>
referring to other nodes of type Node<X>
.
In any case, here's the proposal put forward in this issue for how to make it statically type safe:
class A<X extends num> {
final X x;
A(this.x);
// We introduce the notion of instance members conditioned on
// a list of type parameter constraints.
if <X extends int>
bool get isEven => x.isEven;
}
void main() {
A<int> a1 = A(1);
a1.isEven; // OK.
var a2 = A('Hello');
a2.isEven; // Compile-time error, `String <: int` does not hold.
A<Object> a3 = a1;
a3.isEven; // Compile-time error, `Object <: int` does not hold.
(a1 as dynamic).isEven; // OK.
(a2 as dynamic).isEven; // OK at compile time, throws at run time.
}
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 that foo
takes an optional parameter in the situation where the receiver has a statically known type argument X
such that Null 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:
classMemberDefinition ::= // Modified rule.
classMemberCondition? unconditionalClassMemberDefinition
unconditionalClassMemberDefinition ::= // New rule, equal to classMemberDefinition rule before change.
methodSignature functionBody |
declaration ';'
classMemberCondition ::= // New rule.
'if' '<' classMemberConstraints (',' ('[' classMemberConstraints ']' | '{' classMemberConstraints '}'))? '>'
classMemberConstraints ::= // New rule.
classMemberConstraint (',' classMemberConstraint)*
classMemberConstraint ::= // New rule.
type 'extends' type
Static analysis
Consider an instance member declaration or constructor declaration of the form if memberConds D
in a class or mixin C
.
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 form memberCond1 .. memberCondK, [memberCondK+1 .. memberCondN]
, it is a compile-time error unless D
declares a method or constructor that accepts one or more optional positional parameters.
If memberConds
is of the form memberCond1 .. memberCondK, {memberCondK+1 .. memberCondN}
, it is a compile-time error unless D
declares a method or constructor that accepts one or more optional named parameters.
If D
declares an instance member name
that overrides a member signature s
in a direct superinterface of the enclosing class C
, a compile-time error occurs unless s
has a member condition sMemberCond
such that satisfaction of sMemberCond
implies satisfaction of the member condition memberConds
.
(We have simple cases like Iterable<E>
which is a superinterface of List<E>
. If a conditional member of List
overrides a conditional member of Iterable
, and it has a single constraint E extends T
for some specific type T
, the same condition can be used in List
. 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 case B
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 like e.myGetter
, a method invocation like e.myMethod(42)
, etc.) and assume that the name of the member is n
. Assume that the member signature named n
in the interface of the static type of e
has a condition if 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 in memberCond1 .. 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 unless memberCond1 .. 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 of memberCond1 .. 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 unless memberCond1 .. 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 of memberCond1 .. 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<...>()
or C<...>.named()
, where C
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 where X
is a type variable declared by the enclosing class C
, X
is treated as if it had been declared by the class with the bound T
. If a constraint of the form Null extends X
exists where X
is a type variable declared by the enclosing class C
, X
is treated as a nullable type (this is not sound unless we have sound declaration-site variance and X
is declared to be contravariant or invariant, but it will be checked dynamically when X
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:
class A<X extends num> {
final X x;
A(this.x);
if <X extends int>
bool get isEven => x.isEven;
}
void main() {
A<int> a1 = A(1);
a1.isEven; // OK.
var a2 = A('Hello');
a2.isEven; // Compile-time error, `String <: int` does not hold.
A<Object> a3 = a1;
a3.isEven; // Compile-time error, `Object <: int` does not hold.
(a1 as dynamic).isEven; // OK.
(a2 as dynamic).isEven; // OK at compile time, throws at run time.
}
We can allow specific default values in specific cases, and we can (of course) also make constructors conditional:
class C<X> {
X x;
if <{String extends X}>
C({this.x = 'A string!'})
}
void main() {
C<Object>(); C<String>(); C(); // OK, using the string default.
C<int>(); // Compile-time error.
C<int>(x: 12); // OK.
}
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:
class List<E> ... {
...
if <Null extends E>
external factory List<E>([int? length]);
...
if <[E extends Comparable]>
sort([int compare(E a, E b) = Comparable.compare]) {
... // Note: we cannot assume `E extends Comparable` here, that will
... // be true for some invocations, and not for others. But we do know
... // that `compare` is non-null and has type `int Function(E, E)`.
}
}
class Completer<T> ... { // With sound declaration-site variance, we'd use `inout T`.
...
if <[Null extends T]>
complete([FutureOr<T> value]) {...} // The default value of `value` is `null`.
}
class B {}
int compareB(B b1, B b2) {...}
void main() {
List<int> xs = ...;
xs.sort(); // OK.
List<B> ys = ...;
ys.sort(); // Compile-time error.
ys.sort(compareB); // OK.
Completer<int?>().complete(); // OK.
Completer<int>().complete(); // Compile-time error.
(Completer<int>() as Completer<int?>).complete(); // OK statically, throws at run time.
(Completer<int>() as dynamic).complete(); // OK statically, throws at run time.
}
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 type X
, 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) when T
is nullable, which also makes FutureOr<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 that value
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:
class A<X> {
if <X extends Iterable<Object>>
void foo(X x) {...}
}
class B<Y> extends A<List<Y>> {
if <Y extends Object> // OK!
void foo(List<Y> y) {...}
}
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).