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

[extension-types] Automatically provide access to some members of the representation interface #2506

Open
eernstg opened this issue Sep 21, 2022 · 11 comments
Labels
extension-types-later Issues about extension types for later consideration feature Proposed language feature that solves one or more problems

Comments

@eernstg
Copy link
Member

eernstg commented Sep 21, 2022

[Edit: The feature name 'view class' has been changed to 'inline class', then to 'extension type'. Adjusted the text and code below accordingly.]

During the development of the extension type feature, we had several ways to declare concisely that the interface of the given extension type should include specific members of the interface of the underlying representation type. For example:

extension type V(int i) {
  export i show isEven; // <---- This is it!
  V get successor => V(i + 1);
}

void main() {
  var v = V(42);
  print(v.successor.isEven); // OK.
}

This would compile without errors because the type of v.successor is V, and the export declaration adds the specified members (in this case just one member: isEven) to the interface of V, with the signature taken from the interface of int.

A more practical perspective on this mechanism would be that export is a request for automatic generation of a set of forwarding methods (with the same signature as the selected members of the interface of the representation object). Another perspective is that invocations of members in the set of members exported by an export declaration are handled by considering the receiver as having the representation type. That is, v.successor.isEven is desugared to v.successor.i.isEven.

In any case, the semantics of the forwarding method is that it works as if every invocation were an invocation of the forwardee with the same actual arguments (in particular, we get the same default values of optional parameters).

The mechanism as proposed supported show and hide clauses as well as the use of identifiers denoting member names, <type>s denoting sets of member names, get <identifier> and set <identifier> denoting just the getter or just the setter. Special considerations arose for the names of members of Object; for instance, you probably want hide num to mean that all members of the interface of num are removed, except for the members of Object. And so on, lots of stuff to consider.

extension type V(int i) {
  export i show int hide isEven; // Export all members of `int` except `isEven`.
  V get successor => V(i + 1);
}

In any case, it seems useful to be able to select any subset of the members provided by the representation type and make them available on the extension type.

Note that the perspective where the export declaration is understood as a way to implicitly induce a set of forwarders will immediately generalize to other class-like declarations:

class C {
  int i;
  C(this.i);
  export i show isEven; // Add `bool get isEven => i.isEven;` to this class.
}

mixin M {...} // Same idea.

extension on C {
  export i show isOdd; // Adds `bool get isOdd => i.isOdd;`
}

This issue serves to keep in mind that we might still want to add a mechanism like that.

@eernstg eernstg added feature Proposed language feature that solves one or more problems inline-classes-later Features that may fit into the inline class feature, but weren't added initially labels Sep 21, 2022
@eernstg eernstg changed the title [views] Automatically provide access to some members of the representation interface [inline class] Automatically provide access to some members of the representation interface Jan 30, 2023
@rubenferreira97
Copy link

rubenferreira97 commented Jan 30, 2023

Note that the perspective where the export declaration is understood as a way to implicitly induce a set of forwarders will immediately generalize to other class-like declarations:

class C {
  int i;
  C(this.i);
  export i show isEven; // Add `bool get isEven => i.isEven;` to this class.
}

mixin M {...} // Same idea.

extension on C {
  export i show isOdd; // Adds `bool get isOdd => i.isOdd;`
}

This feature would be awesome for composition (optionally with Dependency Injection). This allows abstracting the dependencies out of a class and achieve inversion of control.

@eernstg Could we "mimic" inheritance like this? I like the idea of achieving composition with interfaces. Or would this be a completely horrid idea?

// maybe change to interface class in future dart, but if we want common behavior an abstract class is also nice
abstract class WeatherService {
  double fetchTodayTemperature();
}

class AccuWeatherService implements WeatherService {
  @override
  double fetchTodayTemperature() {
    // fetch from AccuWeather
    return 20.0;
  }
}

class GoogleWeatherService implements WeatherService {
  @override
  double fetchTodayTemperature() {
    // fetch from Google
    return 21.0;
  }
}

class WeatherControllerUsingCompositionAndDI implements WeatherService {
    WeatherService weatherService;
    // export weatherService show WeatherService;
    // or
    // Some syntax for single line
    // export WeatherService weatherService show WeatherService;
   
    //@inject (AccuWeatherService or GoogleWeatherService)
    WeatherControllerUsingCompositionAndDI(this.weatherService);
}

class WeatherControllerUsingComposition implements WeatherService {
    WeatherService weatherService = AccuWeatherService();
    // export weatherService show WeatherService;
}

class WeatherControllerSimple {
    WeatherService weatherService = AccuWeatherService();
    // export weatherService show (WeatherService or *);
}

With this approach we can type WeatherService service = WeatherControllerUsingComposition();. Dumb in this example but I hope the idea is clear.

Inheritance still got it's place no doubts, but this way we can easily achieve composition over inheritance mantaining the type polymorphic with the advantages of composition.

@eernstg
Copy link
Member Author

eernstg commented Jan 31, 2023

@rubenferreira97 wrote:

Could we "mimic" inheritance like this?

It wouldn't work out of the box. In particular, there's a big difference between method forwarding and message delegation known as the 'Self problem'. The core of this problem is that if an object o1 handles a message m by sending that message to some other object o2 (the 'forwardee') and the body of o2.m (or something called from there, directly or indirectly) sends some other message n to itself, then it will be sent to o2 and not o1. In other words, o1 may seem to "inherit some methods" from o2, but declarations in the class of o1 are ignored during execution of those inherited methods, they don't get to override anything.

class A {
  void m() => n();
  void n() => print('A.n');
}

class B extends A {
  void n() => print('B.n');
}

void ForwardingB implements A {
  final A a = A();
  void m() => a.m(); // Just forward every method invocation to `a`.
  void n() => print('ForwardingB.n'); // Except that we want to override `n`.
}

void main() {
  A a = B();
  a.m(); // Prints 'B.n'.
  a = ForwardingB();
  a.m(); // Prints 'A.n', not 'ForwardingB.n'.
}

Given that overriding doesn't work, I'd be very careful about recommending that inheritance is replaced by forwarding. It is true that forwarding is more flexible than inheritance (especially if we're forwarding to a mutable variable), but I wouldn't consider it to be a viable approach in general.

Also note that the static analysis gets quite tricky in the case where the language supports true delegation (that is, a variant of forwarding where the Self problem has been solved in the language itself). See, for example this paper.

@rubenferreira97
Copy link

rubenferreira97 commented Jan 31, 2023

Maybe the expression "mimic inheritance" was not the best choice of words because it can lead to a wrong interpretation of what I was asking (that paper seems very interesting but a completely different thing. I will take a deeper look). I don't know how should I rephrase it tho, interfaces concretized through (composition) forwarding?

I kinda understand what you see as a self problem, but I still don't understand why it would be a real problem in this example. It seems we are just complying to an interface A (we don't care about inheritance and overrides, we just need a way to express forwarding - if we should or should not forward; thats the programmer call). Doesn't the compiler just need to ensure that all interface A methods are implemented? And/or error on duplicate concrete implementations?

In your example I would see this as syntactic sugar:

class A {
  void m() => n();
  void n() => print('A.n');
}

// Forwarding implements A so we need to comply with it, we don't care how, it's an interface
void Forwarding implements A {
  // Syntax sugar for `void m() => a.m();` // Just forward m invocation to `a`.
  export final A a = A() show m; 
  
  // If we declare both `show m` and `void m` it's a duplicate, compile error
  // void m() => print('Forwarding.m'); // compile error

 // "Except that we want to override `n`." -> No, we want to enforce interface A. How that's decided it's up to the developer.
  void n() => print('Forwarding.n'); 
}

// ForwardingAll implements A so we need to comply with it, we don't care how, it's an interface
void ForwardingAll implements A {
  // Syntax sugar for `void m() => a.m();` and `void n() => a.n();`
  export final A a = A() show (* or A); 

  void methodThatOnlyForwardingAllHas() => print('Hi!');
}

It's verbose to forward each method. This simply would allow developers to forward any method with a better granularity.

@eernstg
Copy link
Member Author

eernstg commented Jan 31, 2023

... [the Self problem is not] a real problem ... we are just complying to an interface A

You could say that, but I tend to think that forwarding should be considered to be a specialized technique which must be used with a lot of caution, rather than a better way to express inheritance.

Consider the perspective that it could be a recommended approach to stop using inheritance, say, from a class A, and instead having an instance variable final A a; and then use forwarding export a as A;.

The problem is that everybody who's maintaining code like that would need to know that there should never be an overriding declaration. As soon as any one of those classes-using-composition-to-emulate-inheritance implements one or more of the methods of the forwardee rather than forwarding, we're looking at a subtle bug.

