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

Nested properties using one of the suggested syntaxes from above:

        // foo is a public property, also instance of this.constructor:
        this.foo.publicMethod()
        this.foo['public'+'Method']()
        this.foo##.privateMethod()
        this.foo##['private'+'Method']()
        this.foo#.protectedMethod()
        this.foo#['protected'+'Method']()

        // foo is a protected property, also instance of this.constructor:
        this#.foo.publicMethod()
        this#.foo['public'+'Method']()
        this#.foo##.privateMethod()
        this#.foo##['private'+'Method']()
        this#.foo#.protectedMethod()
        this#.foo#['protected'+'Method']()

        // foo is a private property, also instance of this.constructor:
        this##.foo.publicMethod()
        this##.foo['public'+'Method']()
        this##.foo##.privateMethod()
        this##.foo##['private'+'Method']()
        this##.foo#.protectedMethod()
        this##.foo#['protected'+'Method']()

Neste properties using the proposal syntax (plus string access):

        // foo is a public property, also instance of this.constructor:
        this.foo.publicMethod()
        this.foo['public'+'Method']()
        this.foo.##privateMethod()
        this.foo[##'private'+'Method']()
        this.foo.#protectedMethod()
        this.foo[#'protected'+'Method']()

        // foo is a protected property, also instance of this.constructor:
        this.#foo.publicMethod()
        this.#foo['public'+'Method']()
        this.#foo.##privateMethod()
        this.#foo[##'private'+'Method']()
        this.#foo.#protectedMethod()
        this.#foo[#'protected'+'Method']()

        // foo is a private property, also instance of this.constructor:
        this.##foo.publicMethod()
        this.##foo['public'+'Method']()
        this.##foo.##privateMethod()
        this.##foo[##'private'+'Method']()
        this.##foo.#protectedMethod()
        this.##foo[#'protected'+'Method']()
trusktr commented 5 years ago

Personally I like obj#.foo and obj##.foo where obj# and obj## are like objects, because it means we could provide a way to leak protected or private members selectively to other code.

For example, suppose that obj## means "a reference to obj in private access mode", and then we can leak it on purpose, so that we have the choice of hard privacy or soft privacy:

// some-module.js
// a "module private" or "module protected" pattern

const privates = new WeakMap

export class Foo {
  ##foo = 'foo'

  constructor() {
    privates.set(this, this##)
  }
}

// another class in the same module
export class Bar {
  constructor() {
    let f = new Foo
    console.log(privates.get(f).foo)
    console.log(privates.get(f)['f'+'oo'])
  }
}
import Bar from './some-module'
new Bar

// Output:
// "foo"
// "foo"

Notice that because we use the obj/obj#/obj## syntax, and that therefore . and [] work like before, we can pass around reference that are in different "access modes" and access props with . or [].

The engine would need the new concept of an "access mode" associated with a single reference. Some things (among other things) we'd have to consider:

class Obj {
  foo = 1
  #bar = 2
  ##baz = 3

  test() {
    // same instance
    console.assert(this instanceof Obj)
    console.assert(this# instanceof Obj)
    console.assert(this## instanceof Obj)

    // they're all the same object, just that the references are in different "access modes":
    console.assert(this === this)
    console.assert(this === this#)
    console.assert(this === this##)

    // enumeration, etc, is based on access mode:
    console.log(Object.keys(this)) // ["foo"]
    console.log(Object.keys(this#)) // ["bar"]
    console.log(Object.keys(this##)) // ["baz"]

    // get descriptors, like we already know! Access mode filters them internally
    console.log(Object.getOwnPropertyDescriptors(this)) // { foo: { ... } }
    console.log(Object.getOwnPropertyDescriptors(this#)) // { bar: { ... } }
    console.log(Object.getOwnPropertyDescriptors(this##)) // { baz: { ... } }

    // set descriptors, like we already know!
    Object.setOwnPropertyDescriptor(this, 'foo', {...})
    Object.setOwnPropertyDescriptor(this#, 'bar', {...})
    Object.setOwnPropertyDescriptor(this##, 'baz', {...})

    // use JavaScript like we already know!
  }
}

let o = new Obj
o.test()

I think it is not just easy to reason about it this way, but also just easy to work this syntax with all of today's existing tools.

class Foo {
  // creates a accessor descriptor with get/set, just like normal.
  // Only access mode determines whether or not you are allowed to interact with
  // the foo property, which is tangential to how descriptors work.
  get #foo() { ... }
  set #foo() { ... }

  ##bar = "bar"

  test() {
    // access mode is tangential to concepts we already know:
    console.log(typeof this) // "object"
    console.log(typeof this#) // "object"
    console.log(typeof this##) // "object"

    console.log('bar: ', this##.bar) // bar: bar
  }
}

const f = new Foo

// We'd may have to bikeshed some things, but it is totally doable:

// the same as in the methods? Seems to make sense. They're the same object.
console.log(typeof f) // object
console.log(typeof f#) // object
console.log(typeof f##) // object

f.foo = 1 // works
f#.foo = 2 // Throws an Error, something like "can not gain protected access outside of a class"?
f##.foo = 2 // Throws an Error, something like "can not gain private access outside of a class"?
                  // ^ those errors do not leak information about which properties exist.

class Bar extends Foo {
  test() {
    console.log(typeof this) // object
    console.log(typeof this#) // object
    console.log(typeof this##) // object

    this.foo = 'foo' // it works, new public prop
    this#.foo = 'foo' // it works, inherited protected setter used
    this##.foo = 'foo' // it works, and is a private property in Bar scope

    super.test()
    this##.bar = 'lorem'
    console.log('bar: ', this##.bar) // bar: lorem
  }
}

Notice the output with respect to bar is:

bar: bar
bar: lorem

because there is one bar in each private scope (hard privacy in that we can not detect private variable by detecting errors setting them, so setting private variables always works, and sets them in the current class scope).

Doing it this way becomes about "permission to access protected or private scope depending on which symbol is used on an object reference".

Something else to bike shed:


Sense muuuuuuch it makes, me thinks!

trusktr commented 5 years ago

We'd have to get into more details eventually, but from a top level perspective I like that with this approach I can work with objects just like before.

import _ as 'lodash'

class Foo {
  ##foo = 3
  ##bar = 4
  test() {
    console.log(_.pick(this, [ 'bar' ])) // { bar: 4 }
    _.assign(this##, { baz: 'baz' })
    console.log(this##.baz) // "baz"
  }
}

This also means opportunities to inspect access mode.

    // ...
    let ref = this
    console.log(Reflect.accessMode(ref)) // "public"
    ref = this#
    console.log(Reflect.accessMode(ref)) // "protected"
    ref = this##
    console.log(Reflect.accessMode(ref)) // "private"

Access mode also dictates access mode along the prototype chain during lookup, so:

Maybe the engine needs to internally mark each property as "protected" or "private", to look it up during access. Public props don't need any internal value. But although I've no experience even looking at engine code, this "access mode" idea doesn't seem too hard.

trusktr commented 5 years ago

A special case: super# and super## are treated like super, not like normal object references; you can't assign them to variables, etc. f.e.

class Foo {
  constructor() {
    let s = super# // Uncaught SyntaxError: 'super#' keyword unexpected here
  }
}
ljharb commented 5 years ago

As I’ve stated on other issues; i don’t think the concept of “access levels” is appropriate for javascript. “protected” is a poor choice of name, since it doesn’t actually protect anything (since anything can get access to protected members by temporarily subclassing, and extracting methods).

There’s no way in JS I’m aware of to robustly share access at any future time (ie, to later-created subclasses) without using lexical scope - so I’m not clear on what problem language support for “protected” would solve.

It’s clear that syntax space can be created for it - but not that there’s value in doing so. See #86 for example (your own issue) and many similar issues/comments on this repo.

littledan commented 5 years ago

I think this question is answered both by the several previous threads where you raised this issue, the decorator future path and by @ljharb's post.

trusktr commented 5 years ago

since anything can get access to protected members by temporarily subclassing, and extracting methods)

that's not what protected is for. If I give you a reference, you can't call the methods from public space. that's what protected is for. I'm not as much concerned about people grabbing the method source.

ljharb commented 5 years ago

I'm confused; a function has the same behavior no matter where it's invoked from.

trusktr commented 5 years ago

without using lexical scope - so I’m not clear on what problem language support for “protected” would solve.

I just described. ^

There’s no way in JS I’m aware of to robustly share access at any future time (ie, to later-created subclasses) without using lexical scope

Yeah there is. If I can imagine it, others can too!

It's not hard to imagine...

ljharb commented 5 years ago

I believe there will be strong opposition to ever making a first-class function's call behave differently based on where it's invoked (as opposed to where/how it's defined).

trusktr commented 5 years ago

a function has the same behavior no matter where it's invoked from.

yes, but protected/private access will only work on this class with the statically created hierachry slot mentioned in my previous comment (i.e. created during class definition and given to objects at [[construct]])

It's like super: you can borrow a method, but you ain't gonna change what super refers to!

shannon commented 5 years ago

@ljharb I just want to mention again because you have stated this before.

(since anything can get access to protected members by temporarily subclassing, and extracting methods).

The final keyword would be used on the class to prevent further subclassing.

trusktr commented 5 years ago

edited typos in my previous comment

ljharb commented 5 years ago

@shannon thanks, that's a useful clarification - so how would a parent class that provides "protected" data ensure that child classes (that extend the parent long after the parent is finished evaluating) are final?

@trusktr i see, so you're saying i couldn't extract a protected method from the bottom of one chain and .call it on the bottom of another chain?

trusktr commented 5 years ago

plus what Shannon said, and I've also mentioned in the "other threads" how to implement final at runtime in ES6.

shannon commented 5 years ago

@ljharb

how would a parent class that provides "protected" data ensure that child classes (that extend the parent long after the parent is finished evaluating) are final?

It wouldn't. As a developer I would only export final classes. Protected would be internal to library/module. I could be mistaken, but I believe that is the point of protected. To avoid redundancy, and allow you to keep some shared data between subclasses without exposing it to your public API.

trusktr commented 5 years ago

so how would a parent class that provides "protected" data ensure that child classes (that extend the parent long after the parent is finished evaluating) are final?

they don't, only the child classes define if they are final. This is convenience for library authors.

so you're saying i couldn't extract a protected method from the bottom of one chain and .call it on the bottom of another chain?

Right, .call and .apply won't change the internal classes that the methods operate on.

There could be additional checks in place to throw meaningful errors, f.e. maybe methods using the sigils can only be .called or .applied on objects that match the internal hierarchy. Additionally the scope where the .call and .apply happen can be checked, just like regular method calls, and errors thrown when the scope is wrong. or etc

trusktr commented 5 years ago

accessing __proto__##.foo would work fine too, given it happens in the correct scope. Otherwise, everything is just regular properties.

trusktr commented 5 years ago

@shannon mentioned

I could be mistaken, but I believe that is the point of protected. To avoid redundancy, and allow you to keep some shared data between subclasses without exposing it to your public API.

additionally if we can add internal mechanics to save these references in their access modes to variables (let o = obj#), then we can make "package private" too (not just "package protected") where hard private variables of one class can be shared with other select classes.

shannon commented 5 years ago

@ljharb here is a rather simple example:

class Vehicle {
    #velocity = { x: 0, y: 0 };
    #position = { x: 0, y: 0 };

    protected #wheels = 0;
    protected #topspeed = 10;

    update(acceleratorDown, wheelRotation) {
        // caculate new #velocity based on user input
        // clamp velocity to #topspeed 
        // caculate new #position based on #velocity
    }
}

class Car extends Vehicle {
    #wheels = 4;
}

class SlowCar extends Car {
    #topspeed = 1;
}

class FastCar extends Car {
    #topspeed = 100;
}

export default final class PlayerCar extends Car {};
export final class PlayerSlowCar extends SlowCar {};
export final class PlayerFastCar extends FastCar {};

We don't want to expose these values to be edited by the player but we do want to keep the code DRY and allow for declarative subclassing. This is almost certainly a desirable thing to do and I don't see why it wouldn't fit within JavaScript. So I'm really not sure why you keep stating that there is no value in doing this. The alternative is closed over variables or weak maps and then we are just right back where we started.

shannon commented 5 years ago

I don't know if other languages do this but to me it would be ideal if final didn't prevent subclassing completely, but just ended the chain for protected members. in other words any further subclassing didn't inherit the protected members but it wouldn't error. I don't know how others feel about that though.

loganfsmyth commented 5 years ago

Wouldn't you still be able to do

class FastCarSubclass extends Object.getPrototypeOf(PlayerFastCar) {
  readProtectedTopSpeed() {
    return this.#topspeed;
  }
}
const getTopSpeed = FastCarSubclass.prototype.readProtectedTopSpeed;

var fastCar = new PlayerFastCar();
getTopSpeed.call(fastCar); // 100

Even if the exported constructors are final, the parent constructors are not. They'd have to be made final too somehow, after all of your 3 subclasses have been created.

shannon commented 5 years ago

@loganfsmyth Well, I would expect that using the final keyword would walk the prototype chain and set a final parent constructor. So __proto__ would not work either.

loganfsmyth commented 5 years ago

@shannon Wouldn't that mean

final class PlayerCar extends Car {};

would make Car final meaning that

final class PlayerSlowCar extends SlowCar

would then fail because it was extending a final class?

shannon commented 5 years ago

@loganfsmyth not quite what I meant. It would just set the parent constructor of PlayerCar to a final version of Car. It wouldn't retroactively go and and change all the other classes to final.

Edit: I meant to say PlayerCar in the case of your first example. PlayerSlowCar would have a parent constructor of a final version of SlowCar.

trusktr commented 5 years ago

@loganfsmyth I think you meant Object.getPrototypeOf(PlayerFastCar).constructor, but yeah, good point.

There's always some solution that can be imagined:

trusktr commented 5 years ago

Shannon's idea seems perfect; a "final version".

Just like the ideas here with obj## being references, so too can .constructor be a special reference of the same function, in "final mode". May as well re-use the reference mode idea!

(I'm in mobile, sorry for typos!)

loganfsmyth commented 5 years ago

@shannon If we aren't mutating the existing class, what does making a final version of a class entail? We'd have to have a full copy of the prototype object and all the methods with the new [[HomeObject]]? Wouldn't that also mean that any property access on that would be different? Say I have

class Car {
  static foo = 4;
}
final class PlayerCar extends Car {
  increment() {
    const parent = Object.getPrototypeOf(PlayerCar);
    parent.foo += 1;
    return parent.foo;
  }
}

is parent this final copy of Car? So the Car.foo is not changed by this code?

@trusktr

I think you meant Object.getPrototypeOf(PlayerFastCar).constructor, but yeah, good point.

Nope, the prototype of the class constructor is the parent class. The .constructor property of class instances is irrelevant in this context.

ljharb commented 5 years ago

@shannon Every single subclass, though, is done after the parent class is finished, whether it's exported or not. I don't see how SlowCar would get access to Car's protected methods without allowing anything extending Car at any time to do the same.

shannon commented 5 years ago

@loganfsmyth

Parent would be the same value as the expression (final class extends Car). So as far as I my understanding Car.foo would be changed by this code. Since it's static and not redeclared.

I would expect it to just walk the chain and the parent class of the class retrieved from the expression (final class extends Car) would be the expression (final class extends Vehicle)

shannon commented 5 years ago

@ljharb Anything extending Car could. But you would never export Car. And by the logic in the rest of this thread about walking the prototype chain you should not be able to get to it unless you export it. Or export a subclass without the final keyword.

ljharb commented 5 years ago

@shannon The original Car is always accessible from the prototype chain of the exported subclasses, whether the subclass is final or not.

shannon commented 5 years ago

@ljharb I understand that it currently is this way. But the final keyword should walk the chain and set each parent to a finalized version. Preventing any subclassing of parent classes anywhere in the chain.

ljharb commented 5 years ago

@shannon that would mean that making the 5th child, say, final, would mutate all 4 non-final parents - the effect would be that a given parent class could only ever be in the inheritance chain of one final class, no?

shannon commented 5 years ago

@ljharb it doesn't need to mutate any of the parents. It just sets its own parent to a final version of it's parent.

Am I not explaining this well enough? I don't know why this is so confusing.

I doesn't need to make any changes to existing classes. It just needs to set it's parent to the expression final class extends Parent when then does the same and it set it's own parent. This happens recursively down the chain until you end up with final versions of every class in the chain.

Sorry forgot the extends keyword. Trying to explain too fast.

ljharb commented 5 years ago

@shannon how would you specify that "version" such that it, also, wouldn't expose the original parent on its prototype chain? Also, wouldn't that break code that depends on the identity of instanceof, or that compares the prototype chain of two final subclasses (which should both be instances of the same parent)?

How would that work when someone does expose the original parent - suddenly the final subclass isn't an instance of the original parent?

shannon commented 5 years ago

@ljharb Ok so maybe final class extends Parent wouldn't be completely equivalent because of the issue you have described. The last parent in the chain would have to be handled specially I guess.

I would expect that comparing two final subclasses to have the same prototype chain because I would wouldn't expect that the engine to actually call that expression for every extension. I was just trying to give a succinct example of what the value would be and distinguish it from mutating the original classes. I would expect that they would be deduped accordingly and they would actually be referring to the same objects.

Instanceof should work as they would actually still be instances of the original parent class. Extending the parent class doesn't change that.

I will need to think on this some more.

ljharb commented 5 years ago

Perhaps it would be better (rather than chatting on the class fields repo) if you created your own repo for a proposal for protected. If it could be demonstrated that "protected" (no matter what the name of the feature was) was both viable and idiomatically appropriate for the language, I think it would be a more reasonable time to discuss how it interacts with class fields. At the moment, it's not clear to me that there will ever be a viable proposal to interact with it.

trusktr commented 5 years ago

Also, wouldn't that break code that depends on the identity of instanceof, or that compares the prototype chain of two final subclasses (which should both be instances of the same parent)?

With the idea of "reference mode" described above, which is extended from the original idea of "access mode", instanceof and === would work the same. Here's @shannon's idea expressed with my "reference mode" idea:

class Foo {
  #protectedProp = 42
}

final class Bar extends Foo {}

console.log( Reflect.isFinal(Foo) ) // false
console.log( Reflect.isFinal(Bar) ) // true
console.log( Reflect.isFinal( Object.getPrototypeOf( Bar ) ) ) // true

// the same reference:
console.log( Foo === Object.getPrototypeOf( Bar ) ) // true

// and instanceof works:
console.log( (new Bar) instanceof Foo ) // true
console.log( (new Bar) instanceof Object.getPrototypeOf( Bar ) ) // true

Basically the reference returned by Object.getPrototypeOf( Bar ) is the same reference to Foo, but it has a "reference mode" of "final".

Just like above ideas, if it gets assigned to a new variable, the reference mode carries over to the new variable, but doesn't change the reference.

Seems like this allows for very dynamic possibilities, and isn't too complicated to understand.


Perhaps it would be better (rather than chatting on the class fields repo) if you created your own repo for a proposal for protected

This is more of a proposal for changing current private fields proposal (and adding protected while at it), but I can definitely see a separate repo being useful.

trusktr commented 5 years ago

Plus, even if the references were not the same (i.e. if we decide the "reference mode" idea is bad, and that we should have different references), then instanceof should still work fine because the engine can set Symbol.hasInstance on the classes to whatever it needs to be to make the check work.

ljharb commented 5 years ago

(Note that nothing can ever go on Reflect that isn’t a Proxy trap)

js doesn’t have first-class references; and I’d find it very harmful to make === return true for Teo observably distinct objects.

Using Symbol.hasInstance is a good answer to instanceof, but everything still has to make sense when someone does export the original parent class constructor - including === when walking the prototype chain.

trusktr commented 5 years ago

The alternative is to make references unique then, just like Function.prototype.bind gives a new "version" of the function.

Okay so in this case Symbol.hasInstance will come into play for instanceof.

but everything still has to make sense when someone does export the original parent class constructor - including === when walking the prototype chain.

Can you provide some code samples?

trusktr commented 5 years ago

(Note that nothing can ever go on Reflect that isn’t a Proxy trap)

Didn't know that! Thanks for pointing it out. That seems limited in terms of "reflection", but that's another topic.

trusktr commented 5 years ago

Personally I'm not confused about the "reference modes" idea. It's also not complicated to learn, though yes it would be a feature unique to JS if it were added, but IMO it leads to some really cool features.

Cool features:

The current stage 3 proposal of this repo isn't that flexible. For example.

With the "reference mode" idea, all of that just simply works, and honestly I don't think it would be difficult to learn.

trusktr commented 5 years ago
_.pick(obj#, 'a', 'b', 'c') // huge win! (assuming you trust your libs)
trusktr commented 5 years ago

@shannon, in your mind, how would the final concept work if someone writes

import FinalClass from 'somewhere'

new FinalClass.prototype.__proto__.__proto__.__proto__.constructor

?

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?

With something like "reference modes", we wouldn't have to make a new prototype chain, which means a definitive source (f.e. an code in an author's library) can update the prototype of a single class dynamically and it will reflect wherever the class is used. Prototype objects themselves would have reference modes depending on which class scope they are accessed in.


@rdking Any thoughts? If anyone, I know you'll find the best and worst pros and cons of the idea. :D

trusktr commented 5 years ago

If we think of obj === obj# as comparing the same object (in different access modes), well, they're the same object, so it is true. Easy to understand.

New helper functions could be useful (this time not on Reflect as per above comment):

console.log( obj === obj# ) // true
console.log( Object.sameReferenceMode( obj, obj# ) ) // false
console.log( Object.sameReferenceMode( obj#, obj# ) ) // true
console.log( Object.sameReferenceMode( otherObj#, obj# ) ) // false
rdking commented 5 years ago

@trusktr final is not supposed to affect base classes, only the class it is applied to. Using it in any other way would be both counter-intuitive and destructive. Think about what happens if you declare a branching class hierarchy with even 1 final leaf class. All subsequently subclassed classes branching off of any base class of that tree would fail because their constructors had been marked final. So for your question above: "That dog don't hunt."

@ljharb Wow! You really dragged them all down a bad road this time. Without realizing it, they'd all but entirely given up the point of "protected"! After trying this discussion with you many times, I've decided a few things are true

This means that you're right. If a derived class decides to share the protected members of its base, then, due to polymorphism, it has effectively shared the same protected members of all subclasses derived from the same base. This, however, is not a bad thing as it does not affect the base class, nor prevent the base class from altering its private API.

Before I get to the point, it's time for that lexicon:

It should be clear that any class is free to leak its private or protected API to its public API. It's should also be clear that a base class has no say in what a derived class does with the protected or public API. As such, the purpose of protected is to provide a non-private API that is not immediately part of the public API. The reason such a thing is wanted is that it gives us a well-defined method of providing 3rd party code with the ability to take advantage of, or even extend our logic without resorting to the more dangerous practices of monkey-patching and API invalidation.

One of the best arguments for "hard private" is the API invalidation problem, where developers are ill-advisedly making use of API surface that was not intended to be public. By requiring subclassing, that grants the developer the freedom of making monkey-patch-like changes to the functionality of an API without preventing the original API's developer from making reasonable updates. Protected essentially grants control a few otherwise private details to the derived class. What the derived class does with that protected API is no more the responsibility of the base class than would be the public API.

However, that does not mean that the protected API is the same as the public API. Their surfaces are still distinct. A leaking derived class would still need to provide methods that leak the protected interface. This is no different than if a given class decided to leak its private data.

trusktr commented 5 years ago

@rdking, well said about protected!

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

final is not supposed to affect base classes, only the class it is applied to

Ah, right! So modifying the constructor property on only FinalClass.prototype makes much sense because it does not impact base classes. (But then there's also the question of how to keep classes private only to a library, while the end user can only use the "final" class, but it's whole 'nother discussion).

Anywho, the discussion of final is getting a little further from the main topic of private/protected properties. I believe Symbol.hasInstance would have final covered well enough if FinalClass.prototype.constructor is a unique reference (and the "reference mode" idea would cover it fully if there aren't any other ideas).

trusktr commented 5 years ago

@loganfsmyth

getTopSpeed.call(fastCar); // 100

That wouldn't work with the above idea where I mentioned that with APIs like Function.prototype.call/apply the engine would know in which class scope the APIs are being called in, and can thus throw an error if they are being accessed in invalid class scope (i.e. outside-of-a-class scope).


Sidenote, @littledan, I think you closed this issue too early because I haven't raised this idea before in another thread ("access mode" and "reference mode" ideas), and it might seem as if your opinion matters more than a multitude of community member opinions when an issue is closed quickly, generally speaking.

It would be nice for it to be open (and bonus if we could somehow get JS community members more involved in the discussions).

How can we garner more community involvement?