Skip to content

(WIP) Private name class field transformation #8

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

Conversation

joeywatts
Copy link

@joeywatts joeywatts commented Aug 28, 2018

Description of Changes

  • Transforms class properties with private names into WeakMap declarations
  • Transforms private name accesses into calls to a helper function which accesses the appropriate emitted WeakMap.
  • Transforms private name mutations (except unary operators) into calls to a helper function which accesses the appropriate emitted WeakMap.

Outside the Scope of this PR

Steps to Test

  • Build the compiler (npm run build:compiler)
  • Run tests (npm test).
  • Check compiler output of any example programs using ES private properties.

kpfleming and others added 19 commits August 21, 2018 13:45
and check that private names not used in parameters

Signed-off-by: Max Heiber <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
@@ -3382,12 +3385,17 @@ namespace ts {
addRange(statements, funcStatements, classBodyEnd + 1);
}

// Add other class statements (such as the WeakMap declarations output by the 'esnext'
// transformer for private names).
addRange(statements, classStatements, /*start*/ 1);
Copy link
Author

Choose a reason for hiding this comment

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

Could use some guidance here. This was to avoid the WeakMaps being placed after the return statement for the class. How were end of declaration markers ending up in classStatements? classStatements is filtered by isVariableStatementWithInitializer. It seems that end of declaration markers are synthetic nodes, wouldn't they not be variable statements at all?

Copy link

Choose a reason for hiding this comment

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

Not sure I follow, but would be happy to discuss

if (nameString in environment) {
return environment[nameString].weakMap;
}
throw new Error("Accessing undeclared private name.");
Copy link
Author

Choose a reason for hiding this comment

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

I should probably not throw here? This should be caught by a parse error, but what should the transformer do? It seems like the approach that's usually taken is that the transformer should just keep the original node if something like this occurs, in which case, I should change this to return undefined and have callers bail on the transformation.

Copy link

Choose a reason for hiding this comment

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

Given that you said "This should be caught by a parse error":

  • I think what you want is an assertion, Debug.assertNever, Debug.assert, etc. are used elsewhere in the transformer

  • What case should be caught by the parser? If you give an example, I should be able to figure out what the parser does

Copy link
Author

Choose a reason for hiding this comment

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

Thanks! I'll check out the usage of Debug.assert*

I just mean that if a private name is accessed in a class, but it is not declared, the parser will output something like "Private name not declared (or whatever the actual wording is)."

class X {
     something() { this.#myProperty = 1; } // "#myProperty" is not declared.
}

Copy link
Author

Choose a reason for hiding this comment

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

I've resolved this to emit unchanged output for undeclared private names (which is similar to the behavior of other syntax errors).

setSourceMapRange(statement, moveRangePastModifiers(property));
setCommentRange(statement, property);
setOriginalNode(statement, property);

Choose a reason for hiding this comment

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

This looks like a mistake.

if (isPrivateProperty(property)) {
continue;
}
const statement = createStatement(transformInitializedProperty(property, receiver));
Copy link

@Neuroboy23 Neuroboy23 Sep 7, 2018

Choose a reason for hiding this comment

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

By bailing out early here, it changes the order in which the property initializers are evaluated. We need to preserve a strict top-down evaluation order. Probably the right way to do this is to add the statements for private fields into the same statements array being added to below, preserving order.

Choose a reason for hiding this comment

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

Input:

let a = 0;
class C {
    foo = ++a;
    #x = ++a;
}

Output:

let a = 0;
class C {
    constructor() {
        _x.set(this, ++a); // BAD: evaluation order is swapped
        this.foo = ++a; // BAD: evaluation order is swapped
    }
}
var _x = new WeakMap;

Copy link
Author

Choose a reason for hiding this comment

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

This is somewhat complicated because the TypeScript transformer will transform class property initializers to constructor statements. If the target output is ESNext, then we need to keep the private named property declaration in the class to keep valid ESNext syntax.

After further thought, I now think that we should move this behavior for ALL properties (not just private names) to the ESNext transformer, since you can now have property initializers in ESNext. (I'm also hoping that the initializer semantics are the same in the ESNext proposal as they are in the TypeScript class implementation). I'd like to get the TypeScript team's thoughts on this idea.

Copy link
Author

Choose a reason for hiding this comment

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

I've now fixed this issue with private-named property initializers. It now relies on the TS transformer moving the initializers to the constructor (in the original order) and initializing the WeakMap values to undefined at the top of the constructor (like _x.set(this, void 0);).

@mheiber
Copy link

mheiber commented Sep 7, 2018

Could you add some tests? Happy to talking testing strategy

@@ -1194,6 +1194,11 @@ namespace ts {
);
}

function isPrivateProperty(member: ClassElement): member is PropertyDeclaration {
Copy link

Choose a reason for hiding this comment

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

The return type of the function doesn't match what it does: the body checks not just that the member is a property declaration, but that the members' name is a private name. So which is right, the return value or the behavior?

Also, the name could be confusing because private properties are also private properties: maybe name it something like "privateNamedProperty" would be better?

Copy link
Author

Choose a reason for hiding this comment

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

This can be more correctly expressed as member is PropertyDeclaration & { name: PrivateName }. Maybe there should be a PrivateNamedPropertyDeclaration:

interface PrivateNamedPropertyDeclaration extends PropertyDeclaration {
    name: PrivateName;
}

if (isCompoundAssignment(node.operatorToken.kind)) {
let setReceiver: Expression;
let getReceiver: Identifier;
if (!isIdentifier(node.left.expression) && !isKeyword(node.left.expression.kind)) {
Copy link

Choose a reason for hiding this comment

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

what happens in the case where the first half of the conjunction is true but the second is false?
In that case node.left.expression would not be an identifier, but you are casting to identifier on line 485. How could we correctly handle this case?

Copy link
Author

Choose a reason for hiding this comment

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

Ah, good catch. I'll see if I can get these types right. For context, this is checking whether the receiver (node.left.expression) is an identifier or a keyword (like this). If it's neither, it treats it as a non-trivial expression which could result in modification of the receiver, so we need to create a temporary variable to store the result of this expression.

@joeywatts joeywatts force-pushed the private-name-transformation branch from c8090dc to fec6ad8 Compare September 11, 2018 18:12
@joeywatts
Copy link
Author

Added a bunch of test cases and addressed some bugs that I've found. Let me know if there are suggestions for more tests or any perceived issues!

Signed-off-by: Joseph Watts <[email protected]>
};
return Test;
}());
var _field = new WeakMap;
Copy link

Choose a reason for hiding this comment

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

minor: why is the declaration of _field below the class definition?

console.log(this.#field);
}
}

Copy link

Choose a reason for hiding this comment

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

is there a way to test the es2015 emit? I'd like to see what this looks like with a class class

Copy link

@mheiber mheiber Oct 12, 2018

Choose a reason for hiding this comment

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

//@target: ES2015

if (isPrivateName(node.name)) {
const weakMapName = accessPrivateName(node.name);
if (!weakMapName) {
return node;
Copy link

Choose a reason for hiding this comment

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

if the checker doesn't catch undeclared private names do they just end up in the output unchanged?

Copy link
Author

Choose a reason for hiding this comment

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

The checker should catch them and output errors, but the checker won't have any impact on the transformer. Even if the checker errors, the transformer needs to output some code. If a private name is used and there's no declaration, we just leave it in the output unchanged.


function endPrivateNameEnvironment(): PrivateNameEnvironment {
const privateNameEnvironment = currentPrivateNameEnvironment();
// Destroy private name environment.
Copy link

Choose a reason for hiding this comment

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

nothing uses the return value. Do we need it?

function endPrivateNameEnvironment(): PrivateNameEnvironment {
const privateNameEnvironment = currentPrivateNameEnvironment();
// Destroy private name environment.
delete privateNameEnvironmentStack[privateNameEnvironmentIndex--];
Copy link

@mheiber mheiber Oct 1, 2018

Choose a reason for hiding this comment

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

I had to look up what delete array[index] does. It makes the value of the array at index undefined. Is that what we want?

  • If the privateNameEnvironmentStack is truly a stack, then we could use pop() to remove and last() (from core.ts) to get the current environment. We wouldn't need to keep track of an index.
  • If the privateNameEnvironmentStack is not actually a stack, then we can still avoid having undefineds in our array with splice
  • If we do want undefineds in our array, I'd prefer the more explicit arr[index] = undefined

@@ -3738,6 +3738,28 @@ namespace ts {
&& isLeftHandSideExpression(node.left);
}

export function isCompoundAssignment(kind: BinaryOperator): kind is CompoundAssignmentOperator {
Copy link

@mheiber mheiber Oct 1, 2018

Choose a reason for hiding this comment

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

note for other reviewers: these two functions were moved from generators.ts

@mheiber mheiber changed the title (WIP) Private name transformation (WIP) Private name class field transformation Oct 4, 2018
});
const ctor = find(
members,
(member) => isConstructorDeclaration(member) && !!member.body
Copy link

Choose a reason for hiding this comment

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

I wasn't able to come up with valid TS where there's a constructor without a body: what is the !!body check for?

Copy link
Author

@joeywatts joeywatts Oct 9, 2018

Choose a reason for hiding this comment

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

This actually shouldn’t be relevant in the ESNext transformer. Constructor without a body is valid syntax with overload signatures but they should be elided by the time the code gets here

@@ -26,6 +26,19 @@ namespace ts {
let enclosingFunctionFlags: FunctionFlags;
let enclosingSuperContainerFlags: NodeCheckFlags = 0;


Copy link

@mheiber mheiber Oct 13, 2018

Choose a reason for hiding this comment

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

Easier said than done, but I think it would fit the style of the codebase (and also be safer and easier to manage) if we use the stack itself instead of implementing a separate privateNameEnvironmentStack.

This is done in ts.ts using saveStateAndInvoke but the general pattern is like this (I think):

let privateNameEnvironment = undefined;

function visitClassDeclaration(...) {
    const savedPrivateNameEnvironment = privateNameEnvironment
    privateNameEnvironment = {}
    visitEachChild ....
    doStuff()
    privateNameEnvrironment = savedPrivateNameEnvironment
}

Written mostly to check my own understanding. I'm experimenting with this on a branch.

Copy link

Choose a reason for hiding this comment

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

PR for the change: joeywatts#6

Copy link

Choose a reason for hiding this comment

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

doh! Re your follow-up: silly of me to forget that we have to keep an environment stack around to deal with lexical scoping


function createClassPrivateFieldGetHelper(context: TransformationContext, receiver: Expression, privateField: Identifier) {
context.requestEmitHelper(classPrivateFieldGetHelper);
return createCall(getHelperName("_classPrivateFieldGet"), /* typeArguments */ undefined, [ receiver, privateField ]);
Copy link

Choose a reason for hiding this comment

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

privateField here is an AST Node (Identifier) that will be reused in multiple parts of the AST. Is that safe? It's conceivable that a later stage in the transform pipeline might modify one branch of the AST and not expect another branch to change as a result.

Copy link
Author

Choose a reason for hiding this comment

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

The transformers that are visiting the AST return new nodes (they don't mutate the existing AST as I understand it). I believe it should be fine to use the Identifier in two places, but let me check to see if there's a place in existing code where that is done. I know there are setTextRange and setCommentRange functions that actually can modify AST nodes...

Copy link

Choose a reason for hiding this comment

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

I think the way you did it is how it's done elsewhere, too, but I'm curious about how it works.

Copy link

@Neuroboy23 Neuroboy23 left a comment

Choose a reason for hiding this comment

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

@joeywatts, did you intentionally deal with statics in these commits, or is it just natural fallout that initializers of static private-named properties are handled? This transformation doesn't strip the type annotation from static properties when targeting esnext. If you had to deal with statics explicitly, is there opportunity to split that into a separate PR? If no to either of those things, please amend this PR to strip away the type annotations.

Input:

class C {
    static #name: any = 5;
}

Output (--target esnext):

class C {
    static #name: any;
}
C.#name = 5;

Also, is this related? When I use the same input with --target es2015, I get this:

C:\dev\TypeScript\built\local\tsc.js:83978
                throw e;
                ^

TypeError: Cannot use 'in' operator to search for '#name' in undefined
    at accessPrivateName (C:\dev\TypeScript\built\local\tsc.js:66538:28)
    at visitBinaryExpression (C:\dev\TypeScript\built\local\tsc.js:66745:35)
    at visitorWorker (C:\dev\TypeScript\built\local\tsc.js:66478:28)
    at visitorNoDestructuringValue (C:\dev\TypeScript\built\local\tsc.js:66454:20)
    at visitNode (C:\dev\TypeScript\built\local\tsc.js:60939:23)
    at Object.visitEachChild (C:\dev\TypeScript\built\local\tsc.js:61213:59)
    at visitExpressionStatement (C:\dev\TypeScript\built\local\tsc.js:66719:23)
    at visitorWorker (C:\dev\TypeScript\built\local\tsc.js:66504:28)
    at visitor (C:\dev\TypeScript\built\local\tsc.js:66451:20)
    at visitNodes (C:\dev\TypeScript\built\local\tsc.js:60990:48)

@joeywatts joeywatts closed this Nov 7, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants