-
Notifications
You must be signed in to change notification settings - Fork 2k
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
[CS2] Restore bound class methods via runtime check to avoid premature calling of bound method before binding #4561
[CS2] Restore bound class methods via runtime check to avoid premature calling of bound method before binding #4561
Conversation
src/nodes.coffee
Outdated
@@ -1362,6 +1365,9 @@ exports.Class = class Class extends Base | |||
@ctor = method | |||
else if method.isStatic and method.bound | |||
method.context = @name | |||
else if method.bound | |||
@boundMethods.push method.name | |||
method.constructorName = @name |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Attach the class name (so that it can be passed as an argument to the runtime check in the method body), not sure if this is already available to the method
somehow?
Removed method.bound = false
, as now it needs to know about its bound-ness when compiling itself
src/nodes.coffee
Outdated
@@ -1399,7 +1405,8 @@ exports.Class = class Class extends Base | |||
method.name = new (if methodName.shouldCache() then Index else Access) methodName | |||
method.name.updateLocationDataIfMissing methodName.locationData | |||
method.ctor = (if @parent then 'derived' else 'base') if methodName.value is 'constructor' | |||
method.error 'Methods cannot be bound functions' if method.bound | |||
method.error 'Cannot define a constructor as a bound function' if method.bound and method.ctor | |||
# method.error 'Cannot define a bound method in an anonymous class' if method.bound and not @name |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For illustration, this would be option (1) (disallow bound methods in anonymous classes) of the three options I outlined for dealing with anonymous classes with bound methods
src/nodes.coffee
Outdated
@@ -2140,6 +2154,9 @@ exports.Code = class Code extends Base | |||
wasEmpty = @body.isEmpty() | |||
@body.expressions.unshift thisAssignments... unless @expandCtorSuper thisAssignments | |||
@body.expressions.unshift exprs... | |||
if @isMethod and @bound and not @isStatic and @constructorName |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The addition of the actual runtime check (using new _boundMethodCheck
helper). Currently deals with anonymous classes via option (2) (forgo the runtime check for anonymous classes) with and @constructorName
src/nodes.coffee
Outdated
@@ -3097,6 +3114,13 @@ exports.If = class If extends Base | |||
|
|||
UTILITIES = | |||
modulo: -> 'function(a, b) { return (+a % (b = +b) + b) % b; }' | |||
_boundMethodCheck: -> " |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
helper that throws runtime error if bound method called with a this
that isn't an instance of its parent class
as discussed in coffeescript6/discuss#84, this results in a stack trace where the offending method is in the second line
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a reason this helper’s name starts with an underscore? Our other helpers’ names don’t.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@GeoffreyBooth I think @connec was mimicking babel's _classCallCheck
. Updated to boundMethodCheck
test/classes.coffee
Outdated
@@ -1574,3 +1670,76 @@ test "CS6 Class extends a CS1 compiled class with super()", -> | |||
eq B.className(), 'ExtendedCS1' | |||
b = new B('three') | |||
eq b.make(), "making a cafe ole with caramel and three shots of espresso" | |||
|
|||
test 'Bound method called as callback before binding throws runtime error', -> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
all the new tests are here, including tests that the runtime error actually occurs for the edge case and that bound methods work ok in other situations. Quite possible there are some other scenarios that should be tested, if explained I'll add more tests
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Checking for errors goes in error_messages.coffee
. Not sure why, we should probably refactor them out of there into the relevant test files, but for now that’s where all such tests go.
@GeoffreyBooth ok updated (based on your suggestion) to run the check against the parent class (and only run it for child class bound methods). Had to get a little fancy with making sure that bound methods can properly reference the base class (which could be an arbitrary |
src/nodes.coffee
Outdated
@@ -1362,6 +1380,10 @@ exports.Class = class Class extends Base | |||
@ctor = method | |||
else if method.isStatic and method.bound | |||
method.context = @name | |||
else if method.bound | |||
@boundMethods.push method.name | |||
@setParentRef(o) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if we have any bound methods, see if the parent class expression should be cached to a variable ref and save the (possibly updated) parent on method
so that it can be used in the runtime check
src/nodes.coffee
Outdated
o.indent += TAB | ||
|
||
result = [] | ||
if @assignParentRef |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if we have bound methods and the parent class expression should be cached (as determined by setParentRef()
), turn the class expression into a (ref = parent.class().expression, class Child extends ref {...})
-style expression
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Whilst this is a nice way of doing (so I'm not suggesting changing it), but the .cache
mechanism exists for this purpose, and could be used to generate code like:
class Child extends (ref = parent.class().expression) {}
src/nodes.coffee
Outdated
@@ -2140,6 +2169,9 @@ exports.Code = class Code extends Base | |||
wasEmpty = @body.isEmpty() | |||
@body.expressions.unshift thisAssignments... unless @expandCtorSuper thisAssignments | |||
@body.expressions.unshift exprs... | |||
if @isMethod and @bound and not @isStatic and @parentClass |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the runtime check, basically _boundMethodCheck(this, parentClassRef)
. Will only be added for child class bound methods
src/nodes.coffee
Outdated
@@ -1399,7 +1421,7 @@ exports.Class = class Class extends Base | |||
method.name = new (if methodName.shouldCache() then Index else Access) methodName | |||
method.name.updateLocationDataIfMissing methodName.locationData | |||
method.ctor = (if @parent then 'derived' else 'base') if methodName.value is 'constructor' | |||
method.error 'Methods cannot be bound functions' if method.bound | |||
method.error 'Cannot define a constructor as a bound function' if method.bound and method.ctor |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In error messages, let’s use the phrasing “bound (fat arrow) function”. MDN calls =>
functions “arrow functions,” so that’s what most people will probably know them as. I think we should be explicit about “fat” arrow to distinguish from ->
.
src/nodes.coffee
Outdated
proxyBoundMethods: (o) -> | ||
@ctor.thisAssignments = for name in @boundMethods by -1 | ||
name = new Value(new ThisLiteral, [ name ]).compile o | ||
new Literal "#{name} = #{name}.bind(this)" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rather than new Literal
, shouldn’t this be new Assign
etc.?
src/nodes.coffee
Outdated
boundMethodCheck: -> " | ||
function(instance, Constructor) { | ||
if (!(instance instanceof Constructor)) { | ||
throw new Error('Bound instance method accessed before binding') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add semicolon.
test/classes.coffee
Outdated
@@ -1574,3 +1670,76 @@ test "CS6 Class extends a CS1 compiled class with super()", -> | |||
eq B.className(), 'ExtendedCS1' | |||
b = new B('three') | |||
eq b.make(), "making a cafe ole with caramel and three shots of espresso" | |||
|
|||
test 'Bound method called as callback before binding throws runtime error', -> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Checking for errors goes in error_messages.coffee
. Not sure why, we should probably refactor them out of there into the relevant test files, but for now that’s where all such tests go.
@connec and @ryansolid, do you mind reviewing? |
@GeoffreyBooth ok pushed changes based on your comments |
@helixbass Please merge |
@GeoffreyBooth ok merged |
@ryansolid, does this PR solve your issue? @connec, do you mind reviewing? I think as soon as this gets merged in we can release a new beta, unless you think #4493 is close enough to be worth waiting for. All tests pass. |
@GeoffreyBooth I'll take a look this evening, all going well. I think it would be worth waiting for #4493, once the tests are in it should be pretty much there. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a clean patch that LGTM!
I have a small concern, but I don't think it should hold up a merge since it does fix the problem.
Do we think of this change as setting guarantees for the bound method? If so, the guarantee that "you'll be called with a context that is, at least, an instanceof
the parent class" isn't anywhere near as valuable as "you'll be called on an instanceof
the class". I suspect we could deal with anonymous classes by assigning it to a generated variable and using that in the check.
If we don't think of it as a guarantee to the method, but instead, as was the purpose of the original issue, as a safeguard against accidentally invoking a bound method before binding, then maybe we shouldn't perform the instanceof
check at all, and simply check this != null
. That would still satisfy the tests, whilst only opening up the number of cases where you could .call
/.apply
/.bind
the prototype method (and cutting out the need for the parentRef
shenanigans).
I reckon if we felt it was the latter, we could change it in this PR. If we think it's the former, bringing the check back to the actual class could be a later iteration.
FWIW my current feeling is to remove the instanceof
check, and revisit it later if for some reason people are writing code where they want the guarantee of the right instance.
@connec You raise a good point. Wouldn’t checking just for I thought there is no anonymous class issue, since it’s not possible to |
Honestly I didn't follow that discussion very well. My understanding was that was why the check switched to the parent class (because there's no usable reference for the class Base
constructor: ->
@boundMethod()
new class extends Base
boundMethod: =>
console.log 'bound!' In that case there's no reference to use for the But yeh, checking for |
Well switching to checking for |
Yup! |
@connec so if a not-yet-bound method is run in a callback style (in any environment), If that's true, then your suggestion and thinking make sense to me, as the |
This sounds very speculative. I think unless you can come up with a realistic example, we should probably limit it to the scenario in question. We can always expand it later. |
My concern is mostly about the inherent ugliness of the thing. If we had always had to insert such a check — we never would have added bound methods in the first place. CS2 is an opportunity to take them out. Leaving them in (most of the time) with a nasty runtime check to make sure that you're not running into a catch-22 seems like a half-assed implementation. Seems like we can do better. But if this is the best option... |
If the runtime check is deemed inappropriate on philosophical/perf/... grounds, I'll argue again that leaving bound methods as-is (with documentation of the breaking change) is better than removing bound methods. I still think it's theoretically equivalent to the
So in theory I'd say just document it the same way as however you're documenting the However I think the use case that started the discussion (things like Backbone Removing bound methods would be a hugely-breaking change for the sake of a little elegance. I'll again invoke Python 3 as a very clear warning against doing this. While I respect the feeling that bound methods might've never been added in the first place had this been the initial situation, CS2 is not a new "initial situation", it bears the weight of all the code written under 1.x So for bound methods that leaves me weighing the runtime check vs just warning about possibly subtly breaking code (hand-in-hand with a similar warning about |
Sorry for not being clear, @GeoffreyBooth, that was my suggestion all along 😅 I had another read of coffeescript6/discuss#84 and there was a proposal that would avoid runtime checks by compiling class Child extends Parent {
constructor () {
super(...arguments)
this.method = () => {
return console.log(this)
}
}
method () {
throw new Error('Bound instance method accessed before binding')
}
} It would possibly be acceptable even to drop the prototype method completely, effectively bring CS2's bound methods in-line with ES's Stage 2 public fields proposal. |
That’s remarkably elegant. I feel like there must be a catch. |
The fact that it doesn't work with |
Yet another option could be to rename the prototype method (leaving it class Child extends Parent {
constructor () {
super(...arguments)
this.method = this.method$.bind(this)
}
method$ () {
return console.log(this)
}
} This is a simplified version of this proposal without the runtime check. This behaviour would be equivalent to that of |
That looks ridiculous. If I saw that as my output I would wonder what the hell had happened. I suppose instead of Okay, so unless there’s anything else, can we merge this in? @jashkenas I’m sorry for the inelegance, but this appears to be the least offensive solution to the problem. If a better solution can be found, it can become a new PR. I don’t think backward compatibility should be sacrificed for the sake of elegance. |
I'd add the method renaming does havok to trying to resolve prototype chains. That proposal only worked because the properly named method is on the prototype too and all it was accomplishing was removing calling the check once the method was bound. However once you call super on an inherited bound method it ends up calling that function up the prototype chain which essentially lands us at this PR. If we were ok breaking inheritance atleast super could work assuming you couldn't inherit bound methods. I'm sure there is a way for this check to disappear post binding for inheritance chains that are less than 3 deep but I doubt it's worth the complexity. So while I'm in full support of this PR (or leaving it largely as was pre #4530), if there was a directional precedence that made that impossible, I think we should go with an approach that keeps bound methods but makes them not inheritable like Typescript does. But that would require having super atleast work in bound methods. But even that would still be a significant breaking change which we are trying to avoid. So obviously very happy to just merge this PR and deal with that separately if needed. |
I think I agree with @helixbass and @ryansolid that just leaving bound methods in, and documenting the breakage is a bit better than a nasty runtime check — but I'll defer to @GeoffreyBooth and @connec's call on this. |
I'd add that ES6 compilation (whatever it's downsides) would seem like a better way to go for CS2 than this workaround. CS1 > ES6 > this PR |
@jashkenas The problem is that we’ve already received bug reports for the “just leave them in” approach, which was released in 2.0.0-beta1. Granted we hadn’t documented the pitfalls, but I think it’s much safer for the community at large if we add the check; I would rather get complaints about “what’s this random helper I didn’t want in my code” than “my classes don’t work in CS2.” I can’t imagine too many CoffeeScript users really understand the ins and outs of prototypes and class compilation, and the differences in how CS2 classes are generated versus CS1. We can always remove the check later if we feel confident that it’s not needed. @xixixao I don’t understand your comment. In CS2 we are outputting ES classes. ES classes don’t support bound methods, hence the question of what to output in their place. @connec Aside from philosophical debate, is this PR ready to merge in? |
@GeoffreyBooth I meant ESWhateverVersionThatSupportsThis, Babel: var A = function A() {
var _this = this;
this.f = function () {
return _this.x;
};
}; Also you are still gonna keep getting "my classes don't work" tickets with this PR, it's just gonna be clear why they don't work. This PR doesn't change what you can and cannot do, it just makes the error obvious. |
@xixixao There is an idiomatic approach of writing every non-constructor or non-static class method as fat arrow bound so that regardless of how it was used you could set and forget and have it always bound to your class. CS1 let you do this, and I'm sure it's a practice used religiously by atleast a few dev shops since it aligns with how most OOP languages work when it comes to class methods which was really the only thing to serve as basis if you go back a few years. From my understanding that ES8 or maybe never proposal doesn't support both inheriting from bound methods or binding inherited methods and calling super. Both really change things. The first for library/framework writers since they would need to remove all bound methods from their inheritable classes. Any inherited class code that was expecting this behavior could then also be broken. The second definitely breaks any of that application code that wants to bind an inherited method. It's arguable whether most of these methods actually need to be bound. It's possible to wrap any inherited method inside these new bound methods, but they essentially become something different as they are no longer part of the prototype chain. @jashkenas Secondly, having it silently fail I can see being pretty bad. Not bad for those aware I suppose but because it spans over multiple files/classes people might not get what's happening. I mean all the control is really in the child inherited class arguably so it is definitely on the child class writer to do the right thing. But they won't know until it causes weird errors on execution. |
@GeoffreyBooth yes, the PR looks good to me.
|
Thanks @helixbass! @connec, I think there’s just #4493 left before we release 2.0.0-beta3. That PR looks possibly ready. |
@ryansolid Do you want to try compiling your project using the |
Yeah just tried it with a couple sample projects, everything looks good from server through client. Even running through a tight loop rendering benchmark where the check method was present in a few locations the performance loss wasn't that bad about .15 ms per loop. So all in all I'm pretty content with this. Thank you @GeoffreyBooth, @connec, and @helixbass for making this happen. |
Report from the wild: I just had the bound method runtime check kick in on some (new) code where I was dereferencing a bound method off @ryansolid no problem, thanks for working through the alternatives and coming up with a good one. If you're curious about the performance difference if the bound check was inlined, would want to turn off the bound method check for code that you feel sure doesn't need it (eg in production), ... it shouldn't be that hard to roll your own slightly modified build, I'd be happy to help make that happen |
@helixbass I’m trying to write the documentation for this. This is the example I’m thinking of using, based on this test: class Base
constructor: ->
@onClick() # This works
clickHandler = @onClick
clickHandler() # This throws a runtime error
class Component extends Base
onClick: =>
console.log 'Clicked!', @ I’m testing this via |
@GeoffreyBooth it works because it's being called normally (thus with expected |
Ah, okay. So is the documentation correct or would you change anything? |
@GeoffreyBooth ya that example illustrates the basic idea nicely and concisely |
Hello, is there a way to turn this off? I am migrating an older codebase to version 2 of coffescript that is using a |
No, sorry, there are no config options for changing the behavior of the compiler (other than the few we already have, like |
As discussed in coffeescript6/discuss#84 by @ryansolid @connec @GeoffreyBooth: restore bound methods with a runtime check to avoid silent breakage of code that under
2
would call the method before it was bound (ie a base class constructor calling a derived class bound method in a non-this
-preserving way eg as a callback)Much of the code and tests here are restored from pre-#4530, I’ll comment in the code on new stuff I’ve added
As I commented in coffeescript6/discuss#84, there seems to be a question of how to handle anonymous classes (with bound methods). If my suggestion (3) (give anonymous classes with bound methods a name) is deemed the best option, I can try and implement that behavior (instead of currently (2) (forgo the runtime check for anonymous classes))