Skip to content

Commit

Permalink
Optimize various switch statements (#1044)
Browse files Browse the repository at this point in the history
* Optimize various switch statements

1. This implements full case folding for equivalent branches
2. This implements default collapsing when all cases are "default"

The default collapsing is hard to describe, but easy to demonstrate:

```js
switch (expression) {
  case 1:
  case 2:
  case 3:
  default:
    doSomething();
}
```

Because all of the case `expression`s are side-effect free, we can consider just the case bodies. Because all cases fall through to the last branch, we
can check to see if this branch is always guaranteed to run due to a `default` branch. If everything is good, we can eliminate the switch statement entirely.

I've hit this a few times when programming TypeScript. I tend to write exhaustive type checking, and a times that leads to code like:

```js
switch (type) {
  case "Foo":
  case "Bar":
    if (process.env.NODE_ENV !== 'production') {
      throw new Error('invariant failed!');
    }
  default:
    doSomething(type);
}
```

In production builds, I I strip the invariant checks out for file size (my tests should have caught if it were ever possible to hit those cases). And this used to leave me with large useless switch statements:

```js
switch (type) {
  case "Foo":
  case "Bar":
  default:
    doSomething(type);
}
```

This creates the "all cases are default" behavior, which I'd really like to have minimized.

* Prune branches that are empty at the end of a switch

* Convert simple switch statements into if statements

* Keep cases that can skip a side-effect

* tmp

* Collapse constants into default and tests

* Fix issues with side-effects

* Add test for non-break abortions in if-else

* add some tests

* Collapse inert bodies

* Remove useless branch check

The branch can't be the default, because it would have already been handled by the switch removal code.

Co-authored-by: fabiosantoscode <fabiosantosart@gmail.com>
  • Loading branch information
jridgewell and fabiosantoscode committed Aug 24, 2021
1 parent 9c0481f commit 75df7e0
Show file tree
Hide file tree
Showing 5 changed files with 2,112 additions and 128 deletions.
319 changes: 278 additions & 41 deletions lib/compress/index.js
Expand Up @@ -53,7 +53,6 @@ import {
AST_Boolean,
AST_Break,
AST_Call,
AST_Case,
AST_Catch,
AST_Chain,
AST_Class,
Expand Down Expand Up @@ -109,6 +108,7 @@ import {
AST_String,
AST_Sub,
AST_Switch,
AST_SwitchBranch,
AST_Symbol,
AST_SymbolBlockDeclaration,
AST_SymbolCatch,
Expand Down Expand Up @@ -1592,58 +1592,251 @@ def_optimize(AST_Switch, function(self, compressor) {
}
}
}
if (aborts(branch)) {
var prev = body[body.length - 1];
if (aborts(prev) && prev.body.length == branch.body.length
&& make_node(AST_BlockStatement, prev, prev).equivalent_to(make_node(AST_BlockStatement, branch, branch))) {
prev.body = [];
}
}
body.push(branch);
}
while (i < len) eliminate_branch(self.body[i++], body[body.length - 1]);
self.body = body;

for (let i = 0; i < body.length; i++) {
let branch = body[i];
if (branch.body.length === 0) continue;
if (!aborts(branch)) continue;

for (let j = i + 1; j < body.length; i++, j++) {
let next = body[j];
if (next.body.length === 0) continue;
if (
branches_equivalent(next, branch, false)
|| (j === body.length - 1 && branches_equivalent(next, branch, true))
) {
branch.body = [];
branch = next;
continue;
}
break;
}
}

let default_or_exact = default_branch || exact_match;
default_branch = null;
exact_match = null;

// Prune any empty branches at the end of the switch statement.
{
let i = body.length - 1;
for (; i >= 0; i--) {
let bbody = body[i].body;
if (is_break(bbody[bbody.length - 1], compressor)) bbody.pop();
if (!is_inert_body(body[i])) break;
}
// i now points to the index of a branch that contains a body. By incrementing, it's
// pointing to the first branch that's empty.
i++;
if (!default_or_exact || body.indexOf(default_or_exact) >= i) {
// The default behavior is to do nothing. We can take advantage of that to
// remove all case expressions that are side-effect free that also do
// nothing, since they'll default to doing nothing. But we can't remove any
// case expressions before one that would side-effect, since they may cause
// the side-effect to be skipped.
for (let j = body.length - 1; j >= i; j--) {
let branch = body[j];
if (branch === default_or_exact) {
default_or_exact = null;
body.pop();
} else if (!branch.expression.has_side_effects(compressor)) {
body.pop();
} else {
break;
}
}
}
}


// Prune side-effect free branches that fall into default.
if (default_or_exact) {
let default_index = body.indexOf(default_or_exact);
let default_body_index = default_index;
for (; default_body_index < body.length - 1; default_body_index++) {
if (!is_inert_body(body[default_body_index])) break;
}
let side_effect_index = body.length - 1;
for (; side_effect_index >= 0; side_effect_index--) {
let branch = body[side_effect_index];
if (branch === default_or_exact) continue;
if (branch.expression.has_side_effects(compressor)) break;
}
// If the default behavior comes after any side-effect case expressions,
// then we can fold all side-effect free cases into the default branch.
// If the side-effect case is after the default, then any side-effect
// free cases could prevent the side-effect from occurring.
if (default_body_index > side_effect_index) {
let prev_body_index = default_index - 1;
for (; prev_body_index >= 0; prev_body_index--) {
if (!is_inert_body(body[prev_body_index])) break;
}
let before = Math.max(side_effect_index, prev_body_index) + 1;
let after = default_index;
if (side_effect_index > default_index) {
// If the default falls into the same body as a side-effect
// case, then we need preserve that case and only prune the
// cases after it.
after = side_effect_index;
body[side_effect_index].body = body[default_body_index].body;
} else {
// The default will be the last branch.
default_or_exact.body = body[default_body_index].body;
}

// Prune everything after the default (or last side-effect case)
// until the next case with a body.
body.splice(after + 1, default_body_index - after);
// Prune everything before the default that falls into it.
body.splice(before, default_index - before);
}
}

// See if we can remove the switch entirely if all cases (the default) fall into the same case body.
DEFAULT: if (default_or_exact) {
let i = body.findIndex(branch => !is_inert_body(branch));
let caseBody;
// `i` is equal to one of the following:
// - `-1`, there is no body in the switch statement.
// - `body.length - 1`, all cases fall into the same body.
// - anything else, there are multiple bodies in the switch.
if (i === body.length - 1) {
// All cases fall into the case body.
let branch = body[i];
if (has_nested_break(self)) break DEFAULT;

// This is the last case body, and we've already pruned any breaks, so it's
// safe to hoist.
caseBody = make_node(AST_BlockStatement, branch, {
body: branch.body
});
branch.body = [];
} else if (i !== -1) {
// If there are multiple bodies, then we cannot optimize anything.
break DEFAULT;
}

let sideEffect = body.find(branch => {
return (
branch !== default_or_exact
&& branch.expression.has_side_effects(compressor)
);
});
// If no cases cause a side-effect, we can eliminate the switch entirely.
if (!sideEffect) {
return make_node(AST_BlockStatement, self, {
body: decl.concat(
statement(self.expression),
default_or_exact.expression ? statement(default_or_exact.expression) : [],
caseBody || []
)
}).optimize(compressor);
}

// If we're this far, either there was no body or all cases fell into the same body.
// If there was no body, then we don't need a default branch (because the default is
// do nothing). If there was a body, we'll extract it to after the switch, so the
// switch's new default is to do nothing and we can still prune it.
const default_index = body.indexOf(default_or_exact);
body.splice(default_index, 1);
default_or_exact = null;

if (caseBody) {
// Recurse into switch statement one more time so that we can append the case body
// outside of the switch. This recursion will only happen once since we've pruned
// the default case.
return make_node(AST_BlockStatement, self, {
body: decl.concat(self, caseBody)
}).optimize(compressor);
}
// If we fall here, there is a default branch somewhere, there are no case bodies,
// and there's a side-effect somewhere. Just let the below paths take care of it.
}

if (body.length > 0) {
body[0].body = decl.concat(body[0].body);
}
self.body = body;
while (branch = body[body.length - 1]) {
var stat = branch.body[branch.body.length - 1];
if (stat instanceof AST_Break && compressor.loopcontrol_target(stat) === self)
branch.body.pop();
if (branch.body.length || branch instanceof AST_Case
&& (default_branch || branch.expression.has_side_effects(compressor))) break;
if (body.pop() === default_branch) default_branch = null;
}

if (body.length == 0) {
return make_node(AST_BlockStatement, self, {
body: decl.concat(make_node(AST_SimpleStatement, self.expression, {
body: self.expression
}))
body: decl.concat(statement(self.expression))
}).optimize(compressor);
}
if (body.length == 1 && (body[0] === exact_match || body[0] === default_branch)) {
var has_break = false;
var tw = new TreeWalker(function(node) {
if (has_break
|| node instanceof AST_Lambda
|| node instanceof AST_SimpleStatement) return true;
if (node instanceof AST_Break && tw.loopcontrol_target(node) === self)
has_break = true;
});
self.walk(tw);
if (!has_break) {
var statements = body[0].body.slice();
var exp = body[0].expression;
if (exp) statements.unshift(make_node(AST_SimpleStatement, exp, {
body: exp
}));
statements.unshift(make_node(AST_SimpleStatement, self.expression, {
body:self.expression
}));
return make_node(AST_BlockStatement, self, {
body: statements
if (body.length == 1 && !has_nested_break(self)) {
// This is the last case body, and we've already pruned any breaks, so it's
// safe to hoist.
let branch = body[0];
return make_node(AST_If, self, {
condition: make_node(AST_Binary, self, {
operator: "===",
left: self.expression,
right: branch.expression,
}),
body: make_node(AST_BlockStatement, branch, {
body: branch.body
}),
alternative: null
}).optimize(compressor);
}
if (body.length === 2 && default_or_exact && !has_nested_break(self)) {
let branch = body[0] === default_or_exact ? body[1] : body[0];
let exact_exp = default_or_exact.expression && statement(default_or_exact.expression);
if (aborts(body[0])) {
// Only the first branch body could have a break (at the last statement)
let first = body[0];
if (is_break(first.body[first.body.length - 1], compressor)) {
first.body.pop();
}
return make_node(AST_If, self, {
condition: make_node(AST_Binary, self, {
operator: "===",
left: self.expression,
right: branch.expression,
}),
body: make_node(AST_BlockStatement, branch, {
body: branch.body
}),
alternative: make_node(AST_BlockStatement, default_or_exact, {
body: [].concat(
exact_exp || [],
default_or_exact.body
)
})
}).optimize(compressor);
}
let operator = "===";
let consequent = make_node(AST_BlockStatement, branch, {
body: branch.body,
});
let always = make_node(AST_BlockStatement, default_or_exact, {
body: [].concat(
exact_exp || [],
default_or_exact.body
)
});
if (body[0] === default_or_exact) {
operator = "!==";
let tmp = always;
always = consequent;
consequent = tmp;
}
return make_node(AST_BlockStatement, self, {
body: [
make_node(AST_If, self, {
condition: make_node(AST_Binary, self, {
operator: operator,
left: self.expression,
right: branch.expression,
}),
body: consequent,
alternative: null
})
].concat(always)
}).optimize(compressor);
}
return self;

Expand All @@ -1654,6 +1847,50 @@ def_optimize(AST_Switch, function(self, compressor) {
trim_unreachable_code(compressor, branch, decl);
}
}
function branches_equivalent(branch, prev, insertBreak) {
let bbody = branch.body;
let pbody = prev.body;
if (insertBreak) {
bbody = bbody.concat(make_node(AST_Break));
}
if (bbody.length !== pbody.length) return false;
let bblock = make_node(AST_BlockStatement, branch, { body: bbody });
let pblock = make_node(AST_BlockStatement, prev, { body: pbody });
return bblock.equivalent_to(pblock);
}
function statement(expression) {
return make_node(AST_SimpleStatement, expression, {
body: expression
});
}
function has_nested_break(root) {
let has_break = false;
let tw = new TreeWalker(node => {
if (has_break) return true;
if (node instanceof AST_Lambda) return true;
if (node instanceof AST_SimpleStatement) return true;
if (!is_break(node, tw)) return;
let parent = tw.parent();
if (
parent instanceof AST_SwitchBranch
&& parent.body[parent.body.length - 1] === node
) {
return;
}
has_break = true;
});
root.walk(tw);
return has_break;
}
function is_break(node, stack) {
return node instanceof AST_Break
&& stack.loopcontrol_target(node) === self;
}
function is_inert_body(branch) {
return !aborts(branch) && !make_node(AST_BlockStatement, branch, {
body: branch.body
}).has_side_effects(compressor)
}
});

def_optimize(AST_Try, function(self, compressor) {
Expand Down
4 changes: 1 addition & 3 deletions test/compress/functions.js
Expand Up @@ -1328,9 +1328,7 @@ issue_2620_4: {
expect: {
var c = "FAIL";
!function() {
switch (NaN) {
case void (c = "PASS"):
}
if (NaN === void (c = "PASS"));
}();
console.log(c);
}
Expand Down
10 changes: 2 additions & 8 deletions test/compress/issue-1750.js
Expand Up @@ -16,10 +16,7 @@ case_1: {
}
expect: {
var a = 0, b = 1;
switch (true) {
case a || true:
b = 2;
}
if (true === (a || true)) b = 2;
console.log(a, b);
}
expect_stdout: "0 2"
Expand All @@ -44,10 +41,7 @@ case_2: {
}
expect: {
var a = 0, b = 1;
switch (0) {
case a:
a = 3;
}
if (0 === a) a = 3;
console.log(a, b);
}
expect_stdout: "3 1"
Expand Down

0 comments on commit 75df7e0

Please sign in to comment.