All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.google.javascript.jscomp.DevirtualizeMethods Maven / Gradle / Ivy

Go to download

Closure Compiler is a JavaScript optimizing compiler. It parses your JavaScript, analyzes it, removes dead code and rewrites and minimizes what's left. It also checks syntax, variable references, and types, and warns about common JavaScript pitfalls. It is used in many of Google's JavaScript apps, including Gmail, Google Web Search, Google Maps, and Google Docs.

There is a newer version: v20240317
Show newest version
/*
 * Copyright 2009 The Closure Compiler Authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.google.javascript.jscomp;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.base.Predicates.alwaysTrue;
import static com.google.common.collect.ImmutableList.toImmutableList;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.Iterables;
import com.google.javascript.jscomp.OptimizeCalls.ReferenceMap;
import com.google.javascript.rhino.IR;
import com.google.javascript.rhino.JSDocInfo;
import com.google.javascript.rhino.Node;
import com.google.javascript.rhino.Token;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.jspecify.nullness.Nullable;

/**
 * Rewrites prototype and static methods as global, free functions that take the receiver as their
 * first argument.
 *
 * 

This transformation simplifies the call graph so smart name removal, cross module code motion * and other passes can do more. * *

To work effectively, this pass depends on {@link * com.google.javascript.jscomp.disambiguate.DisambiguateProperties} running first to do a lot of * heavy-lifting. It assumes that different methods will have unique names which in general isn't * true for source JavaScript. * *

This pass should only be used in production code if property and variable renaming are turned * on. Resulting code may also benefit from `--collapse_anonymous_functions` and * `--collapse_variable_declarations` * *

This pass only rewrites functions that are part of a type's prototype or are statics on a * ctor/interface function. A host of other preconditions must also be met. Functions that access * the "arguments" variable arguments object are not eligible for this optimization, for example. * *

For example: * *

 *     A.prototype.accumulate = function(value) {
 *       this.total += value; return this.total
 *     }
 *     var total = a.accumulate(2)
 * 
* *

will be rewritten as: * *

 *     var accumulate = function(self, value) {
 *       self.total += value; return self.total
 *     }
 *     var total = accumulate(a, 2)
 * 
* *

A similar transformation occurs for: * *

 *     /** @constructor *\/
 *     function A() { }
 *
 *     A.accumulate = function(value) {
 *       this.total += value; return this.total
 *     }
 *     var total = a.accumulate(2)
 * 
*/ class DevirtualizeMethods implements OptimizeCalls.CallGraphCompilerPass { private final AbstractCompiler compiler; private ReferenceMap refMap; DevirtualizeMethods(AbstractCompiler compiler) { this.compiler = compiler; } @Override public void process(Node externs, Node root, ReferenceMap refMap) { checkState(this.refMap == null, "`process` should only be called once."); this.refMap = refMap; for (Map.Entry> referenceGroup : refMap.getPropReferences()) { processReferenceList(referenceGroup.getKey(), referenceGroup.getValue()); } } private void processReferenceList(String name, List sites) { ImmutableListMultimap functionsBySite = ReferenceMap.getFunctionNodes(sites); if (functionsBySite.isEmpty()) { return; // We can't devirtualize without a definition. } // Use the first definition to ensure that all invocations are after the definition // (temporally). It's possible that if class `A` is defined after class `B` there are calls to // `A::foo` before `B::foo` is defined. Node canonicalDefinitionSite = Iterables.get(functionsBySite.keySet(), 0); ImmutableList callSites = sites.stream() // If a site has no associated functions, it must be a call site. .filter((s) -> !functionsBySite.containsKey(s)) .collect(toImmutableList()); if (callSites.isEmpty()) { return; // No need to devirtualize without calls. } // Check that this method is safe for devirtualization. These are in (estimated) increasing // order of cost. if (!functionsBySite.keySet().stream().allMatch((s) -> isEligibleDefinitionSite(name, s))) { return; } else if (!functionsBySite.values().stream().allMatch(this::isEligibleDefinitionFunction)) { return; } else if (!callSites.stream() .allMatch((c) -> isEligibleCallSite(c, canonicalDefinitionSite))) { return; } else if (!allDefinitionsEquivalent(functionsBySite.values())) { // Remember that this is only valid because we already checked the scoping of the definitions. return; } String devirtualizedName = rewrittenMethodNameOf(name); JSDocInfo bestJSDoc = NodeUtil.getBestJSDocInfo(canonicalDefinitionSite); boolean isConstantName = bestJSDoc != null && bestJSDoc.isConstant(); for (Node callSite : callSites) { rewriteCall(callSite, devirtualizedName, isConstantName); } // We only have to rewrite one definition. We've checked they're all identical so any of them // can replace the others. The un-rewritten ones will be dead-code eliminated. rewriteDefinition(canonicalDefinitionSite, devirtualizedName, isConstantName); } private boolean isPrototypeOrStaticMethodDefinition(Node node) { Node parent = node.getParent(); Node grandparent = node.getGrandparent(); if (parent == null || grandparent == null) { return false; } switch (node.getToken()) { case MEMBER_FUNCTION_DEF: if (NodeUtil.isEs6ConstructorMemberFunctionDef(node)) { return false; // Constructors aren't methods. } return true; case GETPROP: { // Case: `Foo.prototype.bar = function() { }; if (!node.isFirstChildOf(parent) || !NodeUtil.isExprAssign(grandparent) || !parent.getLastChild().isFunction()) { return false; } if (NodeUtil.isPrototypeProperty(node)) { return true; } if (isDefinitelyCtorOrInterface(node.getFirstChild())) { return true; } return false; } case STRING_KEY: { // Case: `Foo.prototype = { // bar: function() { }, // }` checkArgument(parent.isObjectLit(), parent); if (!parent.isSecondChildOf(grandparent) || !NodeUtil.isPrototypeAssignment(grandparent.getFirstChild()) || !node.getFirstChild().isFunction()) { return false; } return true; } default: return false; } } private boolean isDefinitelyCtorOrInterface(Node receiver) { String qname = receiver.getQualifiedName(); if (qname == null) { return false; } // It's safe to rely on the global scope because normalization has made all names unique. No // local will shadow a global name, and we're ok with missing some statics. Var var = refMap.getGlobalScope().getVar(qname); if (var == null) { return false; } if (var.isClass()) { return true; } JSDocInfo jsdoc = var.getJSDocInfo(); if (jsdoc == null) { return false; } else if (jsdoc.isConstructorOrInterface()) { return true; // Case: `@constructor`, `@interface`, `@record`. } return false; } /** * Determines if a method definition site is eligible for rewrite as a global. * *

In order to be eligible for rewrite, the definition site must: * *

    *
  • Not be exported *
  • Be for a prototype method *
*/ private boolean isEligibleDefinitionSite(String name, Node definitionSite) { switch (definitionSite.getToken()) { case GETPROP: case MEMBER_FUNCTION_DEF: case STRING_KEY: case MEMBER_FIELD_DEF: break; default: // No other node types are supported. throw new IllegalArgumentException(definitionSite.toString()); } // Exporting a method prevents rewrite. CodingConvention codingConvention = compiler.getCodingConvention(); if (codingConvention.isExported(name, /* local= */ false)) { return false; } if (!isPrototypeOrStaticMethodDefinition(definitionSite)) { return false; } return true; } /** * Determines if a method definition function is eligible for rewrite as a global function. * *

In order to be eligible for rewrite, the definition function must: * *

    *
  • Be instantiated exactly once *
  • Be the only possible implementation at a given site *
  • Not refer to its `arguments`; no implicit varags *
  • Not be an arrow function *
*/ private boolean isEligibleDefinitionFunction(Node definitionFunction) { checkArgument(definitionFunction.isFunction(), definitionFunction); if (definitionFunction.isArrowFunction()) { return false; } for (Node ancestor = definitionFunction.getParent(); ancestor != null; ancestor = ancestor.getParent()) { // The definition must be made exactly once. (i.e. not in a loop, conditional, or function) if (isScopingOrBranchingConstruct(ancestor)) { return false; } // TODO(nickreid): Support this so long as the definition doesn't reference the name. // We can't allow this in general because references to the local name: // - won't be rewritten correctly // - won't be the same across multiple definitions, even if they are node-wise identical. if (ancestor.isClass() && localNameIsDeclaredByClass(ancestor)) { return false; } } if (NodeUtil.has(definitionFunction, Node::isSuper, alwaysTrue())) { // TODO(b/120452418): Remove this when we have a rewrite for `super`. We punted initially due // to complexity. return false; } if (NodeUtil.doesFunctionReferenceOwnArgumentsObject(definitionFunction)) { // Functions that access "arguments" are not eligible since rewriting changes the structure of // the function params. return false; } return true; } /** * Determines if a method call is eligible for rewrite as a global function. * *

In order to be eligible for rewrite, the call must: * *

    *
  • Property is never accessed outside a function call context. *
*/ private boolean isEligibleCallSite(Node access, Node definitionSite) { Node invocation = access.getParent(); if (!NodeUtil.isInvocationTarget(access) || !invocation.isCall()) { // TODO(nickreid): Use the same definition of "a call" as // `OptimizeCalls::ReferenceMap::isCallTarget`. // // Accessing the property in any way besides CALL has issues: // - tear-off: invocations can't be tracked // - as constructor: unsupported rewrite // - as tagged template string: unsupported rewrite // - as optional chaining call: original invocation target may be null/undefined and // rewriting creates an unconditional call to a well-defined global function return false; } // We can't rewrite functions called in modules that do not depend on the defining module. // This is due to a subtle execution order change introduced by rewriting. Example: // // `x.foo().bar()` => `JSCompiler_StaticMethods_bar(x.foo())` // // Note how `JSCompiler_StaticMethods_bar` will be resolved before `x.foo()` is executed. In // the case that `x.foo()` defines `JSCompiler_StaticMethods_bar` (e.g. by dynamically loading // the defining module) this change in ordering will cause a `ReferenceError`. No error would // be thrown by the original code because `bar` would be resolved later. // // We choose to use module ordering to avoid this issue because: // - The other eligibility checks for devirtualization prevent any other dangerous cases // that JSCompiler supports. // - Rewriting all call-sites in a way that preserves exact ordering (e.g. using // `ExpressionDecomposer`) has a significant code-size impact (circa 2018-11-19). JSChunkGraph moduleGraph = compiler.getModuleGraph(); @Nullable JSChunk definitionModule = moduleForNode(definitionSite); @Nullable JSChunk callModule = moduleForNode(access); if (definitionModule == callModule) { // Do nothing. } else if (callModule == null) { return false; } else if (!moduleGraph.dependsOn(callModule, definitionModule)) { return false; } return true; } /** Given a set of method definitions, verify they are the same. */ private boolean allDefinitionsEquivalent(Collection definitions) { if (definitions.isEmpty()) { return true; } Node definition = Iterables.get(definitions, 0); checkArgument(definition.isFunction(), definition); return definitions.stream().allMatch((d) -> compiler.areNodesEqualForInlining(d, definition)); } /** * Rewrites object method call sites as calls to global functions that take "this" as their first * argument. * *

Before: o.foo(a, b, c) * *

After: foo(o, a, b, c) */ private void rewriteCall(Node getprop, String newMethodName, boolean isConstantName) { checkArgument(getprop.isGetProp(), getprop); Node call = getprop.getParent(); checkArgument(call.isCall(), call); Node receiver = getprop.getFirstChild(); // This rewriting does not exactly preserve order of operations; the newly inserted static // method name will be resolved before `receiver` is evaluated. This is known to be safe due // to the eligibility checks earlier in the pass. // // We choose not to do a full-fidelity rewriting (e.g. using `ExpressionDecomposer`) because // doing so means extracting `receiver` into a new variable at each call-site. This has a // significant code-size impact (circa 2018-11-19). receiver.detach(); getprop.replaceWith(receiver); Node newReceiver = IR.name(newMethodName).copyTypeFrom(getprop).srcref(getprop); if (isConstantName) { newReceiver.putBooleanProp(Node.IS_CONSTANT_NAME, true); } call.addChildToFront(newReceiver); if (receiver.isSuper()) { // Case: `super.foo(a, b)` => `foo(this, a, b)` receiver.setToken(Token.THIS); } call.putBooleanProp(Node.FREE_CALL, true); compiler.reportChangeToEnclosingScope(call); } /** * Rewrites method definitions as global functions that take "this" as their first argument. * *

Before: a.prototype.b = function(a, b, c) {...} * *

After: var b = function(self, a, b, c) {...} */ private void rewriteDefinition( Node definitionSite, String newMethodName, boolean isConstantName) { final Node function; final Node subtreeToRemove; final Node nameSource; switch (definitionSite.getToken()) { case GETPROP: function = definitionSite.getParent().getLastChild(); nameSource = definitionSite; subtreeToRemove = NodeUtil.getEnclosingStatement(definitionSite); break; case STRING_KEY: case MEMBER_FUNCTION_DEF: function = definitionSite.getLastChild(); nameSource = definitionSite; subtreeToRemove = definitionSite; break; default: throw new IllegalArgumentException(definitionSite.toString()); } // Define a new variable after the original declaration. Node statement = NodeUtil.getEnclosingStatement(definitionSite); Node newNameNode = IR.name(newMethodName).srcrefIfMissing(nameSource); Node newVarNode = IR.var(newNameNode).srcrefIfMissing(nameSource); newVarNode.insertBefore(statement); if (isConstantName) { newNameNode.putBooleanProp(Node.IS_CONSTANT_NAME, true); } // Copy the JSDocInfo, if any, from the original declaration JSDocInfo originalJSDoc = NodeUtil.getBestJSDocInfo(definitionSite); newVarNode.setJSDocInfo(originalJSDoc); // Attach the function to the new variable. function.detach(); newNameNode.addChildToFront(function); // Create the `this` param. String selfName = newMethodName + "$self"; Node paramList = function.getSecondChild(); paramList.addChildToFront(IR.name(selfName).srcrefIfMissing(function)); compiler.reportChangeToEnclosingScope(paramList); // Eliminate `this`. replaceReferencesToThis(function.getSecondChild(), selfName); // In default param values. replaceReferencesToThis(function.getLastChild(), selfName); // In function body. // Clean up dangling AST. NodeUtil.deleteNode(subtreeToRemove, compiler); compiler.reportChangeToEnclosingScope(newVarNode); } /** Replaces references to "this" with references to name. Do not traverse function boundaries. */ private void replaceReferencesToThis(Node node, String name) { if (node.isFunction() && !node.isArrowFunction()) { // Functions (besides arrows) create a new binding for `this`. return; } for (Node child = node.getFirstChild(); child != null; ) { final Node next = child.getNext(); if (child.isThis()) { Node newName = IR.name(name).srcref(child).copyTypeFrom(child); child.replaceWith(newName); compiler.reportChangeToEnclosingScope(newName); } else { replaceReferencesToThis(child, name); } child = next; } } private @Nullable JSChunk moduleForNode(Node node) { Node script = NodeUtil.getEnclosingScript(node); CompilerInput input = compiler.getInput(script.getInputId()); return input.getChunk(); } private static String rewrittenMethodNameOf(String originalMethodName) { return "JSCompiler_StaticMethods_" + originalMethodName; } /** * Returns {@code true} iff a node may change the variable bindings of its subtree or cause that * subtree to be executed not exactly once. * *

This method does not include CLASS because CLASS does not always create a new binding and it * is important for the success of this optimization to consider class methods. * * @see {@link #localNameIsDeclaredByClass()} */ private static boolean isScopingOrBranchingConstruct(Node node) { return NodeUtil.isControlStructure(node) // Branching. || node.isAnd() // Branching. || node.isOr() // Branching. || node.isFunction() // Branching & scoping. || node.isBlock(); // Scoping. } /** * Returns {@code true} iff a CLASS subtree declares a name local to the class body. * *

Example: * *

{@code
   * const Foo = class Bar {
   *   qux() { return Bar; }
   * }
   * }
*/ private static boolean localNameIsDeclaredByClass(Node clazz) { checkArgument(clazz.isClass(), clazz); if (clazz.getFirstChild().isEmpty()) { return false; // There must be a name. } else if (NodeUtil.isStatement(clazz)) { return false; // The name must be local. } return true; } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy