S5: Semantics for Accessors
Tags: JavaScript, Programming Languages, Semantics
Posted on 11 December 2011.Getters and setters (known as accessors) are a new feature in ECMAScript 5 that extend the behavior of assignment and lookup expressions on JavaScript objects. If a field has a getter defined on it, rather than simply returning the value in field lookup, a getter function is invoked, and its return value is the result of the lookup:
Similarly, if a field has a setter defined on it, the setter function is called on field update. The setter function gets the assigned value as its only argument, and its return value is ignored:
Getters and setters have a number of proposed uses―they can be used to wrap
DOM objects that have interesting effects on assignment, like
onmessage
and onbeforeunload
, for example. We leave
discovering good uses to more creative JavaScript programmers, and focus on
their semantic properties here.
The examples above are straightforward, and it seems like a simple model might work out quite easily. First, we need some definitions, so we'll start with what's in λJS. Here's a fragment of the values that λJS works with, and the most basic of the operations on objects:
v := str | { str1:v1, ⋯, strn:vn } | func(x ⋯) . e | ⋯ e := e[e] | e[e=e] | e(e, ⋯) | ⋯ (E-Lookup) { ⋯, str:v, ⋯ }[strx] → v when strx = str (E-Update) { ⋯, str:v, ⋯}[strx=v'] → { ⋯, str:v', ⋯} when strx = str (E-UpdateAdd) { str1:v1, ⋯}[str=v] → { str:v, str1:v1, ⋯} when str ≠ str1, ⋯
We update and set fields when they are found, and add fields if there is an update on a not-found field. Clearly, this isn't enough to model the semantics of getters and setters. On lookup, if the value of a field is a getter, we need to have our semantics step to an invocation of the function. We need to make the notion of a field richer, so the semantics can have behavior that depends on the kind of field. We distinguish two kinds of fields p, one for simple values and one for accessors:
p := [get: vg, set: vs] | [value: v] v := str | { str1:p1, ⋯, strn:pn } | func(x ⋯) . e | ⋯ e := e[e] | e[e=e] | e(e, ⋯) | ⋯
The updated rules for simple values are trivial to write down (differences in bold):
(E-Lookup) { ⋯, str:[value:v], ⋯ }[strx] → v when strx = str (E-Update) { ⋯, str:[value:v], ⋯}[strx=v'] → { ⋯, str:[value:v'], ⋯} when strx = str (E-UpdateAdd) { str1:v1, ⋯}[str=v] → { str:[value:v], str1:v1, ⋯} when str ≠ str1, ⋯
But now we can also handle the cases where we have a getter or setter. If a lookup expression e[e] finds a getter, it applies the function, and the same goes for setters, which get the value as an argument:
(E-LookupGetter) { ⋯, str:[get:vg, set:vs], ⋯ }[strx] → vg() when strx = str (E-UpdateSetter) { ⋯, str:[get:vg, set:vs], ⋯}[strx=v'] → vs(v') when strx = str
Great! This can handle the two examples from the beginning of the post. But those two examples weren't the whole story for getters and setters, and our first fragment wasn't the whole story for λJS objects.
Consider this program:
Here, we see that the functions also have access to the target object of the
assignment or lookup, via the this
parameter. We could try to
encode this into our rules, but let's not get too far ahead of ourselves.
JavaScript objects have more subtleties up their sleeves. We can't forget
about prototype inheritance. Let's start with the same object o
,
this time called parent
, and use it as the prototype of another
object:
Take a minute to guess what you think each of the values should be. Click here to see the answers (which hopefully are what you expected).
So, JavaScript is passing the object in the lookup expression into the function, for both field access and field update. Something else subtle is going on, as well. Recall that before, when an update occurred on a field that wasn't present, JavaScript simply added it to the object. Now, on field update, we see that the assignment traverses the prototype chain to check for setters. This is fundamentally different from JavaScript before accessors―assignment never considered prototypes. So, our semantics needs to do two things:
- Pass the correct
this
argument to getters and setters; - Traverse the prototype chain for assignments.
Let's think about a simple way to pass the this
argument to
getters:
(E-LookupGetter) { ⋯, str:[get:vg, set:vs], ⋯ }[strx] → vg({ ⋯, str:[get:vg, set:vs], ⋯ }) when strx = str
Here, we simply copy the object over into the first argument to the
function vg. We can (and do) desugar
functions to have an implicit first this
argument to line up with
this invocation. But we need to think carefully about this rule's interaction
with prototype inheritance.
Here is E-Lookup-Proto from the original λJS:
(E-Lookup-Proto) { str1:v1, ⋯, "__proto__": vp, strn:vn, ⋯}[str] → vp[str] when str ≠ str1, ⋯, strn, ⋯
Let's take a moment to look at this rule in conjunction with E-LookupGetter. If the field isn't found, and
__proto__ is present, it looks up the __proto__ field and performs the same
lookup on that object (we are eliding the case where proto is not present or
not an object for this presentation). But note something crucial: the
expression on the right hand side drops everything about the original
object except its prototype. If we applied this rule to child
above, the getter rule would pass parent
to the getter instead of
child
!
The solution is to keep track of the original object as we traverse the prototype chain. If we don't, the reduction relation simply won't have the information it needs to pass in to the getter or setter when it reaches the right point in the chain. This is a deep change―we need to modify our expressions to get it right:
p := [get: vg, set: vs] | [value: v] v := str | { str1:p1, ⋯, strn:pn } | func(x ⋯) . e | ⋯ e := e[e] | e[e=e] | ev[e] | ev[e=e] | e(e, ⋯) | ⋯
And now, when we do a prototype lookup, we can keep track of the same
this
argument (written as vt)
the whole way up the chain, and the rules for getters and setters can use this
new piece of the expression:
(E-Lookup-Proto) { str1:v1, ⋯, "__proto__": vp, strn:vn, ⋯}vt[str] → vpvt[str] when str ≠ str1, ⋯, strn, ⋯ (E-LookupGetter) { ⋯, str:[get:vg, set:vs], ⋯ }vt[strx] → vg(vt) when strx = str (E-UpdateSetter) { ⋯, str:[get:vg, set:vs], ⋯}vt[strx=v'] → vs(vt,v') when strx = str
This idea was inspired by Di Gianantonio, Honsell, and Liquori's 1998 paper, A lambda calculus of objects with self-inflicted extension. They use a similar encoding to model method dispatches in a small prototype-based object calculus. The original expressions, e[e] and e[e=e], simply copy values into the new positions once the subexpressions have reduced to values:
(E-Lookup) v[str] → vv[str] (E-Update) v[str=v'] → vv[str=v']
The final set of evaluation rules and expressions is a little larger:
p := [get: vg, set: vs] | [value: v] v := str | { str1:p1, ⋯, strn:pn } | func(x ⋯) . e | ⋯ e := e[e] | e[e=e] | ev[e] | ev[e=e] | e(e, ⋯) | ⋯ (E-Lookup) v[str] → vv[str] (E-Update) v[str=v'] → vv[str=v'] (E-LookupGetter) { ⋯, str:[get:vg, set:vs], ⋯ }vt[strx] → vg(vt) when strx = str (E-Lookup-Proto) { str1:v1, ⋯, "__proto__": vp, strn:vn, ⋯}vt[str] → vpvt[str] when str ≠ str1, ⋯, strn, ⋯ (E-UpdateSetter) { ⋯, str:[get:vg, set:vs], ⋯}vt[strx=v'] → vs(vt,v') when strx = str (E-Update-Proto) { str1:v1, ⋯, "__proto__": vp, strn:vn, ⋯}vt[str=v'] → vpvt[str=v'] when str ≠ str1, ⋯, strn, ⋯
This is most of the rules―we've elided some details to only present the
key insight behind the new ones. Our full semantics (discussed in our last post), handles the
details of the arguments
object that is implicitly available
within getters and setters, and using built-ins, like
defineProperty
, to add already-defined functions to existing
objects as getters and setters.