tc39 / proposal-decorators

Decorators for ES6 classes
https://arai-a.github.io/ecma262-compare/?pr=2417
2.74k stars 106 forks source link

Annotations vs Decorators #115

Closed drpicox closed 2 years ago

drpicox commented 6 years ago

It is not an issue, but I think that it is a discussion that must happen.

Java Annotations is the first thing similar to decorators that I have ever seen. They seem very similar to decorators, but they are not. If you have experience with C++ and Java, compare C++ Templates with Java Generics, C++ Templates creates new code for each parametrization, Java Generics are nothing more than type checking sugar syntax.

Java Annotations are just that: annotations. A kind of comment. It is a kind of metadata that expresses the properties of classes, attributes, or parameters. They have no behavior, no code. You can think like they were interfaces: things that your code satisfies.

Because Java Annotations have no code, java programmers naturally split the usage into two parts: definitions and implementations. That decouples usage with implementation. In the case of Java, Java itself provides many of those annotation definitions (like persistence annotations). They have no code, and no behavior, associated.

Third-party libraries are responsible for applying annotations effects. You can think in frameworks, application containers, and others reading those annotations and executing their actions.

The most notable case is that we annotated java objects are plain java objects. Those objects still work when the library is not loaded. It is essential because it means that you can reuse and test them with no libraries.

So, how could it apply to the Javascript world?

Annotations are just comments, no code. As I have said, they are like interfaces.

Interfaces in Javascript emerge from objects themselves. You do not need to create a Javascript interface, you create your classes and your objects satisfying some signature, and you have it, an implicit interface.

Annotations in Javascript could work very similar to interfaces. Java declares annotations like Interfaces, which implies that Java code must import those definitions to use them. Javascript could skip this step and accept any arbitrary annotation name with any random associated value.

They would enable the creation of pure code without any dependency on any framework.

So, how could it work?

Using annotations:

@defineElement('num-counter')
class Counter extends HTMLElement {
  @observed #x = 0;

  @bound
  #clicked() {
    this.#x++;
  }

  constructor() {
    super();
    this.onclick = this.#clicked;
  }

  connectedCallback() { this.render(); }

  @bound
  render() {
    this.textContent = this.#x.toString();
  }
}

Using programmatic API:

class Counter extends HTMLElement {
  #x = 0;

  #clicked() {
    this.#x++;
  }

  constructor() {
    super();
    this.onclick = this.#clicked;
  }

  connectedCallback() { this.render(); }

  render() {
    this.textContent = this.#x.toString();
  }
}
Object.annotate(Counter, ['defineElement', 'num-counter']);
Object.annotateFields(Counter, { render: ['bound'] });
Object.annotatePrivates(Counter.prototype, { clicked: ['bound'], x: ['observed'] });

And to access to them can be done programmatically:

const counter = new Counter();
Object.getAnnotations(counter);

Thus, frameworks can consume those objects, understand their annotations, behave as they were expected in such context. At the same time, objects could be freely used outside those frameworks, without any dependency.

littledan commented 6 years ago

We've been considering this question for a while. Ultimately, "imperative" decorators are more general than "declarative" annotations, in the sense that you can implement annotations in terms of decorators, but then there are things you can do with decorators that you couldn't do with annotations. @rbuckton is working on bridging the gap in his metadata proposal. The current idea is that the metadata proposal would be a clean follow-on to the decorators proposal. How does that sound to you?

drpicox commented 6 years ago

Nice! It sounds good.

In fact, declarative stuff can be achieved with a generic imperative library. I just hope that frameworks take this in consideration and split definitions from implementations. But, although imperative decorators are more general, they might lock your code and your libraries to a specific library implementation dependency, which is really dangerous. I hope that main framework developers take this into consideration before creating implementations that lock user classes to one or another specific framework.

About:

A poorly written mutating decorator for a class constructor could cause metadata to become lost if the prototype chain is not maintained. Though, not maintaining the prototype chain in a mutating decorator for a class constructor would have other negative side effects as well. @rbuckton

I did not have considered it. Changing the instance (something that some object creator techniques do), may break metada. An example are redux enhancers: they transform created redux stores into redux stores with special behaviours. Redux enhancers usually create new instances. Most of redux enhancers could be implemented as decorators (if not all of them), but in this case, usually, any metadata associated to the original object, would be lost.

littledan commented 6 years ago

@drpicox Do you think popularizing @rbuckton metadata polyfill would be a good strategy towards this goal?

trotyl commented 6 years ago

A possibly related question: would decorator completely break tree-shaking (or dead-code elimination)? Which is a common feature in modern build tools.

In the current demo, @defineElement('num-counter') is used to perform side-effects, so that Counter class must NOT be dropped even if not referenced.

Due to there's no way to determine whether some decorator has global side-effect, so any class with one of more decorator has to be retained in bundle, is that true?

nicolo-ribaudo commented 6 years ago

Some tools allow marking function calls as pure (https://github.com/mishoo/UglifyJS2/commit/1e51586996ae4fdac68a8ea597c20ab170809c43): they would need to do the same for decorators.

zenparsing commented 6 years ago

I'm not sure that decorators are a generalization of annotations. There are some things that we can do with annotations that we cannot do with decorators.

We can annotate functions, for instance without breaking hoisting semantics.

I'm seeing a couple of use cases for language-level annotations that we don't have an answer for:

I can imaging using language-provided annotations for these:

#[nosource] // Using Rust's annotation syntax here
function f() {
  // This source code is hidden
}

#[transferrable]
function g() {
  // This function cannot capture references to the outer lexical environment
}

But with decorators this would not work.

Is all of the power of decorators really worth it?

littledan commented 6 years ago

@zenparsing Thanks for bringing early decorators up; I have been interested in discussing these. I've been chatting informally with some other TC39 members about this sort of thing, such as @leobalter and @apaprocki. Personally, I like the syntax proposed by @rbuckton, @foo: bar.

For early properties that we are sending to the system itself, I could imagine something like @early: nosource function f() { }. I could imagine that we could support decorator syntax for functions only when the thing before the colon is in the appropriate set. This proposal is forward-compatible with starting with imperative decorators with the current syntax.

Another idea is using @@nosource for this kind of thing. However, this option would not extend beyond two namespaces, so I'd disprefer it.

Note that, about using # rather than @ for decorators/annotations, @ljharb has previously proposed a swap, but the committee wasn't quite convinced.

zenparsing commented 6 years ago

Right, I was just using the # ala Rust, I wouldn't really want to use that character here.

If we were doing annotations instead of decorators, I suppose that we would follow C# and only evaluate the annotation constructors when requested (as if they were thunks):

assert.deepEqual(
  // Calling "getAnnotations" executes the annotation factories
  Object.getAnnotations(foo),
  [{ author: 'zenparsing' }]
);

function author(name) {
  return { author: name }
}

@author('zenparsing')
function foo() {}

This is a really simple design, works with hoisting, does not require us to introduce AST-like descriptor interfaces, and would work for the "metadata" use cases. It also prevents users from (ab)using decorators in a way that completely obfuscates runtime semantics.

Are we sure that introducing code-transformation syntax into the language is worth it for the remaining use cases? Why?

I'll try to dig into the meeting notes from around 2014/5 to see if I can find anything.

jridgewell commented 6 years ago

How would this work for private properties?

zenparsing commented 6 years ago

Great question. I suppose I would have to answer how it would work for public fields first, though 🤔

littledan commented 6 years ago

There are tons of use cases for imperative decorators, as opposed to annotations. I am pretty sure that if we made this sort of regression, it would cause an ecosystem revolt. To take one random example: how would you express an @observed field with annotations?

I think we could still support annotations as a follow-on feature, if the kind of laziness you mention is important. For example, @annotation: foo(bar) class Baz { }

drpicox commented 6 years ago

Annotations on fields and parameters, private, public or static, are common in Java. In the worst case scenario it is possible to copy semantics and API, for example:

// source: https://spring.io/guides/gs/rest-service/
package hello;

import java.util.concurrent.atomic.AtomicLong;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class GreetingController {

    private static final String template = "Hello, %s!";
    private final AtomicLong counter = new AtomicLong();

    @RequestMapping("/greeting")
    public Greeting greeting(@RequestParam(value="name", defaultValue="World") String name) {
        return new Greeting(counter.incrementAndGet(),
                            String.format(template, name));
    }
}

And "more recently", other places that annotations can be used ( https://docs.oracle.com/javase/tutorial/java/annotations/basics.html ) :

If your code requires access annotations it can use the Java Reflection API:

drpicox commented 6 years ago

About tree-shaking and pure functions, imagine that we want to do:

@annotate
function greeting() { … }

I just want to remember that this is not a pure-function:

function annotate(func) {
  func.$isAnnotated = true; // this is a side effect, mutates an argument
  return func;
}

A pure function would be something like:

function annotate(func) {
  function copy() {
    return func.apply(this, arguments);
  }
  copy.$isAnnotated = true; // no side effect here, is variable, not argument
  return copy;
}

The second case has a problem: the reference to the original function is lost. Which may cause some problems in some scenarios.

Although the first version is not pure, what is doing has no side effects outside the scope where declaration of the greeting function is being declared. An optimizer could track that a function only mutates arguments, and those arguments are inside a scope, so deduce the whole scope as pure. In such case tree-shaking is possible. So, technically speaking, an enough advanced compiler is able to optimize it.


After looking a little bit the both versions (the impure and the pure ones), they look horrible as annotation, because I should not add more properties to an object that I wouldn't expect to be "public" or could collide with other properties. The correct implementation would be:

var annotationMap = new WeakMap();
function annotate(func) {
  annotationMap.set(func, true); // this is a side effect, mutates an outer scope variable
  return func;
}

In this case the static analysis would be really harder to do. Ok that it can conclude that because annotationMap is a WeakMap (or WeakSet, does not matter) changes on annotationMap only has meaning if we keep track of func, but some times this WeakMap type is not so easy to infer (they could be inside other objects, ...).

And of course, there is no way to do a pure version of such thing.

So... I have no clue how to create beautiful tree-shakeable annotations are possible with current decorators proposal 😞

drpicox commented 6 years ago

About how @observed would work with annotations you have to think in the reverse way.

In the example of the README decorators are creating side effects, @observed even creates a kind of timer.

If something like this would be implemented with annotations, it would expect some kind of runtime, in which you explicitly add the Counter class, and then this runtime will scan for annotations, and setup everything.

That has two implications: now you can have tree-shaking (code is only declaration, not execution), and you can change the runtime library.

In java, what usually happens, is that runtime libraries use reflection to find classes and start doing side effects, so it ends with exactly the same behaviour than annotations. This behaviour could be achieved in javascript also if we are willing to throw away tree-shaking. It is as simple as, instead of doing an API that you give one object and it return your annotations (like WeakMap), do an api that you give an annotation and it tells you all the objects that satisfies that annotation (like Map). Btw, in java tree-shaking is almost worthless, is "server code".

I have to say that I never liked how java frameworks do reflection to automatically activate code, I have the sense that I am loosing the control that of what is being processed and what no. Like I dislike that an import activates something without calling a function.

ljharb commented 6 years ago

You could do something like this, and keep it pure:

const map = new WeakMap();
export function annotate(func, annotation) {
  map.set(func, annotation);
  return func;
}
export function getAnnotation(func) {
  return map.get(func);
};
drpicox commented 6 years ago

But, as I have said: map.set(…) is a side effect, so annotate is not pure.

const result1 = getAnnotation(func);

annotate(func, !result1);
const result2 = getAnnotation(func);

expect(result1).toBe(result2);

Because the expectation fails, it means that somehow func has been mutated in the annotate function call.

littledan commented 4 years ago

We considered adding an annotation syntax like @{ }. It's not part of the current proposal, but could be added in a follow-on proposal. However, the current decorators proposal does have a specific metadata capability, where metadata can be installed by decorators. This gives decorators the opportunity to calculate exactly what metadata they want to store; you can have type signatures in TS, etc. I hope that this solves any concerns about annotation use cases, and I'd like to hear if there are any issues remaining.

pzuraq commented 2 years ago

As noted by @littledan, this proposal has the ability to install metadata, and so it is a superset of annotations. I'm going to close this issue as it seems to be addressed.

drpicox commented 2 years ago

But, is the compiler able to realize that a given decorator has no side-effects, so it can be tree-shaked? If not, it is not a superset, it is a different feature.

pzuraq commented 2 years ago

What types of side effects do you mean, exactly?

drpicox commented 2 years ago

If I am not wrong, a decorator executes a function, right? And this function receives the element as an argument. If that function has a side-effect, it cannot be tree-shakeable. It is very difficult for the bundler to detect if it can be shaken out.

// I may be wrong in the exact details of the current proposal
// component.js
let components = [];
export function component(Class, { kind, name }) {
  components.push({ Class, kind, name });
}
export function getComponent() { return components; }

// HelloWorld.js
import {component} from './component'
@component
class HelloWorld {
  render() { /* ... */}
}

This is not tree-shakeable. If someone does import "HelloWorld";, the class HelloWorld it would become part of the bundle.

But annotations are just the opposite, annotations never have side-effects because annotations are comments that you can retrieve if you have the original symbol.

// Example of hypotetical annotations
// HelloWorld.js
@{{ isComponent: true }}
class HelloWorld {
  render() { /* ... */ }
}

// component.js
function boolean isComponent(Class) {
  return AnnotationsMetadata.get(Class).isComponent === true;
}

This is tree-shakeable. If someone does import "HelloWorld"; it would have no effect, and do not become part of the bundle, unless someone explicitly import { HelloWorld } from './HelloWorld' and use HelloWorld.

Decorators are nice, but they are not a superset of annotations. Just different. IMHO.

Sorry for the past, I misunderstood the comment from @littledan because he spoke only about metadata and types, but not about tree-shaking. I thought that it was another subject clear enough.

ljharb commented 2 years ago

Decorators primarily have effects, so i don’t think they could ever be tree-shaken.

drpicox commented 2 years ago

The problem is not tree-shake the decorators. The problem is tree-shake the decorated thing.

Without decorators, if you have two symbols, but only import one, the second is tree-shaked. But with decorators, if you have two decorated symbols, and only import one, the second cannot be tree-shaked.

The compiler cannot discard anything decorated because it could be added to a global list.