livepack icon indicating copy to clipboard operation
livepack copied to clipboard

Handle class private fields + private methods

Open overlookmotel opened this issue 4 years ago • 4 comments

Class private fields are supported in Node v12, and private methods in Node v14.

Livepack currently ignores both.

Support would be tricky to implement. By definition, both are not accessible from outside the class.

Accessing values when serializing

Methods can be detected from class.toString().

Field values would need one of:

  • Babel plugin add a hidden access method to classes.
  • Access values via V8 %... runtime functions.
  • Transpile use of private fields/methods in Babel plugin.

Recreating private fields in serialized output

Either:

  • Add a hidden method to class definitions to set private fields (possibly could be deleted after used to populate values on class instances).
  • Add code in class constructor which is triggered to set private fields when some special input is passed as argument (NB wouldn't work for static fields).
  • Polyfill use of private fields.

overlookmotel avatar Jan 30 '21 21:01 overlookmotel

Accessing private fields and methods during serialization can be accomplished as follows:

Input:

class X {
  #privField = 1;
  #privMethod() { return 2; }
  static #staticPrivField = 3;
  static #staticPrivMethod() { return 4; }
}

After Livepack Babel plugin:

const [livepack_recordClass, livepack_classSpy] = require('/path/to/livepack/class.js');
const X = livepack_recordClass( class X {
  #privField = 1;
  #privMethod() { return 2; }
  static #staticPrivField = 3;
  static #staticPrivMethod() { return 4; }

  [livepack_classSpy]() { return [this.#privField, this.#privMethod]; }
  static [livepack_classSpy]() { return [this.#staticPrivField, this.#staticPrivMethod]; }
} );

class.js:

const CLASS_SPY = Symbol('livepack.CLASS_SPY'),
  classes = new WeakMap();

function recordClass(Klass) {
  const getStaticPrivates = Klass[CLASS_SPY],
    getInstancePrivates = Klass.prototype[CLASS_SPY];
  delete Klass[CLASS_SPY];
  delete Klass.prototype[CLASS_SPY];

  classes.set( Klass, { getStaticPrivates, getInstancePrivates } );
  return Klass;
}

function getClassPrivates(Klass) {
  return classes.get(Klass).getStaticPrivates.call(Klass);
}

function getInstancePrivates(instance, Klass) {
  return classes.get(Klass).getInstancePrivates.call(instance);
}

module.exports = [recordClass, CLASS_SPY, getClassPrivates, getInstancePrivates];

This leaves the spy methods completely unobservable to user code, as they're removed immediately after being created.

When serializing, getClassPrivates() and getInstancePrivates() can be used to access the private fields/methods.

A similar technique could be used for setting private fields on classes/class instances in output code.

overlookmotel avatar Feb 17 '21 16:02 overlookmotel

See also note on #305 about side effects. That same problem would apply to private fields too.

overlookmotel avatar Nov 16 '21 13:11 overlookmotel

There is a better solution without creating and deleting secret methods:

Input:

class C {
  #x = 1;
  static #y = 2;
};
export default new C();

Babelized:

let _getX, _getY;
class C {
  #x = 1;
  static #y = 2;
  static #_dummy = (
    _getX = obj => obj.#x,
    _getY = obj => obj.#y
  );
  constructor() {
    livepack_tracker( () => [ [_getX, _getY] ] );
  }
}
export default new C();

_getX() and _getY() can then be used to extract the values of private properties of C, or any instance of C.

Same technique can be used in reverse for setting private fields of class instances in output:

// Serialized output
const scopeC = ( (_y, _setX) => [
  class C {
    #x;
    static #y = _y;
    static #_dummy = _setX = (obj, value) => obj.#x = value;
  },
  _setX
] )(2);
const C = scopeC[0],
  c = new C();
scopeC[1](c, 1);
export default c;

There remain 2 difficulties:

  1. Methods must be defined inline in the class to be able to access private fields. Currently they are added to the class prototype externally to the class. This would have to change.
  2. Class instances must be created with new C(). At present they're created with Object.create(C.prototype). Having to use new C() requires some method of exiting the class constructor before it can produce side-effects.

overlookmotel avatar Mar 03 '22 20:03 overlookmotel

Recording which classes an object was instantiated with could be achieved using the "Stamper" method mentioned on MDN.

Input:

class C {
  #foo = 123;
  getFoo() {
    return this.#foo;
  }
}
const obj = new C();
export default obj;

Instrumented:

// NB: Tracker calls omitted here for brevity
let livepack_temp_1;
class C {
  static {
    livepack_temp_1 = this;
  }
  #foo = (livepack_getScopeId.recordClassOf(this, livepack_temp_1), 123);
  getFoo() {
    return this.#foo;
  }
}
const obj = new C();
export default obj;

Internal helper:

livepack_getScopeId.recordClassOf = (obj, Klass) => {
  new ClassRecorder(obj, Klass);
  return obj;
};

class ClassRecorderSuper {
  constructor(obj) {
    return obj;
  }
}

class ClassRecorder extends ClassRecorderSuper {
  #classes;
  constructor(obj, Klass) {
    if (#classes in obj) {
      obj.#classes.push(Klass);
      return obj;
    }
    super(obj);
    obj.#classes = [Klass];
  }

  static getClasses(obj) {
    return (#classes in obj) ? obj.#classes : null;
  }
}

When serializing obj, could use ClassRecorder.getClasses() to get which classes it's instantiated as.

If class has no fields, but has private methods, livepack_getScopeId.recordClassOf(this, livepack_temp_1) would instead be included at start of the class constructor, just after the tracker call. Or if class has a super class, all super() calls would be wrapped livepack_getScopeId.recordClassOf(super(), livepack_temp_1).

recordClassOf() could alternatively store objects in a WeakMap keyed by the object and with array of classes as values. I suspect the "stamper" method will be faster, but should benchmark it - this will be on a hot path as classes will need to be looked up for every single object which is serialized.

overlookmotel avatar Dec 20 '23 13:12 overlookmotel