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
Class method effects #4018
Class method effects #4018
Changes from 2 commits
7b8304e
d524abd
185097d
0960a33
cea520d
bcd7f5a
0e0fdaa
1251f5a
d8ec2ea
056d9f0
9507110
7f51eef
10c4e40
99a794d
72dc62b
ba6e043
f19fc61
132fb94
f09cd5b
10ee697
0350110
d723df6
a2f02c1
f89085c
6d70426
4eff4ed
dd5f73b
333116a
8f498f2
ef0929f
0fcd42d
145d01f
71a27ba
616a6d3
182a0ba
e33e06e
afad828
d290c6c
0609907
a70e660
ea5fe1d
02c1127
ab15b60
ece8f31
275946e
f4766a6
92b1e1d
224a4e1
87e8029
d2b61c9
66a5cf9
76b5cbe
b6bd8d4
99964aa
84b0d94
7490a87
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,47 +1,242 @@ | ||
import { getOrCreate } from '../../../utils/getOrCreate'; | ||
import { CallOptions } from '../../CallOptions'; | ||
import { DeoptimizableEntity } from '../../DeoptimizableEntity'; | ||
import { HasEffectsContext } from '../../ExecutionContext'; | ||
import ChildScope from '../../scopes/ChildScope'; | ||
import Scope from '../../scopes/Scope'; | ||
import { ObjectPath } from '../../utils/PathTracker'; | ||
import { EMPTY_PATH, ObjectPath, PathTracker } from '../../utils/PathTracker'; | ||
import { LiteralValueOrUnknown, UnknownValue, UNKNOWN_EXPRESSION } from '../../values'; | ||
import ClassBody from '../ClassBody'; | ||
import Identifier from '../Identifier'; | ||
import MethodDefinition from '../MethodDefinition'; | ||
import { ExpressionEntity } from './Expression'; | ||
import { ExpressionNode, NodeBase } from './Node'; | ||
|
||
export default class ClassNode extends NodeBase { | ||
body!: ClassBody; | ||
id!: Identifier | null; | ||
superClass!: ExpressionNode | null; | ||
private classConstructor!: MethodDefinition | null; | ||
private deoptimizedPrototype = false; | ||
private deoptimizedStatic = false; | ||
// Collect deoptimization information if we can resolve a property access, by property name | ||
private expressionsToBeDeoptimized = new Map<string, DeoptimizableEntity[]>(); | ||
// Known, simple, non-deoptimized static properties are kept in here. They are removed when deoptimized. | ||
private staticPropertyMap: {[name: string]: ExpressionNode} | null = null; | ||
|
||
bind() { | ||
super.bind(); | ||
} | ||
|
||
createScope(parentScope: Scope) { | ||
this.scope = new ChildScope(parentScope); | ||
} | ||
|
||
hasEffectsWhenAccessedAtPath(path: ObjectPath) { | ||
if (path.length <= 1) return false; | ||
return path.length > 2 || path[0] !== 'prototype'; | ||
deoptimizeAllStatics() { | ||
for (const name in this.staticPropertyMap) { | ||
this.deoptimizeStatic(name); | ||
} | ||
this.deoptimizedStatic = this.deoptimizedPrototype = true; | ||
} | ||
|
||
deoptimizeCache() { | ||
this.deoptimizeAllStatics(); | ||
} | ||
|
||
deoptimizePath(path: ObjectPath) { | ||
const propertyMap = this.getStaticPropertyMap(); | ||
const key = path[0]; | ||
const definition = typeof key === 'string' && propertyMap[key]; | ||
if (path.length === 1) { | ||
if (definition) { | ||
this.deoptimizeStatic(key as string); | ||
} else if (typeof key !== 'string') { | ||
this.deoptimizeAllStatics(); | ||
} | ||
} else if (key === 'prototype' && typeof path[1] !== 'string') { | ||
this.deoptimizedPrototype = true; | ||
} else if (path.length > 1 && definition) { | ||
definition.deoptimizePath(path.slice(1)); | ||
} | ||
} | ||
|
||
deoptimizeStatic(name: string) { | ||
delete this.staticPropertyMap![name]; | ||
const deoptEntities = this.expressionsToBeDeoptimized.get(name); | ||
if (deoptEntities) { | ||
for (const entity of deoptEntities) { | ||
entity.deoptimizeCache(); | ||
} | ||
} | ||
} | ||
|
||
getLiteralValueAtPath( | ||
path: ObjectPath, | ||
recursionTracker: PathTracker, | ||
origin: DeoptimizableEntity | ||
): LiteralValueOrUnknown { | ||
const key = path[0]; | ||
const definition = typeof key === 'string' && this.getStaticPropertyMap()[key]; | ||
if (path.length === 0 || !definition || | ||
(key === 'prototype' ? this.deoptimizedPrototype : this.deoptimizedStatic)) { | ||
return UnknownValue; | ||
} | ||
|
||
getOrCreate(this.expressionsToBeDeoptimized, key, () => []).push(origin); | ||
return definition.getLiteralValueAtPath( | ||
path.slice(1), | ||
recursionTracker, | ||
origin | ||
); | ||
} | ||
|
||
getReturnExpressionWhenCalledAtPath( | ||
path: ObjectPath, | ||
recursionTracker: PathTracker, | ||
origin: DeoptimizableEntity | ||
): ExpressionEntity { | ||
const key = path[0]; | ||
const definition = typeof key === 'string' && this.getStaticPropertyMap()[key]; | ||
|
||
if (path.length === 0 || !definition || | ||
(key === 'prototype' ? this.deoptimizedPrototype : this.deoptimizedStatic)) { | ||
return UNKNOWN_EXPRESSION; | ||
} | ||
|
||
getOrCreate(this.expressionsToBeDeoptimized, key, () => []).push(origin); | ||
return definition.getReturnExpressionWhenCalledAtPath( | ||
path.slice(1), | ||
recursionTracker, | ||
origin | ||
); | ||
} | ||
|
||
hasEffectsWhenAccessedAtPath(path: ObjectPath, context: HasEffectsContext) { | ||
if (path.length === 0) return false; | ||
if (this.deoptimizedStatic) return true; | ||
if (this.superClass && this.superClass.hasEffectsWhenAccessedAtPath(path, context)) return true; | ||
const key = path[0]; | ||
if (key === 'prototype') { | ||
if (path.length === 1) return false; | ||
if (this.deoptimizedPrototype) return true; | ||
const key2 = path[1]; | ||
if (path.length === 2 && typeof key2 === 'string') { | ||
return mayHaveGetterSetterEffect(this.body, 'get', false, key2, context); | ||
} | ||
return true; | ||
} else if (typeof key === 'string' && path.length === 1) { | ||
return mayHaveGetterSetterEffect(this.body, 'get', true, key, context); | ||
} else { | ||
return true; | ||
} | ||
} | ||
|
||
hasEffectsWhenAssignedAtPath(path: ObjectPath) { | ||
if (path.length <= 1) return false; | ||
return path.length > 2 || path[0] !== 'prototype'; | ||
hasEffectsWhenAssignedAtPath(path: ObjectPath, context: HasEffectsContext) { | ||
if (this.deoptimizedStatic) return true; | ||
if (this.superClass && this.superClass.hasEffectsWhenAssignedAtPath(path, context)) return true; | ||
const key = path[0]; | ||
if (key === 'prototype') { | ||
if (path.length === 1) return false; | ||
if (this.deoptimizedPrototype) return true; | ||
const key2 = path[1]; | ||
if (path.length === 2 && typeof key2 === 'string') { | ||
return mayHaveGetterSetterEffect(this.body, 'set', false, key2, context); | ||
} | ||
return true; | ||
} else if (typeof key === 'string' && path.length === 1) { | ||
return mayHaveGetterSetterEffect(this.body, 'set', true, key, context); | ||
} else { | ||
return true; | ||
} | ||
} | ||
|
||
hasEffectsWhenCalledAtPath( | ||
path: ObjectPath, | ||
callOptions: CallOptions, | ||
context: HasEffectsContext | ||
) { | ||
if (!callOptions.withNew) return true; | ||
return ( | ||
this.body.hasEffectsWhenCalledAtPath(path, callOptions, context) || | ||
(this.superClass !== null && | ||
this.superClass.hasEffectsWhenCalledAtPath(path, callOptions, context)) | ||
); | ||
if (callOptions.withNew) { | ||
return path.length > 0 || | ||
(this.classConstructor !== null && | ||
this.classConstructor.hasEffectsWhenCalledAtPath(EMPTY_PATH, callOptions, context)) || | ||
(this.superClass !== null && | ||
this.superClass.hasEffectsWhenCalledAtPath(path, callOptions, context)); | ||
} else { | ||
if (path.length !== 1 || this.deoptimizedStatic) return true; | ||
const key = path[0]; | ||
const definition = typeof key === 'string' && this.getStaticPropertyMap()[key]; | ||
if (!definition) return true; | ||
return definition.hasEffectsWhenCalledAtPath([], callOptions, context); | ||
} | ||
} | ||
|
||
initialise() { | ||
if (this.id !== null) { | ||
this.id.declare('class', this); | ||
} | ||
for (const method of this.body.body) { | ||
if (method instanceof MethodDefinition && method.kind === 'constructor') { | ||
this.classConstructor = method; | ||
return; | ||
} | ||
} | ||
this.classConstructor = null; | ||
} | ||
|
||
mayModifyThisWhenCalledAtPath( | ||
path: ObjectPath | ||
) { | ||
const key = path[0]; | ||
const definition = typeof key === 'string' && this.getStaticPropertyMap()[key]; | ||
if (!definition || this.deoptimizedStatic) return true; | ||
return definition.mayModifyThisWhenCalledAtPath(path.slice(1)); | ||
} | ||
|
||
private getStaticPropertyMap(): {[name: string]: ExpressionNode} { | ||
if (this.staticPropertyMap) return this.staticPropertyMap; | ||
|
||
const propertyMap = this.staticPropertyMap = Object.create(null); | ||
const seen: {[name: string]: boolean} = Object.create(null); | ||
for (const definition of this.body.body) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can this be done by building an up-front lookup table similar to how we do this for object expression? For classes with many definitions, this will scale badly as There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I decided against sharing code with ObjectExpression because there's a lot of small differences between how this works and how objects work (in syntax tree shape, handling of builtin properties, static/prototype distinction, and so on) and the helper functions would become monsters with a list of boolean parameters, and I prefer some vague duplication over that most of the time. Building up a table is probably a good idea. Looking into that. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wait, the thing you're commenting on here is building up a table. The suggestions holds for |
||
if (!definition.static) continue; | ||
// If there are non-identifier-named statics, give up. | ||
if (definition.computed || !(definition.key instanceof Identifier)) { | ||
return this.staticPropertyMap = Object.create(null); | ||
} | ||
const key = definition.key.name; | ||
// Not handling duplicate definitions. | ||
if (seen[key]) { | ||
delete propertyMap[key]; | ||
continue; | ||
} | ||
seen[key] = true; | ||
if (definition instanceof MethodDefinition) { | ||
if (definition.kind === "method") { | ||
propertyMap[key] = definition.value; | ||
} | ||
} else if (definition.value) { | ||
propertyMap[key] = definition.value; | ||
} | ||
} | ||
return this.staticPropertyMap = propertyMap; | ||
} | ||
} | ||
|
||
function mayHaveGetterSetterEffect( | ||
body: ClassBody, | ||
kind: 'get' | 'set', isStatic: boolean, name: string, | ||
context: HasEffectsContext | ||
) { | ||
for (const definition of body.body) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Again this could benefit from a pre-built lookup table. |
||
if (definition instanceof MethodDefinition && definition.static === isStatic && definition.kind === kind) { | ||
if (definition.computed || !(definition.key instanceof Identifier)) { | ||
return true; | ||
} | ||
if (definition.key.name === name && | ||
definition.value.hasEffectsWhenCalledAtPath([], {args: [], withNew: false}, context)) { | ||
return true; | ||
} | ||
} | ||
} | ||
return false; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
module.exports = { | ||
description: 'tracks literal values in class static fields' | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
log("t"); | ||
log("x"); | ||
|
||
class Undef { | ||
static y; | ||
} | ||
if (Undef.y) log("y"); | ||
|
||
class Deopt { | ||
static z = false; | ||
} | ||
unknown(Deopt); | ||
if (Deopt.z) log("z"); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
class Static { | ||
static t() { return true; } | ||
static f() { return false; } | ||
static x = 10; | ||
} | ||
|
||
if (Static.t()) log("t"); | ||
if (Static.f()) log("f"); | ||
if (!Static.t()) log("!t"); | ||
if (Static.x) log("x"); | ||
|
||
class Undef { | ||
static y; | ||
} | ||
if (Undef.y) log("y"); | ||
|
||
class Deopt { | ||
static z = false; | ||
} | ||
unknown(Deopt); | ||
if (Deopt.z) log("z"); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
module.exports = { | ||
description: 'treat getters and setters on classes as function calls' | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
class RetainedByGetter { | ||
get a() { log(); } | ||
} | ||
RetainedByGetter.prototype.a; | ||
|
||
class RetainedBySetter { | ||
set a(v) { log(); } | ||
} | ||
RetainedBySetter.prototype.a = 10; | ||
|
||
class RetainedByStaticGetter { | ||
static get a() { log(); } | ||
} | ||
RetainedByStaticGetter.a; | ||
|
||
class RetainedByStaticSetter { | ||
static set a(v) { log(); } | ||
} | ||
RetainedByStaticSetter.a = 10; | ||
|
||
class RetainedSuper { | ||
static get a() { log(); } | ||
} | ||
class RetainedSub extends RetainedSuper {} | ||
RetainedSub.a; | ||
|
||
class DeoptProto {} | ||
unknown(DeoptProto.prototype); | ||
DeoptProto.prototype.a; | ||
|
||
class DeoptComputed { | ||
static get a() {} | ||
static get [unknown]() { log(); } | ||
} | ||
DeoptComputed.a; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do not believe this is necessary unless you rely on retrieving literal values for computed properties. That is also why you cannot test it.
So it works like this:
When you perform a
getLiteralValueAtPath
or agetReturnExpressionWhenCalledAtPath
you pass aDeoptimizableEntity
as the last parameter (which is usually the Node requesting the literal/return value). The reason is that it is possible a literal/return value might be deoptimized at a later time when binding/tree-shaking progresses. If that happens, then some Node down the chain that is responsible for the deoptimization will calldeoptimizeCache
on this entity. At this point in time, the entity is responsible for "forgetting" the cached value and also deoptimize the cache of all dependent entities that may rely on that value.Admittedly it is kind of difficult to describe such deoptimization scenarios. Ideally I would just put a breakpoint into "deoptimizeCache" of ObjectExpression and see what test triggers it.
This will become even more important in the future as one goal is to move more and more of the initial deoptimizations to "on demand late deoptimization" i.e. only deoptimize when as reassignment is included.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You mean this is only necessary for nodes that themselves retrieve literal values? Do I understand correctly that the call-throughs to definition nodes in the class' own
getLiteralValueAtPath
andgetReturnExpressionWhenCalledAtPath
don't count because they just pass through their argument instead of passing themselves?