dart-lang / language

Design of the Dart language
Other
2.65k stars 202 forks source link

Strict bounds #3795

Open eernstg opened 3 months ago

eernstg commented 3 months ago

This is a proposal to enable a new relationship in the declaration of a formal type parameter: It should be possible to declare that the corresponding actual type argument must be a proper subtype of the bound, it is not enough to be equal to the bound.

sealed class S {}
class A extends S {}
class B extends S {}
class C extends S {}

void f<X strictly extends S>(X x) {}

The type parameter X is treated the same way as X extends S, except that it is an error for X to be equal to S (that is, X <: S must be true, as usual, but S <: X must be false).

This mechanism can play a role which is somewhat similar to that of an abstract class: It is able to provide a certain amount of structure, but it cannot be used directly.

The abstract class describes properties of all subtypes, but we can't create an instance of the abstract class itself. The strictly bounded type parameter will constrain the possible actual type arguments as usual, but the type which is used to specify the bound itself cannot be used. You could say that the strict bound turns the bound into an "abstract type argument".

So how could this be useful?

In general, it allows us to denote a set of types that we often cannot enumerate. In the example above S is a sealed class, which means that it is possible to enumerate the immediate subtypes of S, but with any type which isn't sealed we cannot know that we have included them all. Also, it might be convenient to use strictly extends even with a sealed type, because it is more maintainable.

A strictly bounded type parameter could be used with bound Object? to ensure that a type argument can be any type except a top type, which is something that we can't otherwise express. In particular, this means that the actual type argument cannot be dynamic, but it can be another nullable type (and it can even be Null). This is not possible if we just specify the bound to be Object (which is currently the quasi-standard way to express that a type argument can't be dynamic).

It can also be used with Object? in order to ensure that it is a compile-time error to rely on the greatest closure (that is, the default approach taken when type inference has no information).

class A<X strictly extends Object?> {}

void main() {
  var a = A(); // Compile-time error, because of `strictly`.
  print(a.runtimeType); // Without `strictly`: 'A<dynamic>'.
  var a2 = A<int>(); // No problem!
}

A strict bound could be used with Object in order to avoid a subtle type like FutureOr<Object> (see https://github.com/dart-lang/sdk/issues/54311 for some background info about why we might want to avoid such types).

FutureOr<X> f<X strictly extends Object>() ...

void main() {
  FutureOr<Object> x = f(); // Used to be subtle; now an error.
  ...
}

A strict bound could be used with a sealed type in order to specify a finite, statically known set of possible type arguments to a generic entity, and at the same time preventing the sealed type itself from abstracting over this set of types as a whole (is the "abstract type argument" usage: all the subtypes are "concrete as type arguments" and can be used as actual type arguments, but the sealed type itself is "abstract as a type argument" and cannot be used directly):

class Unit {}
class Meter implements Unit {}
class Foot implements Unit {}

extension type Length<U strictly extends Unit>(double value) {
  Length<U> operator +(Length<U> other) => Length(value + other.value);
}

Length<U> add<U strictly extends Unit>(Length<U> l1, Length<U> l2) {
  return l1 + l2;
}

void main() {
  var l1 = Length<Meter>(1.0);
  var l2 = Length<Foot>(3.5);
  var l3 = Length<Unit>(-1.7178); // Compile-time error, "you must choose a concrete unit".

  var l4 = l1 + l1; // OK, is `Length<Meter>`.
  var l5 = add(l1, l1); // OK, addition in `add` is polymorphic.
  var l6 = add(l1, l2); // Compile-time error.
}

Without strictly we cannot ensure that the units are respected:

void main() {
  var l1 = Length<Meter>(1.0);
  var l2 = Length<Foot>(3.5);

  var l6 = add(l1, l2); // No error, but should be rejected. Inferred as `add<Unit>(l1, l2)`.
}
lrhn commented 3 months ago

Without Unit being sealed, it doesn't make much sense, since I can always declare abstract class MyUnit extends Unit {} and use Length<MyUnit> which will still not work.

I'd assume that T strict extends Super is a (proper) subtype of Super, so that's at least neat.

Are proper-subtype-bounding only valuable for type parameters, or could I have a normal variable with a proper-subtyped type? (Or is that just useless if the excluded type is abstract, and meaningless if not?)

Maybe this should only be about sealed types. Consider <T extends sealed Super> which requires the passed type to be one of the immediate supertypes of the sealed type Super. Or if any of those types are themselves sealed, then their transitive first non-sealed subtypes. "Don't mention the abstracts!".

Or could it be <T extends concrete Super> which requires T to be a concrete type. T extends concrete Super is then a proper subtype of Super, and X extends Sub (where class Sub extends Super is concrete) would not be a valid argument for T extends concrete Super, because it might be a non-concrete subtype. (Only allowing concrete classes came up in the virtual static method discussion, because you can't call a generative constructor on a type variable, no matter what subtype requirements it satisfies, if the class is abstract.)

It's a new kind of type bound, which means that the bounded type variable is a new kind of type, with either approach. If I have foo<T strict extends Super>(T value) { if (value is SubSub) { ... } } will it promote value to T&SubSub? (It can, and as an intersection type, it's valid, even if SubSub is not a an immediate subtype of Super, or even a concrete class.

eernstg commented 3 months ago

use Length<MyUnit> which will still not work.

I don't see a problem: MyUnit will be considered to be a proper unit. We might not have heard about before, but otherwise it is just as good as Meter.

import 'unit_with_meter_and_foot.dart';

abstract class MyUnit extends Unit {} 

extension on double {
  Length<U> toLength<U strictly extends Unit>() => Length<U>(this);
}

void main() {
  Length<MyUnit> l1 = 2.5.toLength(), l2 = 4.5.toLength();
  Length<Meter> l3 = 5.5.toLength();
  var sum = add(l1, l2); // OK, result is `Length<MyUnit>`.
  add(l1, l3); // Compile-time error.
}

Granted, we might wish to have something which is more flexible and has more expressive power, such that we would be able to make MyUnit an "abstract type argument" as well, rather than an actual unit. That's not easily achieved using anything like strictly extends.

However, a more powerful mechanism might also be a lot more expensive in terms of language and implementation complexity, and we might not want this expressive power so badly that we want to pay that much to get it. ;-)

or could I have a normal variable with a proper-subtyped type?

I think abstract would be the best fit for that concept.

Maybe this should only be about sealed types.

I think sealed types are the simple case that we don't have to cover, because they can already be handled in a manner which is similar to this proposal. It might still be convenient (and more maintainable) to use this feature with a sealed type, it's just not essential. It is essential with types that are not sealed, though, because we can't enumerate the acceptable types and hence we can't provide for exactly that set of types.

This is also the reason why Unit wasn't sealed: It should be possible to declare additional unit types, for anyone who wants to do so.

Or could it be <T extends concrete Super> which requires T to be a concrete type.

Would that be useful? It sounds like a restriction which is somehow similar to abstract, so maybe we could use concreteness in some way to emulate the behavior that we'd want to use T extends concrete Super for.

In any case, it certainly wouldn't allow us to express anything like "every type which is a subtype of Object, but not Object itself".

If I have foo<T strict extends Super>(T value) { if (value is SubSub) { ... } } will it promote value to T&SubSub?

Certainly yes, if value is otherwise promotable, and Subsub is a proper subtype of Super (it does not matter whether it's direct or indirect, and it would presumably be OK to promote value to T & Super as well).

eernstg commented 3 months ago

It could be claimed that we can use existing mechanisms. It is indeed possible to take some steps:

sealed class Unit {}
sealed class _GhostUnit {}
abstract class Meter implements Unit, _GhostUnit {}
abstract class Foot implements Unit, _GhostUnit {}

extension type Length<U extends Unit>(double value) {
  Length<U> operator +(Length<U> other) => Length(value + other.value);
}

Length<U> add<U extends Unit>(Length<U> l1, Length<U> l2) {
  return l1 + l2;
}

void main() {
  var l1 = Length<Meter>(1.0);
  var l2 = Length<Foot>(3.5);
  var l3 = Length<Unit>(-1.7178); // Ouch, no compile-time error!

  var l4 = l1 + l1; // OK.
  var l5 = add(l1, l1); // OK.
  var l6 = add(l1, l2); // Yes! Compile-time error.
  var l7 = add<Unit>(l1, l2); // Ouch, we can circumvent it!
}

In other words, this is definitely not a complete emulation.