rdking / proposal-class-members

https://zenparsing.github.io/js-classes-1.1/
7 stars 0 forks source link

Babel plugin #9

Open mbrowne opened 5 years ago

mbrowne commented 5 years ago

I created a preliminary Babel plugin for this proposal. I probably seem like an unlikely person to create this plugin, but although I still favor the class fields proposal I do think this proposal deserves at least a chance of becoming better known by the community. I think being able to actually try it out like we now can with class fields is an important part of that. (And also, to be honest, this was a good opportunity to update my Babel plugin authoring skills to Babel 7 ;-) ).

Currently it only transpiles private variables, including private static variables (AKA class variables). I wrote some code to transform public properties as well and put them on the prototype as per @rdking's desire, but I commented it out since that part seems to still be evolving. Also, I think it would be disastrous to simply put properties on the prototype without doing something to address the foot-gun, so I didn't want to just put public properties on the prototype and leave it at that. (However well-known the foot-gun might be outside of classes, putting data properties on the prototype would be surprising enough to many developers in the first place that expecting people to be aware of the foot-gun is completely unrealistic IMO.)

I realize that if the plugin doesn't implement this proposal correctly, it could do more harm than good, but I also had to consider what makes the most sense for transpilation purposes, so I settled on WeakMaps as with @babel/plugin-proposal-class-properties (which implements the class fields proposal, and which I used as a reference for the implementation).

My goal here was to get a basic POC working...I'm not really interested in updating it with all the features in the proposal. @rdking, I don't know if you would have time or interest in working on it, but if not then maybe one of the other supporters of this proposal or classes 1.1 would be interested.

A note on the implementation: the only way to add new syntax to Babel is to fork the parser. Unfortunately the only way I was able to get it completely working was to use yarn's resolutions feature because I also needed to fork @babel/types, which needed to make parsing-related helper methods work. Because of that and other reasons, using the existing examples directory is a lot easier than creating your own project. Hopefully I'll get a response to this issue: https://github.com/babel/babel/issues/9009.

CC @hax @shannon @Igmat @bdistin @aimingoo

mbrowne commented 5 years ago

Here's how instance variables are transpiled...

Source:

class Demo {
  let x, y;
  const z = 0;

  foo() {
    this::x = 1;
    console.log(this::x, this::y);
    console.log(this::z);
    // TypeError
    this::z = 1;
  }
}

new Demo().foo();

Transpiled:

function _classInstanceVariableGet(receiver, privateMap) { if (!privateMap.has(receiver)) { throw new TypeError("attempted to get instance variable on non-instance"); } return privateMap.get(receiver).value; }

function _classInstanceVariableSet(receiver, privateMap, value) { if (!privateMap.has(receiver)) { throw new TypeError("attempted to set instance variable on non-instance"); } var descriptor = privateMap.get(receiver); if (!descriptor.writable) { throw new TypeError("attempted to set non-writable instance variable"); } descriptor.value = value; return value; }

const Demo = (() => {
  class Demo {
    constructor() {
      _x.set(this, {
        writable: true,
        value: void 0
      });

      _y.set(this, {
        writable: true,
        value: void 0
      });

      _z.set(this, {
        writable: false,
        value: 0
      });
    }

    foo() {
      _classInstanceVariableSet(this, _x, 1);

      console.log(_classInstanceVariableGet(this, _x), _classInstanceVariableGet(this, _y));
      console.log(_classInstanceVariableGet(this, _z)); // TypeError

      _classInstanceVariableSet(this, _z, 1);
    }

  }

  var _x = new WeakMap();

  var _y = new WeakMap();

  var _z = new WeakMap();

  return Demo;
})();

new Demo().foo();
rdking commented 5 years ago

@mbrowne Thanks for this. I'm going to look at the work you've done and see if I can get it to align more with this proposal. This first step alone is already monumental.

rdking commented 5 years ago

Thinking about how this plugin works has made me realize something I'm not sure how I feel about just yet. The following is perfectly valid code:

