language
language copied to clipboard
Type parameters with default value for classes
This is a possible solution for #283 and other similar issues.
This is the first time writing a slightly formal proposal, so please be patient and let's work together to improve this.
I may not have considered potential issues, and my language is certainly not as precise as it should be, but I think the idea can be understood.
Overview
This proposal suggests that we allow for a type parameter T
on a class to have a default value.
With this proposal, we could do the following:
// The default value for `T` in `Foo` is `int`.
class Foo<T default int> {
Foo({
this.bar = 42,
T baz = 0,
T qux = 'a', // Error: The default value for a variable of the generic type 'T' should have a type 'int'.
});
final T bar;
}
class Quuz extends Foo {}
typedef Quux<T default String> = Foo<T>;
void main() {
var fooDefault = Foo(); // Inferred as Foo<int> instead of Foo<dynamic>
print(fooDefault.bar); // 42
var fooStringWrong = Foo<String>(); // Error: The named parameter 'bar' is required, but there's no corresponding argument.
var fooStringRight = Foo<String>(bar: '42', baz: '0', bar: 'a');
var quuz = Quuz();
print(quuz.bar); // 42
var quux = Quux(bar: '42', baz: '0', bar: 'a');
}
Motivations
Adding or removing type parameters from a class is inconvenient (https://github.com/dart-lang/language/issues/283)
Consider the following cases:
Case 1: Changing the number of type paramters of a class that already has type parameters
// Library
class Foo<T> {}
// Client
class Bar extends Foo<int> {}
Then, adding a new type parameter to Foo
is a breaking change:
// Library
class Foo<T, U> {}
// Client
class Bar extends Foo<int> {} // The type 'Foo' is declared with 2 type parameters, but 1 type arguments were given.
Case 2: If the client has strict-raw-types
enabled, adding a type parameter to a class that has no type parameter
// Library
class Foo {}
// Client
class Bar extends Foo {}
Then, adding a new type parameter to Foo
is a breaking change:
// Library
class Foo<T> {}
// Client
class Bar extends Foo {} // The generic type 'Foo<dynamic>' should have explicit type arguments but doesn't.
Known workarounds
Using typedef
For some cases, using typedef
may be a valid workaround.
Consider the following case.
// Library
class Foo<T> {}
// Client
class Bar extends Foo<int> {}
Instead of simply adding a new type parameter to Foo
, we provide another class and Foo
as a typedef
.
// Library
class NewFoo<T, U> {}
typedef Foo<T> = NewFoo<T, int>; // Here, `int` is the "default" value for `U`
// Client
class Bar extends Foo<int> {} // The client does not break
However, this is not always desirable, because if we want U
to be used by the client, it will have to refer to the new class:
// Library
class NewFoo<T, U> {}
typedef Foo<T> = NewFoo<T, int>; // Here, `int` is the "default" value for `U`
// Client
class Bar extends Foo<int> {}
class Baz extends NewFoo<int, String> {} // Baz can't simply extend `Foo`, because `U` is only available in `NewFoo`.
Having a default value, in this case, wouldn't be less breaking. It would still be breaking for Baz
to support the U
parameter. However, it would be done without the need of two different classes.
Allowing for default values in constructors where the field type is a generic type
Currently, the following is an error:
class Foo<T> {
Foo([this.bar = 42]); // A value of type 'int' can't be assigned to a variable of type 'T'.
final T bar;
}
By having a default type parameter value, we can accept default values in the constructor where T
is expected, as long as the type has type U
and U <: D
, being D
the default type for T
.
Reduce "clutter" for common cases
This is more than nothing a way of making some codes terser.
For intance, consider the following library code:
// --- Library.
class A<T extends Tag> {}
abstract interface class Tag {}
final class DefaultTag implements Tag {}
Now, we don't know how the clients are going to use library, but we know that the common case is to use DefaultTag
as parameter. The client can introduce their own Tag
s, but it's an exceptional case that's not commonly used.
By providing DefaultTag
as the default value for T
, the majority of the clients can extend from the raw type A
, while the exceptional cases can specify their custom Tag
.
Syntax
Considering that we are dealing exclusively with classes[^1], the grammar for class declaration would be changed in the following way:
[^1]: I think it is possible to extend this to non-classes too, like regular functions methods, but it's out of the scope of this proposal. If we do, we don't have to split typeParameter
into classTypeParameter
.
+⟨classTypeParameter⟩ ::= ⟨metadata⟩ ⟨identifier⟩ (extends ⟨typeNotVoid⟩)? (default ⟨type⟩)?
+⟨classTypeParameters⟩ ::= ‘<’ ⟨classTypeParameter⟩ (‘,’ ⟨classTypeParameter⟩)* ‘>’
-⟨classDeclaration⟩ ::= abstract? class ⟨typeIdentifier⟩ ⟨typeParameters⟩? ⟨superclass⟩? ⟨interfaces⟩?
+⟨classDeclaration⟩ ::= abstract? class ⟨typeIdentifier⟩ ⟨classTypeParameters⟩? ⟨superclass⟩? ⟨interfaces⟩?
‘{’ (⟨metadata⟩ ⟨classMemberDeclaration⟩)* ‘}’
| abstract? class ⟨mixinApplicationClass⟩
-⟨typeAlias⟩ ::= typedef ⟨typeIdentifier⟩ ⟨typeParameters⟩? ‘=’ ⟨type⟩ ‘;’
+⟨typeAlias⟩ ::= typedef ⟨typeIdentifier⟩ ⟨classTypeParameters⟩? ‘=’ ⟨type⟩ ‘;’
| typedef ⟨functionTypeAlias⟩
This would allow us to specify the type parameter default value with the following syntax:
class Foo<T default int> {}
class Bar<T extends num default int> {}
typedef Baz<T default String> = Foo<T>;
typedef Quz<T default double> = Bar<T>;
Alternative syntax
Using =
instead of default
For default values in a parameter, =
is used.
void foo([int bar = 42]) {}
We could use =
also for default type parameter values.
class Foo<T = int> {}
class Bar<T extends num = int> {}
// OR
class Bar<T = int extends num> {}
The syntax is terser.
However, one^2 could argue that it's syntatically confusing when used with extends
.
In the first case, T extends num = int
may give the idea that we are equalling num
to int
. In the second case, T = int extends num
may give the idea that int
extends num
(which is not entirely false, but in the context we want to mean that T
extends num).
There are other shorter keywords that could be used, but I don't think any of them is semantically clearer than default
.
Using parenthesis instead of default
As suggested by @hydro63, an alternative would be to use parenthesis, in the following way:
class Foo<T(int)> {}
class Bar<T(int) extends num> {}
This approach has the following pros:
- It's as terse as using
=
; - It's not as ambiguous as
=
when used withextends
.
The con, however, in my opinion, is that it does not conveys the semantics as good as default
or =
.
Someone who is just starting with Dart may understand what T default int
means, but will hardly understand what T(int)
means without reading the documentation.
Semantics
- Let
T
be a type parameter of a classC
with default valueV
:- It's a compile-time error if
T
has a boundU
andV <: U
[^3] does not hold; - For all constructors
c
inC
, any parameter of typeT
inc
can have a default valuev
iffv
is constant andv
has a static typeV2
such thatV2 <: V
[^3]; - When
C
is used as a raw type:- If
T
has no bound, the instantiation to bound algotithm should inferT
to beV
instead ofdynamic
; - If
T
has a boundU
, the instantiation to bound algotithm should inferT
to beV
instead ofU
.
- If
- It's a compile-time error if
[^3]: These semantics may be changed if we have statically checked declaration-site variance. For instance, if the type parameter T
is contravariant with a default value U
, we may want to guarantee that T <: U
.
Other questions
Should default type parameter value be inferred from constructor?
Consider this same code as before:
class Foo<T> {
Foo([this.bar = 42]);
final T bar;
}
With the proposed changes above, it still wouldn't work, unless we specified T default int
.
We can infer default int
from the default parameter of the constructor.
If we have more than one parameter, like:
class Foo<T> {
Foo([this.bar = 42, this.baz = 42.0]);
final T bar;
}
Then we could apply the LUB algorithm. In this case, it would be inferred as T default num
.
My personal opinion is that we shouldn't do this, and I would rather prefer for a default value for the type parameter to be always explicitly stated.