JavaScript is an oddball of a language. Though inspired by Smalltalk, it uses a C-like syntax. It combines aspects of procedural, functional, and object-oriented programming (OOP) paradigms. It has numerous, often redundant, approaches to solving almost any conceivable programming problem and is not strongly opinionated about which are preferred. It's weakly and dynamically typed, with a mazelike approach to type coercion that trips up even experienced developers.
JavaScript also has its warts, traps, and questionable features. New programmers struggle with some of its more difficult concepts—think asynchronicity, closures, and hoisting. Programmers with experience in other languages reasonably assume things with similar names and appearances will work the same way in JavaScript and are often wrong. Arrays aren't really arrays; what's the deal with this
, what's a prototype, and what does new
actually do?
The Trouble with ES6 Classes
The worst offender by far is new to JavaScript's latest release version, ECMAScript 6 (ES6): classes. Some of the talk around classes is frankly alarming and reveals a deep-rooted misunderstanding of how the language actually works:
"JavaScript is finally a real object-oriented language now that it has classes!"
Or:
"Classes free us up from thinking about JavaScript's broken inheritance model."
Or even:
"Classes are a safer, easier approach to creating types in JavaScript."
These statements don't bother me because they imply there's something wrong with prototypical inheritance; let's set aside those arguments. These statements bother me because none of them are true, and they demonstrate the consequences of JavaScript's "everything for everyone" approach to language design: It cripples a programmer's understanding of the language more often than it enables. Before I go any further, let's illustrate.
JavaScript Pop Quiz #1: What's the Essential Difference Between These Code Blocks?
function PrototypicalGreeting(greeting = "Hello", name = "World") { this.greeting = greeting this.name = name } PrototypicalGreeting.prototype.greet = function() { return `${this.greeting}, ${this.name}!` } const greetProto = new PrototypicalGreeting("Hey", "folks") console.log(greetProto.greet())
class ClassicalGreeting { constructor(greeting = "Hello", name = "World") { this.greeting = greeting this.name = name } greet() { return `${this.greeting}, ${this.name}!` } } const classyGreeting = new ClassicalGreeting("Hey", "folks") console.log(classyGreeting.greet())
The answer here is there isn't one. These do effectively the same thing, it's only a question of whether ES6 class syntax was used.
True, the second example is more expressive. For that reason alone, you might argue that class
is a nice addition to the language. Unfortunately, the problem is a little more subtle.
JavaScript Pop Quiz #2: What Does the Following Code Do?
function Proto() { this.name = 'Proto' return this; } Proto.prototype.getName = function() { return this.name } class MyClass extends Proto { constructor() { super() this.name = 'MyClass' } } const instance = new MyClass() console.log(instance.getName()) Proto.prototype.getName = function() { return 'Overridden in Proto' } console.log(instance.getName()) MyClass.prototype.getName = function() { return 'Overridden in MyClass' } console.log(instance.getName()) instance.getName = function() { return 'Overridden in instance' } console.log(instance.getName())
The correct answer is that it prints to console:
> MyClass > Overridden in Proto > Overridden in MyClass > Overridden in instance
If you answered incorrectly, you don't understand what class
actually is. This isn't your fault. Much like Array
, class
is not a language feature, it's syntactic obscurantism. It tries to hide the prototypical inheritance model and the clumsy idioms that come with it, and it implies that JavaScript is doing something that it is not.
You might have been told that class
was introduced to JavaScript to make classical OOP developers coming from languages like Java more comfortable with the ES6 class inheritance model. If you are one of those developers, that example probably horrified you. It should. It shows that JavaScript's class
keyword doesn't come with any of the guarantees that a class is meant to provide. It also demonstrates one of the key differences in the prototype inheritance model: Prototypes are object instances, not types.
Prototypes vs. Classes
The most important difference between class- and prototype-based inheritance is that a class defines a type which can be instantiated at runtime, whereas a prototype is itself an object instance.
A child of an ES6 class is another type definition which extends the parent with new properties and methods, which in turn can be instantiated at runtime. A child of a prototype is another object instance which delegates to the parent any properties that aren't implemented on the child.
Side note: You might be wondering why I mentioned class methods, but not prototype methods. That's because JavaScript doesn't have a concept of methods. Functions are first-class in JavaScript, and they can have properties or be properties of other objects.
A class constructor creates an instance of the class. A constructor in JavaScript is just a plain old function that returns an object. The only thing special about a JavaScript constructor is that, when invoked with the new
keyword, it assigns its prototype as the prototype of the returned object. If that sounds a little confusing to you, you're not alone—it is, and it's a big part of why prototypes are poorly understood.
To put a really fine point on that, a child of a prototype isn't a copy of its prototype, nor is it an object with the same shape as its prototype. The child has a living reference to the prototype, and any prototype property that doesn't exist on the child is a one-way reference to a property of the same name on the prototype.
Consider the following:
let parent = { foo: 'foo' } let child = { } Object.setPrototypeOf(child, parent) console.log(child.foo) // 'foo' child.foo = 'bar' console.log(child.foo) // 'bar' console.log(parent.foo) // 'foo' delete child.foo console.log(child.foo) // 'foo' parent.foo = 'baz' console.log(child.foo) // 'baz'
In the previous example, while child.foo
was undefined
, it referenced parent.foo
. As soon as we defined foo
on child
, child.foo
had the value 'bar'
, but parent.foo
retained its original value. Once we delete child.foo
it again refers to parent.foo
, which means that when we change the parent's value, child.foo
refers to the new value.
Let's look at what just happened (for the purpose of clearer illustration, we're going to pretend these are Strings
and not string literals, the difference doesn't matter here):
The way this works under the hood, and especially the peculiarities of new
and this
, are a topic for another day, but Mozilla has a thorough article about JavaScript's prototype inheritance chain if you'd like to read more.
The key takeaway is that prototypes don't define a type
; they are themselves instances
and they're mutable at runtime, with all that implies and entails.
Still with me? Let's get back to dissecting JavaScript classes.
JavaScript Pop Quiz #3: How Do You Implement Privacy in Classes?
Our prototype and class properties above aren't so much "encapsulated" as "hanging precariously out the window." We should fix that, but how?
No code examples here. The answer is that you can't.
JavaScript doesn't have any concept of privacy, but it does have closures:
function SecretiveProto() { const secret = "The Class is a lie!" this.spillTheBeans = function() { console.log(secret) } } const blabbermouth = new SecretiveProto() try { console.log(blabbermouth.secret) } catch(e) { // TypeError: SecretiveClass.secret is not defined } blabbermouth.spillTheBeans() // "The Class is a lie!"
Do you understand what just happened? If not, you don't understand closures. That's okay, really—they're not as intimidating as they're made out to be, they're super useful, and you should take some time to learn about them.
JavaScript Pop Quiz #4: What's the Equivalent to the Above Using the class
Keyword?
Sorry, this is another trick question. You can do basically the same thing, but it looks like this:
class SecretiveClass { constructor() { const secret = "I am a lie!" this.spillTheBeans = function() { console.log(secret) } } looseLips() { console.log(secret) } } const liar = new SecretiveClass() try { console.log(liar.secret) } catch(e) { console.log(e) // TypeError: SecretiveClass.secret is not defined } liar.spillTheBeans() // "I am a lie!"
Let me know if that looks any easier or clearer than in SecretiveProto
. In my personal view, it's somewhat worse—it breaks idiomatic use of class
declarations in JavaScript and it doesn't work much like you'd expect coming from, say, Java. This will be made clear by the following:
JavaScript Pop Quiz #5: What Does SecretiveClass::looseLips()
Do?
Let's find out:
try { liar.looseLips() } catch(e) { // ReferenceError: secret is not defined }
Well… that was awkward.
JavaScript Pop Quiz #6: Which Do Experienced JavaScript Developers Prefer—Prototypes or Classes?
You guessed it, that's another trick question—experienced JavaScript developers tend to avoid both when they can. Here's a nice way to do the above with idiomatic JavaScript:
function secretFactory() { const secret = "Favor composition over inheritance, `new` is considered harmful, and the end is near!" const spillTheBeans = () => console.log(secret) return { spillTheBeans } } const leaker = secretFactory() leaker.spillTheBeans()
This isn't just about avoiding the inherent ugliness of inheritance, or enforcing encapsulation. Think about what else you might do with secretFactory
and leaker
that you couldn't easily do with a prototype or a class.
For one thing, you can destructure it because you don't have to worry about the context of this
:
const { spillTheBeans } = secretFactory() spillTheBeans() // Favor composition over inheritance, (...)
That's pretty nice. Besides avoiding new
and this
tomfoolery, it allows us to use our objects interchangeably with CommonJS and ES6 modules. It also makes composition a little easier:
function spyFactory(infiltrationTarget) { return { exfiltrate: infiltrationTarget.spillTheBeans } } const blackHat = spyFactory(leaker) blackHat.exfiltrate() // Favor composition over inheritance, (...) console.log(blackHat.infiltrationTarget) // undefined (looks like we got away with it)
Clients of blackHat
don't have to worry about where exfiltrate
came from, and spyFactory
doesn't have to mess around with Function::bind
context juggling or deeply nested properties. Mind you, we don't have to worry much about this
in simple synchronous procedural code, but it causes all kinds of problems in asynchronous code that are better off avoided.
With a little thought, spyFactory
could be developed into a highly sophisticated espionage tool that could handle all kinds of infiltration targets—or in other words, a façade.
Of course you could do that with a class too, or rather, an assortment of classes, all of which inherit from an abstract class
or interface
…except that JavaScript doesn't have any concept of abstracts or interfaces.
Let's return to the greeter example to see how we'd implement it with a factory:
function greeterFactory(greeting = "Hello", name = "World") { return { greet: () => `${greeting}, ${name}!` } } console.log(greeterFactory("Hey", "folks").greet()) // Hey, folks!
You might have noticed these factories are getting more terse as we go along, but don't worry—they do they same thing. The training wheels are coming off, folks!
That's already less boilerplate than either the prototype or the class version of the same code. Secondly, it achieves encapsulation of its properties more effectively. Also, it has a lower memory and performance footprint in some cases (it may not seem like it at first glance, but the JIT compiler is quietly working behind the scenes to pare down duplication and infer types).
So it's safer, it's often faster, and it's easier to write code like this. Why do we need classes again? Oh, of course, reusability. What happens if we want unhappy and enthusiastic greeter variants? Well, if we're using the ClassicalGreeting
class, we probably jump directly into dreaming up a class hierarchy. We know we'll need to parameterize the punctuation, so we'll do a little refactoring and add some children:
// Greeting class class ClassicalGreeting { constructor(greeting = "Hello", name = "World", punctuation = "!") { this.greeting = greeting this.name = name this.punctuation = punctuation } greet() { return `${this.greeting}, ${this.name}${this.punctuation}` } } // An unhappy greeting class UnhappyGreeting extends ClassicalGreeting { constructor(greeting, name) { super(greeting, name, " :(") } } const classyUnhappyGreeting = new UnhappyGreeting("Hello", "everyone") console.log(classyUnhappyGreeting.greet()) // Hello, everyone :( // An enthusiastic greeting class EnthusiasticGreeting extends ClassicalGreeting { constructor(greeting, name) { super(greeting, name, "!!") } greet() { return super.greet().toUpperCase() } } const greetingWithEnthusiasm = new EnthusiasticGreeting() console.log(greetingWithEnthusiasm.greet()) // HELLO, WORLD!!
It's a fine approach, until someone comes along and asks for a feature that doesn't fit cleanly into the hierarchy and the whole thing stops making any sense. Put a pin in that thought while we try to write the same functionality with factories:
const greeterFactory = (greeting = "Hello", name = "World", punctuation = "!") => ({ greet: () => `${greeting}, ${name}${punctuation}` }) // Makes a greeter unhappy const unhappy = (greeter) => (greeting, name) => greeter(greeting, name, ":(") console.log(unhappy(greeterFactory)("Hello", "everyone").greet()) // Hello, everyone :( // Makes a greeter enthusiastic const enthusiastic = (greeter) => (greeting, name) => ({ greet: () => greeter(greeting, name, "!!").greet().toUpperCase() }) console.log(enthusiastic(greeterFactory)().greet()) // HELLO, WORLD!!
It's not obvious that this code is better, even though it's a bit shorter. In fact, you could argue that it's harder to read, and maybe this is an obtuse approach. Couldn't we just have an unhappyGreeterFactory
and an enthusiasticGreeterFactory
?
Then your client comes along and says, "I need a new greeter that is unhappy and wants the whole room to know about it!"
console.log(enthusiastic(unhappy(greeterFactory))().greet()) // HELLO, WORLD :(
If we needed to use this enthusiastically unhappy greeter more than once, we could make it easier on ourselves:
const aggressiveGreeterFactory = enthusiastic(unhappy(greeterFactory)) console.log(aggressiveGreeterFactory("You're late", "Jim").greet())
There are approaches to this style of composition that work with prototypes or classes. For example, you could rethink UnhappyGreeting
and EnthusiasticGreeting
as decorators. It would still take more boilerplate than the functional-style approach used above, but that's the price you pay for the safety and encapsulation of real classes.
The thing is, in JavaScript, you're not getting that automatic safety. JavaScript frameworks that emphasize class
usage do a lot of "magic" to paper over these kinds of problems and force classes to behave themselves. Have a look at Polymer's ElementMixin
source code some time, I dare you. It's arch-wizard levels of JavaScript arcana, and I mean that without irony or sarcasm.
Of course, we can fix some of the issues discussed above with Object.freeze
or Object.defineProperties
to greater or lesser effect. But why imitate the form without the function, while ignoring the tools JavaScript does natively provide us that we might not find in languages like Java? Would you use a hammer labeled "screwdriver" to drive a screw, when your toolbox had as actual screwdriver sitting right next to it?
Finding the Good Parts
JavaScript developers often emphasize the language's good parts, both colloquially and in reference to the book of the same name. We try to avoid the traps set by its more questionable language design choices and stick to the parts that let us write clean, readable, error-minimizing, reusable code.
There are reasonable arguments about which parts of JavaScript qualify, but I hope I've convinced you that class
is not one of them. Failing that, hopefully you understand that inheritance in JavaScript can be a confusing mess and that class
neither fixes it nor spares you having to understanding prototypes. Extra credit if you picked up on the hints that object-oriented design patterns work fine without classes or ES6 inheritance.
I'm not telling you to avoid class
entirely. Sometimes you need inheritance, and class
provides cleaner syntax for doing that. In particular, class X extends Y
is much nicer than the old prototype approach. Beside that, many popular front-end frameworks encourage its use and you should probably avoid writing weird non-standard code on principle alone. I just don't like where this is going.
In my nightmares, a whole generation of JavaScript libraries are written using class
, with the expectation that it will behave similarly to other popular languages. Whole new classes of bugs (pun intended) are discovered. Old ones are resurrected that could easily have been left in the Graveyard of Malformed JavaScript if we hadn't carelessly fallen into the class
trap. Experienced JavaScript developers are plagued by these monsters, because what is popular is not always what is good.
Eventually we all give up in frustration and start reinventing wheels in Rust, Go, Haskell, or who knows what else, and then compiling to Wasm for the web, and new web frameworks and libraries proliferate into multilingual infinity.
It really does keep me up at night.
No comments:
Post a Comment