class Ex {
  let foo = 1;
  const static foo = 2;
  foo = 3; //on the prototype
  static foo = 4; // on the constructor
  inst foo = 5; //If public "fields" is merged to this proposal.... on the instance
}

This is the natural side effect of not maintaining a single namespace between public and private members. Access for each looks like this:

  //let foo = 1;
  this::foo;
  //const static foo = 2;
  this.constructor::foo;
  //foo = 3;
  this.foo; //Original value lives at: Ex.prototype.foo || Object.getPrototypeOf(this).foo || this.__proto__.foo;
  //static foo = 4;
  this.constructor.foo;
  //inst foo = 5;
  this.foo; //No means to access original value

That's 5 completely distinct namespaces to put members. Considering the contention between protoype-based and instance-based properties, we can reduce the set to 4, but that's still a large amount of duplication of the same name. It's the same thing in class-fields so it's nothing new, but still...

hax commented 5 years ago

Actually I myself also wrote a experimental babel fork for js-classes-1.1: https://github.com/hax/babel/commit/a274086e7b4a2f3a2071374ab35143635c1c96e3 (only parser ready, no transform yet).

mbrowne commented 5 years ago

FYI, the source code of the parser for my implementation is on this branch: https://github.com/mbrowne/babel/tree/class-members-syntax

Diff: https://github.com/mbrowne/babel/compare/babel-parser-minimum-setup...mbrowne:class-members-syntax

(It doesn't include parsing of the hidden modifier since that's part of classes 1.1 but not this proposal.)

mbrowne commented 5 years ago

@rdking If you were to add the inst keyword for public instance properties, then IMO it would be a good idea to drop the syntax for public prototype properties. I don't see a significant use case for dedicated syntax for both (assuming decorators would still be able to change the placement with this proposal).

mbrowne commented 5 years ago

...well, using a decorator to make an inst property into a prototype property might be weird, but anyway I don't see why you'd need prototype data properties if you already had instance properties.

rdking commented 5 years ago

The point of the prototype property is just like the point of not injecting super even when it is needed. It enables legacy behaviors. Then there's the whole thing about how fields don't really fit the OO paradigm of ES. I understand that many if not most people ignore the whole feature just to avoid the 1:5 foot-gun case of non-function objects on the prototype. However, that doesn't mean everyone does. As has been stated before: "If it can be done, someone is doing it."

If I include inst, it will come in 2 flavors:

There's no way at all that I will consider selecting one over the other as the only approach. This is something public-fields gets horribly wrong. It's always been up to the developer whether or not to set or define an instance-specific property. If it must exist, this "fields" thing should continue to reflect that.


On the other side of things, I'm writing up a proposal to fix the prototype foot-gun once and for all. I'm also going to write a proposal to add a few new functions to Object to handle the needs @ljharb mentioned. They've been using a butter knife to turn an allen-head screw for far too long. As I see it, that's why people want these "fields", to make it easier for the butter knife to turn the screw. This other proposal will provide the much needed allen wrenches.

ljharb commented 5 years ago

Adding new methods to Object would have to make sense on its own (I’m skeptical here, but as the one who’s added every post-ES6 Object method, I’m open to being convinced), but would not alter the class syntax proposal landscape unless the entire ecosystem switched en masses to use them, which it won’t.

rdking commented 5 years ago

The proposal I'm writing will work regardless of the class syntax in use, and will be especially useful for cases where prototype inheritance is fully utilized. It may take quite some time before the entire ecosystem could warm to using the new functions. However, if that happens, "fields" may wind up being seen as an anachronism... maybe. It all depends on whether or not TC39 accepts the proposal once submitted.

hax commented 5 years ago

using a decorator to make an inst property into a prototype property might be weird

Actually I never understand why decorators provide the power of changing placement... for example, from prototype to static (why not the author just use static keyword)...

rdking commented 5 years ago

It's a side effect of allowing decorators to generate properties of any kind.

hax commented 5 years ago

@rdking I mean it may be possible only allow modification of value of decorated definitions, though still allow generate new properties of any kind.

mbrowne commented 5 years ago

A good example use case of changing placement is the @bound decorator shown here: https://github.com/tc39/proposal-decorators/blob/master/README.md#decorators. Note: in this case the decorator actually leaves the original prototype method alone and adds an additional instance method rather than just replacing the method descriptor with a new placement. There are other possible approaches of course, while still leaving the original prototype method untouched...you could add the bound method as a private field given the class fields proposal (not sure if the current spec allows for that anymore), or with this proposal you could add it as a private instance variable.

mbrowne commented 5 years ago

...so I guess I'm not disagreeing with @hax that actually changing placement of an existing field isn't particularly useful, but I'd have to think about it some more as there might be use cases I'm overlooking.

hax commented 5 years ago

@mbrowne Yes, I know @bound, and we also know some are against this decorator (you can check the issue list in decorator proposal, may be already be closed). Actually I think @bound is a wrong way to solve the issue, we need some better solutions (for example, method extract operator).

I very doubt there is any good use case of changing placement.

Some TC39 members (don't remember who, maybe @ljharb ) said private symbol give too much power by default? But it seems decorators is what really give too much power by default...

ljharb commented 5 years ago

A method extraction operator wouldn’t solve the use case of a @bound decorator, because it would have to create a new function object every time, and it’s important that the bound method be === across all uses of the same instance.

rdking commented 5 years ago

I just looked at the link regarding @bound. Isn't there a way to implement it that won't suffer any of the inheritance issues? I'm thinking that @bound could:

  1. Assert(this[targetFn] === ConstructorFn.prototype[targetFn])
  2. If (Symbol.bindings in this) a. Let p be the topmost object in the prototype chain b. Define targetFn.bind(this) on p
  3. Else a. Let p be Object.create(Object.getPrototypeOf(this)) b. Define [Symbol.bindings]=true on p c. Define targetFn.bind(this) on p d. Object.setPrototypeOf(this, p)

Done this way, if a child class overrides the function, the binding never happens. But if the function is not overridden by a child class, the binding is respected, and can still be mocked out.

ljharb commented 5 years ago

Adding a new symbol isn’t really a viable solution for what will be many kinds of decorators.

rdking commented 5 years ago

It doesn't matter if it's a well-known Symbol or if it is a Symbol known only to @bound. The result would be the same.

ljharb commented 5 years ago

Your spec steps would have the bound method shared across instances, which is also not what is desired. It must be === for the same instance, but not across instances. To be clear, the use case is, in an instance method, passing/returning this.foo - doing this::foo or similar wouldn’t work there.

mbrowne commented 5 years ago

One way to avoid inheritance issues with bound methods (if decorators allow it) is to make the instance method private. It could still delegate to a public prototype method so it's still mockable. But I don't know if a private field (or instance variable) would always be desirable, and people might find it confusing that decorating a public method creates an additional private method.

rdking commented 5 years ago

Sorry if I omitted it, but I was writing with the though that @bound would somehow deposit a function call in the constructor that would perform these steps. This would guarantee that the activity is per-instance. Not quite sure how to do that just yet without leaving behind an unwanted extra property.

rdking commented 5 years ago

@ljharb I figured out how to get rid of the extra field. Here's a test of the idea (not in a decorator):

function assert(val) {
  if (!val) throw new Error("Assertion failed!");
}

function bind(fn, instance, dummyProp) {
  assert(fn === Test.prototype[fn.name]);
  Object.defineProperty(instance, dummyProp, {
    configurable: true,
    set(val) {
      delete this[dummyProp];
    }
  });

  if (bind.bindings in instance) {
    let p = Object.getPrototypeOf(instance);
    Object.defineProperty(p, fn.name, {
      configurable: true,
      writable: true,
      value: fn.bind(instance)
    });
  }
  else {
    let p = Object.create(Object.getPrototypeOf(instance), {
      [bind.bindings]: { value: true },
      [fn.name]: {
        configurable: true,
        writable: true,
        value: fn.bind(instance)
      }
    });
    Object.setPrototypeOf(instance, p);
  }
}

bind.bindings = Symbol("Bindings");

var Test = class Test {
  //@bound
  toBeBound() {
    if (this instanceof Test) {
      console.log("Called from a Test instance");
    }
    else {
      console.log("Called from a non-Test instance");
    }
  }
  constructor() {
    //added by @bound toBeBound() {...}
    this.junkField = bind(this.toBeBound, this, "junkField");
  }
}

var test1 = new Test;
var test2 = new Test;
console.log(`test1 has a 'junkField' = ${"junkField" in test1}`);
console.log(`test2 has bindings = ${bind.bindings in test2}`);
console.log(`toBeBound() is the same in both test objects = ${test1.toBeBound === test2.toBeBound}`);

console.log(`calling toBeBound() from the prototype...`);
Test.prototype.toBeBound()
console.log(`calling toBeBound() from test1...`);
test1.toBeBound();
console.log(`calling toBeBound() from test1 with a new object as the context...`);
test1.toBeBound.call({});

I ran this code in Chrome debugger. Here's the output:

VM370:48 test1 has a 'junkField' = false
VM370:49 test2 has bindings = true
VM370:50 toBeBound() is the same in both test objects = false
VM370:52 calling toBeBound() from the prototype...
VM370:36 Called from a non-Test instance
VM370:54 calling toBeBound() from test1...
VM370:33 Called from a Test instance
VM370:56 calling toBeBound() from test1 with a new object as the context...
VM370:33 Called from a Test instance

Isn't this the desired effect?

ljharb commented 5 years ago

It seems like it's still installing a bound method on a prototype, when the necessary case is to only ever put it as an own property on the instance.

rdking commented 5 years ago

The method on the prototype is the unbound original function. This is necessary to ensure that inheritance isn't broken by @bound. If a subclass is created with an override of toBeBound(), no instance-specific binding will be created (unless the subclass also uses @bound).

If the assert is removed, then the @bound decorator ends up binding the subclass's toBeBound(). That's also a valid approach. But if the original function does not exist on the prototype, then there's no consistent solution that will not break inheritance.

mbrowne commented 5 years ago

What actual problem are we trying to solve? I think the current implementation of the @bound decorator in the decorators repo (which of course is just an example, not part of the spec) already works quite well. It could be improved by obtaining the method itself by using this[key] as in Nicolo's quick demo here. So I'll probably update my implementation as well.

For more context, see https://github.com/tc39/proposal-decorators/issues/146#issuecomment-433386216.

rdking commented 5 years ago

I just saw Nicolo's example. Does exactly what I would expect. Before I was working off the descriptions provided by @hax & @ljharb. It didn't seem like the limitations they described should really exist. They would only exist if the decorator reduced the prototype function to a mere field.

hax commented 5 years ago

because it would have to create a new function object every time

@ljharb Why method extract operator can't keep the same bound version? And if it could, would you agree bound decorator is redundant?

ljharb commented 5 years ago

@hax if it did, it'd have to be frozen - and it'd also have to be kept around for the lifetime of the realm, which has a performance and memory impact that implementations would likely be unwilling to accept.

Yes, I would agree that if a memoized method extraction operator existed, then there'd be no need for a @bound decorator.

Note, though, that there's still tons of use cases for changing the placement, so it's not really productive to try to pick apart a particular decorator :-)

hax commented 5 years ago

@ljharb

there's still tons of use cases for changing the placement

Could you give some examples? Thank you.

it'd have to be frozen

Why it have to be frozen... security reason?

which has a performance and memory impact

I have no idea why @bound solution do not need frozen and have no such impact. 🤯

try to pick apart a particular decorator

Consider current possibility of changing placement:

Except bound usage (prototype => own) it's hard to understand why someone want to change placement using decorator instead of just using (or not using) static keyword. That's why I only discuss @bound.

hax commented 5 years ago

@ljharb And as previous comments, a good @bound implementation just add extra, not change the placement. So it seems no use cases of changing placement.

ljharb commented 5 years ago

I don't recall any examples off hand, but many were presented by the champions over the lifetime of this proposal - even if you manage to successfully find alternatives to all of them, there's really not a strong argument to limit an ability that already has use cases.

hax commented 5 years ago

@ljharb The problem is where can I (and all programmers who want to understand the motivation and use cases) find such use cases (of changing replacement)? Note I myself also a contributor of decorator proposal. If I can't find the information, I don't think it would be easier for 99% programmers to find them.

ljharb commented 5 years ago

That's a fair criticism for the champions of that proposal (I'm not one).

hax commented 5 years ago

@rdking @mbrowne The Nicolo's implementation of @bound has a small issue that it use {configurable: false} so if subclass use @bound again it will throw.

