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

Normative: Add initializer callback for side effects #165

Merged
merged 7 commits into from
Nov 8, 2018
Merged

Conversation

littledan
Copy link
Member

@littledan littledan commented Oct 30, 2018

Many decorators will want to perform a side effect when instantiating
a class, for example:

  • To select [[Set]] rather than [[DefineOwnProperty]] semantics for
    defining a field
  • To store the field in an entirely different place, neither an ordinary
    property or private field (e.g., MobX stores some fields in a Map)
  • To register the instance under construction in some way (e.g., from
    a class decorator). "Instance finishers" have been proposed for this
    purpose, but it's not clear when to run such a finisher. For many use
    cases (e.g., interacting with the DOM), an instance "starter" is just
    as good, due to run-to-completion semantics.

This patch adds a third decorator descriptor kind of "initializer" to register
an initializer to run, interspersed with field initializers.

This PR depends on #163 and #164

Fixes #44

@littledan
Copy link
Member Author

Copy link
Member

@ljharb ljharb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If key is null and any placement is provided, that looks to me like programmer error. Could we instead throw when a placement is provided unnecessarily, just like we throw in #30?

@littledan
Copy link
Member Author

@ljharb I agree that error checking in general should be strict where there's no reasonable meaning, but in #57 I think we settled on not artificially restricting expressiveness where there is something that could make sense. That's the principle that I'm trying to follow here.

@ljharb
Copy link
Member

ljharb commented Oct 30, 2018

How does providing a placement for “no property” make sense?

@littledan
Copy link
Member Author

littledan commented Oct 30, 2018

@ljharb It affects when it runs and what the receiver is. The typical usage will be to pass "own", so it's called on instances during instance construction. Not providing a placement is an error in this patch.

@rdking
Copy link

rdking commented Oct 30, 2018

Isn't this trying entirely too hard to avoid the constructor? This is very quickly approaching the "magic" annotations of Java. Adding repeatable nonsense to the list of properties just to trigger side effects can:

  1. make tooling more difficult to write since it would have to understand null as a "special" property name where currently {[null]:1} is perfectly valid (albeit weird) code.
  2. confuse developers since multiple properties of the same name appear to be being defined on the object
  3. be easier done and easier to understand just by using the tried and true constructor.

@ljharb
Copy link
Member

ljharb commented Oct 30, 2018

@littledan gotcha, that makes sense then - thanks.

@littledan
Copy link
Member Author

@rdking

  1. For the {[null]:1} case: Note that the system will have already turned it into "null" by the time it reaches the decorator, so no loss in expressivity.
  2. This patch doesn't affect that property; you can have duplicate (ordinary) field and method declarations without this patch (but it's not really useful, given how coalescing works)
  3. The purpose here is to help frameworks or similar things insert logic into the path. Lots of people have run into similar problems, which led to decorators in general, and in particular this PR.

@jridgewell
Copy link
Member

I like the idea, but it seems a bit strange to have a field name provided to the decorator for it to just throw it away. How hard would it be to have a decorator without a name, just a init?

class Ex {
  @dec x = 1
  // no init
  @sideEffect ;

  // or some init
  @sideEffect this.x = 2; // Option A
  @sideEffect { this.x = 2 }; // Option B
}

@rdking
Copy link

rdking commented Oct 30, 2018

@jridgewell That makes more sense visually, but what would the decorator receive? Is the side effect independent of the field it's supposed to act against?

@rdking
Copy link

rdking commented Oct 30, 2018

Better question, if the decorator is just supposed to trigger some side-effect without regard to any particular field, then why isn't it a class-level decorator?

@littledan
Copy link
Member Author

@jridgewell I am not sure if we need that syntax extension. The hope here is that these side effects will be used by semantically field-like declarations, or added by a class decorator. Do you have a use case in mind for those other forms?

@jridgewell
Copy link
Member

I guess not, it was just my first reaction.

@littledan
Copy link
Member Author

I probably didn't explain the use cases very clearly in the above commit message. As an example, you could make a decorator to give a field declaration [[Set]] semantics, so the following would work:

class MyComponent extends HTMLElement {
  @set onclick = (event) => { /* ... */ }
}

As the following:

function set(desc) {
  assert(desc.placement === "own");
  assert(desc.kind === "field");
  assert((typeof desc.key)[0] === "s");  // symbol or string
  assert(desc.descriptor.configurable);
  assert(desc.descriptor.writable);
  assert(desc.descriptor.enumerable);
  return {key: null, placement: "own", descriptor: { },
          initializer() { this[desc.key] = desc.initializer.call(this) } };
}

@nicolo-ribaudo
Copy link
Member

Instead of key: null, I think that kind: "initializer" would be better. An "initializer" must have the placement and initializer fields, and it throws if it has key or descriptor. Reusing existing kind: "field" to declare something that is not a field (since it doesn't get defined anywhere) seems like unnecessary overloading.

@littledan
Copy link
Member Author

@nicolo-ribaudo That suggestion sounds totally reasonable to me. Want to write it up in a replacement PR?

@nicolo-ribaudo
Copy link
Member

I can't probably do it this week since I don't have my PC, but if no one does it first I hope I will be able to do it next week.

Many decorators will want to perform a side effect when instantiating
a class, for example:
- To select [[Set]] rather than [[DefineOwnProperty]] semantics for
  defining a field
- To store the field in an entirely different place, neither an ordinary
  property or private field (e.g., MobX stores some fields in a Map)
- To register the instance under construction in some way (e.g., from
  a class decorator). "Instance finishers" have been proposed for this
  purpose, but it's not clear when to run such a finisher. For many use
  cases (e.g., interacting with the DOM), an instance "starter" is just
  as good, due to run-to-completion semantics.

This patch permits decorators to create elements of the form
  { kind: "initializer", placement: "own", initializer: fn }
to be used purely for their side effect, with evaluation semantics
and ordering identical to fields, and interspersed with them in
evaluation order.
@littledan
Copy link
Member Author

Updated to include kind: "initializer" as recommended by @mweststrate and @nicolo-ribaudo . Documentation in the explainer still needed (anyone interested?). Reviews welcome!

Copy link
Member

@nicolo-ribaudo nicolo-ribaudo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I don't remember its name right now, but there is a function which checks that there aren't duplicated keys which probably should be updated to handle ElementDescriptors without [[Key]].
  2. Maybe we should throw if the initializer property of a { kind: "initializer" } element is undefined, since it would be almost certainly a programming error?

spec.html Show resolved Hide resolved
spec.html Outdated
@@ -689,7 +720,7 @@ <h1>DecorateConstructor ( _elements_, _decorators_ )</h1>
1. Append _elementsAndFinisher_.[[Finisher]] to _finishers_.
1. If _elementsAndFinisher_.[[Elements]] is not *undefined*,
1. Set _elements_ to the concatenation of _elementsAndFinisher_.[[Elements]] and _privateElements_.
1. If there are two class elements _a_ and _b_ in _elements_ such that _a_.[[Key]] is _b_.[[Key]] and _a_.[[Placement]] is _b_.[[Placement]], throw a *TypeError* exception.
1. If there are two class elements _a_ and _b_ in _elements_ such that _a_.[[Kind]] is not `"initializer"` and _b_.[[Kind]] is not `"initializer"`, _a_.[[Key]] is _b_.[[Key]], and _a_.[[Placement]] is _b_.[[Placement]], throw a *TypeError* exception.
Copy link
Member

@nicolo-ribaudo nicolo-ribaudo Nov 2, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be made more readable? (I don't know if it valid from a spec-grammar point of view)

1. If there are two class elements _a_ and _b_ in _elements_ such that all the following conditions are true:
  1. _a_.[[Kind]] is not `"initializer"`
  1. _b_.[[Kind]] is not `"initializer"`
  1. _a_.[[Key]] is _b_.[[Key]]
  1. _a_.[[Placement]] is _b_.[[Placement]]
1. Then throw a *TypeError* exception.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, done

spec.html Outdated
1. Let _initializer_ be ? Get(_elementObject_, `"initializer"`).
1. Let _elements_ be ? Get(_elementObject_, `"elements"`).
1. If _elements_ is not *undefined*, throw a *TypeError* exception.
1. If _kind_ not `"field"`,
1. If _kind_ not `"field"` or `"initializer"`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo: "If kind is not"

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

spec.html Outdated
<ul>
<li>_element_.[[Key]] absent.</li>
<li>_element_.[[Descriptor]] is absent.</li>
<li>_element_.[[Placement]] is `"own"`.</li>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might consider allowing "static" as the decorator way of doing https://github.com/tc39/proposal-class-static-block

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree; static should be allowed here since they also can have initializers

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see this as a way towards a static block. This is just a way that a decorator can add extra code to run. But decorators are already able to run code at the end of when a class is executing, in the finisher callback. We could allow this out of consistency (it would have slightly different timing from finishers), but I don't see a use case.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A use case is the same as for "own" - being able to invoke a setter on a superclass (in this case, on a superclass's constructor that had, say, static set foo(v) {})

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ljharb What would be the problem with using a finisher for that?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Certainly you could use a finisher for that - but then you'd be forced to use a class-level decorator to change the semantics of a specific field, so instead of:

class C {
  @useSet static a = x;
  static b = y;
  @useSet static c = z;
}

you'd need to have:

@useSet('a')
@useSet('c')
class C {
  static a = x;
  static b = y;
  static c = z;
}

or:

@useSet('a', 'c')
class C {
  static a = x;
  static b = y;
  static c = z;
}

both of which are decidedly less ergonomic and elegant.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see why that would be the case. You can use a finisher on a static field declaration too.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ljharb, wait, but you can use finisher with a field or a method decorator. It is not required to use class-level decorators to activate finishers.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ahh, that wasn't clear to me. In that case - instead of this "initializer" approach, is there a reason not to allow finishers on "own" fields, to provide an "instance finisher"?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ljharb yeah, I came to the same idea in my comment below :)

@Lodin
Copy link
Contributor

Lodin commented Nov 2, 2018

@littledan I have a concern about finisher and initializer field. They kinda duplicate each other. E.g. if initializer fields can be static it almost removes profit finisher brings. If they cannot, it looks strange: why they cannot do it?

Then I came to a thought: why not to do the constructor callback that works exactly as finisher, but during class instantiation? It would reduce not only amount of code that is necessary to describe the logic, but would also be clearer and obvious to use. Or am I missing something?

spec.html Outdated
1. Let _elements_ be ? Get(_elementObject_, `"elements"`).
1. If _kind_ is `"initializer"`,
1. If _key_ is not *undefined*, throw a *TypeError* exception.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this check happen right after Let _key_ be ? Get(_elementObject_, "key")?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I previously tried to sort the validations towards the end, but I can do them mixed inline if you prefer.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like in general we strive to have validations occur as soon as possible, to avoid unnecessary observable behavior.

@littledan
Copy link
Member Author

littledan commented Nov 2, 2018

@Lodin I agree that there's overlap between initializers and fields; that's why I started off this PR by saying it was a field with a null key. I also agree that static initializers are not extremely interesting; that's why this patch omits them. A constructor callback could be a fine way to add additional behavior to a descriptor that's already there, but if you want the only behavior to be running the callback (as in the @set example), I'm not sure how we'd make it work.

@Lodin
Copy link
Contributor

Lodin commented Nov 2, 2018

@littledan Oh I see. My bad, started to read, caught the idea and immediately wrote it. Thanks for explanation.

Well, then, maybe decorators don't need finishers? Initializer fields could be used instead.

@littledan
Copy link
Member Author

Finishers are something different, for when the class itself is done being created. They are useful for instance elements as well, e.g., to attach runtime metadata.

@littledan
Copy link
Member Author

littledan commented Nov 4, 2018

To @ljharb 's point on the overlap between finishers and initializers: This patch ignores the return value of initializers, but a finisher which returns non-undefined can replace the constructor. The other issue is a subtle one about ordering: All the finishers run after all the field declarations. If we were to try to have initializers subsume finishers, as @ljharb seems to be suggesting above, we'd make the following changes:

  • Permit both instance and static initializers (and prototype, why not?)
  • Run the initializers after the field declarations (so it's a three-step process: methods, fields, initializers)
  • Use the return value for the initializer to replace the object under construction (whether it's the instance or the constructor--or maybe we would ban instance and prototype initializers returning non-undefined?)

(Should we rename initializer to finisher? It's a bit confusing to juggle the term initializer for what this PR introduces and initializer for what a field has.)

@rbuckton
Copy link
Collaborator

rbuckton commented Nov 4, 2018

As an example, you could make a decorator to give a field declaration [[Set]] semantics, so the following would work: […]

I still argue that a field should have [[Set]] semantics by default. That said, do we really need this if you can do constructor replacement in a finisher?

@ljharb
Copy link
Member

ljharb commented Nov 4, 2018

Regardless of whether Set or Define was the default, I think you'd want to be able to use a decorator on a single field to have the other semantic, without having to replace the entire constructor and deal with the .constructor and prototype chain difficulties there in.

@littledan
Copy link
Member Author

Let's keep the Set vs Define discussion in the class fields repo.

@rbuckton I don't think you would even need constructor replacement for it, just the ability to perform a side-effecting operation (which you would get either from finishers or static initializers).

The above post is about, if we want to try to replace finishers with initializers (as @ljharb suggested above), then how would we do it.

Do you see any part of the above plan that wouldn't provide a replacement for finalizers while enabling the kind of decorator described in the commit message? Would it be too complicated, or unergonomic?

@rbuckton
Copy link
Collaborator

rbuckton commented Nov 4, 2018

I am very concerned this will further muddy the waters for the decorators proposal. I think we would be better served to consider this as a follow-on proposal rather than adopt it as part of the current proposal.

In addition I feel it hijacks the semantics of initializer, since for the case where key is not null, the initializer return value is important, while when key is null, the initializer return value is ignored. It very-much feels like we are mixing metaphors and that this could be a source of confusion.

If we were to add this, I would be more willing to accept something like finisher for instances that runs when the current class constructor is evaluated, though not the previously proposed "instance finisher" semantics that waited to evaluate instance finishers until after all constructors (including subclasses) were run.

@littledan
Copy link
Member Author

littledan commented Nov 4, 2018

@rbuckton Are you talking about this PR or the plan in #165 (comment) ? The later comment would be like your last paragraph, except the "instance finisher" would run just after all the field initializers and before the constructor body.

@nicolo-ribaudo
Copy link
Member

nicolo-ribaudo commented Nov 4, 2018

How could an instance finisher work in a nice way? I'm trying to understand it using static fields/class finishers, but I'm struggling to implement even simple thigns (replace [[Define]] with [[Set]]): how can I replace a field with a finisher?

This works, but it puts the decorator and the field in different places:

@useSetOn("foo")
class A extends Base {
  static foo = 2;
}

function useSetOn(name) { return _useSetOn.bind(null, name); }
function _useSetOn(name, descriptor) {
  let index = descriptor.elements.findIndex(el => el.key === name);
  let { initializer } = descriptor.elements[index];
  descriptor.elements.splice(index, 1);
  descriptor.finisher = Class => {
    Class.foo = initializer.call(Class);
  };
}

demo

This doesn't work (it throws):

class A extends Base {
  @useSet static foo = 2;
}

function useSet(descriptor) {
  let { initializer, key } = descriptor;
  return {
    finisher(Class) {
      Class[key] = initializer.call(Class);
    }
  };
}

demo


Btw, with finishers instead of initializers this would never work:

class A {
  @useSet foo = 2;
  bar = this.foo;
}

@ljharb
Copy link
Member

ljharb commented Nov 4, 2018

I was envisioning a field decorator able to run an instance finisher as well, to avoid that very problem.

@nicolo-ribaudo
Copy link
Member

nicolo-ribaudo commented Nov 4, 2018

I used class finishers with static fields, but if we had instance finishers I could ask the same question for instance field.

@littledan
Copy link
Member Author

@nicolo-ribaudo This is a good point. It seems like I was being too optimistic about one thing subsuming the other. Maybe we should just stick with the own-only initializers proposed in this PR.

@ljharb
Copy link
Member

ljharb commented Nov 4, 2018

If finishers aren’t a solution, then I’m still not sure why this PR wouldn’t cover both own and static.

@nicolo-ribaudo
Copy link
Member

A possible fix to my point would be to make finishers be declared like normal element descriptors, similarly to the initializers proposed in this PR. The decorator would then become

function useSet(descriptor) {
  let { initializer, key } = descriptor;
  return {
    kind: "finisher",
    placement: "static",
    finisher(Class) {
      Class[key] = initializer.call(Class);
    }
  };
}

However, if I had to choose I'd still prefer initializers, since they allow me to keep the correct ordering in cases like

class A {
  @useSet foo = 2;
  bar = this.foo;
}

@nicolo-ribaudo
Copy link
Member

@rbuckton

In addition I feel it hijacks the semantics of initializer, since for the case where key is not null, the initializer return value is important, while when key is null, the initializer return value is ignored. It very-much feels like we are mixing metaphors and that this could be a source of confusion.

If key is null, the initializer's return value isn't discarded but it is used to initialize a "null" property:

class A {
  @dec foo = 2;
}

function dec(desc) {
  return {
    ...desc,
    key: null
  }
}

(new A)[null]; // 2

Also move error checks in ToElementDescriptor to happen ASAP
@littledan
Copy link
Member Author

@ljharb @nicolo-ribaudo Oh, right. Thanks for bearing with me. The most recent commit permits initializers to be static (or prototype, since why not), and doesn't make any of the other changes mentioned, since we've deduced upthread that they don't make sense. Thoughts?

@littledan
Copy link
Member Author

One question remains: Should we look at the return value of the initializer callback, and throw an exception if it's not undefined? This could help avoid programmer errors of the kind @rbuckton may be thinking of, and it could make the non-undefined return value a possible future extension point.

@nicolo-ribaudo
Copy link
Member

One question remains: Should we look at the return value of the initializer callback, and throw an exception if it's not undefined? This could help avoid programmer errors of the kind @rbuckton may be thinking of, and it could make the non-undefined return value a possible future extension point.

👍 Decorators, even being as powerful as possible, should be as strict as possible to avoid as many potential errors as possible.

spec.html Outdated
@@ -563,7 +617,7 @@ <h1>Element Descriptors</h1>
<p>An <dfn>element descriptor</dfn> describes an element of a class or object literal and has the following shape:</p>
<pre><code class=typescript>
interface ElementDescriptor {
kind: "method" or "field"
kind: "method", "initializer" or "field"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
kind: "method", "initializer" or "field"
kind: "method", "initializer", or "field"

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@@ -689,7 +743,12 @@ <h1>DecorateConstructor ( _elements_, _decorators_ )</h1>
1. Append _elementsAndFinisher_.[[Finisher]] to _finishers_.
1. If _elementsAndFinisher_.[[Elements]] is not *undefined*,
1. Set _elements_ to the concatenation of _elementsAndFinisher_.[[Elements]] and _privateElements_.
1. If there are two class elements _a_ and _b_ in _elements_ such that _a_.[[Key]] is _b_.[[Key]] and _a_.[[Placement]] is _b_.[[Placement]], throw a *TypeError* exception.
1. If there are two class elements _a_ and _b_ in _elements_ such that all of the following are true:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads a bit awkwardly; maybe it should be in an abstract operation?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is following @nicolo-ribaudo 's suggestion above. I think the factoring might be awkward too; let's work out these editorial issues during Stage 3.

spec.html Outdated
@@ -781,26 +842,38 @@ <h1>ToElementDescriptor ( _elementObject_ )</h1>
<emu-alg>
1. Assert: _elementObject_ is an ECMAScript language value.
1. Let _kind_ be ? ToString(? Get(_elementObject_, `"kind"`)).
1. If _kind_ is not one of `"method"` or `"field"`, throw a *TypeError* exception.
1. If _kind_ is not one of `"initializer"`, `"method"` or `"field"`, throw a *TypeError* exception.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
1. If _kind_ is not one of `"initializer"`, `"method"` or `"field"`, throw a *TypeError* exception.
1. If _kind_ is not one of `"initializer"`, `"method"`, or `"field"`, throw a *TypeError* exception.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

1. If _kind_ is `"method"`,
1. If _initializer_ is not *undefined*, throw a *TypeError* exception.
1. Let _elements_ be ? Get(_elementObject_, `"elements"`).
1. If _elements_ is not *undefined*, throw a *TypeError* exception.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this use a HasProperty, instead of a Get, since it’s just checking existence? Or is the intention to allow an explicit undefined property - and if so, that seems like it’d be an error, so why allow it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Get matches the use of Get when actually using the elements in a class decorator, per POLS.

@littledan
Copy link
Member Author

Any further concerns with this patch? @rbuckton It's unclear to me whether we've addressed your concerns.

In my opinion, it's important to get this change in Decorators v1, because I don't want an idiom of "throwaway fields" (used for their side effect) to emerge. I believe it would be very difficult for implementations to optimize away the storage space used in throwaway fields, so I don't want to encourage it in the ecosystem. These have been recommended in various issue threads, and the need for a side effect in this path has been known for more than a year.

@littledan littledan changed the title Normative: Permit null-keyed fields for side effects Normative: Add initializer callback for side effects Nov 5, 2018
@littledan
Copy link
Member Author

OK, given the all-around positive reviews, merging this PR. We'll review it in the November 2018 TC39 meeting, and revert or revisit if necessary. Thanks for the help, everyone!

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

Successfully merging this pull request may close these issues.

8 participants