tc39 / proposal-class-fields

Orthogonally-informed combination of public and private fields proposals
https://arai-a.github.io/ecma262-compare/?pr=1668
1.72k stars 113 forks source link

The concept of "access mode" or "reference mode" #195

Closed trusktr closed 5 years ago

trusktr commented 5 years ago

(NOTE This first post was originally about having both private and protected features with symmetrical syntax, but the next comments evolved into the concept of "access mode" and "reference mode")

Personally I like the more poetic/verbose/meaningfully-clear private/protected keywords, and my editor's autocompletion is fast enough (if my typing speed can't beat it).

But if we're going to stick with the # sigil, can we at least figure how protected will be allowed the possibility of entering the picture later (if it ever does), without it being asymmetrical? (f.e. # for private and protected for protected wouldn't be symmetrical)

What about having both # and ##?

I feel like # for protected and ## for private would be nice. The longer one with more symbols is for private because private is more hidden than protected.

class Foo {
    publicMethod() {
        this.##privateMethod()
        this[##'private'+'Method']()
    }
    #protectedMethod() {}
    ##privateMethod() {}
}

class Bar extends Foo {
    publicMethod() {
        super.publicMethod()
        super['public'+'Method']()

        this.#protectedMethod()
        this[#'protected'+'Method']()
    }
}

const bar = new Bar
bar.publicMethod()
bar['public'+'Method']()

Personally I would be fine losing the . symbol when accessing protected or private:

        super.publicMethod()
        super['public'+'Method']()
        this##privateMethod()
        this[##'private'+'Method']()
        this#protectedMethod()
        this[#'protected'+'Method']()

For dynamic string access, and alternative could be the # symbols go outside:

        super.publicMethod()
        super['public'+'Method']()
        this##privateMethod()
        this##['private'+'Method']()
        this#protectedMethod()
        this#['protected'+'Method']()

or

        super.publicMethod()
        super['public'+'Method']()
        this.##privateMethod()
        this##['private'+'Method']()
        this.#protectedMethod()
        this#['protected'+'Method']()

But that last one is the most awkward. If we want to preserve the notion of accessing props on an object with ., then another possibility is:

        super.publicMethod()
        super['public'+'Method']()
        this##.privateMethod()
        this##['private'+'Method']()
        this#.protectedMethod()
        this#['protected'+'Method']()

which seems to be clear: this, this#, and this## are all treated like objects, and the . and [] patterns stay the same as they have always been.

Other objects:

        other.publicMethod()
        other['public'+'Method']()
        other##.privateMethod()
        other##['private'+'Method']()
        other#.protectedMethod()
        other#['protected'+'Method']()

Is it possible to leave space in the spec for protected, in case it is added later?

trusktr commented 5 years ago

@loganfsmyth Oh wait, I forgot that I mentioned that "access mode" applies to prototypes too, so FastCarSubclass.prototype.readProtectedTopSpeed returns nothing but undefined in public scopes.

A class internally can write PlayerFastCar.prototype#.readProtectedTopSpeed or PlayerFastCar.prototype##.readProtectedTopSpeed to access protected and private members respectively.

I think this should work because the prototype property of the class constructor is not writable and not configurable.

If the public code changes the prototype of an instance object, well then that's another story (and the same rules apply as with super in that moving stuff around via prototype doesn't change the underlying classes that the transplanted code is associated with).

rdking commented 5 years ago

@trusktr I've been trying to figure out how to word this properly. If I'm not careful, I might find myself in a contradiction...

What are your thoughts on the "access mode" and "reference mode" ideas specifically?

Reference mode and access mode aren't good concepts. In this sense, I agree with @ljharb. They don't really fit the language. The problem is one of scopes. When you think of modes, you think of something that can be altered, just like "Night Shift" on my Macbook is a mode that the display can take to ease eye strain during late hours. Instead of thinking in that way, try reversing the picture.

When code outside of any class looks at a class instance, it should only see the public API. When code inside a subclass looks at an instance of the class or a subclass of one of its base classes, it should only see the common protected API (which includes the instance's public API). When code inside any class looks at an instance of itself, it should see the entire private API (which includes the protected API of all bases).

It's just like dealing with a nested function's closure.

let x = 1; //Public
function A() {
  let y = 2; //Protected
  function B() {
    let z = 3; //Private
    return {
      printFromB() {
        try {
          console.log(`x = ${x}`);
        } catch(e) {
          console.log(`"x" is out of scope`);
        }
        try {
          console.log(`y = ${y}`);
        } catch(e) {
          console.log(`"y" is out of scope`);
        }
        try {
          console.log(`z = ${z}`);
        } catch(e) {
          console.log(`"z" is out of scope`);
        }
      }
    };
  }
  return Object.assign(B(), {
    printFromA() {
      try {
        console.log(`x = ${x}`);
      } catch(e) {
        console.log(`"x" is out of scope`);
      }
      try {
        console.log(`y = ${y}`);
      } catch(e) {
        console.log(`"y" is out of scope`);
      }
      try {
        console.log(`z = ${z}`);
      } catch(e) {
        console.log(`"z" is out of scope`);
      }
    }
  });
}

let retval = Object.assign(A(), {
  printFromGlobal() {
    try {
      console.log(`x = ${x}`);
    } catch(e) {
      console.log(`"x" is out of scope`);
    }
    try {
      console.log(`y = ${y}`);
    } catch(e) {
      console.log(`"y" is out of scope`);
    }
    try {
      console.log(`z = ${z}`);
    } catch(e) {
      console.log(`"z" is out of scope`);
    }
  }
});

retval.printFromGlobal();
retval.printFromA();
retval.printFromB();

From this perspective, "protected" is like a means of sharing the scope of function A with select other functions but not with Global, and without sharing the function B. Can you see how this perspective is different from that of "access modes"?

rdking commented 5 years ago

@trusktr I forgot to address something. The reason obj## notation as you've described it here is not such a good idea is that it gives off the illusion that you're accessing separate objects when accessing public, protected, and private members. In truth, even if the underlying mechanics uses 3 separate objects for those containers, you want to at least make it look like they're all owned by the same object.

What makes it more perplexing is that from an access perspective, either you can access it or you can't. No matter what scope you're in, that should be true. If you don't maintain that kind of perspective, you'll create inconsistencies in the mental model. That's why I said you need to think of it in terms of scopes. "What can you access from a given scope?" is the question to ask. Right now, your "access modes" seem to ask "What can you access with a given context?". The problem with that is that the answer changes depending on your scope! For instance, is it at all safe to use ## outside of a class?

ljharb commented 5 years ago

@rdking I largely agree with your comments in https://github.com/tc39/proposal-class-fields/issues/195#issuecomment-451051155 - however, what thhat effectively means is that both the protected API and the public API need to follow semver (ie, distinguish between breakage and non-breakage), since external consumers could be relying on either one - which effectively makes both APIs identically public. What is the benefit of language-level support for what amounts to a second simultaneous public API? (when one could be similarly conveyed by any number of conventions, either with prefixes, symbols, etc)

rdking commented 5 years ago

@ljharb

...what that effectively means is that both the protected API and the public API need to follow semver

Agreed.

since external consumers could be relying on either one - which effectively makes both APIs identically public.

Please don't conflate the two surfaces like that. Sure, by some means other than directly in the lexical scope of the donor class, they are indeed both accessible. However, the scope from which they are accessible is a critical difference.

What is the benefit of language-level support for what amounts to a second simultaneous public API? (when one could be similarly conveyed by any number of conventions, either with prefixes, symbols, etc)

I stated it before, didn't I? When compared with the _ convention which invites monkey-patching, protected invites the cleaner approach of inheritance. A patch-level update that changes something about the internals of a protected method could and often does easily break monkey-patched code. However, the protected API and classes that inherit from it will continue to work properly.

Protected promotes cooperation between library developers and users by giving them well-defined points of modification. It also makes it easier for developers to add or remove such points. This is not something you get from the existing conventions.

shannon commented 5 years ago

@trusktr @ljharb @loganfsmyth

How do we make that illegal? How do we make all the constructor properties in the prototype chain be "final versions" outside of the module without affecting the internal classes in the module? Do we copy the prototypes and make a whole new prototype chain for FinalClass?

I can see now why no one was understanding what I meant. It doesn't make much sense because of circular logic. I'm starting to wonder if the final keyword could just sever the prototype chain all together. And create a fresh base class that just wraps calls to the parent constructor and public methods and prevents external code from reaching the parent. You wouldn't be able to use instanceof to compare to the parent but that would be expected if it was defined this way.

Having said all that, first class private symbols makes this problem much simpler. Share the private symbols where needed and don't expose them in your public API and this makes the protected keyword unnecessary.

ljharb commented 5 years ago

@shannon how would you suggest sharing anything (beyond lexical scope, of course) that doesn’t cause it to be fully public to everything?

shannon commented 5 years ago

@ljharb well in my example it could easily just be lexical scope as these would likely be in the same file. But if you are leading to the issue about sending the symbols cross file/module then I think you would have to rely on a future proposal to do this securely.

However, in practice, you really don't need to worry about this extra security. During your development you can trust that the modules you import are valid and will not be middleman replaced to capture the private symbols. For production you will most likely be bundling your application so again you can trust that the modules you import are valid. Then the only way to capture keys would be a source modification. And as you have stated many times, this out of the scope of the encapsulation proposed.

ljharb commented 5 years ago

Production includes “node” which typically does not bundle; anything that can be imported anywhere can generally be imported anywhere - if a feature that attempts to allow privileged sharing is language-level, it’s important to me that, in practice, you can rely on only privileged observers can access the shared values.

shannon commented 5 years ago

@ljharb Well then I would argue that replacing a file module is exactly equivalent to modifying source through babel or other means. You would want the base class to be importing the sublcass to share symbols, not the other way around. So the fact that the base class can be imported anywhere doesn't come into play here. The only way to capture symbols would be to modify what import SubClass from './sublcass.js' means (e.g. replacing the source of sublcass.js entirely) inside the base class file (the file that determined the access in the first place).

ljharb commented 5 years ago

I’m not talking about source modification; I’m talking about importing any specifier/file path at runtime and thus getting access to the “protected” data.

Base classes can’t know about all subclasses (spread across modules) without creating circular dependencies; are you suggesting that’d be the only way to make it work?

rdking commented 5 years ago

@shannon Wouldn't that require the base class to import every possible subclass? That's completely backward, and impossible. It would require the subclass to exist before the base class is ever declared. Besides, there are ways to pass the symbols along during construction.

Suppose the base class checks to see if newTarget === Base. If this is false, then it attaches an array of keys to the instance using a Symbol for the property key. It then sets that key as the value of a property using a well known key (like Symbol.Inheritance). The subclass can then grab the well-known key to get the inheritance key, and get the shared Symbols using the inheritance key. It would then need to delete both of those properties. I've done this before.

ljharb commented 5 years ago

@rdking i don’t believe there’s any way the superclass can run code after the subclass constructor’s super call - ie, no way to enforce that the subclass has deleted the property. It seems like both a bug farm and a lot of boilerplate to require every subclass to add delete this[Symbol.inheritance] or similar in its constructor.

shannon commented 5 years ago

@ljharb @rdking I guess I should just stop thinking out loud. Yes my way would require circular dependencies.

I can still see the value in wanting to share private symbols between many small subclasses in the same file though.

rdking commented 5 years ago

@ljharb I agree, it's a lot of boilerplate. I'm just stating that there is a method for doing so. Also, yes, the base class can indeed enforce the deletion. All that's required is that the 2 keyed properties be factory-produced getters. The getters could capture the value to be returned, delete itself, then return the result. Fairly simple stuff.

rdking commented 5 years ago

@shannon

I can still see the value in wanting to share private symbols between many small subclasses in the same file though.

That's still easily done without any language support. Just define a class that leaks a bunch of private symbols, then use those private symbols in the other classes. Not much to that.

ljharb commented 5 years ago

@rdking that works if the subclass accesses the property; however, if it forgets, then the value would remain as a public property on the instance, which could be used to affect the parent class’s behavior for that instance, without the subclass intending that exposure. iow, the superclass author wouldn’t be in charge of access control for that info.

As to that class that leaks private symbols, anyone could import it, so that’s also making things fully public.

rdking commented 5 years ago

@ljharb

that works if the subclass accesses the property; however, if it forgets, then the value would remain as a public property on the instance...

True enough. I don't usually need to account for sloppy programmers, so I sometimes forget they exist.

As to that class that leaks private symbols, anyone could import it, so that’s also making things fully public.

Not true here. The leaky class doesn't get exported. So it can't be imported from anywhere.

ljharb commented 5 years ago

ah, you mean doing all of that in lexical scope. That certainly works, but with that restriction you don’t need private symbols, you can use weak maps, lenses, and all sorts of patterns. Language-level access sharing would need to work (and restrict access) across modules imo to carry its own weight.

rdking commented 5 years ago

@ljharb

Language-level access sharing would need to work (and restrict access) across modules imo to carry its own weight.

Hence, "protected" support. This private symbol sharing is just that, a means of providing for "protected". The only problem is that, like you've noticed, without language support it's both sloppy and can be done by other means nearly equivalently. Putting some kind of protected support in the engine means that the engine itself cleanly handles the transfer, and ensures that access remains restricted until some class spills the beans.

shannon commented 5 years ago

@rdking

That's still easily done without any language support. Just define a class that leaks a bunch of private symbols, then use those private symbols in the other classes. Not much to that.

Yea that was my point about private symbols making the problem much simpler.

I agree with you that language support for protected is required to work around these other issues. (and I agree with @ljharb's line of questioning around it)

Sorry if I brought this conversation off topic.

rdking commented 5 years ago

@trusktr I've been thinking further, and there's 1 other issue with your approach. SomeClass.prototype.constructor can be deleted from the prototype after class evaluation. If your access modes were dependent on some state tied to that property, deletion of that property is all it would take to remove the state.

trusktr commented 5 years ago

@rdking

For instance, is it at all safe to use ## outside of a class?

Nope, it's an error in that case. Though it could be possible to have an extended syntax for applying visibility helpers that are assigned to variables. In gave a syntax idea of it (f.e. search for :private and :fooPriv in https://github.com/tc39/proposal-class-fields/issues/205).

SomeClass.prototype.constructor can be deleted from the prototype after class evaluation. If your access modes were dependent on some state tied to that property, deletion of that property is all it would take to remove the state.

These access modes are not dependent on that property at all. Everything needed for it to work is tracked and associated to a constructor during class definition time, and tampering with the prototype after the fact wouldn't break it (just like it is currently impossible to change [[HomeObject]] of a conciseMethod() {}, and therefore super can not be "broken").

In other words, just like there is currently no way to change the value of [[HomeObject]] inside a concise or class method, there is no way to modify what this## does inside of a concise or class method because it's already defined during definition time.


Off topic:

@shannon @ljharb

if you are leading to the issue about sending the symbols cross file/module then I think you would have to rely on a future proposal to do this securely.

To keep library exports private to a library, we can export a function that returns the library-only reference, count how many times the function is called, and if called too many times (i.e. imported by other modules besides library code), then we throw an error.

The function could accept an object passed in by each importing module, and in those objects the function can set a flag in the too-many-accesses case, then each importing module can detect the flag and make itself fail completely (f.e. self destruct).

The only way for a someone (who wants the private library export badly enough) to get it is to have access to the source code to change it, but source modification is outside of the scope of the conversation.

It's work, but doable. Want example code, or was that good enough explanation?