Skip to content
This repository has been archived by the owner on Jan 25, 2022. It is now read-only.

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

Closed
trusktr opened this issue Jan 2, 2019 · 73 comments
Closed

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

trusktr opened this issue Jan 2, 2019 · 73 comments

Comments

@trusktr
Copy link

trusktr commented Jan 2, 2019

(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
Copy link
Author

trusktr commented Jan 2, 2019

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
Copy link
Author

trusktr commented Jan 2, 2019

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
Copy link
Author

trusktr commented Jan 2, 2019

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:

  • lookup on the prototype chain only happens when an object reference is in public or protected mode
    • public lookup works the like before
    • protected lookup internally relies on the engine keeping track of which classes are in an object's class hierarchy by some method other than looking at prototypes because we can change the prototypes of objects created from classes after they've been constructed.
  • With private access mode, there's no prototype chain lookup, only own property lookup.

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 trusktr changed the title Not leaving protected behind in the dust. The concept of "access mode" (was: Not leaving protected behind in the dust) Jan 2, 2019
@trusktr
Copy link
Author

trusktr commented Jan 2, 2019

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
Copy link
Member

ljharb commented Jan 2, 2019

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
Copy link
Member

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
Copy link
Author

trusktr commented Jan 2, 2019

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
Copy link
Member

ljharb commented Jan 2, 2019

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

@trusktr
Copy link
Author

trusktr commented Jan 2, 2019

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 would super easy to store a list of which properties are private in a [[slot]] which is visible only to the engine.
  • Then store the class hierarchy (another list) of an object in another slot.
  • Then in places where we encounter obj##
    • if the lexical scope is permitted to access private at this point (by checking the mentioned slots), set a flag on the reference to true. Now it will always be true for that particular reference, which means the engine has to track references with C classes or structs if nit already.
  • etc

It's not hard to imagine...

@ljharb
Copy link
Member

ljharb commented Jan 2, 2019

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
Copy link
Author

trusktr commented Jan 2, 2019

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
Copy link

shannon commented Jan 2, 2019

@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
Copy link
Author

trusktr commented Jan 2, 2019

edited typos in my previous comment

@ljharb
Copy link
Member

ljharb commented Jan 2, 2019

@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
Copy link
Author

trusktr commented Jan 2, 2019

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

@shannon
Copy link

shannon commented Jan 2, 2019

@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
Copy link
Author

trusktr commented Jan 2, 2019

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
Copy link
Author

trusktr commented Jan 2, 2019

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

@trusktr
Copy link
Author

trusktr commented Jan 2, 2019

@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
Copy link

shannon commented Jan 2, 2019

@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
Copy link

shannon commented Jan 2, 2019

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
Copy link

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
Copy link

shannon commented Jan 2, 2019

@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
Copy link

@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
Copy link

shannon commented Jan 2, 2019

@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
Copy link
Author

trusktr commented Jan 2, 2019

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

There's always some solution that can be imagined:

  • A runtime solution is to delete the constructor property, and implement your own hasInstance helper or symbol method for use internally, then external code can't access the constructor.
  • Or the language can keep track of whether the class referred to by Object.getPrototypeOf(PlayerFastCar).constructor was ever exported, and if not it can not be extended.
  • Or something like private class Base {} could mean that the class can not be extended outside of the scope where it is defined. The scope can be selectively passed around with a function.
  • Plus Shannon's ideas I missed while typing. ^

@trusktr
Copy link
Author

trusktr commented Jan 2, 2019

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
Copy link

@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.

@trusktr
Copy link
Author

trusktr commented Jan 3, 2019

@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
Copy link
Author

trusktr commented Jan 3, 2019

@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?

@trusktr trusktr changed the title The concept of "access mode" (was: Not leaving protected behind in the dust) The concept of "access mode" or "reference mode" Jan 3, 2019
@trusktr
Copy link
Author

trusktr commented Jan 3, 2019

@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
Copy link

rdking commented Jan 3, 2019

@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
Copy link

rdking commented Jan 3, 2019

@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
Copy link
Member

ljharb commented Jan 3, 2019

@rdking I largely agree with your comments in #195 (comment) - 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
Copy link

rdking commented Jan 3, 2019

@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
Copy link

shannon commented Jan 3, 2019

@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
Copy link
Member

ljharb commented Jan 3, 2019

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

@shannon
Copy link

shannon commented Jan 3, 2019

@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
Copy link
Member

ljharb commented Jan 3, 2019

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
Copy link

shannon commented Jan 3, 2019

@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
Copy link
Member

ljharb commented Jan 3, 2019

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
Copy link

rdking commented Jan 3, 2019

@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
Copy link
Member

ljharb commented Jan 3, 2019

@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
Copy link

shannon commented Jan 3, 2019

@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
Copy link

rdking commented Jan 3, 2019

@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
Copy link

rdking commented Jan 3, 2019

@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
Copy link
Member

ljharb commented Jan 3, 2019

@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
Copy link

rdking commented Jan 3, 2019

@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
Copy link
Member

ljharb commented Jan 3, 2019

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
Copy link

rdking commented Jan 3, 2019

@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
Copy link

shannon commented Jan 3, 2019

@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
Copy link

rdking commented Jan 3, 2019

@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
Copy link
Author

trusktr commented Jan 10, 2019

@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 #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?

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants