Search This Blog

Thursday, July 9, 2020

Making Sense of ES6 Class Confusion (Javascript)

https://www.toptal.com/javascript/es6-class-chaos-keeps-js-developer-up#:~:text=Prototypes%20vs.-,Classes,is%20itself%20an%20object%20instance.&text=Functions%20are%20first%2Dclass%20in,be%20properties%20of%20other%20objects.

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 Arrayclass 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'  
Note: You'd almost never write code like this in real life—it's terrible practice—but it demonstrates the principle succinctly.

In the previous example, while child.foo was undefined, it referenced parent.foo. As soon as we defined foo on childchild.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):

Walking through the prototype chain to show how missing references are dealt with in JavaScript.

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

PHÂN BIỆT QUẢN TRỊ VÀ QUẢN LÝ

PHÂN BIỆT QUẢN TRỊ VÀ QUẢN LÝ Hội đồng quản trị, tiếng Anh là BOD (Board Of Directors). Còn Ban giám đốc hay Ban quản lý tiếng Anh là BOM (B...