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

Class brand #13

Closed
dcleao opened this issue Sep 22, 2020 · 26 comments
Closed

Class brand #13

dcleao opened this issue Sep 22, 2020 · 26 comments

Comments

@dcleao
Copy link

dcleao commented Sep 22, 2020

If private fields are specific of classes (are they not?), there is no case where you'd want to check for the existance of a particular private field which could not be satisfied by testing for a given value being an instance of the current class.

While instanceof can be spoofed, and might not be a 100% safe option because of that, a new, unspoofable class brand check could be devised, whose uselfuness would go beyond testing the existance of a private field.

Example:

if (o is of class) {

}

Or, can the class of an object which is an instance of an "actual" JS class be spoofed as well (via constructor)?

@ljharb
Copy link
Member

ljharb commented Sep 22, 2020

There are indeed techniques to install private fields onto arbitrary objects via a combo of super and return override. While instanceof does not work cross-realm builtins, private fields aren't an issue there.

It seems like this suggestion would require every class to contain an implicit private field, that's not accessible to user code, that your new mechanism would test. That could have performance implications for all existing classes.

Another challenge to totally new syntax is that the list of available reserved words is quite small, and there's not much that would be appropriate.

@dcleao
Copy link
Author

dcleao commented Sep 23, 2020

Objects which get private fields installed using said technique effectively become instances of the class returning the objects in the constructor.

Is there the goal for your proposal to work cross-realms? That's something unexpected, imo, for instances of custom classes (where private fields can be declared).

If there isn't already some kind of per instance internal custom branding mechanism, required to implement other existing class functionality, an alternative to not bring that cost to all classes could be to make it opt-in.

There could be some additional cost to implement it for classes having no private fields, though.

One way to maybe have this branding using existing mechanisms and no additional costs would be for instances of these opting-in classes to have their prototype be immutable. The prototype object itself would have its constructor property be immutable. Not sure if it possible to prevent creating local constructor properties in the instances, which would allow overriding the prototype's inherited value.

Example:

stable class Foo {
   
}

// Somewhere
if (o instanceof Foo) {

}

The problem would be shifted into providing more guarantees to objects created using classes.

Possibly, even, the machinery achieving this could be provided as utilities, so that the same guarantees could be achieved by poor-man constructor functions, not using the class statement.

@ljharb
Copy link
Member

ljharb commented Sep 23, 2020

Certainly constructor being reliable (it is precisely zero reliable currently) would be great (altho this new form would still have to install a locked-down own Symbol.hasInstance property to make instanceof reliable).

However, forcing a class to adopt observable changes in order to be checked in this manner violates encapsulation - it makes "adding brand checking" a breaking change. A critical ability with private fields is the ability to make adding and removing them on a class be entirely unobservable (by adding a guard around the field access).

@hax
Copy link
Member

hax commented Sep 23, 2020

However, forcing a class to adopt observable changes in order to be checked in this manner violates encapsulation - it makes "adding brand checking" a breaking change.

I don't get it. As I understand, "adding brand checking" is always a breaking change.

A critical ability with private fields is the ability to make adding and removing them on a class be entirely unobservable (by adding a guard around the field access).

What "adding and "removing private fields on a class" mean? If a private fields was installed on an object u can't remove it. So I guess u mean in the next version of your code u remove/add private field, but in almost all cases (refactoring public fields to private fields or something like that), it will be breaking change.

@ljharb
Copy link
Member

ljharb commented Sep 23, 2020

Adding unguarded brand checking is a breaking change. With a guard (try/catch, or this proposal), it can be made to not be a breaking change.

Yes, I'm talking about across time - so I can avoid a semver-major bump. Certainly removing a public property is unavoidably breaking, but we're not talking about public fields here :-)

@hax
Copy link
Member

hax commented Sep 23, 2020

So what's the key difference between #x in o guard and a general guard like o is class ? Isn't that could also make it not be a breaking change?

@ljharb
Copy link
Member

ljharb commented Sep 23, 2020

The former is the user opting in to checking an implementation detail they created.

The latter is automatically opting all classes and instances into carrying around additional hidden internal state, on the possibility someone will apply this is class check.

I'm not saying that this kind of general "is class" check is bad - I'd love it to exist - but it has its own problems, and simply wouldn't address the ergonomics. I have code that's accessing #x on o, and i want to check before i do that if #x exists in o - it would be pretty confusing if the only way to do that didn't involve mentioning #x.

@hax
Copy link
Member

hax commented Sep 23, 2020

I am not sure how these two have significant difference in practice. Or maybe I'd like to say "opting in to checking an implementation detail they created" seems not common mental model developers have... It seem only apply to spec writers's checking internal slot cases.

As my experiences, js programmers only use key in o when key is dynamic or computed, but private fields are mostly static. This is also the reason why I think it will make much sense when reification involved.

I think we need some real use cases to check what programmers expect. In my current impression, #x in o is much like a patch to overcome the missing of is class.

@ljharb
Copy link
Member

ljharb commented Sep 23, 2020

In my experience, in is often used whenever o comes from an uncontrolled source, regardless of whether key is static or not.

@hax
Copy link
Member

hax commented Sep 23, 2020

comes from an uncontrolled source

All public apis can't control the source of the objects passed to them, but I don't think they use in everywhere, in most time authors just assume u passed in a correct thing.

@ljharb
Copy link
Member

ljharb commented Sep 23, 2020

That code is often written to be brittle does not lessen our responsibility to provide mechanisms for writing code robustly.

@hax
Copy link
Member

hax commented Sep 24, 2020

I agree. :)

But let's go back to in. My point is code like "literal" in o (which is the only form if we rule out the reification) mostly use as a loose type check aka. duck-type check (check whether it follow some interface or protocol) if programmers really want to test something. But if private field, private fields concept not interface/protocol but as u say is implementation detail. And a much strict static thing, I feel the "duck-type" checking more likely to mapping to "nominal type check" or general brand check.

@devsnek
Copy link
Member

devsnek commented Sep 24, 2020

With private declarations being able to be shared among objects (for example with the private declaration proposal), asking if something matches the private fields of a specific class is not granular enough. You need it on a per-field basis.

@dcleao
Copy link
Author

dcleao commented Sep 24, 2020

@ljharb

However, forcing a class to adopt observable changes in order to be checked in this manner violates encapsulation

Yes, you're right. However, it just seems like a perfectly acceptable compromise for a new, much needed and powerful capability — safe class testing in JavaScript.
If one really needs the ability to test for specific private fields, without compatibility breakage, one could still use the try/catch technique.

@dcleao
Copy link
Author

dcleao commented Sep 24, 2020

@hax

In my current impression, #x in o is much like a patch to overcome the missing of is class.

Precisely.
TypeScript's type guards exist for dealing with this much needed capability. Of course, they're much more general in TypeScript, supporting typeof types and interfaces, for example.

@dcleao
Copy link
Author

dcleao commented Sep 24, 2020

@devsnek
About https://github.com/tc39/proposal-private-declarations, it says:

A proposal to add Private Declarations, allowing trusted code outside of the class lexical scope to access private state.

It's interesting to see typing as something which could be reduced to the most granular level, the property.
But I don't think that that is what devs really need... Typically, you'll want to test the existence of more than one property or condition, a set of invariants which you depend on... — a type.

Friend classes need access to several of each other's members. Would we export all private declarations to all friend classes?

Given that with private properties we've effectively left the Symbol, prototype and Hash Table interpretation of objects, I believe it would be better to just go the Type way.

Solution A
A possible solution which would handle the needs of both proposals (branding and shared private state) would be:

  1. Define the concept of an interface (e.g. https://github.com/tc39/proposal-first-class-protocols)
  2. Allow private fields in an interface
  3. Classes implementing an interface with private properties need to declare those (or these get implicitly declared)
  4. Allow exporting an interface type
  5. Require explicitly exporting the private part of an interface to specific friend modules

Solution B
Alternatively, create the concept of something like interfaces whose visibility to the outside depends on it being universally or specifically exported, to all or to certain modules.
They would be implemented by classes, using class private members. The interface would be exported to all or just friend modules, thus providing access to said implementations, even if implemented with private members.

I would almost certainly never use private properties on ad hoc "untyped", plain (HashTable) objects.
And, afaict, it looks like an anti-pattern to be promoting for.

@hax
Copy link
Member

hax commented Sep 24, 2020

@devsnek I am not sure I understand what you mean. I think code could help. Could u give a small example? Thank you very much!

@ljharb
Copy link
Member

ljharb commented Sep 24, 2020

@dcleao in JS, a "friend class" isn't really a thing that exists. With private declarations, two friend classes would be ones created in the same lexical scope as the declaration.

That you might never use that doesn't mean it's a) bad or b) others won't. I'd use it constantly, and I think it's quite a good pattern.

@ljharb
Copy link
Member

ljharb commented Dec 14, 2020

var someGlobal;
class C() {
  constructor() {
    someGlobal = this;
    throw null;
  }
}

class D extends C {
  #field = 42;
  constructor() {
    super();
  }
  hasField() {
    return #field in this;
  }
}

try { new D(); } catch { }

someGlobal instanceof D // true
someGlobal.constructor === D // true
someGlobal.hasField() // false
someGlobal of class D // ??

In this example, what is someGlobal of class D? Certainly it could be defined to be false, if we define of class to mean "has D's constructor completed".

However, what about this example?

var someGlobal;
class C() {
  constructor() {
    someGlobal = this;
  }
}

class D extends C {
  #field = 42;
  constructor() {
    super();
    throw null;
  }
  hasField() {
    return #field in this;
  }
}

