All of us long ago wanted normal encapsulation in JS, which could be used without unnecessary gestures. We also want convenient constructs for declaring class properties. And, finally, we want all these features in the language to appear so as not to break existing applications.
It would seem that here it is happiness: the class-fields-proposal , which after many years of torment of the tc39 committee did get to stage 3
and even got implemented in chrome .
Honestly, I would really like to write an article just about why it is worth using a new feature of the language and how to do it, but unfortunately, the article will not be about that at all.
I will not repeat here the original description , FAQ and changes in the specification , but only briefly outline the main points.
Declaring fields and using them inside the class:
class A { x = 1; method() { console.log(this.x); } }
Access to non-class fields:
const a = new A(); console.log(ax);
It would seem that everything is obvious and we have been using this syntax for many years with the help of Babel and TypeScript .
Only there is a nuance. This new syntax uses the [[Define]]
, rather than the [[Set]]
semantics with which we have lived all this time.
In practice, this means that the code above does not equal this:
class A { constructor() { this.x = 1; } method() { console.log(this.x); } }
And in fact, it is equivalent to this:
class A { constructor() { Object.defineProperty(this, "x", { configurable: true, enumerable: true, writable: true, value: 1 }); } method() { console.log(this.x); } }
And, although for the example above, both approaches do, in fact, the same thing, it is a VERY SERIOUS difference, and here is why:
Suppose we have such a parent class:
class A { x = 1; method() { console.log(this.x); } }
Based on it, we created another one:
class B extends A { x = 2; }
And they used it:
const b = new B(); b.method(); // 2
After that, for some reason, class A
was changed in a seemingly backward-compatible way:
class A { _x = 1; // , , get x() { return this._x; }; set x(val) { return this._x = val; }; method() { console.log(this._x); } }
And for [[Set]]
semantics, this is indeed a backward compatible change, but not for [[Define]]
. Now the b.method()
call will display 1
instead of 2
in the console. And this will happen because Object.defineProperty
overrides the property dexryptor and, accordingly, the A / heterter from class A
will not be called. In fact, in the child class we overshadow the parent's x
property, just as we can do it in the lexical scopa:
const x = 1; { const x = 2; }
True, in this case the linter with his no-shadowed-variable
/ no-shadow
rules will save us, but the likelihood of someone doing a no-shadowed-class-field
tends to zero.
By the way, I will be grateful for a more successful Russian-language term for shadowed
.
In spite of all the above, I am not an irreconcilable opponent of the new semantics (although I would prefer a different one), because it has its own positive aspects. But, unfortunately, these advantages do not outweigh the main disadvantage - we have been using [[Set]]
semantics for many years, because it is precisely this that is used in babel6
and TypeScript
, by default.
True, it is worth noting that in babel7
default value has been changed .
More original discussions on this topic can be found here and here .
And now we come to the most controversial part of this proposal. So controversial that:
however, due to a rather weak argument, such discussions also appeared ( one , two )
Private fields are declared as follows:
class A { #priv; }
And access to them is as follows:
class A { #priv = 1; method() { console.log(this.#priv); } }
I will not even raise the topic of the fact that the mental model behind this is not very intuitive ( this.#priv !== this['#priv']
) does not use the already reserved words private
/ protected
(which will surely cause additional pain for TypeScript developers), it is not clear how to extend this for other access modifiers , and the syntax itself is not very beautiful. Although all this was the original reason that pushed me to a deeper study and participation in discussions.
This all concerns the syntax where subjective esthetic preferences are very strong. And with this one could live and get used to it over time. If it were not for one thing: there is a very significant problem of semantics ...
WeakMap
Let's take a look at what is behind the existing prosobal. We can rewrite the example from above with encapsulation and without using the new syntax, but keeping the semantics of the current proposal:
const privatesForA = new WeakMap(); class A { constructor() { privatesForA.set(this, {}); privatesForA.get(this).priv = 1; } method() { console.log(privatesForA.get(this).priv); } }
By the way, on the basis of this semantics, one of the committee members even built a small utility library , which allows using the private state now, in order to show that such functionality is too overestimated by the committee. The formatted code takes only 27 lines.
In general, everything is pretty good, we get hard-private
, which can not be reached / intercepted / tracked from the external code and we can get access to the private fields of another instance of the same class, for example:
isEquals(obj) { return privatesForA.get(this).id === privatesForA.get(obj).id; }
Well, it is very convenient, except for the fact that this semantics, in addition to encapsulation itself, also includes brand-checking
(you can not google what it is - you can hardly find relevant information).brand-checking
is the opposite of duck-typing
, in the sense that it does not check the public intefrace of the object, but the fact that the object was built using trusted code.
Such a check, in fact, has a certain scope - it is mainly related to the security of calling untrusted code in a single address space with a trusted and ability to exchange objects directly without serialization.
Although some engineers consider this to be a necessary part of proper encapsulation.
Despite the fact that this is a rather curious opportunity, which is closely related to the pattern ( short and longer description),
Realms
proposal and scientific work in the field of Computer Science, which Mark Samuel Miller (he is also a member of the committee), in my experience , in the practice of most developers, this almost never occurs.
By the way, I came across a membrane (although I didn’t know what it was then) when I rewrote vm2 to fit my needs.
brand-checking
As mentioned earlier, brand-checking
is the opposite of duck-typing
. In practice, this means that having such code:
const brands = new WeakMap(); class A { constructor() { brands.set(this, {}); } method() { return 1; } brandCheckedMethod() { if (!brands.has(this)) throw 'Brand-check failed'; console.log(this.method()); } }
brandCheckedMethod
can only be called with an instance of class A
and even if an object that preserves invariants of this class acts as a target, this method will throw an exception:
const duckTypedObj = { method: A.prototype.method.bind(duckTypedObj), brandCheckedMethod: A.prototype.brandCheckedMethod.bind(duckTypedObj), }; duckTypedObj.method(); // 1 duckTypedObj.brandCheckedMethod(); //
Obviously, this example is rather synthetic and the benefits of such duckTypedObj
questionable, as long as we don’t remember Proxy
.
One of the very important use cases for proxies is metaprogramming. In order for the proxy to perform all the necessary useful work, the methods of objects that are wrapped with a proxy should be executed in the context of the proxy, and not in the context of the target, ie:
const a = new A(); const proxy = new Proxy(a, { get(target, p, receiver) { const property = Reflect.get(target, p, receiver); doSomethingUseful('get', retval, target, p, receiver); return (typeof property === 'function') ? property.bind(proxy) : property; } });
Call proxy.method();
does the useful work declared in the proxy and returns 1
, while calling proxy.brandCheckedMethod();
instead of twice doing a useful job from a proxy, throw an exception, because a !== proxy
, which means brand-check
failed.
Yes, we can perform methods / functions in the context of the real target, and not a proxy, and for some scenarios this is enough (for example, to implement the pattern), but this is not enough for all cases (for example, to implement reactive properties: MobX 5 already uses a proxy for this, Vue.js and Aurelia experiment with this approach for future releases).
In general, as long as a brand-check
needs to be done explicitly, this is not a problem - the developer simply consciously has to decide what trade-off he is doing and whether he needs it, moreover, in the case of an explicit brand-check
you can implement it in such a way that the error would not be thrown on trusted proxies.
Unfortunately, the current proposal deprives us of this flexibility:
class A { #priv; method() { this.#priv; // brand-check } }
Such a method
will always throw an exception if it is called not in the context of an object constructed using the constructor A
And the worst thing is that brand-check
is implicit here and is mixed with another functionality - encapsulation.
While almost necessary for any code,
brand-check
has a fairly narrow range of applications. And combining them into one syntax will result in a lot of unintentional brand-check
in the user code, when the developer intended only to hide implementation details.
And the slogan, which is used to promote this propozala # is the new _
only aggravates the situation.
You can also read a detailed discussion of how an existing propozal breaks a proxy . One of the Aurelia developers and the author Vue.js spoke in the discussion .
Also, my commentary , describing in more detail the difference between different proxy usage scenarios, may seem interesting to someone. As well as in general, all discussion of the relationship of private fields and membranes .
All these discussions would make little sense if there were no alternatives. Unfortunately, no alternative proposal even got into stage1 , and, as a result, neither had the chance to be sufficiently developed. However, I will list here the alternatives that somehow solve the problems described above.
brand-check
, problems with the membrane pattern (although this also offers an adequate solution) and the lack of a convenient syntaxFrom the tone of the article, perhaps it may seem that I condemn the committee - this is not so. It only seems to me that during those years (depending on what to take as a starting point, it could be even decades), which the committee worked on encapsulation in JS, a lot of things in the industry changed, and the look could be blurred, which led to a false prioritization .
Moreover, we, as a community, are putting pressure on tc39 forcing them to release features faster, while giving very little feedback in the early stages of propozal, bringing down their indignation only at the moment when there is little that can be changed.
There is an opinion that in this case the process simply failed.
After dipping into this with my head and communicating with some representatives, I decided that I would do my best to prevent a repetition of this situation - but I can do a little (write a review article, do the implementation stage1
in babel
and just that).
But the most important thing is the feedback - so I would ask you to take part in this short survey. And I, in turn, will try to bring it to the committee.
Source: https://habr.com/ru/post/428119/
All Articles