language icon indicating copy to clipboard operation
language copied to clipboard

Type parameters with default value for classes

Open mateusfccp opened this issue 5 months ago • 7 comments

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 Tags, 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 with extends.

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 class C with default value V:
    • It's a compile-time error if T has a bound U and V <: U[^3] does not hold;
    • For all constructors c in C, any parameter of type T in c can have a default value v iff v is constant and v has a static type V2 such that V2 <: V[^3];
    • When C is used as a raw type:
      • If T has no bound, the instantiation to bound algotithm should infer T to be V instead of dynamic;
      • If T has a bound U, the instantiation to bound algotithm should infer T to be V instead of U.

[^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.

mateusfccp avatar Sep 06 '24 13:09 mateusfccp