Skip to content
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

Type parameters with default value for classes #4087

Open
mateusfccp opened this issue Sep 6, 2024 · 7 comments
Open

Type parameters with default value for classes #4087

mateusfccp opened this issue Sep 6, 2024 · 7 comments
Labels
feature Proposed language feature that solves one or more problems

Comments

@mateusfccp
Copy link
Contributor

mateusfccp commented Sep 6, 2024

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 (#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 classes1, the grammar for class declaration would be changed in the following way:

+⟨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, one2 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 <: U3 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 <: V3;
    • 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.

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.

Footnotes

  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.

  2. https://github.com/dart-lang/language/issues/283#issuecomment-839603127

  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. 2

@mateusfccp mateusfccp added the feature Proposed language feature that solves one or more problems label Sep 6, 2024
@hydro63
Copy link

hydro63 commented Sep 6, 2024

I like this. It's a logical extension of having a default values in functions, and i can already see many situations where it could be useful.

The syntax of T default D is okay, nothing special and easy to understand, but it's also quite long. It could definitely work, without introducing many reading problems for the developper, or parsing problems for the compiler.

But since it's quite long, it means the class definition could become too long with this addition, so much so, that it could hurt the readibility of the declaration. And so, i would like to propose a sort of C++-like syntax of T(D), which is equivalent to T default D or T = D. This syntax is a lot shorter than T default D, it also doesn't have as much ambiguity as T=D, when used in combination with extends etc.

Examples

class Foo<T, U(D)>
class Foo<T, U(D), V(E)>
class Foo<T, ValueT(int) extends num>

Things to solve

What needs to be solved right now, other than the exact behaviour in code, is the LSP support, type inferring and syntax highlighting.

This syntax needs to have a good LSP support for quickly catching type errors, inferring the actual type, inferring when the developer needs to specify a type, when the default type and the type it extends are incompatible and so on.

The syntax highlighting is important, because it can effectively prevent some readability issues from happening, without modifying the syntax. The syntax highlighting issue is about differentiating the generic type from the default type - when looking at the class definition, the generic type should be the most eye catching. The default type instead should be less visible than the generic type, and should instead serve as a hint for the developper. It's a difference between ValueT(int) and ValueT(int). The default type should be less eyecatching because, even though the type has a default value, it's still generic and the developper should still be counting on it being generic. It also prevents the earlier issue with T = D being ambiguous, since the developer would be more focused on the T, rather than both types having the same importance.

*Imagine the bold and italic is a syntax highlighting

@mateusfccp
Copy link
Contributor Author

The syntax of T default D is okay, nothing special and easy to understand, but it's also quite long. It could definitely work, without introducing many reading problems for the developper, or parsing problems for the compiler.

But since it's quite long, it means the class definition could become too long with this addition, so much so, that it could hurt the readibility of the declaration. And so, i would like to propose a sort of C++-like syntax of T(D), which is equivalent to T default D or T = D. This syntax is a lot shorter than T default D, it also doesn't have as much ambiguity as T=D, when used in combination with extends etc.

This is a possibility. I agree that default is very verbose. Statically wise, however, I think it makes sense.

For instance, let's consider someone who never read a Dart code before, reading both of these:

// 1
class Foo<T default int> {}

// 2
class Foo<T(int)> {}

In my opinion, even if one doesn't know Dart well, it will be mostly clear what (1) means, but I couldn't tell the same for (2).

I'm going to add your suggestion as an alternative in the proposal, though.

What needs to be solved right now, other than the exact behaviour in code, is the LSP support, type inferring and syntax highlighting.

Although those are important matters, I think they matter more once (if) the proposal is refined and accepted.

For now, I think we should think about the implications (I surely missed some of them) of these changes and how we can solve it properly (if they are even solvable with this approach).

@hydro63
Copy link

hydro63 commented Sep 6, 2024

In my opinion, even if one doesn't know Dart well, it will be mostly clear what (1) means, but I couldn't tell the same for (2).

People don't do generics if they are beginners in a language or programming. Also, the problem about the syntax being more difficult to understand for beginners, could be easily mitigated by 3 minutes google search.

Regardless, i was talking about syntax highlighting, because i think it can solve the issues with ambiguity, without changing the syntax (whichever syntax is gonna be implemented). I saw it as a possible solution, more than as a nice thing to have.

I agree that the implications should be studied first, rather than the developer experience, but that's not my area of expertise, since i don't do generics very often (generally an interface will suffice).

@mateusfccp
Copy link
Contributor Author

People don't do generics if they are beginners in a language or programming.

I don't agree. People who are beginners in programming in general may not use generic, but people who come to Dart with a programming background, will certainly use generics. It's not an advanced feature or anything.

Also, the problem about the syntax being more difficult to understand for beginners, could be easily mitigated by 3 minutes google search.

This is true, and we could expand it to every syntax of the language, but I still think the team values easiness of adoption.

I'm not against cryptic syntaxes myself, but as far as I know, one of the goals of Dart is to be as accessible as possible, so I'm trying to align the proposal with the overall language objective.

@Wdestroier
Copy link

I like the idea of allowing an explicit default generic type. It's very useful once in a lifetime.

What should be inferred for the following class?

class Foo<T> { // T extends ???
  final T bar;

  Foo.baz([this.bar = 42]);
  Foo.qux([this.bar = "42"]);
}

If the generic type was implicit, as which option would T in Foo([this.bar = 42]); be inferred?

class Foo<T extends dynamic> {} // 1, current behavior and compile-time error.
class Foo<T extends int> {} // 2, breaking change and possibly error-prone.
class Foo<T = int> {} // 3, compile-time error.
class Foo<T extends int = int> // 4, breaking change and possibly error-prone.
// ??? 5

@mateusfccp
Copy link
Contributor Author

@Wdestroier

This is one of the reasons why I avoided dealing with inference in my proposal.

IMO, we shouldn't infer the default value based on the constructor.

But, if we do, for your case where we have many constructors, we would have to do LUB between all arguments that has static type T. In this case, between int and String, which would result in Object.

IMO this does not scale well, so I still think that we should simply not infer the default value of a type parameter ever.

@lrhn
Copy link
Member

lrhn commented Sep 6, 2024

By having a default type parameter value, we can accept default values in the constructor where …

Not really. A default constructor parameter value isn't necessarily sound just because the type argument can be omitted. It needs it to be omitted, otherwise you can write Foo<Never>() and presumably get the default int value as argument.

That invocation needs to be invalid, while also allowing Foo<int>() to be valid.

That's a different feature: optionally optional parameters, parameters which can only be omitted if their default value is valid for the actual parameter type.
I'm pretty sure I specified something like that before.

One of the consequences of that is that it must be visible in function types too.
Something like Foo Function([T arg = int]), meaning a parameter which can be omitted if it's type accepts int.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Proposed language feature that solves one or more problems
Projects
None yet
Development

No branches or pull requests

4 participants