-
Notifications
You must be signed in to change notification settings - Fork 106
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
Should static initializers see the decorated or undecorated version of the class? #211
Comments
How does TypeScript solve this problem? Finish hooks are supposed to run after the static initializers run (per the Munich 2016 consensus). Do you want to run replacers before static initializers, and finishers afterwards? |
Given the following: @decorator
class C {
static instance = new C();
clone() { return new C(); }
} Typescript emits the following: var __decorate = ...
var C_1;
let C = C_1 = class C {
clone() { return new C_1(); }
};
C.instance = new C_1();
C = C_1 = __decorate([
decorator
], C); Static initializers currently get the undecorated C, while inside the class body we use the decorated C (the If you wanted let _instance;
class C {
static get instance() { return _instance || (_instance = new C()); }
clone() { return new C(); }
} As to whether replacers should run first, that's an interesting question. There are possibly two different desired outcomes, given two different use cases. In one case, for /** replace object with a noop if an environment key is not set */
function conditional(key, value) {
return descriptor => {
if (process.env[key] === value) return;
descriptor.extras = [{ kind: "hook", placement: "static", replace: () => noop }];
};
}
function noop() {}
...
@conditional("NODE_ENV", "debug")
class C {
static instance = new C();
} I wouldn't want each use of |
If the class is replaced, why would the class body (which is potentially no longer in existence) reference the decorated In other words, I think I'd expect the outer |
@replacing
class P {
constructor(x, y) { this.x = x; this.y = y }
add(other) { return new P(this.x + other.x, this.y + other.y) }
}
new P(0, 0).add(1, 1) instanceof P; // false? |
Indeed; if it's going to replace |
I would expect that if i decorate a class with a decorator (and it replaces my class) any reference to that class (inside or out) be the decorated version. |
Hmm, maybe not. |
@ljharb: When we first implemented decorators, we did exactly that (the inner let C = @decoratorWithReplaceHook
class {
copy() { return new C(); }
} While feasible, this is definitely not ergonomic. |
Could we make a replaced class whose body contains a reference to its own identifier binding throw? That way perhaps we could address it with the class access proposal. |
What would be the purpose of that? It seems like it would be better just to replace the binding, rather than make it unusable at all. It would also become a refactoring hazard. Why should adding a decorator to my class require me to completely rewrite it? |
How would we replace the binding in an initializer that’s already been evaluated prior to the replacement decorator? Note I’m not suggesting any decorator - only one that replaces the class. |
That would mean that code that works fine one day could break unexpectedly when a dependency changes to add a "replace" hook. I also don't know that the class access proposal is any kind of solution for this, as it would have the same issue as the lexical binding (in that it itself is also effectively a lexical binding for the class). |
yeah that's a fair point. How would you proposal specifying a way for the replacement to alter the inner binding as well? Defer invocation of static initializers until after all decorators have ran? |
That requires some thinking. I'm likely to be on the side of having static initializers run after replacers, since it would align with how instance initializers would evaluate (as an instance of whatever the decorated class becomes), with the caveat that something like the |
The TypeScript emit (where static initializers run before decorators) is mostly due to the fact that Stage 1 decorators had no concept of "phases" when it comes to decorator evaluation, so you couldn't separate out constructor replacement from "I want to freeze the static side of the class" like you can with hook evaluation. |
Thinking about an example of a replacement decorator... Say you have a react component. The component is decorated with a store connect decorator which composes the component. In this scenario, the result of the decoration is a wrapper component that renders the decorated one. It’s of a completely different heritage (tho it is still descendent of Component) Every HOC decorator pattern i’ve seen proposed (and used) does not expect the class name to be the decorated one. However, I’m not sure if that’s a problem. (unless we cannot get a reference to the replaced class) |
It seems better to me to stick to the actual JS thing of, lexical bindings can't magically change out from under you while you're executing synchronously. In other words: @replacing
class P {
constructor(x, y) { this.x = x; this.y = y }
add(other) { return new P(this.x + other.x, this.y + other.y) }
}
new P(0, 0).add(1, 1) instanceof P === false; I think it makes sense that if I, the class author, want the @replacing
function f() { return f; }
assert(f() !== f);
@replacing
const f = function () { return f; }
assert(f() === f); |
@replacing
function f() { return f; }
assert(f() !== f); That would be incorrect, as function declarations do not declare a lexical binding inside their body: > function f() { console.log("f"); f = function () { console.log("g"); } }
undefined
> f()
f
undefined
> f()
g
undefined
> Function expressions however, do declare a lexical binding inside their body, if they have a name. |
ha, fair enough - so this really is a unique problem to class declarations combined with replacers :-/ I wonder why class declarations deviated from function declarations in this way - it'd have made this problem now much more straightforward :-) |
Agreed, I recall raising this concern within the TypeScript team roughly 5 years ago (that the inner lexical binding for class declarations made no sense), but was not heavily involved in TC39 at the time. Outside of transpiled decorators in an ES6 or later environment, I imagine there are few if any cases where the inner lexical binding would be relevant. In my experience (and judging from the customer feedback that led to TypeScript's current handling of the lexical binding WRT decorators), most developers seem to assume |
Would it be worth pursuing a change to remove the inner binding on a class declaration, or is that likely to be web incompatible? |
We would need data on that. We would still have the same odd behavior WRT named class expressions, however, though it would more closely align with the behavior of function expressions. |
That said, in my experience the inner lexical binding has been more of a curse than a blessing, and any time I've come across a case where the fact the outer and inner binding mattered, it was usually the case that the inner lexical binding was worked around (as in the TypeScript emit above), rather than leveraged. However that's not enough data points for an accurate assessment. |
More examples :) I'm starting to see two kinds of replacements. One is classical where the replacement is a subclass of the decorated, and the other is a completely different thing. (noop or component swap) like so: import React from 'react';
@connect('somekey-in-state-store')
class Foo extends React.Component {
static propTypes = {
data: PropTypes.any // passed from connected wrapper
}
someMethod () {
return Foo;
}
render () {
return (
<div />
);
}
}
assert(Foo.propTypes.data === undefined)
assert(Foo.prototype.someMethod === undefined) |
If a replace hook outputs a new class, it would be surprising if static field initializers saw the old, underlying class. This patch runs replace hooks before static field initializers, in order to provide the expected behavior. Finish hooks still run afterwards, so that they have a view on the entire class. As part of this change, a single hook can have both finish and replace callbacks. Closes #211
We talked about this in the decorators call. Would running replace hooks before static field initializers, and finishers after, solve this issue? This alternative is specified in #232. |
OK, sounds like #232 is a bad idea; thanks to @nicolo-ribaudo for detailed analysis. Additionally, @zenparsing raised an important refactoring hazard for this change. I'll be thinking about how we can do something closer to TypeScript semantics with respect to scoping, since it seems like they hit on a good resolution. Note that there's a lot going on with these bindings, so it will be a little complicated to fix. I don't think we can just remove inner bindings, as they are very important for, e.g., class expressions. I'm also a little uneasy to change the inner binding from immutable to mutable for decorated classes--we only need the system to be able to mutate it, not JS code. At the same time, I imagine if you have an arrow function in a static field initializer, it will eventually see the decorated class. |
From my humble point of view, the decorators are only a way to add functionality orthogonally to a class and therefore everything we can do with decorators can be done with code inserted into in the class body. When a constructor returns an object of another class, the static members of the class are not altered and can be called without problems. class T {
constructor() {
return {a: 1}
}
static m() {
console.log('static method');
}
z() {
console.log('unreachable method')
}
}
T.m();
const t = new T();
console.assert(!(t instanceof T)); With this type of construction we do not say "the class is changed", we understand that the constructor has decided to change the type of the instance of the object that it returns. Analogously, when a decorator do a substitution of the constructor, the class is not changing as such, only is modifyed his constructor. It is possible that constructor returns an object of the class (it is making a wrapper around the constructor) or an object of another class or a function, but the class as such has not been modified, only its constructor has been modified. More precisely, when we say that a decorator substitutes a class we should say that a decorator replaces the constructor of the class. It is a small difference, but the meaning changes substantially. |
@pabloalmunia Interesting. Maybe we should give a way to wrap the constructor's functionality without wrapping the rest of the class. Among what we've discussed concretely, #232 is fairly close to this, but it still exposes more than just the constructor itself. I'm not sure exactly what sort of object we'd expose for the wrapping, though, to be more minimal than #232. I'm also not sure what to do about the expectations that static fields be present on the thing that's being wrapped. |
I've been giving this issue a bit of thought (apologies for the length of this post). It relates to a pretty core use case of decorators: Lots of people want to be able to write a decorator which replaces the entire class. This sort of thing would come up for function decorators as well (where anonymous function expressions have their own immutable inner binding, like classes). @rbuckton suggested above eliminating the inner binding of classes. However, this inner binding has been widely implemented (in V8, JSC, SpiderMonkey, ChakraCore, TypeScript and Babel, going back to Babel 5.8.38 at least), and I'm not sure if it would be ecosystem-compatible (or desirable) to eliminate it. I see two approaches to this problem in transpilers:
Have people been complaining about the Babel behavior? I don't see any issues about this in the appropriate label in the issue tracker, cc @nicolo-ribaudo . Given this, I see three options:
I expect that we'd have a hard time getting consensus in TC39 on either of these proposals in option 3, since they feel a bit like playing fast and loose with JS semantics. It's not nice to make something change underneath your feet when it was previously stable. @nicolo-ribaudo pointed out to be that, if we go with built-in decorators, we will have the opportunity to add multiple built-in decorators, for the semantics of both 1 and 2. I looked into the So, I'd like to reconsider starting with option 1. It seems nice that it's a way to preserve expectations about recursion, and thinking about the function decorator case, it makes a lot of sense there too. |
If the decorator replaces a class with an object or a function.... you would be initializing those static fields on a non-class? is that what i’m reading? |
I think it would be very bizarre to replace the class and then apply OldClass's fields to NewClass by default. |
At least since I started working on decorators (about 1 year ago), we didn't receive any complaint. I also tried to search for older unlabeled issues, but I couldn't find anything. If I had to choose, I'd vote either for option 2 or option "without a number". Adding static fields to the replacement seems wrong: let result =
@replaceWith(class B {})
class A {
static foo = 2;
bar = 3;
} Why should |
Note that TypeScript provides the original class to static initializers, not the replaced one: #211 (comment) What about a
|
Updating the binding is tricky territory, as we'd be making the immutable inner binding into a mutable one. However, this seems to be basically the only way to meet full TypeScript expectations. If people are OK with that, I like @nicolo-ribaudo 's decomposition. Let's start with just |
in the case of the |
@jsg2021 Are you OK with methods inside the class always seeing the original class, before the decorator which replaced the class is applied? |
Yes? I would even go further to say, I would expect/want that. A's statics, as well as fields and such only see A instances. Outsiders see B. Only B can see A. B is not A, nor expected to have any relation to A. B composes A. |
For any coming to this issue late, as I did, let me spell out some things that are all in the above, but scattered here and there and sometimes somewhat assumed. This means that if you have this function: function extendWithNiftyFeature(cls) {
return class extends cls {
niftyFeature() {
console.log("Nifty feature called");
}
}
} (perhaps and you have this @wrap(extendWithNiftyFeature)
class X {
methodOne() {
this.niftyFeature();
}
methodTwo() {
new X().niftyFeature();
}
} then this happens: const x = new X();
x.methodOne(); // "Nifty feature called"
x.methodTwo(); // TypeError: (intermediate value).niftyFeature is not a function If that error seems strange to you (as it did to me at first), it's useful to realize that class X {
methodOne() {
this.niftyFeature();
}
methodTwo() {
new X().niftyFeature();
}
}
X = extendWithNiftyFeature(X);
const x = new X();
x.methodOne(); // "Nifty feature called"
x.methodTwo(); // TypeError: (intermediate value).niftyFeature is not a function The This is consistent with named function expressions, which create an immutable inner binding for the function name "use strict";
let foo = function foo(...args) {
if (args.length === 0) {
console.log("Calling foo(1)");
foo(1);
} else {
console.log(`foo got ${JSON.stringify(args)}`);
}
};
let bar = foo;
foo = function() {
console.log("New foo");
};
bar();
// =>
// Calling foo(1)
// foo got [1] (Function declarations don't create the immutable inner binding, so if it were a declaration, you'd see So the upshot is that having
|
This question remains a hot topic; see #329 for later discussion. Personally, I suspect that this issue makes it hard to support class replacement, though maybe there's some way to decorate just what happens when the constructor runs (rather than the class's identity). |
This has been resolved in the latest version of the proposal, static initializers see the decorated version of the class. |
In https://tc39.github.io/proposal-decorators/#runtime-semantics-class-definition-evaluation, step 34 assigns the binding for className to F in the class body, prior to any
replace
hooks being evaluated in step 36. Is this intended effect? In TypeScript, we addressed feedback that the binding inside the class body would be the decorated class, otherwise you might see issues:However we would also have to address how that would affect static initializers:
Should
instance
be the undecoratedC
or the decoratedC
? If it needs to be the decoratedC
, how do we deal with "finish" hooks that freeze the class?The text was updated successfully, but these errors were encountered: