Skip to content

Extension type: Confusing behavior when an extension type both implements the wrapped type and define members with the same name #60675

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

Open
rrousselGit opened this issue May 5, 2025 · 5 comments

Comments

@rrousselGit
Copy link

Consider:

class Base {
  void method1() {}
  void method2(int arg) {  }
}

extension type Subclass(A a) implements Base  {
  void method2(String arg) {  }
}

The fact that this snippet is legal feels very odd to me.

Subclass implements Base, but Subclass.method2 member doesn't respect that of Base.method2. The latter expects an int param, and the former a String.

This leads to weirdness such as:

Base base;
base.method2(42); // OK
base.method2('hello') // assignment error

Subclass subclass;
subclass.method2(42); // assignment error
subclass.method2('hello'); // OK

I'm aware that this method2 definition is not an override (and hence why the linter doesn't request for an @override). But it feels odd that we "implemented" an interface, yet the implementation of our class clearly violates that interface.

It feels like it should be a compilation error to both implements the encapsulated type and define members with the same name.

@eernstg
Copy link
Member

eernstg commented May 5, 2025

This is working as intended.

The point is, as you mention, that there is no override relation between Subclass.method2 and Base.method2, which is also the reason why it's called a redeclaration rather than an overriding declaration, and it has its own metadata @redeclare (corresponding to @override).

The choice to invoke Base.method2 or Subclass.method2 at any given call site will never be made at run time, it is always known at compile time that a given call site will invoke Subclass.method2 (because this is a statically resolved invocation, just like a static method or an extension method), or that it will use object-oriented dispatch to invoke an instance method named method2 on a receiver whose static type is Base (and this could call an implementation in any number of different declarations, with Base.method2 known statically, but otherwise in no way special).

So there is no soundness requirement that those two declarations should be compatible with each other in any way, they just happen to have the same name.

We could of course have required that a redeclare relationship must obey the same rules as an override relationship, even though it is not motivated by soundness. We instead chose to allow the signature of a redeclaration to be completely independent of the signature of the corresponding redeclaree (haha, let's just say that's a word ;-). This allows the extension type to specify exactly the signature which is most appropriate for the given member.

@rrousselGit
Copy link
Author

I get how this is happening. But I must insist if I were to show this to most Dart developers, they'd find this very confusing (fortunately extension types are rare).

I think the main issue IMO is the usage of the word implements.

If I do class A implements B, I'd expect that given:

B value = A();
value.member();

I could replace B value with A value, and the code would work strictly the same (modulo maybe some depreciation notices and stuff)

Consider static extensions instead: Extensions cannot shadow members of a type:

class A {
  int get value => 0;
}

extension on A {
  int get value => 42;
}

print(a.value); // Will print 0

This makes static extensions and extension type a bit inconsistent here IMO.
A static extension cannot "redeclare" an interface's member ; even though static extensions and extension type are fairly similar concepts.

@lrhn
Copy link
Member

lrhn commented May 5, 2025

The point of subclass substitutability is that you can use an instance of the subclass as an instance of the superclass.

That doesn't mean that it necessarily has the same API if you use it as the subclass. It's traditionally that way, but for a statically resolved (non-virtual) member, which extension members are, the member you get for the name member2 doesn't have to be the same for type A and type B, and here it isn't.

(I think we suggested a lint to avoid accidentally shadowing, and an annotation @redeclare to say that you did it on purpose.)

@rrousselGit
Copy link
Author

rrousselGit commented May 5, 2025

I understand on a technical level :)
The concern is more about syntax and consistency.

Relying on implements Type instead of a different keyword feels like a violating of the existing Dart conventions.
Using implements Type here feels like if instead of mixin Foo on Type we wrote mixin Foo extends Type. That's reusing an existing keyword for a slightly different concept, which causes confusion.

Say we had extension type Foo(int a) substitute int {} the intent would be clearer (or another keyword like show/reopen or something).

In fact, until this very comment, because of relying on implements, I naively though extension types could also implements any other interface (which they can't).

@eernstg
Copy link
Member

eernstg commented May 5, 2025

All other things equal, I'd have preferred a different word than 'implements' for the clause that declares a subtype relationship from an extension type to another type, exactly because this word makes it tempting to assume that members behave in a similar way as they do across an implements relationship on a reified type like a class. However, structural words (reserved words or even just built-in identifiers) are expensive, so there's a lot of pressure in the direction of reusing an existing one.

By the way, it is true that the extension type is a subtype of the operands of its implements clause, so the word implements works just fine for that aspect.

However, it's crucial that reasoning about extension type members is based on an entirely different kind of thinking than that which is applicable to reified types like classes.

Member lookups for extension typed receivers must be understood as static types. If the static type of a receiver expression e in an expression like e.foo() is an extension type T, and foo is resolved as an extension type member (in T or in some other extension type), then the invocation e.foo() will execute that particular member implementation. Any change that changes the static type of the receiver may yield a different outcome.

This means that the reasoning is inherently going off track if we do something like

.. replace B value with A value ..

where B is an extension type (and A may or may not be an extension type) and then expect that

the code would work strictly the same

"Changing the static type of a receiver" in general means "changing the behavior" for extension type members. When a member invocation resolves to an extension type member, you know exactly which implementation (that is, which piece of code) the invocation will run, but if the static type of the receiver changes (for any reason) then your investigation into what this invocation will do must start from scratch.

This is similar to a paradigm shift rather than an implementation detail.

You mentioned extension members as well (that is, the old extension declarations, not extension type). They are in the same paradigm, in that they rely on static resolution. Any change to the static type of the receiver can cause the resolution to choose a different extension member, or turn the invocation into an error because there is no most specific extension member with the new receiver type.

For both extension type members and extension members it is necessary to use the kind of reasoning which is based on static resolution using the static type of the receiver.

With standard object-oriented semantics, an object o has a set of member implementations, and you'll get exactly the same foo behavior for any invocation that calls a member named foo on that receiver o, no matter which object-oriented (class/mixin/enum) type the receiver expression had at compile time.

But the object-oriented semantics is irrelevant to the statically resolved invocations, and vice versa. It's necessary to think about these two kinds of member accesses as two different things (starting from the point, at the latest, where something has a behavior which is surprising).

Extensions cannot shadow members of a type

That's right, if the receiver type has an instance member with the specified name then it will be the result of the resolution, and it doesn't matter whether there exist any extension members with the same name, they will not be applicable.

Note, however, that an extension method can certainly be invoked even though there is an instance member with the same name: We just have to make sure that the instance member isn't known statically:

class A {}

class B extends A {
  String get g => 'Instance member';
}

extension on A {
  String get g => 'Extension member';
}

void main() {
  A a = B();
  print(a.g); // 'Extension member'.
}

So we need to know which paradigm is being used for each member access (statically resolved or OO dispatched), and then we know what to expect ("the code which will run is known exactly at compile time", vs. "the object is known to have some implementation of this member, and that's what we will run").

Finally, the reason why an extension type can redeclare members of the representation type is that the extension type feature was created in order to allow us to "dress up" an existing object with a new interface, without paying for it in terms of allocating a wrapper object. The extension type (that is, the "new clothes" that the object is wearing) is explicitly used with the given object, as opposed to extension members which are implicitly added to the existing statically known interface of the receiver, so it makes sense to say that the extension type, if desired, can contradict the representation interface. For example:

extension on int {
  Cm get cm => Cm(this);
  Inch get inch => Inch(this);
}

extension type Cm(int _value) {
  Cm operator +(Cm other) => Cm(this._value + other._value);
}

extension type Inch(int _value) {
  Inch operator +(Inch other) => Inch(this._value + other._value);
}

void main() {
  final sum1 = 1.cm + 2.cm; // Has type `Cm`.
  final sum2 = 3.inch + 4.inch; // Has type `Inch`.
  final sum3 = 5.cm + 6.inch; // Compile-time error.
  final Cm sum4 = sum2; // Compile-time error.
}

In this case we assume that we'd like to have an operator + (and probably a bunch of other members), and they should have return types which aren't subtypes of the return types of the operator + of the underlying representation type (because we want to keep track of the fact that a particular object with run-time type int should actually be considered to be an Inch or a Cm, and not just an arbitrary int).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants