Kyle Simpson | You dont know Javascript (Episode 12)
If you are new to programming or JS, be aware that these books are not intended as a gentle intro to Javascript.
· 17 phút đọc.
| NOTE: | | : – - | | Work in progress |
We’ve thoroughly explored objects, prototypes, classes, and now the this
keyword. But we’re now going to revisit what we’ve learned so far from a bit of a different perspective.
What if you could leverage all the power of the objects, prototypes, and dynamic this
mechanisms together, without ever using class
or any of its descendants?
In fact, I would argue JS is inherently less class-oriented than the class
keyword might appear. Because JS is a dynamic, prototypal language, its strong suit is actually… delegation.
Preamble
Before we begin looking at delegation, I want to offer a word of caution. This perspective on JS’s object [[Prototype]]
and this
function context mechanisms is not mainstream. It’s not how framework authors and libraries utilize JS. You won’t, to my knowledge, find any big apps out there using this pattern.
So why on earth would I devote a chapter to such a pattern, if it’s so unpopular?
Good question. The cheeky answer is: because it’s my book and I can do what I feel like!
But the deeper answer is, because I think developing this understanding of one of the language’s core pillars helps you even if all you ever do is use class
-style JS patterns.
To be clear, delegation is not my invention. It’s been around as a design pattern for decades. And for a long time, developers argued that prototypal delegation was just the dynamic form of inheritance.1 But I think that was a mistake to conflate the two.2
For the purposes of this chapter, I’m going to present delegation, as implemented via JS mechanics, as an alternative design pattern, positioned somewhere between class-orientation and object-closure/module patterns.
The first step is to de-construct the class
mechanism down to its individual parts. Then we’ll cherry-pick and mix the parts a bit differently.
What’s A Constructor, Anyway?
In Chapter 3, we saw constructor(…)
as the main entry point for construction of a class
instance. But the constructor(…)
doesn’t actually do any creation work, it’s only initialization work. In other words, the instance is already created by the time the constructor(…)
runs and initializes it – e.g., this.whatever
types of assignments.
So where does the creation work actually happen? In the new
operator. As the section “New Context Invocation” in Chapter 4 explains, there are four steps the new
keyword performs; the first of those is the creation of a new empty object (the instance). The constructor(…)
isn’t even invoked until step 3 of new
’s efforts.
But new
is not the only – or perhaps even, best – way to create an object “instance”. Consider:
// a non-class "constructor"
function Point2d(x,y) {
// create an object (1)
var instance = {};
// initialize the instance (3)
instance.x = x;
instance.y = y;
// return the instance (4)
return instance;
}
var point = Point2d(3,4);
point.x; // 3
point.y; // 4
There’s no class
, just a regular function definition (Point2d(…)
). There’s no new
invocation, just a regular function call (Point2d(3,4)
). And there’s no this
references, just regular object property assignments (instance.x =…
).
The term that’s most often used to refer to this pattern of code is that Point2d(…)
here is a factory function. Invoking it causes the construction (creation and initialization) of an object, and returns that back to us. That’s an extremely common pattern, at least as common as class-oriented code.
I comment-annotated (1)
, (3)
, and (4)
in that snippet, which roughly correspond to steps 1, 3, and 4 of the new
operation. But where’s step 2?
If you recall, step 2 of new
is about linking the object (created in step 1) to another object, via its [[Prototype]]
slot (see Chapter 2). So what object might we want to link our instance
object to? We could link it to an object that holds functions we’d like to associate/use with our instance.
Let’s amend the previous snippet:
var prototypeObj = {
toString() {
return `(${this.x},${this.y})`;
},
}
// a non-class "constructor"
function Point2d(x,y) {
// create an object (1)
var instance = {
// link the instance's [[Prototype]] (2)
_proto:_ prototypeObj,
};
// initialize the instance (3)
instance.x = x;
instance.y = y;
// return the instance (4)
return instance;
}
var point = Point2d(3,4);
point.toString(); // (3,4)
Now you see the _proto_
assignment that’s setting up the internal [[Prototype]]
linkage, which was the missing step 2. I used the _proto_
here merely for illustration purposes; using setPrototypeOf(…)
as shown in Chapter 4 would have accomplished the same task.
New Factory Instance
What do you think would happen if we used new
to invoke the Point2d(…)
function as shown here?
var anotherPoint = new Point2d(5,6);
anotherPoint.toString(5,6); // (5,6)
Wait! What’s going on here? A regular, non-class
factory function in invoked with the new
keyword, as if it was a class
. Does that change anything about the outcome of the code?
No… and yes. anotherPoint
here is exactly the same object as it would have been had I not used new
. But! The object that new
creates, links, and assigns as this
context? That object was completely ignored and thrown away, ultimately to be garbage collected by JS. Unfortunately, the JS engine cannot predict that you’re not going to use the object that you asked new
to create, so it always still gets cteated even if it goes unused.
That’s right! Using a new
keyword against a factory function might feel more ergonomic or familiar, but it’s quite wasteful, in that it creates two objects, and wastefully throws one of them away.
Factory Initialization
In the current code example, the Point2d(…)
function still looks an awful lot like a normal constructor(…)
of a class
definition. But what if we moved the initialization code to a separate function, say named init(…)
:
var prototypeObj = {
init(x,y) {
// initialize the instance (3)
this.x = x;
this.y = y;
},
toString() {
return `(${this.x},${this.y})`;
},
}
// a non-class "constructor"
function Point2d(x,y) {
// create an object (1)
var instance = {
// link the instance's [[Prototype]] (2)
_proto:_ prototypeObj,
};
// initialize the instance (3)
instance.init(x,y);
// return the instance (4)
return instance;
}
var point = Point2d(3,4);
point.toString(); // (3,4)
The instance.init(…)
call makes use of the [[Prototype]]
linkage set up via _proto_
assignment. Thus, it delegates up the prototype chain to prototypeObj.init(…)
, and invokes it with a this
context of instance
– via implicit context assignment (see Chapter 4).
Let’s continue the deconstruction. Get ready for a switcheroo!
var Point2d = {
init(x,y) {
// initialize the instance (3)
this.x = x;
this.y = y;
},
toString() {
return `(${this.x},${this.y})`;
},
};
Whoa, what? I discarded the Point2d(…)
function, and instead renamed the prototypeObj
as Point2d
. Weird.
But let’s look at the rest of the code now:
// steps 1, 2, and 4
var point = { _proto:_ Point2d, };
// step 3
point.init(3,4);
point.toString(); // (3,4)
And one last refinement: let’s use a built-in utility JS provides us, called Object.create(…)
:
// steps 1, 2, and 4
var point = Object.create(Point2d);
// step 3
point.init(3,4);
point.toString(); // (3,4)
What operations does Object.create(…)
perform?
create a brand new empty object, out of thin air.
link the
[[Prototype]]
of that new empty object to the function’s.prototype
object.
If those look familiar, it’s because those are exactly the same first two steps of the new
keyword (see Chapter 4).
Let’s put this back together now:
var Point2d = {
init(x,y) {
this.x = x;
this.y = y;
},
toString() {
return `(${this.x},${this.y})`;
},
};
var point = Object.create(Point2d);
point.init(3,4);
point.toString(); // (3,4)
Hmmm. Take a few moments to ponder what’s been derived here. How does it compare to the class
approach?
This pattern ditches the class
and new
keywords, but accomplishes the exact same outcome. The cost? The single new
operation was broken up into two statements: Object.create(Point2d)
and point.init(3,4)
.
Help Me Reconstruct!
If having those two operations separate bothers you – is it too deconstructed? – they can always be recombined in a little factory helper:
function make(objType,…args) {
var instance = Object.create(objType);
instance.init(…args);
return instance;
}
var point = make(Point2d,3,4);
point.toString(); // (3,4)
| TIP: | | : – - | | Such a make(…)
factory function helper works generally for any object-type, as long as you follow the implied convention that each objType
you link to has a function named init(…)
on it. |
And of course, you can still create as many instances as you’d like:
var point = make(Point2d,3,4);
var anotherPoint = make(Point2d,5,6);
Ditching Class Thinking
Quite frankly, the deconstruction we just went through only ends up in slightly different, and maybe slightly better or slightly worse, code as compared to the class
style. If that’s all delegation was about, it probably wouldn’t even be useful enough for more than a footnote, much less a whole chapter.
But here’s where we’re going to really start pushing the class-oriented thinking itself, not just the syntax, aside.
Class-oriented design inherently creates a hierarchy of classification, meaning how we divide up and group characteristics, and then stack them vertically in an inheritance chain. Moreover, defining a subclass is a specialization of the generalized base class. Instantiating is a specialization of the generalized class.
Behavior in a traditional class hierarchy is a vertical composition through the layers of the inheritance chain. Attempts have been made over the decades, and even become rather popular at times, to flatten out deep hierarchies of inheritance, and favor a more horizontal composition through mixins and related ideas.
I’m not asserting there’s anything wrong with those ways of approaching code. But I am saying that they aren’t naturally how JS works, so adopting them in JS has been a long, winding, complicated road, and has variously accreted lots of nuanced syntax to retrofit on top of JS’s core [[Prototype]]
and this
pillar.
For the rest of this chapter, I intend to discard both the syntax of class
and the thinking of class.
Delegation Illustrated
So what is delegation about? At its core, it’s about two or more things sharing the effort of completing a task.
Instead of defining a Point2d
general parent thing that represents shared behavior that a set of one or more child point
/ anotherPoint
things inherit from, delegation moves us to building our program with discrete peer things that cooperate with each other.
I’ll sketch that out in some code:
var Coordinates = {
setX(x) {
this.x = x;
},
setY(y) {
this.y = y;
},
setXY(x,y) {
this.setX(x);
this.setY(y);
},
};
var Inspect = {
toString() {
return `(${this.x},${this.y})`;
},
};
var point = {};
Coordinates.setXY.call(point,3,4);
Inspect.toString.call(point); // (3,4)
var anotherPoint = Object.create(Coordinates);
anotherPoint.setXY(5,6);
Inspect.toString.call(anotherPoint); // (5,6)
Let’s break down what’s happening here.
I’ve defined Coordinates
as a concrete object that holds some behaviors I associate with setting point coordinates (x
and y
). I’ve also defined Inspect
as a concrete object that holds some debug inspection logic, such as toString()
.
I then create two more concrete objects, point
and anotherPoint
.
point
has no specific [[Prototype]]
(default: Object.prototype
). Using explicit context assignment (see Chapter 4), I invoke the Coordinates.setXY(…)
and Inspect.toString()
utilities in the context of point
. That is what I call explicit delegation.
anotherPoint
is [[Prototype]]
linked to Coordinates
, mostly for a bit of convenience. That lets me use implicit context assignment with anotherPoint.setXY(…)
. But I can still explicitly share anotherPoint
as context for the Inspect.toString()
call. That’s what I call implicit delegation.
Don’t miss this: We still accomplished composition: we composed the behaviors from Coordinates
and Inspect
, during runtime function invocations with this
context sharing. We didn’t have to author-combine those behaviors into a single class
(or base-subclass class
hierarchy) for point
/ anotherPoint
to inherit from. I like to call this runtime composition, virtual composition.
The point here is: none of these four objects is a parent or child. They’re all peers of each other, and they all have different purposes. We can organize our behavior in logical chunks (on each respective object), and share the context via this
(and, optionally [[Prototype]]
linkage), which ends up with the same composition outcomes as the other patterns we’ve examined thus far in the book.
That is the heart of the delegation pattern, as JS embodies it.
| TIP: | | : – - | | In the first edition of this book series, this book (“this & Object Prototypes”) coined a term, “OLOO”, which stands for “Objects Linked to Other Objects” – to stand in contrast to “OO” (“Object Oriented”). In this preceding example, you can see the essence of OLOO: all we have are objects, linked to and cooperating with, other objects. I find this beautiful in its simplicity. |
Composing Peer Objects
Let’s take this delegation even further.
In the preceding snippet, point
and anotherPoint
merely held data, and the behaviors they delegated to were on other objects (Coordinates
and Inspect
). But we can add behaviors directly to any of the objects in a delegation chain, and those behaviors can even interact with each other, all through the magic of virtual composition (this
context sharing).
To illustrate, we’ll evolve our current point example a fair bit. And as a bonus we’ll actually draw our points on a <canvas>
element in the DOM. Let’s take a look:
var Canvas = {
setOrigin(x,y) {
this.ctx.translate(x,y);
// flip the canvas context vertically,
// so coordinates work like on a normal
// 2d (x,y) graph
this.ctx.scale(1,-1);
},
pixel(x,y) {
this.ctx.fillRect(x,y,1,1);
},
renderScene() {
// clear the canvas
var matrix = this.ctx.getTransform();
this.ctx.resetTransform();
this.ctx.clearRect(
0, 0,
this.ctx.canvas.width,
this.ctx.canvas.height
);
this.ctx.setTransform(matrix);
this.draw(); // < – where is draw()?
},
};
var Coordinates = {
setX(x) {
this.x = Math.round(x);
},
setY(y) {
this.y = Math.round(y);
},
setXY(x,y) {
this.setX(x);
this.setY(y);
this.render(); // < – where is render()?
},
};
var ControlPoint = {
// delegate to Coordinates
_proto:_ Coordinates,
// NOTE: must have a <canvas id="my-canvas">
// element in the DOM
ctx: document.getElementById("my-canvas")
.getContext("2d"),
rotate(angleRadians) {
var rotatedX = this.x Math.cos(angleRadians) -
this.y Math.sin(angleRadians);
var rotatedY = this.x Math.sin(angleRadians) +
this.y Math.cos(angleRadians);
this.setXY(rotatedX,rotatedY);
},
draw() {
// plot the point
Canvas.pixel.call(this,this.x,this.y);
},
render() {
// clear the canvas, and re-render
// our control-point
Canvas.renderScene.call(this);
},
};
// set the logical (0,0) origin at this
// physical location on the canvas
Canvas.setOrigin.call(ControlPoint,100,100);
ControlPoint.setXY(30,40);
// [renders point (30,40) on the canvas]
//…
// later:
// rotate the point about the (0,0) origin
// 90 degrees counter-clockwise
ControlPoint.rotate(Math.PI / 2);
// [renders point (-40,30) on the canvas]
OK, that’s a lot of code to digest. Take your time and re-read the snippet several times. I added a couple of new concrete objects (Canvas
and ControlPoint
) alongside the previous Coordinates
object.
Make sure you see and understand the interactions between these three concrete objects.
ControlPoint
is linked (via _proto_
) to implicitly delegate ([[Prototype]]
chain) to Coordinates
.
Here’s an explicit delegation: Canvas.setOrigin.call(ControlPoint,100,100);
; I’m invoking the Canvas.setOrigin(…)
call in the context of ControlPoint
. That has the effect of sharing ctx
with setOrigin(…)
, via this
.
ControlPoint.setXY(…)
delegates implicitly to Coordinates.setXY(…)
, but still in the context of ControlPoint
. Here’s a key detail that’s easy to miss: see the this.render()
inside of Coordinates.setXY(…)
? Where does that come from? Since the this
context is ControlPoint
(not Coordinates
), it’s invoking ControlPoint.render()
.
ControlPoint.render()
explicitly delegates to Canvas.renderScene()
, again still in the ControlPoint
context. renderScene()
calls this.draw()
, but where does that come from? Yep, still from ControlPoint
(via this
context).
And ControlPoint.draw()
? It explicitly delegates to Canvas.pixel(…)
, yet again still in the ControlPoint
context.
All three objects have methods that end up invoking each other. But these calls aren’t particularly hard-wired. Canvas.renderScene()
doesn’t call ControlPoint.draw()
, it calls this.draw()
. That’s important, because it means that Canvas.renderScene()
is more flexible to use in a different this
context – e.g., against another kind of point object besides ControlPoint
.
It’s through the this
context, and the [[Prototype]]
chain, that these three objects basically are mixed (composed) virtually together, as needed at each step, so that they work together as if they’re one object rather than three seperate objects.
That’s the beauty of virtual composition as realized by the delegation pattern in JS.
Flexible Context
I mentioned above that we can pretty easily add other concrete objects into the mix. Here’s an example:
var Coordinates = { /… / };
var Canvas = {
/… /
line(start,end) {
this.ctx.beginPath();
this.ctx.moveTo(start.x,start.y);
this.ctx.lineTo(end.x,end.y);
this.ctx.stroke();
},
};
function lineAnchor(x,y) {
var anchor = {
_proto:_ Coordinates,
render() {},
};
anchor.setXY(x,y);
return anchor;
}
var GuideLine = {
// NOTE: must have a <canvas id="my-canvas">
// element in the DOM
ctx: document.getElementById("my-canvas")
.getContext("2d"),
setAnchors(sx,sy,ex,ey) {
this.start = lineAnchor(sx,sy);
this.end = lineAnchor(ex,ey);
this.render();
},
draw() {
// plot the point
Canvas.line.call(this,this.start,this.end);
},
render() {
// clear the canvas, and re-render
// our line
Canvas.renderScene.call(this);
},
};
// set the logical (0,0) origin at this
// physical location on the canvas
Canvas.setOrigin.call(GuideLine,100,100);
GuideLine.setAnchors(-30,65,45,-17);
// [renders line from (-30,65) to (45,-17)
// on the canvas]
That’s pretty nice, I think!
But I think another less-obvious benefit is that having objects linked dynamically via this
context tends to make testing different parts of the program independently, somewhat easier.
For example, Object.setPrototypeOf(…)
can be used to dynamically change the [[Prototype]]
linkage of an object, delegating it to a different object such as a mock object. Or you could dynamically redefine GuideLine.draw()
and GuideLine.render()
to explicitly delegate to a MockCanvas
instead of Canvas
.
The this
keyword, and the [[Prototype]]
link, are a tremendously flexible mechanism when you understand and leverage them fully.
Why This?
OK, so it’s hopefully clear that the delegation pattern leans heavily on implicit input, sharing context via this
rather than through an explicit parameter.
You might rightly ask, why not just always pass around that context explicitly? We can certainly do so, but… to manually pass along the necessary context, we’ll have to change pretty much every single function signature, and any corresponding call-sites.
Let’s revisit the earlier ControlPoint
delegation example, and implement it without any delegation-oriented this
context sharing. Pay careful attention to the differences:
var Canvas = {
setOrigin(ctx,x,y) {
ctx.translate(x,y);
ctx.scale(1,-1);
},
pixel(ctx,x,y) {
ctx.fillRect(x,y,1,1);
},
renderScene(ctx,entity) {
// clear the canvas
var matrix = ctx.getTransform();
ctx.resetTransform();
ctx.clearRect(
0, 0,
ctx.canvas.width,
ctx.canvas.height
);
ctx.setTransform(matrix);
entity.draw();
},
};
var Coordinates = {
setX(entity,x) {
entity.x = Math.round(x);
},
setY(entity,y) {
entity.y = Math.round(y);
},
setXY(entity,x,y) {
this.setX(entity,x);
this.setY(entity,y);
entity.render();
},
};
var ControlPoint = {
// NOTE: must have a <canvas id="my-canvas">
// element in the DOM
ctx: document.getElementById("my-canvas")
.getContext("2d"),
setXY(x,y) {
Coordinates.setXY(this,x,y);
},
rotate(angleRadians) {
var rotatedX = this.x Math.cos(angleRadians) -
this.y Math.sin(angleRadians);
var rotatedY = this.x Math.sin(angleRadians) +
this.y Math.cos(angleRadians);
this.setXY(rotatedX,rotatedY);
},
draw() {
// plot the point
Canvas.pixel(this.ctx,this.x,this.y);
},
render() {
// clear the canvas, and re-render
// our control-point
Canvas.renderScene(this.ctx,this);
},
};
// set the logical (0,0) origin at this
// physical location on the canvas
Canvas.setOrigin(ControlPoint.ctx,100,100);
//…
To be honest, some of you may prefer that style of code. And that’s OK if you’re in that camp. This snippet avoids [[Prototype]]
entirely, and only relies on far fewer basic this.
-style references to properties and methods.
By contrast, the delegation style I’m advocating for in this chapter is unfamiliar and uses [[Prototype]]
and this
sharing in ways you’re not likely familiar with. To use such a style effectively, you’ll have to invest the time and practice to build a deeper familiarity.
But in my opinion, the “cost” of avoiding virtual composition through delegation can be felt across all the function signatures and call-sites; I find them way more cluttered. That explicit context passing is quite a tax.
In fact, I’d never advocate that style of code at all. If you want to avoid delegation, it’s probably best to just stick to class
style code, as seen in Chapter 3. As an exercise left to the reader, try to convert the earlier ControlPoint
/ GuideLine
code snippets to use class
.
Footnotes
“Treaty of Orlando”; Henry Lieberman, Lynn Andrea Stein, David Ungar; Oct 6, 1987; https://web.media.mit.edu/~lieber/Publications/Treaty-of-Orlando-Treaty-Text.pdf ; PDF; Accessed July 2022 ↩
“Classes vs. Prototypes, Some Philosophical and Historical Observations”; Antero Taivalsaari; Apr 22, 1996; https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.56.4713&rep=rep1&type=pdf ; PDF; Accessed July 2022 ↩