class Animal {
  void live() { ... eat(); ... }
  void eat() {
    print('eating in a very generic manner');
  }
}

class Cat implements Animal {
  final animal = Animal();
  export animal as Animal hide eat; // We can say `hide`, so why not?
  void eat() {
    print('open the mouth dramatically with each bite and say grrrrrrrrrr!');
  }
}

void main() {
  Animal a = Cat();
  a.eat(); // Fine, says grrrrrrrrrr!
  a.live(); // Oops, when it eats, it's done in a generic manner, no drama!
}

If we want to override anything then we'd basically need to switch back to using inheritance (so we might need to change an entire class hierarchy). I consider that to be a strong hint that "use composition rather than inheritance" is a questionable piece of advice, or at least error prone and hard to handle.

That said, I still think forwarding is sufficiently useful to come up with a proposal like this issue. 😀 It is particularly useful with inline classes, because they never give rise to polymorphism: If you're calling an inline class member then it's because the static type of the receiver is that inline type or a subtype (which is another inline type), so we never have a situation where "the member that we should have invoked" is different from the one that we know statically at the call site.

@rubenferreira97
Copy link

I really appreciate your thoughtful answer. It opens up a new view of thinking, but also problems that I might not have considered.

I am not saying we should replace inheritance when it seems the "correct way" to tackle the problem. I am, myself, more of a fan in using "the right tool for the job" rather than "use composition over inheritance" everytime.

However I still think this is not a problem with forwarding, more like using composition wrongly.

class Animal {
  void live() { eat(); }
  void eat() {
    print('eating in a very generic manner');
  }
}

class Cat implements Animal { 
  final animal = Animal();
  
  @override
  void live() => animal.live();

  @override
  void eat() {
    print('open the mouth dramatically with each bite and say grrrrrrrrrr!');
  }
}

void main() {
  Animal a = Cat();
  a.eat(); // Fine, says grrrrrrrrrr!
  a.live(); // Oops, when it eats, it's done in a generic manner, no drama!
}

You can still find this "bug" today. I only view forwarding as syntax sugar, the problem is already there to begin with 😉.

@eernstg
Copy link
Member Author

eernstg commented Feb 1, 2023

using "the right tool for the job" rather than "use composition over inheritance" everytime.

+100 for that tool thing! ;-)

About "composition over inheritance", I'm somewhat worried about this motto being used as a general rule, in particular when it overrides the rule about "the right tool for the job". For example, Wikipedia: Composition over inheritance clearly describes "composition over inheritance" as a general principle.

I believe it's fair to say that this article does not at all emphasize that composition may be a suitable tool in some cases, and inheritance may be a suitable tool in other cases, and those cases have a completely different conceptualization: The former fits a "has-a" relationship and the latter fits an "is-a" relationship, and those two things are very, very different. The Wikipedia page does say that

In other words, it is better to compose what an object can do (has-a) than extend what it is (is-a).[1]

but that's the only time it mentions 'has-a' and 'is-a', and it seems to suggest that we can freely reorganize any given class across that distinction, and we should really turn as much as possible into the 'has-a' bucket. I believe this can only be done (without breaking all clients) if we introduce forwarding methods for all the 'has-a' parts, and then it gets a lot more blurry whether it's still a 'has-a' relationship.

Conversely, if we don't have forwarding methods then there's no Self problem, and having a (genuine) 'has-a' relationship is one of the fundamental and crucial tools in the OO toolbox, no problems with that.

I think I've learned the following from this discussion: A feature in Dart that supports concise generation of sets of forwarding methods (let's say we are using syntax like export someGetter show ... hide ...;) should be enhanced with a lint that helps avoiding the Self problem.

This lint should flag every instance member declaration D named m in a class C that implements a member of a type T which has been exported by a superclass S (which could be a class or a mixin application).

class A {
  void m() => n();
  void n() {}
}

class B implements A {
  final A a = A();
  export a show A;
}

class C extends B {
  void n() {} // Lint!
}

@cedvdb
Copy link

cedvdb commented May 23, 2023

I prefer the original syntax with show / hide inline class Username (String value) show toUpperCase , the class body doesn't seem like the right place to export.

Also what's the reason to hide by default ?

Personally I would have

  • inline class Username (String value) { } : shows all
  • inline class Username (String value) : hides String { } : hides all
  • inline class Username (String value) : shows toUpperCase { }: only shows toUpperCase
  • inline class Username (String value) : hides toUpperCase { }: only hides toUpperCase

@eernstg
Copy link
Member Author

eernstg commented May 23, 2023

@cedvdb wrote:

the class body doesn't seem like the right place to export.

That is certainly a point that could be argued.

However, here are a couple of reasons why I tend to think that an export mechanism fits rather well in the class body:

First, an export directive in a class body is a concise notation for a set of concrete member declarations in the class body. It should be an implementation detail for a client whether a class C has a foo method because it is declared in C, or it is inherited from some superclass, or it is obtained by forwarding to some other object. The client should just be able to see that foo can be called on a receiver of type C with a specific formal parameter list and return type.

In any case, IDEs should probably show all the implicitly induced members when the user is hovering on an export directive. This makes it reasonable to think about an export directive as a "folded" set of declarations.

Next, the export directive is able to establish a forwarding relation to different targets, and they would (typically) be looked up in the class body:

class A { void m1() {}}
class B { void m2() {}}

class C {
  final A a = A();
  final B b = B();
  export a; // Implicitly induces `void m1() => a.m1();`.
  export b; // Implicitly induces `void m2() => b.m2();`.
}

It would give rise to a surprising treatment of scopes if we used something like class C export a, b {}, because a and b are not in scope outside the class body.

what's the reason to hide by default ?

Earlier proposals had different variants, but the inline class feature which has been accepted by the language team makes the inline class type and the representation type unrelated.

This is useful in the case where the inline class is used to establish a certain compile-time discipline on the use of an object. For instance, if we have inline class IdNumber(int value) {...} because we wish to use a plain int representation (we don't want to pay for a wrapper object), and yet we want 1 + myIdNumber to be a compile-time error, then we need to avoid IdNumber <: int. This is basically the case where we want to have a "newtype" in the Haskell sense of that word.

I proposed that we could offer a very different set of defaults using a different declaration syntax, here. The idea is that an extension class CExt extends C {...} is syntactic sugar for an inline class with defaults that are optimized for adding a bunch of "sticky extension methods" to an existing type. We would then have access (by default) to all members of the interface of C on a receiver of type CExt, and CExt is a subtype of C.

In other words, I certainly agree that different combinations of defaults are worth considering. It's just that "don't export any representation type members (other than Object members)" is a meaningful default as long as it goes along with a default type relation which is "the inline type and the representation type are unrelated".

@cedvdb
Copy link

cedvdb commented May 23, 2023

and yet we want 1 + myIdNumber to be a compile-time error

Why do you want that ? Typically, I'm just looking to wrap primitives with domain concerns and this is the kind of things I'm looking for :

inline class Username(String _value) {
  // ...
  bool get isValid() => // ...
}
final message = "hello eernstg";
final username = Username("eernstg");
print(message.endsWith(username));
print(username.endsWith(message));

// but have a compile time error here
inline class Description (String _value) {}
updateDescription(Description description) {}
updateDescription(username) {}

// flutter
Text(username)

So print(message.endsWith(username)) will be impossible ?

It should be an implementation detail for a client whether a class C has a foo method because it is declared in C or it is inherited from some superclass, or it is obtained by forwarding to some other object.

Where would that information leak to the consumer ? I assume the IDE would not show to the consumer any of that info, and if it did it could be misleading

inline class Username(String value) : shows String hides toUpperCase {  // showing this part to consumer
  String toUpperCase() => value.toUpperCase();
}
  export a; // Implicitly induces `void m1() => a.m1();`.
  export b; // Implicitly induces `void m2() => b.m2();`.

Wow this is totally different than the original views hide / show. This looks a bit like the kind of "code generation" I'd expect of a macro:

@DelegatingFacade
class MyClass {
  @Delegate() final A _a;
  @Delegate() final B _b;
}

@eernstg
Copy link
Member Author

eernstg commented May 24, 2023

and yet we want 1 + myIdNumber to be a compile-time error

Why do you want that?

Because addition of ID numbers is basically guaranteed to be a bug, and it could be useful to (1) use a cheap/fast representation and (2) get a compile-time error each time we happen to write code where that bug occurs. Another example could be a very simple kind of unit management:

inline class Liter {
  final double value;
  Liter(this.value);
  Liter operator +(Liter other) => Liter(value + other.value);
  Liter operator -(Liter other) => Liter(value - other.value);
  Liter operator *(double other) => Liter(value * other);
}

void main() {
  var vol1 = Liter(10), vol2 = Liter(5);
  var volSum = vol1 + vol2, volDiff = vol1 - vol2;
  var volScale = vol1 * 10;
  
  // Compile-time error.
  // vol1 * vol2;
}

This will reduce to simple operations on double values by inlining and other simplifications at compile time, but you will get the compile-time checks. For instance, it doesn't make sense to multiply a volume by a volume, but you could scale it by multiplying a volume by a plain number: volScale is fine, but vol1 * vol2 is an error.

You could do the same thing using an actual wrapper object (just delete the word inline), and this would be safer: You can perform dynamic member invocations, and you could use a private instance variable (_value rather than value) if you want to encapsulate the representation object. vol1 as double will succeed when you use an inline class, thus eliminating the encapsulation whether or not you use _value, whereas a regular class will protect you against that kind of leakage.

The trade-off is real: Do you want the full-fledged object behaviors that a regular wrapper object will give you? Or do you want the zero-cost abstraction of an inline class? The former will incur a time/space cost at run time, the latter uses compile-time-only encapsulation which can be violated by a cast. Of course, if you want those static checks you should just avoid having casts away from inline types.

So print(message.endsWith(username)) will be impossible ?

Check out #3090. You would use implements String to get a subtype relationship and gain access to the members of String on a receiver of type Username.

inline class Username(String _value) implements String {
  // ...
  bool get isValid() => // ...
}

inline class Description (String _value) {} // May or may not have `implements String`.

void updateDescription(Description description) {}

void main() {
  final message = "hello eernstg";
  final username = Username("eernstg");
  print(message.endsWith(username));
  print(username.endsWith(message)); // OK.

  // Original code: `updateDescription(username) {}`.
  // I assume this was intended:
  updateDescription(username); // Error, `Username` is not assignable to `Description`.

  // flutter
  Text(username); // This would be OK when `Username implements String`.
}

If you want more control over forwarding behavior you could consider an export mechanism as proposed in this issue.

Where would that information leak to the consumer ?

We would use implements String to indicate that this inline class supports all members of String, with signatures that are correct overrides over the ones in String. If we use a show/hide directive in the header then we do reveal that the members of String except toUpperCase are forwarded to the underlying representation object (and that's none of our business if we are just using Username):

inline class Username(String value) : shows String hides toUpperCase {  // showing this part to consumer
  String toUpperCase() => value.toUpperCase();
}

So I'd prefer this one:

inline class Username(String value) implements String {
  String toUpperCase() => value.toUpperCase();
}

We don't even need an export in this case, because the default behavior would be that there is a forwarder for all members of String except the one which is explicitly redeclared by Username. Otherwise we could have export value hide toUpperCase, e.g., if we don't want to write a new declaration, we just want to make it a compile-time error to call toUpperCase on a Username.

(Yes, removing a member in a subtype is a new mechanism; you could say that it works like a declaration that has no implementation and just indicates that it is a compile-time error to call this member on this receiver type. This came up because there was a discussion about using an inline class ReadOnlyList<E> to wrap a List<E>, but make it an error to mutate that list by removing add and a bunch of other members. In any case, if you don't have implements String respectively implements List<E> then you're just adding some members using an export directive, no members are removed because we don't have that supertype.)

This looks a bit like the kind of "code generation" I'd expect of a macro:

That won't quite do it:

abstract class A {
  void m([double i]);
}

class B1 extends A {
  void m([double i = 1]) {}
}

class B2 extends A {
  void m([double i = 2]) {}
}

const maybeOneAndAHalf = 1.5;

class C {
  final A a;
  C(this.a);
  void m([double i = maybeOneAndAHalf]) => a.m(i); // Not a faithful forwarding.
}

So we need a little bit of language magic in order to be able to write a forwarding function which will faithfully preserve the behavior of a direct call to the forwardee, and you can't generate Dart code to do that.

@eernstg eernstg added extension-types-later Issues about extension types for later consideration and removed extension-types inline-classes-later Features that may fit into the inline class feature, but weren't added initially labels Oct 17, 2023
@eernstg
Copy link
Member Author

eernstg commented Oct 17, 2023

This feature will not be included in the first version of extension types: re-labeling as 'extension-types-later'.

@eernstg eernstg changed the title [inline class] Automatically provide access to some members of the representation interface [extension-types] Automatically provide access to some members of the representation interface Mar 26, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
extension-types-later Issues about extension types for later consideration feature Proposed language feature that solves one or more problems
Projects
None yet
Development

No branches or pull requests

3 participants