try { new D(); } catch { }

someGlobal instanceof D // true
someGlobal.constructor === D // true
someGlobal.hasField() // true
someGlobal of class D // false

In this case, the previous definition of of class (the only possible one, i'd argue) means that this must be false, but it does in fact have the field - and that's what this proposal checks.

In other words, while I think that a generic proposal for this is awesome, I think it simply does not replace or subsume this proposal's featureset, because of currently allowed edge cases. Given other delegate feedback from today's TC39 incubator call, I'm going to close this (but i encourage you to pursue this in a different proposal).

@ljharb ljharb closed this as completed Dec 14, 2020
@hax
Copy link
Member

hax commented Dec 14, 2020

I disagree. someGlobal of class D still could be true.

The semantic only depend on how we define the time to install the brand.

Note this is only the edge case (which intentionally create a partial object --- a very bad thing in real OOP), I really don't like use such impractical edge case to differentiate two possible solution.

@ljharb
Copy link
Member

ljharb commented Dec 14, 2020

If the full constructor for D has not run - even if the private fields are all installed - it would be highly inappropriate to claim it's a "real instance" of D.

The intention of this proposal is to detect if a specific field is present. The proposal mentioned in this issue simply doesn't - and can not possibly - satisfy that for all cases.

@bathos
Copy link

bathos commented Dec 14, 2020

It’s also worth noting that even if one of the private fields is initialized, it is still possible that not all of them are.

class Foo extends function(o) { return o; } {
  #first = 1;
  #second = (() => { throw null })();

  static hasFirst = o => checkHas(() => o.#first);
  static hasSecond = o => checkHas(() => o.#second);
}

let checkHas = fn => {
  try {
    return fn(), true;
  } catch {
    return false;
  }
};

let obj = {};

try { new Foo(obj); } catch {}

console.log('obj.#first exists', Foo.hasFirst(obj));   // true
console.log('obj.#second exists', Foo.hasSecond(obj)); // false

It’s not just unsuccessful construction where this can come up, either. Private fields can be manually reified and shared with things outside the class scope where they were declared — class scope only constrains their syntactic availability, but you can create two classes (or even arbitrary object factories) that initialize the same private field.

Example of manual reification w/ slot-style TypeError messages
class PrivateField {
  #add; #get; #set;

  constructor(name) {
    let capability = PrivateField.#createCapability(`${ name }`);

    this.#get = capability.get;
    this.#set = capability.set;
    this.#add = capability.add;
  }

  get(o) {
    return PrivateField.#access(this.#get, o);
  }

  has(o) {
    try {
      return this.#get(o), true;
    } catch {
      return false;
    }
  }

  initialize(o, v) {
    return PrivateField.#access(this.#add, o), this.#set(o, v), o;
  }

  set(o, v) {
    return PrivateField.#access(this.#set, o, v);
  }

  static #access(fn, o, v) {
    try {
      return fn(o, v);
    } catch {
      throw new TypeError('Illegal invocation');
    }
  }

  static #createCapability(name) {
    // Doesn’t have to be eval to work; this is just to let it
    // show up with a specific name in devtools.

    return eval(
      `(class $ extends function(o){return o}{` +
        `#${ name };` +
        `constructor(o){super(o)}` +
        `static add=o=>void new $(o);` +
        `static get=o=>o.#${ name };` +
        `static set=(o,v)=>o.#${ name }=v` +
      `})`
    );
  }
}

let $eggCount = new PrivateField('eggCount');
let example = { get eggCount() { return $eggCount.get(this); } };

$eggCount.initialize(example, 12);

console.log(example, example.eggCount);

@hax
Copy link
Member

hax commented Dec 15, 2020

If the full constructor for D has not run - even if the private fields are all installed - it would be highly inappropriate to claim it's a "real instance" of D.

This is only a definition issue, not a practical issue. Actually in other OOP languages like Java or C#, u can also throw error in constructor to and the instanceof or is or similar test in those languages also return true.

@ljharb
Copy link
Member

ljharb commented Dec 15, 2020

I think it’s both - if we’re going to add new syntax solely for determining if something is a “real” instance of a class, it’d have to actually meet that definition, or else it would be confusing in practice.

@hax
Copy link
Member

hax commented Dec 16, 2020

@ljharb It's not a black/white problem. In other OOP languages, there is also similar dark side (throw before constructor/initializer finished, is that object a "real" instance?). Eventually there will be a definition for such edge cases.

So the only important point is whether "class brand"-style solution could achieve the same behavior to "duck type"-style solution.

@ljharb
Copy link
Member

ljharb commented Dec 16, 2020

That's something that I think is great to explore in a separate "class brand" proposal - but it still doesn't completely solve the problem this proposal solve, so it wouldn't make sense to block this one on it.

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

5 participants