Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
May 25, 2021 12:42 am GMT

JavaScript Inheritance: the Dark Arts

Inheritance remains one of the most relied upon and misunderstood features of JavaScript to this day. Since ES2015 JavaScript developers have been able to ignore how the inheritance sausage is made by relying on the class syntax that hides the nitty gritty details, until they run into its mind-bending edge cases.

In this post we'll explore the secrets of JavaScript inheritance: [[Prototype]] and constructors.

But first, put your knowledge to the test:

How many can you get right?

1. Overriding getters and setters

console.log('Overriding getters and setters');class SuperClass {  _value = undefined;  get value() { return this._value; }}class SubClass extends SuperClass {  set value(to) { this._value = to; }}const sub = new SubClass();sub.value = 5;// What gets logged?console.log(sub.value); // undefined

2. Deleting from a class instance

console.log('Deleting from a class instance');class MyClass {  fn1 = function() {}  fn2() {}}const myInstance = new MyClass();// What gets logged?delete myInstance.fn1;console.log(myInstance.fn1); // undefineddelete myInstance.fn2;console.log(myInstance.fn2); // fn2() {}

3. Deleting from an object

console.log('Deleting from an object');const myObject = {  fn() {},  toString() {},};// What gets logged?delete myObject.fn;console.log(myObject.fn); // undefinedconsole.log(myObject.toString); // toString() {}myObject.toString = undefinedconsole.log(myObject.toString); // undefineddelete myObject.toString;console.log(myObject.toString); // toString() { [native code] }

4. Overriding constructors???

class MyClass {  constructor() {    console.log("Original Consturctor");  }}MyClass.prototype.constructor = function Overridden() {  console.log("Overridden Constructor");}// What gets logged?const instance = new MyClass(); // "Original Constructor"console.log(instance.constructor.name);  // Overriddenconsole.log(instance.constructor.prototype === Object.getPrototypeOf(instance)); // false

If you got all of the above right then maybe you're already grizzled JavaScript veteran and know all the ins and outs of OOJS (Object Oriented JavaScript).

For the rest of us, it's time to open Pandora's Box.

Inheritance

In OOP (Object Oriented Programming), inheritance is the mechanism used build a new object or class ontop another object or class.

JavaScript has inheritance but doesn't have static "classes" like static OO languages (C++, C#, Java). Instead, JavaScript links objects together by prototypes. Even in ES2015, class is mostly just syntactic sugar for objects with prototypal relationships.

At a glance, OOJS using class appears sane.

class Base {  prop = 'hello world';}class Sub extends Base {  //}const sub = new Sub();// sub has access to properties on baseconsole.log(sub.prop);  // "hello world"

But how does this really work? What is a "class" and how does sub have access to prop?

Enter: [[Prototype]]

JavaScript uses prototypes to achieve inheritance. All objects have a [[Prototype]] internal slot which is the object being inherited from. Internal slots are internal to the JavaScript interpreter. Some internal slots are exposed via functions like Object.getPrototypeOf() and many aren't exposed at all.

An object's [[Prototype]] can be null or another object which itself has a [[Prototye]] slot. An object's linked list of [[Prototype]]s (i.e. myObject.[[Prototype]].[[Prototype]].[[Prototype]]...) is called its "prototype chain" and terminates with null.

To lookup a property on an object the JavaScript interpreter performs a lookup on the top-level object, then that object's [[Prototype]], then [[Prototype]].[[Prototype]], and so on until reaching null.

We can use Object.create(proto) to create a new object with proto as its [[Prototype]] and use Object.getPrototypeOf(obj) to get the [[Prototype]] of an object obj

const ancestor = Object.create(null);const parent = Object.create(ancestor);const child = Object.create(parent);// child inherits from parentconsole.log(Object.getPrototypeOf(child) === parent); // true// parent inherits from ancestorconsole.log(Object.getPrototypeOf(parent) === ancestor); // true// ancestor inherits nothingconsole.log(Object.getPrototypeOf(ancestor) === null); // true

We can also use Object.setPrototypeOf(sub, base) to change the [[Prototype]] of an object sub to another object (or null), base. Notice - unlike static OO languages we can dynamically change inheritance heirarchies at runtime! For performance reasons this is strongly advised against. According to Benedikt Muerer of v8, a every time you change the prototype chain, a kitten dies.

const base = { prop: 'hello world' };const sub = {};console.log(sub.prop); // undefinedObject.setPrototypeOf(sub, base);console.log(sub.prop); // "hello world"Object.setPrototypeOf(sub, null);console.log(sub.prop); // undefined

Objects created using the object literal syntax {} inherit from JavaScript's base Object.prototype which in-turn inherits from null.

const obj = {};console.log(Object.getPrototypeOf(obj) === Object.prototype); // trueconsole.log(Object.getPrototypeOf(Object.prototype) === null); // true

Functions

Functions are a regular JavaScript objects, but with additional internal slots. Like regular objects they have properties and a [[Prototype]] internal slot, but unlike other objects they are callable thanks to a [[Call]] internal method.

Constructors are functions with some specific attributes.

Enter: Constructors

Constructor functions compliment prototypes by making prototype configuration and object creation and inialisation easy and consistent. Inheritance can still be achieved without constructors (for example with Object.create) but it's less common.

Any non-arrow function (any function created with the function keyword) can be used as a constructor. All non-arrow functions have a prototype property, initialized to a new object with only one property prototype.constructor whose value is the constructor function. Note that a function's prototype property is NOT the same as that functions [[Prototype]] internal slot.

Constructors have to be called with a the new operator (unless being used within another constructor function for inheritance) for the this variable to be created and bound correctly. The this object's [[Prototype]] is set to the constructors prototype property.

It's good practice to begin constructor names with an uppercase character so you know to call them with new.

function Constructor() {} console.log(Constructor.prototype); // { constructor: f }const instance = new Constructor();console.log(Object.getPrototypeOf(instance) === Constructor.prototype) // true// i.e. instance.[[Prototype]] === Constructor.prototype

When called with new, construtors implicitly return their this object.

let this_ref;function Constructor() {  console.log(Object.getPrototypeOf(this) === Constructor.prototype); // true  this_ref = this;  // implicitly returns `this`}const that = new Constructor();console.log(that === this_ref); // true;

"classes" created with the ES2015 (e.g. class MyClass {...}) are also simply constructor functions (typeof MyClass === 'function') but whose internal slots are configured differently, such as [[IsClassConstructor]] that causes classes to throw a TypeError if called without the new operator, unlike constructor functions not created with the class syntax.

Given that instances created with the new operator inherit from their constructors prototype property, we can create functions on the prototype property that will be inherited by the instances.

function Person() {  //}Person.prototype.sayHello = function() {  console.log('hello');}const person = new Person();person.sayHello();  // 'hello'

ES2015 classes without ES2015 syntax

Now that we know about prototypes and constructors we can replicate the ES2015 class functionality with constructor functions and prototypes.

Using constructor-prototype syntax we have enormous flexibility in how we glue together our objects at the price of having to glue them together manually.

We can manually accomplish what the ES2015 class syntax does for us by maintaining the following:

  • Instance prototype chain: SubClass.prototype.[[Prototype]] must be set to SuperClass.prototype. This sets up the prototype chain of instances constructed from new SubClass(...) such that:
    • subclass_instance.[[Prototype]] === SubClass.prototype
    • subclass_instance.[[Prototype]][[Prototype]] === SuperClass.prototype
    • subclass_instance.[[Prototype]][[Prototype]][[Prototype]] === Object.prototype
    • subclass_instance.[[Prototype]][[Prototype]][[Prototype]][[Prototype]] === null
  • Constructor prototype chain: SubClass.[[Prototype]] must be set to SuperClass. This means the SubClass function inherits "static" properties from SuperClass (properties on the SuperClass constructor function) such that:
    • SuperClass.staticProperty = 5
    • SubClass.staticProperty === 5
  • Initialisation: When the SubClass constructor is called with new, it needs to immediately call the SuperClass constructor function binding its this value (SuperClass.call(this, ...)), in order to initialise SuperClass on this properly.
    • The ES2015 class syntax forces us to call the super constructor using super() at the beginning of our subclasses constructor function, or else the interpreter will throw an error. This is not forced in constructor-prototype syntax so we need to remember it ourselves! Otherwise our class instances will not be properly initialised.

Our object relations for the model described above are:

Inheritance Relations

Don't be intimidated by the number of objects and connections - if you can grok the diagram then you can derive an understanding of everything relating OOJS.

The super Problem

The only class functionality we can't exactly replicate with constructors and prototypes is super.

function Base() {}Base.prototype.fn = function() {  console.log('base');}function AnotherBase() {}AnotherBase.prototype.fn = function() {  console.log('another base');}function Sub() {}Object.setPrototypeOf(Sub, Base);Sub.prototype.fn = function() {  console.log('sub');  // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!  // "super" call, hardcoded to `Base`  // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!  Base.prototype.fn.call(this);}const sub = new Sub();sub.fn();// sub// baseObject.setPrototypeOf(Sub, AnotherBase);Object.setPrototypeOf(Sub.prototype, AnotherBase.prototype);sub.fn();// sub// base

Without referencing the superclass, Base, directly we have no way to determine where the current method under invocation sits in the prototype chain, and therefore can't lookup functions that are strictly higher in the prototype chain (i.e. a super call).

By referencing Base directly in an attempt to replicate super, we've destroyed our ability to safely change the prototype since our "super" call would be referencing a function we no longer inherit.

With ES2015, we have a super keyword that still works when we reassign [[Prototype]]

class Base {  fn() {    console.log('base');  }}class AnotherBase {  fn() {    console.log('another base');  }}class Sub extends Base {  fn() {    console.log('sub');    super.fn();  }}const sub = new Sub();sub.fn();// sup// baseObject.setPrototypeOf(Sub, AnotherBase);Object.setPrototypeOf(Sub.prototype, AnotherBase.prototype);sub.fn();// sup// another base

Pre ES2015 classes by example

We'll code a simple inheritance example of 2 classes: a superclass Animal and subclass Dog using the relations described above. Each inheritance layer has 3 associated objects: the constructor function, prototype object and instance object.

Our domain is:

Animal-Dog-Uml

In JavaScript, our objects will be:

Animal-Dog-Inheritance-Relations

/** * @constructor Animal * @abstract * * @param {number} legs */function Animal(legs) {  this.legs = legs;}/** * Abstract static property on Animal constructor * to be overridden by a property the subclasses constructor * * @abstract * @static * @type {string} */Animal.species = undefined;/** * getter on the animal prototype that retrieves the static, overridden * property from the subclasses constructor, `species` *  * @readonly * @type {string} *  * @example * const dog = new Dog() * dog.species; // calls `Animal.prototype.species` -> `Dog.species` */Object.defineProperty(Animal.prototype, 'species', {  enumerable: true,  configurable: false,  /** @returns {string} */  get() {    // alternatively, `const SubClass = this.constructor`    const SubClass = Object.getPrototypeOf(this).constructor;    return SubClass.species;  },})/** * Method on the Animal prototype, inherited by animal instances and subclasses * of Animal * * @param {string} food */Animal.prototype.eat = function(food) {  console.log(`Yum! eating ${food}`);}/** * @constructor Dog * * Subclass of Animal */function Dog() {  const legs = 4;  // we run the inherited constructor, bound to `this`, to initialise our superclass properly  // this effectively "subtypes" `this` to an instance of the superclass (`this` becomes a superset of the superclasses instances type)  Animal.call(this, legs);}// Inherit staticically from AnimalObject.setPrototypeOf(Dog, Animal);// Inherit prototype from AnimalObject.setPrototypeOf(Dog.prototype, Animal.prototype);/** * @override * @type {string} */Dog.species = 'Dog';/** * Override the `eat` method from `Animal.prototype` * Also call the super method * * @override * * @param {*} food */Dog.prototype.eat = function(food) {  console.log('Woof!');  // super call!  Animal.prototype.eat.call(this, food);}const dog = new Dog();dog.eat('chicken');// 'Woof!'// 'Yum! eating chicken'console.log(dog.species);// 'Dog'

Access to inherited properties

One of the most important things to understand when working directly with prototypes is how accessors and operators propagate. Of the following actions, only the get accessor propagates up the prototype chain.

accessor or operatorpropagates up the prototype chain
getyes
setno
deleteno
const base = { prop: 'hello', ref: {} };const sub = {};Object.setPrototypeOf(sub, base);console.log(sub.prop); // 'hello'// the `delete` operator does not propagate// calling delete on `prop` can have no effect on objects in its prototype chaindelete sub.prop;console.log(sub.prop); // 'hello'// similarly, the `set` accessor does not propagateconsole.log(sub.ref === base.ref); // truebase.ref = { a: 'different', object: true };console.log(sub.ref === base.ref); // truesub.ref = { something: 'else' };console.log(sub.ref === base.ref); // false

Who cares?

Most JavaScript application developers don't need to know its inheritance mechanism in great detail. Some of JavaScript's most flexible features, including prototype hacking, are considered footgun's to be avoided. If you feel the need to hack a prototype chain you're probably better off finding another way.

Knowing about prototypes is more important when working in the broader ecosystem with packages or tooling or when monkeypatching libraries (modifying prototypes of objects from third party libraries).

How does TypeScript fit into this?

Unfortunately, like a square peg into a round hole.

TypeScript doesn't attempt to model the fine details of OOJS. It doesn't differentiate between properties on a class instance and properties on a classes prototype.

class MyClass {  instanceProperty: number;  prototypeProperty() {};  constructor() { this.instanceProperty = 5; }}// TypeScript sees instances of MyClass as equivalent to:interface MyClassInstance {  instanceProperty: number;  prototypeProperty() {};}// properties of the prototype and instance are merged together

Moreover, TypeScript doesn't even allow adding new signature to a constructor function.

const MyConstructor: { new(): {} } = function() {}// Type '() => void' is not assignable to type 'new () => {}'.

To use TypeScript on constructor functions have to resort to the unsafe as unknown hack. The language server also won't tell us when our prototype is missing properties

interface MyInstanceAndPrototype {  //  methodOnPrototype() {};}interface MyConstructor extends Function {  new(): MyInstanceAndPrototype;  prototype: MyInstanceAndPrototype;}const MyConstructor = function MyConstructor() {} as unknown as MyConstructor// Forgot to add `MyConstructor.prototype.methodOnPrototype`?// There won't be any TypeScript error

Revisiting our examples

With our understanding of prototypes, constructors and property access, we can revisit our and understand initial examples

Explanation: 1. Overriding getters and setters

console.log('Overriding getters and setters');class SuperClass {  _value = undefined;  get value() { return this._value; }}class SubClass extends SuperClass {  set value(to) { this._value = to; }}const sub = new SubClass();sub.value = 5;// What gets logged?console.log(sub.value); // undefined

What went wrong?

Writing this in pre-ES2015 syntax we have something close to:

console.log('Overriding getters and setters');function SuperClass() {  this._value = undefined;}Object.defineProperty(SuperClass.prototype, 'value', {  get() { return this._value },})function SubClass() {}Object.setPrototypeOf(SubClass, SuperClass);Object.setPrototypeOf(SubClass.prototype, SuperClass.prototype);Object.defineProperty(SubClass.prototype, 'value', {  set(to) { this._value = to; },});const sub = new SubClass();sub.value = 5;// What gets logged?console.log(sub.value); // undefined

Notice we have both SubClass.prototype.value and SuperClass.prototype.vaue.
SubClass.prototype.value overrides SuperClass.prototype.value. SubClass.prototype.value has a setter with NO GETTER!! When we read sub.value, we accessing SubClass.prototype.value which has no getter and a value of undefined by default, and therefore returns undefined. We never reach SuperClass.prototype.value! This issue once cost me 4 hours in debugging hell.

Explanation: 2. Deleting from a class instance

console.log('Deleting from a class instance');class MyClass {  fn1 = function() {}  fn2() {}}const myInstance = new MyClass();// What gets logged?delete myInstance.fn1;console.log(myInstance.fn1); // undefineddelete myInstance.fn2;console.log(myInstance.fn2); // fn2() {}

Writing this in pre-ES2015 syntax we have something close to:

console.log('Deleting from a class instance');function MyClass() {  this.fn1 = function() {};}MyClass.prototype.fn2 = function fn2() {}const myInstance = new MyClass();// What gets logged?delete myInstance.fn1;console.log(myInstance.fn1); // undefineddelete myInstance.fn2;console.log(myInstance.fn2); // fn2() {}

Notice that with class syntax, setting property = ... within the class body is roughly equivalent setting this.property = ... within the classes constructor. It places the property on the class instances.

Conversely, fn2() {} within the class body adds that function to the classes prototype MyClass.prototype.

The delete operator does not propagate up the prototype chain. Therefore we delete fn1 since its on the class instance, but not fn2 since it's on the class prototype.

Explanation: 3. Deleting from an object

console.log('Deleting from an object');const myObject = {  fn() {},  toString() {},};// What gets logged?delete myObject.fn;console.log(myObject.fn); // undefinedconsole.log(myObject.toString); // toString() {}myObject.toString = undefinedconsole.log(myObject.toString); // undefineddelete myObject.toString;console.log(myObject.toString); // toString() { [native code] }

Similar to 2., but now we have an object instance myObject with two functions. All objects created with the literal syntax {} have their [[Prototype]] equal to Object.prototype. Object.prototype has a toString method.

In our example:

  • we override Object.prototype.toString in the assignment of myObject.
    • logging myObject.toString prints our overridden copy, toString() {}
  • we set myObject.toString = undefined, which continues to override Object.prototype.toString but now with a value of undefined.
    • logging myObject.toString prints our overridden copy, undefined
  • we delete toString from myObject. now toString calls will propagate up the prototype chain.
    • logging myObject.toString prints Object.prototype.toString.

Explanation: 4. Overriding constructors???

class MyClass {  constructor() {    console.log("Original Consturctor");  }}MyClass.prototype.constructor = function Overridden() {  console.log("Overridden Constructor");}// What gets logged?const instance = new MyClass(); // "Original Constructor"console.log(instance.constructor.name);  // "Overridden Constructor"console.log(instance.constructor.prototype === Object.getPrototypeOf(instance)); // "false"

This example is bogus. A special place in hell is reserved for people who reassign Constructor.prototype.constructor.

  • Constructors have a prototype property which becomes their instances [[Prototype]] internal slot.
  • The prototype initially has a single property, constructor, which points back to the original constructor function.
  • The Constructor.prototype.constructor is useful to superclasses to create new instances of this's class.

For example, here's a Container class that is safe to extend and still call clone() on:

function Container(items) {  this.items = items;}Container.prototype.clone = function() {  // we rely on prototype.constructor not being overridden  return new (Object.getPrototypeOf(this).constructor)([...this.items]);}function UserContainer(users) {  Container.call(this, users);}Object.setPrototypeOf(UserContainer, Container);Object.setPrototypeOf(UserContainer.prototype, Container.prototype);UserContainer.prototype.logoutAll = function() { /** ... */ }const users = new UserContainer([]);const users2 = users.clone();console.log(users2 instanceof UserContainer); // true

As far as I'm aware there's no good reason to ever change prototype.constructor, other than as a good April Fools joke.

Further Reading

  • Older libraries like express still use prototypes and constructors. Check out Express.Request for an example. Express uses Object.create() to use blueprint objects, req and res, as the [[Prototype]]s for the req and res of a request instance.

Original Link: https://dev.to/nickkelly314/javascript-inheritance-the-dark-arts-1bh1

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To