Skip to content

NNBD_TOP_MERGE produces new and some times less useful member signatures #793

@eernstg

Description

@eernstg

The nnbd spec includes the following case in the definition of NNBD_TOP_MERGE:

NNBD_TOP_MERGE(Object?, void) = void

Together with other similar cases, this implies that if we describe NNBD_TOP_MERGE as a max operation on a partial order, the underlying ordering (let's denote it by <) is dynamic < Object? < void. (It is not important that we don't actually have a partial order, we certainly have a total order on subsets like {void, dynamic, Object?}.)

This differs from earlier discussions where we have assumed that the underlying order was Object? < dynamic < void, but we cannot compare directly because NNBD_TOP_MERGE is applied in a manner which is variance-agnostic (that is: we do the same thing to a function return type and a function parameter, whereas variance would normally dictate that we do "opposite" things in the two cases).

The computation that yields a member signature for m in a class C based on several "nearly identical" ones for m inherited from the direct superinterfaces C will use NNBD_TOP_MERGE to decide on parameter types and return types, so this matters for the interface of every class.

A new thing (which is very nice, but that we didn't dare to do for Dart 2) is that interface specificity with NNBD will be symmetric and computed (so we can combine the member signatures from two different superinterfaces and come up with a member signature which doesn't occur anywhere in the program).

However, this ordering will create some situations that we may not intend:

class DontCare {}

abstract class A1 {
  void get m1;
  DontCare m2(void _);
}

abstract class A2 {
  Object? get m1;
  DontCare m2(Object? _);
}

abstract class A2 {
  dynamic get m1;
  DontCare m2(dynamic _);
}

abstract class B implements A1, A2, A3 {
  // Interface of B:
  //     void get m1;
  //     DontCare m2(void _);
}

Let us consider a covariant position in the member signature first (say, a getter return type):

Dart has previously always given the type void a treatment which is reasonably approximated by saying that it is the ultimate top type. For instance, it was allowed to override a method returning void by a method returning T for any type T, but not vice versa; the rationale was that it is safe to return any object if callers either expect an object of that type (when the static type is T) or if they expect that the method doesn't return anything of interest (when the static type is void). In Dart 2 we just consider the subtype relationship and allow Object and dynamic to override void and vice versa. However, this rule will now choose void for the member signature also in cases where it overrides Object? or dynamic in some superinterfaces.

This may break existing code because a some expressions will now have type void, whereas the current approach (which involves the ordering of declarations as well) could have yielded the type dynamic or Object (now spelled Object?).

With contravariant positions (say, a formal parameter type) we get a similar conflict with previous typing results:

The method m2 in the example gets a parameter type of void. This does not create so many problems, because call sites can pass any object to a parameter of type void, just like they can do that with Object? and dynamic. However, it may create problems for type inference:

void f(List<void> _) {}

void main() {
  f([1]..[0].isEven); // Error: Expression of type 'void' can't be used.
}

Also, it will break implementations where the combined member signature is used to infer the type annotations of parameters: The body of m2 in a subtype of B will suddenly have compile-time errors, because it uses its parameter.

So it is probably not very pragmatic to have these "avoidable void types" in member signatures, neither in covariant nor in contravariant positions.

So I'd recommend that we consider a different ordering: void < dynamic < Object?.

This might look surprising, but given that this mechanism is variance agnostic there is no need to align the ordering with the subtype relationship (or any intuition about subtyping or related concepts); for instance, we could just as well consider the ordering to be reversed: Object? < dynamic < void. In any case, it means that NNBD_TOP_MERGE(Object?, void) = Object? and so on.

If we change NNBD_TOP_MERGE accordingly that then we'd have the following outcome:

abstract class B implements A1, A2, A3 {
  // Interface of B:
  //     Object? get m1;
  //     DontCare m2(Object? _);
}

Intuitively, we would then say the following: This signature type (return type or parameter type) is a top type that may have some ambiguity. If there is any Object? then it wins; otherwise, if there's any dynamic then it wins; otherwise it is all void and we keep that.

@leafpetersen, @lrhn, @munificent, WDYT?

Metadata

Metadata

Assignees

No one assigned

    Labels

    nnbdNNBD related issuesquestionFurther information is requested

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions