Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Decompose class extends calls #2997

Conversation

ChadKillingsworth
Copy link
Collaborator

When the extends clause of a class is not a qualified name, alias the expression so that it can be transpiled.

Allows GETELEM and Mixin functions extends to be correctly transpiled.

@lauraharker
Copy link
Contributor

@ChadKillingsworth I merged a4d3536 in today which made your unit test start failing. sorry :)

testMixinFunction(com.google.javascript.jscomp.Es6RewriteClassExtendsExpressionsTest)java.lang.IllegalStateException: SCRIPT node should be marked as containing feature const declaration.

You should be able to fix in Es6RewriteClassExtendsExpressions with something like:

NodeUtil.markFeatureAsAdded(t.getEnclosingFile(), Feature.CONST_DECLARATIONS)

Also, could this cause problems with the order in which side effects occur?

e.g. converting

f(somethingWithSideEffects1(), class extends somethingWithSideEffects2() {});

to

const tmpExtendsVar = somethingWithSideEffects2();
f(somethingWithSideEffects1(), class extends tmpExtendsVar {});

@ChadKillingsworth
Copy link
Collaborator Author

Yes - the side effects could be problematic. Here's how Babel transpiles your example:

"use strict";

function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }

function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; }

function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; }

f(somethingWithSideEffects1(), function (_somethingWithSideEff) {
  _inherits(_class, _somethingWithSideEff);

  function _class() {
    _classCallCheck(this, _class);

    return _possibleConstructorReturn(this, (_class.__proto__ || Object.getPrototypeOf(_class)).apply(this, arguments));
  }

  return _class;
}(somethingWithSideEffects2()));

I can do something similar for this case - but it will be almost completely un-typecheckable without tremendous work.

The other option would be to restrict this to the common case where the class is not used in an expression. That alone would get us a large win.

@lauraharker
Copy link
Contributor

Just fixing this for class declarations now sounds good to me. After moving class transpilation post-typechecking, we could do something like your Babel example.

@ChadKillingsworth
Copy link
Collaborator Author

Fixed review issues and added the restriction that the class must not be used as part of an expression. Rebased to master and commits squashed.

@ChadKillingsworth
Copy link
Collaborator Author

I realized there were several more common cases where this is safe to do. I updated this PR to include them (and tests).

@lauraharker
Copy link
Contributor

Importing for internal review.

Copy link
Contributor

@lauraharker lauraharker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copying @concavelenz 's comments from internal review

"baz(class extends foo.bar {});"));
}

public void testVarDeclaration() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs some tests for things we can't/won't move.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we have the tests we need now, but will address this with @lauraharker in internal review.

}
break;

case ASSIGN:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We would need to check the LHS of the expression to see if it has side-effects:

get(x++).foo = class extends get(x++).bar {}

@@ -112,6 +112,7 @@ static void addPreTypecheckTranspilationPasses(
Feature.REGEXP_FLAG_U,
Feature.REGEXP_FLAG_Y));
passes.add(es6NormalizeShorthandProperties);
passes.add(es6RewriteClassExtends);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why here instead of immediate before class rewriting? Alternately, why isn't it part of class rewriting?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the comment at https://github.com/google/closure-compiler/pull/2997/files#diff-fa51555d8bc30554f2cbba232a8033bdR41

This must be performed before transpilation of super calls. So much of class rewriting depends on the extends clause being a qualified name.

@ChadKillingsworth ChadKillingsworth force-pushed the class-extends-decompose branch 2 times, most recently from 426e44d to f3322c2 Compare July 20, 2018 14:00
@ChadKillingsworth
Copy link
Collaborator Author

@lauraharker I addressed the review issues.

One note: I decided to go ahead an handle all the cases. When possible, the decomposition happens with a simple alias. However in complex expressions, the class node is now returned decomposed in an IIFE. While these nodes will probably not have very good type checking, that's better than simply failing to transpile valid syntax.

… expression so that it can be transpiled.

Allows GETELEM and Mixin functions extends to be correctly transpiled.
When the class is used as part of an expression, decomposes in an IIFE as previous statements may cause side effects and the order of execution would be changed.
@brad4d
Copy link
Contributor

brad4d commented Jul 30, 2018

Laura and I agreed I should take this PR over, since I have some concerns.

@brad4d
Copy link
Contributor

brad4d commented Jul 30, 2018

here are my concerns.

  1. We're now actively working to move class transpilation to happen after all of the checks passes.
    That's pretty tricky, and I'm reluctant to add yet another pass that will have to be moved.
  2. We already have a pass that does something similar, Es6ExtractClasses, and I feel like the extraction done here would be better done in that pass.

I know number 2 is a no-go right now for sure, because apparently this extraction needs to happen before Es6ConvertSuper, which happens quite early.

I believe we'd be better off waiting until we get the transpilation passes moved before attempting this change.

  1. I think some of the problems this is trying to solve may disappear once transpilation moves after type checking.
  2. I think the pass itself can be simpler if we don't have to worry about type checking happening after it, as mentioned in earlier comments on this PR.

Exactly how long it takes to complete the move of class transpilation passes depends on how many issues we hit, but I'm expecting 2 to 4 weeks.

@ChadKillingsworth can you wait that long?

@ChadKillingsworth
Copy link
Collaborator Author

The lack of support for transpilation of these classes is very frustrating. It's common (in webpack), for ES6 imports/exports to be transpiled as GETELEMS. So if you have a class that extends a class imported from an ES6 module, it can't be transpiled. This comes up very frequently in real world code.

import {Foo} from './foo.js';

class Bar extends Foo {
}

Transpiles to something like:

const fooModule = require('foo');

class Bar extends fooModule['Foo'] {}

This transpilation step occurs before the compiler, thus rendering this code unusable by Closure Compiler.

I've taken multiple stabs at trying to solve this over the years and finally settled on a new small unique pass. I do not think this pass should complicate type checking - because these clauses aren't even allowed right now. And by decomposing simply where possible with a const, type inference should provide very good results in the vast majority of cases.

In addition, I have a strong suspicion that this pass will be needed even in cases where classes aren't transpiled. For instance, solving #3015 probably requires the extends clause to be a qualified name.

In short - I'd rather not wait for type checking. It shouldn't make that scenario worse for any currently supported code. And moving this class should be trivial - it should be movable at the same time that Es6ConvertSuper is moved.

@brad4d
Copy link
Contributor

brad4d commented Aug 2, 2018

OK, I'm OK with this change, but I want to make some tweaks.
I've made them in my internal copy and am running them through tests.

@ChadKillingsworth I'll push my changes back out to this PR so you can review them.

@brad4d brad4d dismissed lauraharker’s stale review August 2, 2018 18:58

I've taken this PR over from Laura

@ChadKillingsworth
Copy link
Collaborator Author

@brad4d your changes lgtm. Thanks!

@brad4d
Copy link
Contributor

brad4d commented Aug 6, 2018

Regular tests look fine. Now testing all the things.
Sent to @blickly for internal review.

@brad4d
Copy link
Contributor

brad4d commented Aug 8, 2018

Submitted internally, should be in GitHub tomorrow.

@tjgq tjgq closed this in 10c0240 Aug 8, 2018
" };",
"}",
"class Foo {}",
"class Bar extends mixObject(Foo) {}"),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ChadKillingsworth How would you tell Closure here that Bar should be augmented with the interface of the internal class? The way we've typically been doing that is to manually define an interface corresponding to the inner mixin class, and then assert that the base class @implements it:

/** @interface */
MixinInterface = function(){};
/** @return {string} */
MixinInterface.prototype.bar = function(){};

/**
 * @constructor
 * @extends {SuperClass}
 * @implements {MixinInterface}
 */
const base = Mixin(SuperClass);

class Foo extends base {
}

My understanding is that if you were to put the @implements annotation directly on the Foo class, Closure would require that Foo implements it, rather than trusting that it is implemented as in the separate @constructor case.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't fully supported yet - this is just step one. This allows the compiler to successfully transpile this code. Type checking is yet to come.

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

Successfully merging this pull request may close these issues.

5 participants