dart-lang / language

Design of the Dart language
Other
2.66k stars 205 forks source link

Pseudo-methods, ::before and ::after #2831

Open DrafaKiller opened 1 year ago

DrafaKiller commented 1 year ago

Proposal

The Pseudo-methods ::before and ::after act as methods, overriding the declared methods which they are bound to. They are a simpler alternative to avoid having to call super, or an option to organize code in your own way.

This also avoids overriding methods and forgetting to call super.

However, the fact that the language itself may not be able to enforce all conditions prescribed on this call is what makes this an anti-pattern.

For example, forgetting to call super.initState() / super.dispose() in flutter.

The basic idea of it is:

Motivation

When extending a class, you might want to keep the logic from the previous implementation. But for that you must call super.method(), and that can be even more inconvenient if that method has a lot of arguments that you must pass in.

Sometimes you might even forget to call it, and other times, it just looks out of place.

Instead we can avoid having to deal with super, and even completely abstracting from the fact that it's being used internally. This way, all we care about is that our code is going to run before or after the method.

Example

Let's imagine we have a file class, with the following methods:

class File {
  void save() { ... }
  void close() { ... }
}

Now, we want to automatically save the file before closing.

Currently, we would have to do:

class AutoSaveFile extends File {
  @override
  void close() {
    save();
    super.close();
  }
}

But with the use of pseudo-methods, we can do:

class AutoSaveFile extends File {
  @override
  void close::before() {
    save();
  }
}

Why "Pseudo-methods"?

Just like in CSS, pseudo-elements ::before and ::after are extensions of an element, which will be displayed before or after the original element.

In our case, pseudo-methods might look like methods, but instead, they are an extension of a method, thus pseudo. Without the main method, the pseudo-methods wouldn't make sense.

Full Syntax Example

abstract class B {
  void init() {
    // Run init code
  }

  // Child must implement code do run after init.
  // In other words, a init::after method must be declared.
  abstract void init::after();
}

class A extends B {
  @override
  void init::before() {
    // Run code before init
  }

  @override
  void init() {
    // Run code before init::before

    // [INTERNAL] init::before();
    super.init();
    // [INTERNAL] init::after();

    // Run code after init::after

    // Notes:
    // Must call super, because a ::before or ::after was defined
    // Otherwise, will throw a compile error
  }

  @override
  void init::after() {
    // Run code after init
  }
}

void main() {
  A().init();
}
eernstg commented 1 year ago

This proposal brings up memories of :before, :after and :around methods in CLOS (cf. this SO question on this topic), and the entire subculture known as aspect-oriented programming, and there are also some connections to design by contract. So there are lots of ideas out there about this kind of feature, and they have been refined over many years.

One obvious place to look for this kind of feature is https://pub.dev/packages/aspectd.

The main trade-off is probably: Do you want this feature to be non-intrusive? That is, should we be able to declare a 'before' or 'after' method for a method C.m without having editing powers over the library that declares C or any of its superclasses?

If they're allowed to be intrusive then I think it's useful to take a look at a manually written version of the same thing:

abstract class B {
  void init() { // The "official" `init`, only used to do method combination.
    initBefore();
    doInit();
    initAfter();
  }

  void doInit() { // The actual implementation of `init` goes here.
    print('Run B.init code');
  }

  void initBefore(); // Could be abstract, could do nothing, whatever fits.
  void initAfter();
}

class A extends B {
  @override
  void doInit() {
    print('Run A.init code');
  }

  @override
  void initBefore() {
    print('Run code before init');
  }

  @override
  void initAfter() {
    print('Run code after init');
  }
}

void main() => A().init();

The point is that this manual version shows all the questions that we might want to consider: Which parameters will we pass to the ::before and ::after methods? Do different declarations of ::before override each other, or do they all get called, according to the override ordering, and similarly for ::after? How do we combine returned results from all these methods?

