Bind methods problem
andresilvasantos opened this issue · comments
Hi Francesco!
Thank you for Polytype, it's very well done and carefully explained. 😃
I'm experiencing the following issue, when dealing with binds in base classes.
If I use only extends in this scenario it works as expected.
import { classes } from 'js/polytype'
class TestDefault {
constructor() {
this.print = this.print.bind(this)
console.log('TestDefault constructor')
}
print() {
console.log('TestDefault Print')
}
}
class Test extends classes(TestDefault) {
constructor() {
super()
this.someVar = 6
console.log('Test constructor')
}
print() {
console.log('Test Print', this.someVar)
super.print()
}
}
const test = new Test()
test.print()
This will print:
TestDefault constructor
Test constructor
Test Print undefined
TestDefault Print
Instead of Test Print undefined, it should be be Test Print 6.
I'm guessing the binding only binds the instance of TestDefault.
I've tried to bind again print in Test, but nothing changed.
Do you know what can be done to solve this?
Thank you!
Hi André, thanks for reporting the issue. I'm glad to hear that you like this project.
The problem you noticed boils down to a very odd peculiarity of Polytype, and it's my fault that I haven't documented that yet.
The thing is that this
in a base costructor is not the same this
as in the rest of an object lifecycle.
Consider for example:
let thisA;
let thisB;
class A {
constructor() {
thisA = this;
}
}
class B extends A {
constructor() {
super();
thisB = this;
}
}
new B();
console.log('thisA === thisB', thisA === thisB);
It outputs as expected
thisA === thisB true
But if the declaration of class B is changed to use Polytype like in
class B extends classes(A) { ... }
, then thisA
and thisB
are no longer identical:
thisA === thisB false
Unfortunately, there's not much I can do to change this particulal behavior, as this is currently a limitation imposed by the language. There is simply no reliable way in JavaScript to invoke a class constructor on an arbitrary object; instead, it's the constructor call that creates a new object and returns it to the caller.
As for your specific problem, the expression this.print.bind(this)
in the TestDefault
constructor is picking the correct method (the print
method of Test
) but binding it to a dummy object (this
in the base constructor - not the real Test
instance). Properties set on the real Test
instance are never reflected back to this object. This is where Polytype could probably do better: the base constructor this
should be less of a dummy and more like a live proxy of the real instance.
The best workaround so far is probably to re-bind the method in the constructor of the derived class. In this way you don't even need to change the base class (which somethimes you cannot).
class Test extends classes(TestDefault) {
constructor() {
super()
+ this.print = Test.prototype.print.bind(this);
this.someVar = 6
console.log('Test constructor')
}
print() {
console.log('Test Print', this.someVar)
super.print()
}
}
Another option is to bind the method after the constructor call:
import { classes } from 'js/polytype'
class TestDefault {
constructor() {
- this.print = this.print.bind(this)
-
console.log('TestDefault constructor')
}
+ init() {
+ this.print = this.print.bind(this)
+ }
+
print() {
console.log('TestDefault Print')
}
}
class Test extends classes(TestDefault) {
constructor() {
super()
this.someVar = 6
console.log('Test constructor')
}
print() {
console.log('Test Print', this.someVar)
super.print()
}
}
const test = new Test()
+ test.init()
test.print()
I'll have to elaborate on the documentation and see what else can be done to improve the behavior of this
in base constructor.
Thank you so much for your prompt response and explanation!
I tried your first solution and it works, but as I'm dealing with lots of inheritances and binds in my project, I created a little hack inside Polytype that helps to do that without having to worry with rebinds, changing the createConstructorTarget:
const createConstructorTarget =
typeSet =>
{
// Added by me.
const isBounded = (func) => {
return func.name.startsWith('bound ')
}
const constructorTarget =
function (...args)
{
const descriptorMapObjList = [];
{
const typeToSuperArgsMap = createTypeToSuperArgsMap(typeSet, args);
const newTarget = new.target;
for (const type of typeSet)
{
const superArgs = typeToSuperArgsMap.get(type) || EMPTY_ARRAY;
const newObj = _Reflect_construct(type, superArgs, newTarget);
const descriptorMapObj = _Object_getOwnPropertyDescriptors(newObj);
descriptorMapObjList.push(descriptorMapObj);
}
}
for (const descriptorMapObj of descriptorMapObjList) {
// Added by me.
for(const key of Object.keys(descriptorMapObj)) {
const value = descriptorMapObj[key].value
if(typeof value === 'function' && isBounded(value)) {
descriptorMapObj[key].value = this[key].bind(this)
}
}
_Object_defineProperties(this, descriptorMapObj)
}
for (let descriptorMapObj; descriptorMapObj = descriptorMapObjList.pop();) {
_Object_defineProperties(this, descriptorMapObj);
}
};
_Object_setPrototypeOf(constructorTarget, null);
return constructorTarget;
};
However, I don't know if this is the proper way to do it without suffering any future issues.
But for now, it seems to work as expected for my use case.
Thank you once again!
I'm afraid your fix will not work in a general case because it is not immediately possible to tell if a method was bound to this
or to another value. In order to do that, either Function.prototype.bind
must be spied upon, or the method itself must be wrapped into a caller that replaces this
if necessary. There are certainly other options, too. I'll be back to you when I come up with a solution to this problem.
Yes, you're totally right. If my limited JS knowledge allows me to find a better solution, I'll share! 😄
Thank you for your insights and I wish you an awesome weekend!
This issue has been tackled in the current version (0.13.2) by stubbing the bind
method. The current solution is far from perfect because it only works for bound functions (not for closures) and only when a superconstructor dummy substitute is used as the value of this
in a call to bind
(and not bound as an argument, or provided by some other means).
The current approach should still solve your specific problem.
I also added a section to the documentation to hopefully help other users.
Thanks again for reporting the issue and please, let me know if you have any more question.