nicolo-ribaudo commented 5 years ago

Yeah sorry, it should probably copy the original descriptor.

mbrowne commented 5 years ago

@hax Yes, that is one of the two tweaks still needed before updating the example implementation, as described at https://github.com/tc39/proposal-decorators/issues/146#issuecomment-433595979 and my comment below it.

@rdking Some of the confusion might come from the fact that the original implementation shown in the decorators repo just changed the placement, so there was no prototype method at all...which defeated one of the main purposes of the decorator in the first place. I reimplemented it as part of this PR, but clearly it's still not perfect.

mbrowne commented 5 years ago

BTW @nicolo-ribaudo, while you're here—any thoughts on https://github.com/babel/babel/issues/9009? I realize this is a low-priority issue for Babel but it does limit the usefulness of using a custom parser if you can't also use a custom version of @babel/types.

rdking commented 5 years ago

@nicolo-ribaudo @mbrowne I was looking over the code of the Babel plugin for this proposal. What I learned is that I need a good tutorial on how to write Babel plugins before attempting to add in all the semantics of this proposal. Is there a good place to go for that?

nicolo-ribaudo commented 5 years ago

https://github.com/jamiebuilds/babel-handbook might be a good resource to start. You can test your plugins on the fly using https://astexplorer.net Also, I strongly recommend reading and trying to understand the code of some plugins in the babel/babel repository.

mbrowne commented 5 years ago

Yes, the section of the handbook about plugins is definitely helpful: https://github.com/jamiebuilds/babel-handbook/blob/master/translations/en/plugin-handbook.md

mbrowne commented 5 years ago

Back to the subject of decorators, the bigger question is the general design of the API. I think everyone is in agreement that it would be good to make it more developer-friendly, but there are multiple possible ways to do that. For anyone who's interested, here's a thread where we were recently discussing that: https://github.com/tc39/proposal-decorators/issues/162#issuecomment-434502133

rdking commented 5 years ago

Well color me depressed. After looking at the meeting notes from Sept. 26, it seems quite clear that TC39 has no intention of considering any alternative proposal other than private symbols, despite not being able to come to any consensus about it. Its also clear that class-fields is a foregone conclusion, despite the unnecessary trade-offs.

@mbrowne I'm still going to do what I can with this plugin you've created, even if I'm the only one who ever uses it. ;-)

hax commented 5 years ago

@rdking I think we still can do sth. I have sent a mail to you.

mbrowne commented 4 years ago

@rdking I came across this article that walks you through the process of adding new syntax to Babel. I haven't read the whole thing, but I thought you might find it interesting: https://lihautan.com/creating-custom-javascript-syntax-with-babel/?utm_source=Iterable&utm_medium=email&utm_campaign=the-overflow-newsletter&utm_content=10-17-19

rdking commented 4 years ago

I think I'm going to try this again. I tried sweetJS, but it really let me down, and there doesn't seem to be enough documentation or support for it. So I'm going to use that article and write a babel plugin. Wish me luck.

rdking commented 4 years ago

@mbrowne Maybe you can help me with this. I want an opinion other than mine on what the transformation should look like. Right now, I'm thinking this:

class Test {
    let v = {};
    const w = 2;
    prop x = 3;
    inst y = 4;
    inst z:= 5;

    print() {
        console.log(`(private this).v = ${v} `);
        console.log(`(private this).w = ${ w } `);
        console.log(`this.x = ${ x } `);
        console.log(`this.y = ${ y } `);
        console.log(`this.z = ${ z } `);
    }

    static print(that) {
        console.log(`(private that).v = ${that:: v} `);
        console.log(`(private that).w = ${ that:: w } `);
        console.log(`that.x = ${ that.x } `);
        console.log(`that.y = ${ that.y } `);
        console.log(`that.z = ${ that.z } `);
    }
}

should be transformed into this:

let Test = (function () {
    //The private data...
    const $$init = $$$init.bind(null, $$initializers);
    const $$pvtData = Object.create(Object.prototype, {
        v: {
            writable: true,
            value: $$init(() => ({}))
        },
        w: {
            value: 2
        }
    });

    const $$initializers = new WeakMap,
          $$pvt = new WeakMap,
    const $$$DP = $$$DP.bind(null, $$initializers, $$pvt, $$pvtData);

    let $$instance;
    let $$staticScope = {
        get print() { return Test.print; },
        set print(val) { Test.print = val; }
    }

    let $$scope = {
        get v() { return $$pvt.get($$instance).v; },
        set v(val) { $$pvt.get($$instance).v = val; },
        get w() { return $$pvt.get($$instance).w; },
        set w(val) { $$pvt.get($$instance).w = val; },
        get x() { return $$instance.x; },
        set x(val) { $$instance.x = val; },
        get y() { return $$instance.y; },
        set y(val) { $$instance.y = val; },
        get z() { return $$instance.z; },
        set z(val) { $$instance.z = val; },
        get print() { return $$instance.print; },
        set print(val) { $$instance.print = val; }
    }

    //This is the beauty of the denegrated "with" keyword. Useless, isnt it?
    with ($$staticScope) with($$scope) {
        //The class definition
        class Test extends $$DP() {
            static print(that) {
                console.log(`(private that).v = ${ $$pvt.get(that).v } `);
                console.log(`(private that).w = ${ $$pvt.get(that).w } `);
                console.log(`that.x = ${ that.x } `);
                console.log(`that.y = ${ that.y } `);
                console.log(`that.z = ${ that.z } `);
            }

            print() {
                $$instance = this;
                console.log(`(private this).v = ${ v } `);
                console.log(`(private this).w = ${ w } `);
                console.log(`this.x = ${ x } `);
                console.log(`this.y = ${ y } `);
                console.log(`this.z = ${ z } `);
            }
        }

        Object.defineProperties(Test.prototype, {
            x: {
                enumerable: true,
                configurable: true,
                writable: true,
                value: 3
            },
            y: {
                enumerable: true,
                configurable: true,
                writable: true,
                value: $$init(() => 4)
            },
            z: {
                enumerable: true,
                configurable: true,
                writable: true,
                value: $$init((key) => {
                    Object.defineProperty(this, key, {
                        enumerable: true,
                        configurable: true,
                        writable: true,
                        value: 5
                    })
                })
            }
        });
    }

    return Test;
})();

with appropriate helpers like these:

function $$$init(initializers, fn) {
    if (typeof (fn) !== "function") {
        throw new TypeError("Invalid initializer. Expected a function.");
    }

    let retval = Object.create(Object.prototype, Object.seal({}));
    initializers.set(retval, fn);
    return retval;
}

function $$initInstance(inst, proto, initializers) {
    let keys = Object.getOwnPropertyNames(proto).concat(
        Object.getOwnPropertySymbols(proto)
    );

    for (let key of keys) {
        let initKey = inst[ key ];
        if (initializers.has(initKey)) {
            inst[ key ] = initializers.get(initKey).call(inst, key);
        }
    }

    return inst;
}

function $$clonePvtData(data, initializers) {
    let retval = Object.assign({}, data);
    return $$initInstance(retval, data, initializers);
}

//The data-parent generator function
function $$$DP($$initializers, $$pvt, $$pvtData, base) {
    if (base && (typeof (base) !== "function")) {
        throw new TypeError("Invalid base class. Expected a function.");
    }
    let retval = function _(...args) {
        let _this = this;
        if (base) {
            _this = Reflect.construct(base, args, new.target);
        }

        let pvtKey = Object.seal(Object.create(Object.getPrototypeOf(_this)));
        Object.setPrototypeOf(_this, pvtKey);
        $$pvt.set(pvtKey, $$clonePvtData($$pvtData, $$initializers));
        return $$initInstance(_this, Test.prototype, $$initializers);
    }

    if (base) {
        Object.setPrototypeOf(retval, base);
        retval.prototype = base.prototype;
    }
    else {
        retval.prototype = Object.prototype;
    }
    return retval;
}

So what do you think? The benefit of this approach is that nothing from the proposal is lost in terms of functionality. Of course TC39 might balk at the use of with, but this is precisely the kind of situation with is good at handling. It lets me fake having an instance-specific closure just by injecting a single $$instance = this at the top of each lexically-defined function.

The 2 scope objects serve to limit what can be seen lexically without interfering with the running function's ability to shadow member variables. The net result is code that acts more like Java/C++ code. You'll still need to use object notation if:

Somehow, the $$$init helper function needs to be added to Reflect so that the prototype becomes 100% mutable, even for instance members.

mbrowne commented 4 years ago

My main feedback would be to think about how to keep the output relatively small, because some apps have to be very careful about their bundle size. To help with this, if it's feasible you might want to consider actually using the class keyword, and you can just initialize anything instance-specific in the constructor. That would ensure that users would still have the option of enabling or disabling babel-plugin-transform-classes, so if they're only targeting ES6+ browsers, they could transpile to native classes. Of course, this would only be helpful if the output is actually smaller that way than transpiling to a function. I'm guessing that you might need an IIFE to wrap the whole thing either way, but that's beside the point.

Note that the bundle size is more of an issue for anything that will be repeated for every class...so the helper functions you showed are definitely a good idea. You can take advantage of babel-helpers to manage these helpers the same way that Babel handles other internal helper functions. See https://babeljs.io/docs/en/babel-helpers. To enable runtime mode for helpers, simply add '@babel/transform-runtime' to the array of plugins in your Babel config file.

Regarding the with keyword, while this seems like a good use case for it on the surface, it might cause problems for anyone who wants to use your Babel plugin in a file where they want to enable strict mode (or inside a strict mode function).

rdking commented 4 years ago

The helpers are definitely runtime. I could make them compile-time, but that would only inflate the size of the compiled version of the code. I was able to add another helper and reduce the amount of code needed to set up the scope objects. I could do something similar to reduce the size of the data members as well.

I don't want to touch the constructor or any other method of the class if I can avoid it. That's why I'm thinking introducing another helper that wraps all the functions, ensuring the closure provides the correct instance variables. As for strict mode, if I detect it at compile-time, I can inflate all instance variables to their fully qualified form and remove the scope wrappers. So I don't consider that to be too much of a hurdle. The idea behind using with like this is to do as little translation as possible on the developer's code.


New Helpers


function $$redirect(scope, isStatic, instance, key, pvtMap) {
    Object.defineProperty(scope, key, {
        enumerable: true,
        get() {
            let target = isStatic ? instance.class : instance[0];
            return (pvtMap) ? pvtMap.get(target)[key] : target[key];
        },
        set(value) {
            let target = isStatic ? instance.class : instance[0];
            (pvtMap) ? (pvtMap.get(target)[key] = value) : (target[key] = value);
        }
    })
}

const $$MemberTypes = Object.freeze({
    Field: Symbol("Field"),                 // inst p = data; inst q := moreData;
    Property: Symbol("Property"),           // prop r = 42;
});

function $$makeDataMember(init, memberType, value) {
    let retval = {
        enumerable: true,
        configurable: true,
        writable: true,
    };
    switch (memberType) {
        case $$MemberTypes.Assignment:
            retval.value = value;
            break;
        case $$MemberTypes.Field:
            retval.value = init(() => value);
            break;
        default:
            throw new TypeError("Invalid member type");
            break;
    }

    return retval;
}

Reworked Compilation

Sloppy Mode

let Test = (function () {
    //The private data...
    const $$initializers = new WeakMap;
    const $$init = $$$init.bind(null, $$initializers);
    const $$pvtData = Object.create(Object.prototype, {
        v: {
            writable: true,
            value: $$init(() => ({}))
        },
        w: {
            value: 2
        }
    });

    const $$pvt = new WeakMap;
    const $$DP = $$$DP.bind(null, $$initializers, $$pvt, $$pvtData);

    let $$instance = [];
    let $$staticScope = {}, $$scope = {};
    $$redirect($$staticScope, true, $$instance, "print");
    $$redirect($$scope, false, $$instance, "v", $$pvt);
    $$redirect($$scope, false, $$instance, "w", $$pvt);
    $$redirect($$scope, false, $$instance, "x");
    $$redirect($$scope, false, $$instance, "y");
    $$redirect($$scope, false, $$instance, "z");

    //This is the beauty of the denegrated "with" keyword. Useless, isnt it?
    with ($$staticScope) with($$scope) {
        //The class definition
        class Test extends $$DP() {
            static print(that) {
                $$instance.unshift(this);
                console.log(`(private that).v = ${ $$pvt.get(that).v } `);
                console.log(`(private that).w = ${ $$pvt.get(that).w } `);
                console.log(`that.x = ${ that.x } `);
                console.log(`that.y = ${ that.y } `);
                console.log(`that.z = ${ that.z } `);
                $$instance.shift();
            }

            print() {
                $$instance.unshift(this);
                console.log(`(private this).v = ${ v } `);
                console.log(`(private this).w = ${ w } `);
                console.log(`this.x = ${ x } `);
                console.log(`this.y = ${ y } `);
                console.log(`this.z = ${ z } `);
                $$instance.shift();
            }
        }

        Object.defineProperties(Test.prototype, {
            x: $$makeDataMember($$init, $$MemberTypes.Property, 3),
            y: $$makeDataMember($$init, $$MemberTypes.Field, () => 4),
            z: $$makeDataMember($$init, $$MemberTypes.Field, 
                (key) => $$makeDataMember($$init, $$MemberTypes.Field, () => 5))
        });

        //Put the class in the holder.
        $$instance.class = Test;

        return Test;
    }
})();

Strict Mode

let Test = (function () {
    //The private data...
    const $$initializers = new WeakMap;
    const $$init = $$$init.bind(null, $$initializers);
    const $$pvtData = Object.create(Object.prototype, {
        v: {
            writable: true,
            value: $$init(() => ({}))
        },
        w: {
            value: 2
        }
    });

    const $$pvt = new WeakMap;
    const $$DP = $$$DP.bind(null, $$initializers, $$pvt, $$pvtData);

    //The class definition
    class Test extends $$DP() {
        static print(that) {
            console.log(`(private that).v = ${ $$pvt.get(that).v } `);
            console.log(`(private that).w = ${ $$pvt.get(that).w } `);
            console.log(`that.x = ${ that.x } `);
            console.log(`that.y = ${ that.y } `);
            console.log(`that.z = ${ that.z } `);
        }

        print() {
            console.log(`(private this).v = ${ $$pvt.get(that).v } `);
            console.log(`(private this).w = ${ $$pvt.get(that).w } `);
            console.log(`this.x = ${ this.x } `);
            console.log(`this.y = ${ this.y } `);
            console.log(`this.z = ${ this.z } `);
        }
    }

    Object.defineProperties(Test.prototype, {
        x: $$makeDataMember($$init, $$MemberTypes.Property, 3),
        y: $$makeDataMember($$init, $$MemberTypes.Field, () => 4),
        z: $$makeDataMember($$init, $$MemberTypes.Field, 
            (key) => $$makeDataMember($$init, $$MemberTypes.Field, () => 5))
    });

    return Test;
})();

In the interest of size, maybe it should always generate the strict mode version.