One thing to note is that this kind of feature probably works best with a general approach to methods where overriding is considered to be a rare exception, and method combination is the normal and desirable semantics (a bit like constructors: You always call a generative constructor for every class in the entire superclass chain; so we'd have @mustCallSuper all over the place).

DrafaKiller commented 1 year ago

The main trade-off is probably: Do you want this feature to be non-intrusive? That is, should we be able to declare a 'before' or 'after' method for a method C.m without having editing powers over the library that declares C or any of its superclasses?

I think it should be non-intrusive, it can be declared for any method, as that would also be a possibility with the current overriding.

If they're allowed to be intrusive then I think it's useful to take a look at a manually written version of the same thing:

There's still a problem with that manually written version, and that is when working with mixins. When extending the method initBefore() in multiple mixins, if a mixin doesn't call super.initBefore() it cuts the chain.

Which parameters will we pass to the ::before and ::after methods?

I think the more intuitive, at least to me, is if the parameters are the same as the original method. That way the method can be overridden as normal, and later just adding ::before or ::after in front of the method name. At least this would feel like the simplest way.

The methods method(), method::before() and method::after() would need to have the same parameters so they are easily chainable. In this case, method::before() and method::after() are strictly bound to the original method, method().

Then it would be up to the original method to call, internally, ::before(), then super, then ::after(). We can see it as method() being a normal overriding method, but is surrounded with these pseudo-methods, if they exist.

How do we combine returned results from all these methods?

I think pseudo-methods shouldn't return, you may declare it with a return type, but pseudo-methods must not have a return. If declaring a pseudo-method with a return keyword, it will throw a compile or syntax error.

Allowing the developer to declare pseudo-methods with void return type might be a future problem when implementing method overloading.

In case of Common Lisp, the return value is ignored.

When calling those methods, the return value will be ignored, as so:

int init::before() { ... }
int init::after() { ... }

// Internal
@override
int init() {
  // Return vales are ignored.
  init::before();
  final value = super.init();
  init::after();

  return value;
}
lrhn commented 1 year ago

If the pseudo functions cannot return anything (which is reasonable), then we should decide what happens if they, or the super method, throws.

If init::before throws, should super.init be called? (Probably not). If super.init throws, should init::after be executed? (.... much less clear. Let's say "no" and also add init::finally which always gets called. Well, at least if ::before didn't throw. Should it depend on whether ::before is declared before or after ::finally?)

Can you override the method and provide ::before and ::after in the same class, or can you only either override or add before/after?

And then there are the parameters. Using the "same parameters" as the original method is under-defined, because the original method is an interface method specified by a function type, which does not include names for positional parameters. If you want to actually access the positional parameters, you need to give them names somehow. Which means repeating them, and avoiding having to do that was one of the benefits listed of having ::before instead of just writing a wrapper method manually. (Or we could just name them $1, ..., $n for you. No readability issues with that. 😁).

satvikpendem commented 1 year ago

This reminds me of the Drop trait in Rust, where when a variable goes out of scope, the compiler automatically calls the drop function, and you can override it however you wish for a certain type.

Levi-Lesches commented 1 year ago

Another thing this will help with: users are often confused about whether to call super.foo() before or after their override. This makes a big difference in the case of Flutter's State:

class MyWidget extends StatefulWidget {
  String initialText;
  const MyWidget(this.initialText);

  @override
  MyState createState() => MyState();
}

class MyState extends State<MyWidget> {
  final controller = TextEditingController();

  @override
  void initState() {
    // This won't work because `super.initState()` must be called *first*
    controller.text = widget.initialText;
    super.initState();
  }

  @override
  void dispose() {
    // This won't work because `super.dispose()` must be called *last*
   super.dispose();
   controller.dispose();
  }
}

Overriding State.initState::after and State.dispose::before instead would be much clearer. Though I suppose this example shows there could be some value to a warning not to override something -- eg, State.initState::before or State.dispose::after. Maybe these should only be overrideable if the superclass explicitly declares them. Otherwise, just override the whole method and call super.foo() yourself?