dart-lang / language

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

[extension-types] Should an explicit extension type be able to "override" members of `Object?`? #1462

Closed eernstg closed 11 months ago

eernstg commented 3 years ago

An extension declaration, as supported by Dart 2.6 and up, makes it an error for a member to have the same name as a member of Object?. The rationale is that extension methods are generally invoked implicitly, and a toString on an extension E wouldn't ever be called because every object has a statically known toString instance method, and instance methods always win.

(Exception: we could do E(o).toString() to invoke an extension member named toString explicitly, but that was considered too inconvenient to have in practice).

However, extension types (as proposed in https://github.com/dart-lang/language/pull/1452, cf. #1426) include a variant called 'explicit extension types', and they have the property that implicit invocations are impossible.

// They keyword `type` means that this is an explicit extension type,
// i.e., it does not enable implicit extension member invocations,
// except on an implicit `this` in its own body.
extension type E on int {
  bool get isEven => this.isEven; // Invokes the instance member.
  bool get isOdd => !isEven; // Invokes the extension member.
}

void main() {
  int i = 42;
  i.isOdd; // Error, `E` does not allow for implicit invocations.
  i.isEven; // Invokes the instance member, as always.

  E e = 42; // OK.
  e.isOdd; // OK, invokes the extension member.
  e.isEven; // NB: Invokes the extension member, not the instance member.
}

This makes it possible and practical to declare and use extension members even in the case where their name is already the name of an instance member. For example, E can "override" isEven on an int. This would be a very brittle design with the existing extension methods feature, because we would have to remember to invoke isEven explicitly (like E(i).isEven, not i.isEven) all the time, and if we forget it somewhere then we'll (silently) get the instance member instead. But when we're using an explicit extension type we're forced to use E as the static type of the receiver, and then we don't have to remember to do anything special in order to invoke isEven (as an extension method).

This also means that we could allow explicit extensions to declare members with the same name as members of Object?:

extension type E2 on int hide toString {
  toString() => 'Something';
}

void main() {
  List<E2> xs = [1];
  xs.forEach((e2) => print(e2.toString())); // 'Something'.
  List<int> ys = xs as List<int>; // Succeeds at run time.
  ys. forEach((i) => print(i.toString())); // '1'.
}

As we can see, this allows an explicit extension type to "override" even members of Object?, as long as the static type of the receiver is the extension type.

(It is required that we include the hide clause to hide toString, because the members of Object? are made available in the interface of the extension type by default, and we need to remove the one from Object? to avoid a name clash).

However, even though both isEven and toString can be "overridden" (in quotes because it's resolved statically), we can use a cast to access the underlying object under its run-time type (or a supertype thereof), and then we'll have standard object-oriented late binding, and we'll call the most specific instance member for the given run-time type.

We could make extension types even more strict and prohibit all declarations of members whose basename is already the basename of a member of the on-type (but that would be a breaking change for existing extension methods), in which case we wouldn't have this dichotomy of "if this static type run the extension method, otherwise run the instance method".

We could also keep the permission to declare instance members that "statically override" instance members. But in this case it seems inconsistent if we make an exception for members of Object?.

The current proposal allows extension members that "statically override" members of the on-type, including the ones that are also members of Object?.

lrhn commented 3 years ago

The rationale is that extension methods are generally invoked implicitly, and a toString on an extension E wouldn't ever be called because every object has a statically known toString instance method, and instance methods always win.

That was not the entire rationale.

As you write, the same could be said for any extension member with the same name as a member of its on type.

The real reason is that members of Object are often invoked implicitly by the language specification. If you write Ext(o).foo() and Ext does not have a foo member, but it has a non-trivial noSuchMethod, should you invoke Ext.noSuchMethod? If so, must that noSuchMethod be a valid override of Object.noSuchMethod, even though no other members of extensions need to follow the signature of the on type? What if the extension declares int toString(int x) => x;, under which conditions would that be called (Ext(o).toString(4) could work). We were anticipating extension types, and worried what would happen with Ext e = o; print("$e");. So, if static extension members were allowed to "override" members of Object, they would either be hard to call, inconsistently called by the platform, or they'd have to follow the Object interface signatures. Rather that making a choice, and complicating the feature, we simply disallowed declaring such members, and with the functionality available, that was no big loss.

That is also the issue here:

extension Money on int {
  String toString() => this < 0 ? "(${-this})" : this.toString();
}
void main() {
 Money e = -42; 
 print(e);
 print("$e");
 print(e.toString());
}

Will these prints print the same thing or different things? The print(e) passes e to a function expecting Object?. That will definitely use the on-type's toString. The print(e.toString()) should call Ext(e).toString(), otherwise there is no reason to allow declaring toString at all. What does print("$e") do? Is the underlying model print("" + e.toString() + "") or is it print(interpolate("", e, ""))?

If the "$e" uses the extension type's toString, then people are also going to get confused when they do print(e) or log(e)or use any other helper function which converts to string, because those will not see the extension type, just an Object? reference.

The only way to win is not to play. The risk of user confusion is an argument against allowing toString at all.

It's plainly impossible to allow == and hashCode to be overridden unless we box. Those are almost entirely used by hash maps, which cast to Object? before calling ==. The only exception is the plain if (e == o) ... check. It might make sense in a few places (like adding equality to collections), but since it's only skin deep, and doesn't work if you pass the two objects to any other general function, it's very likely that it's going to be subtly confusing as well.

(Overriding runtimeType just doesn't make sense, and noSuchMethod is potentially confusing - should it work with implicit invocations too?)


Nits:

  int i = 42;
  i.isOdd; // Error, `E` does not allow for implicit invocations.

It's not an error because int has an isOdd (and isEven) member already.

(It is required that we include the hide clause to hide toString, because the members of Object? are made available in the interface of the extension type by default, and we need to remove the one from Object? to avoid a name clash).

I'd expect an explicitly declared member to automatically hide any instance member which isn't explicitly shown. It would be very annoying if I wrote:

extension OpaqueString on String hide length, operator[] {
  ...
  OpaqueIndex indexOf(Pattern pattern, [int start = 0]) { .... }
  ...
}

and then also have to add a hide for indexOf which I'm explicitly replacing.

eernstg commented 3 years ago

About this:

E2 x = 10;
print(x.toString()); // "Something"
print(x); // print 10

@tatumizer wrote:

I think this requires autoboxing. ... This would be very confusing.

This kind of confusion is certainly the main reason why we would not want to have a member name m which is both the name of an extension member and the name of an instance member, and as @lrhn mentioned, it gets even worse with Object? members, because they

are often invoked implicitly by the language specification

Later, @lrhn writes:

extension Money on int {
  String toString() => this < 0 ? "(${-this})" : this.toString();
}
void main() {
 Money e = -42; 
 print(e);
 print("$e");
 print(e.toString());
}

The print(e.toString()) should call Ext(e).toString(), otherwise there is no reason to allow declaring toString at all.

The proposal only allows an extension member named toString when it is an explicit extension type (extension type ...). So let's assume the variant of Money which is explicit:

extension type Money on int {
  String toString() => this < 0 ? "(${-this})" : this.toString();
}

With this declaration, I agree that print(e.toString()) would call the extension method, and that's also the intention.

What does print("$e") do? Is the underlying model print("" + e.toString() + "") or is it print(interpolate("", e, ""))?

The language specification explicitly says that toString is invoked on the result of evaluating the expression.

If we consider that to be a syntactic desugaring step then it is not unreasonable to say that print("$e") should also invoke the extension method.

It is still true that print(e) would (presumably) invoke the instance member named toString. We might actually want to consider the argument type of print as obsolete and make it String, and we could certainly have a lint that recommends passing a string to the-print-exported-by-dart:core.

So toString will work, mostly. ;-)

It's plainly impossible to allow == and hashCode to be overridden unless we box.

We could then simply allow toString (which is the only Object? member that I've seen actual requests for in this context), and disallow the others.

eernstg commented 3 years ago

One might argue that the boxed entity should support overriding any and all of the Object? members, just like any other class. They would of course be instance members, so they will work as expected in all contexts (like print(e.box) or (e as dynamic).hashCode).

So do we want to introduce a mechanism that allows us to write declarations for E.class (they would then only be available in the boxed object, not as extension members)?

extension type Money on int {
} class {
  String toString() => this < 0 ? "(${-this})" : this.toString();
  void whatEverExtrasYouWantInTheBoxedObject() {}
}
eernstg commented 3 years ago

I got what I paid for :-)

Indeed! ;-)

But this kind of scenario is exactly the reason why I think it is so important to make sure the distinction between a dynamic mechanism like classes and methods is kept visibly distinct from a static mechanism like extensions (extension methods as well as extension types). I do not think it's going to get easier to handle if we try to hide that distinction by using implicit coercions all over the place.

The point is performance.

If you're willing to pay the time and space needed to create a real wrapper object then you will get a more robust entity to work on, no question about that. But if you want to impose a certain discipline on the use of some existing objects (especially a whole object graph where you wouldn't want to wrap each object as you traverse the structure) then the extension types provide a way to do it.

eernstg commented 11 months ago

Closing: We decided that extension types should not have the ability to redeclare the five members of Object.