Make `&` (almost) Behave as a Reference Operator
August 09, 2024 | Tips | en
Suppose you have an ExtendScript class (I mean, “prototype”) whose methods essentially return new object instances. As an obvious example, consider the concat
or slice
methods of Array.prototype
. They always produce a new array rather than reworking the existing one. There are situations where you want to update the calling object —and make this explicit in the code— while still leveraging the methods already available…
Of course, once a new instance is created, you have no other option than to copy it, in whole or in part, into your working reference. I will illustrate here a simple and rather amusing technique to achieve this task.
A few words about the motivation. We need to modify the internal state of an object already referenced (registered, if you prefer) elsewhere in the program. Even locally, a variable myVar
can refer to an entity that we want to reconfigure in place, via a certain method that works fine but has the disadvantage of generating a new object.
The assignment myVar=myVar.createNewObj(args)
then poses a problem. The myVar identifier is simply redirected to a fresh reference in memory and everything that was in the old reference is lost. So you haven't actually updated the existing object, you've simply pointed the variable to a new thing. (By the way, this consumes a little more memory with each new saved-and-lost reference, making life harder for the garbage collector…)
In a perfect world, a mutation method would exist and it would be enough to call
myVar.update_as_told_by_CreateNewObj(args)
to get the job done inside myVar
. But by design many operations on composite objects (such as array concatenation, complex number processing, matrix product…) tend to spawn new entities, because developers assume that operands must persist, and they are certainly right to do so!
So let's consider the simplest example in the world, the traditional 2D point object. Here is one of many approaches to this structure:
// Constructor and prototype (ExtendScript syntax). // --- function MyPoint(/*num*/_x,/*num*/_y,/*?str*/_name) { // Small hack: just makes `new` optional. if( callee!==this.constructor ) return new callee(_x,_y,_name); this.x = _x||0; this.y = _y||0; this.name = String(_name||"<none>"); } MyPoint.prototype.toString = function() { return this.name+" : ("+this.x+","+this.y+")" }; MyPoint.prototype.newScaled = function(k) { return MyPoint( k*this.x, k*this.y) }; // Create two points. // --- var p1, p2; p1 = MyPoint(1,3, "p1"); alert( p1 ); // => "p1 : (1,3)" p2 = p1.newScaled(5); alert( p2 ); // => "<none> : (5,15)"
The little curiosity is that I added a name
property so that we can identify different instances of MyPoint
. The newScaled
method multiplies a point by a given factor and returns a new object, and you can easily imagine all the operations of the same kind (addition, subtraction, vector product, and so on).
Thanks to the custom toString
method, you instantly notice that the point p2
— i.e. p1.newScaled(5)
— has no name. This clearly highlights it is a separate instance, returned by a generating function that leaves p1
unchanged.
Now here is our problem:
// ... var p3 = MyPoint(2,-1,"p3"); // then... p3 = p3.newScaled(7); alert( p3 ); // => "<none> : (14,-7)"
This code is perfectly normal, yet it does not satisfy our secret hopes. It's now a well established fact that p3.newScaled(7)
had to return a new instance of MyPoint
, ok with that, but how could we properly reassign p3
(the identifier) such that the original instance persists and modifies its internal data as told by ...newScaled(7)
?
In particular, how could the scheme
p3 = ... p3.newScaled(7)
;
maintain the p3.name
property unchanged? (Don't lose sight of the essential constraint: the newScaled
method is implemented that way and can no longer be adapted to our needs.)
Considering that newScaled(...)
—and probably all operating procedures of your class— systematically produces a new instance, a requisite is to add to the prototype a method copying certain data (here, the x,y coordinates) from a source object into the active object (this
). The fun fact is that such a cloning helper can be implemented by operator overloading:
MyPoint.prototype['&'] = function(arg) { // Copy arg coordinates in this, then return this! // Operation: `P & arg` this.x = arg.x; this.y = arg.y; return this; };
Look carefully at the code above. What does it mean? It defines the semantics of an expression of the form P & Q
, where P and Q denote MyPoint
instances. It turns out that, in JS, the default behavior of obj1 & obj2
expressions is supremely uninteresting —unless obj1, obj2 are integers. So the &
operator seems ideal for our purpose.
The ExtendScript interpreter silently translates the expression P&Q
into P["&"](Q)
, which calls the above method from P
(becoming the this
context) and pass Q
as argument.
Note. — A second argument (boolean) is passed to the operator-method, but we won't worry about it here.
So you can see that our custom operator is responsible for transferring the coordinates from arg
to this
(that is, from Q to P) and, crucially, the function returns this
. This means that the address of P&Q
is still the address of P
; in code:
(P&Q) === P // true (for MyPoint objs)
Note. — The parentheses are important in the above test because equality operators have a higher (just slightly higher) precedence than bitwise operators. Fortunately, this is not the case with the assignment operator!
Let's use our new toy:
// ExtendScript syntax for constructor and prototype. // --- function MyPoint(_x,_y,_name) { // Makes `new` optional: if( callee!==this.constructor ) return new callee(_x,_y,_name); this.x = _x||0; this.y = _y||0; this.name = String(_name||"<none>"); } MyPoint.prototype.toString = function() { return this.name+" : ("+this.x+","+this.y+")" }; MyPoint.prototype.newScaled = function(k) { return MyPoint( k*this.x, k*this.y) }; MyPoint.prototype['&'] = function(arg) { // Copy arg coordinates in this, then return this! // Operation: `P & arg` this.x = arg.x; this.y = arg.y; return this; }; // New test: var p4 = MyPoint(-8,6,"p4"); // Call newScaled() and update p4 itself (!) p4 & p4.newScaled(0.5); alert( p4 ); // => "p4 : (-4,3)"
It works, but the expression still looks a bit twisted. What happens is that p4.newScaled(0.5)
returns a new point (Q) which is then copied into p4
, thanks to our custom P&Q
processing. There remains a kind of optical problem, p4 & p4...
doesn't look like a direct modification of the p4
object. Can we improve that?
Since p4&...
remains stricly equal to p4
, an equivalent rewrite of the above operation would be:
p4 = p4 & p4.newScaled(0.5);
which just makes the code unnecessarily longer! Yet this is where our latest trick comes into play: p4 = p4&...
can always be abbreviated p4 &= ...
, so we finally get this:
p4 &= p4.newScaled(0.5);
More generally, any expression of the form
P &= (any code that yields a point)
will load the data in P
as-a-reference, while
P = (any code that yields a point)
still renews P
with a new reference.
This difference between P
and P &
—which means nothing in native JavaScript!— here mimicks a syntactic nuance well known to C programmers.