📜 ⬆️ ⬇️

Classic JavaScript inheritance. Parsing the implementation in Babel, BackboneJS and Ember

In this article we will talk about classical inheritance in JavaScript, common patterns of its use, features and frequent application errors. Consider the examples of inheritance in Babel, Backbone JS and Ember JS and try to deduce from them the key principles of object-oriented inheritance to create your own implementation in EcmaScript 5.

The article is for those who are familiar with inheritance in other languages ​​and have encountered attempts to emulate such behavior in JavaScript, as well as for those who are interested in looking under the hood of various libraries and frameworks, comparing their implementation. It turns out that the simple extend function can be implemented very differently. Often, mistakes are made (see “The most common mistake” below).

This article is available in English with a short video presentation on the site Today Software Magazine


About classical inheritance


Classical is understood as OOP-style inheritance. As you know, in pure JavaScript there is no classic inheritance. Moreover, it lacks the notion of classes. And although the modern EcmaScript specification adds syntactic constructions for working with classes, this does not change the fact that it actually uses constructor functions and prototyping. Therefore, this technique is often called "pseudo-classical" inheritance. It pursues perhaps the only goal - to represent the code in the usual OOP-style.
')
There are various inheritance techniques, in addition to the classical: functional, prototype (in its pure form), factory-made, using mixins. The very concept of inheritance, which has gained high popularity among developers, is criticized and in many cases contrasted with a reasonable alternative - composition .

Inheritance, moreover, in the classical style, is not a panacea. Its feasibility depends on the specific situation in a particular project. However, in this article we will not go into the question of the advantages and disadvantages of this approach, but focus on how to properly apply it.

Comparison criteria


So, we decided to apply OOP and classical inheritance in a language that does not support it initially. This decision is often made in large projects by developers who are accustomed to PLO in other languages. It is, moreover, used by many large frameworks: Backbone, Ember JS, etc, as well as the modern EcmaScript specification.

The best advice for applying inheritance is to use it as described in EcmaScript 6, with the keywords class, extends, constructor, etc. If you have such an opportunity, you can not read further, then this is the best option in terms of code readability and performance. All the following description will be useful for the case of using the old specification, when the project has already been started using ES5 and the transition to the new version is not available.
Consider some popular examples of the implementation of classical inheritance.

Let's analyze them in five aspects:

  1. Memory efficiency.
  2. Performance.
  3. Static properties and methods.
  4. Link to the superclass.
  5. Cosmetic details.

Of course, first of all, you should make sure that the template used is efficient in terms of memory and performance. There are no special complaints regarding the examples from popular frameworks in this regard, however, in practice there are often erroneous examples leading to memory leaks and stack sprawl, as we will discuss below.
The remaining listed criteria relate to usability and readability of the code.

More “convenient” we will consider those implementations that are closer in syntax and functionality to classical inheritance in other languages. So, the reference to the superclass (the keyword super) is optional, but its presence is desirable for full emulation of inheritance. By cosmetic details we mean the general design of the code, the convenience of debugging, use with the instanceof operator, etc.

"_Inherits" function in Babel


Consider inheritance in EcmaScript 6 and what we get at the output when compiling code in ES5 using Babel.

The following is an example of a class extension in ES6.

 class BasicClass { static staticMethod() {} constructor(x) { this.x = x; } someMethod() {} } class DerivedClass extends BasicClass { static staticMethod() {} constructor(x) { super(x); } someMethod() { super.someMethod(); } } 

As you can see, the syntax is similar to other OOP languages, with the possible exception of the lack of types and access modifiers. And this is the uniqueness of using ES6 with the compiler: we can afford a convenient syntax, and at the same time get working code on ES5 at the output. None of the following examples can boast such syntactic simplicity, since in them, the inheritance function is implemented immediately in finished form, without syntax transformations.

The Babel compiler implements inheritance using a simple _inherits function:

 function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } 

The main point here can be reduced to this line:

 subClass.prototype = Object.create(superClass.prototype); 

This call creates an object with the specified prototype. The prototype property of the subClass constructor points to a new object, the prototype of which is the prototype parent class superclass . Thus, this is a simple prototypal inheritance disguised as classical in the source code.

The following line of code implements the inheritance of static class fields:

 Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; 

The parent class constructor (that is, the function) becomes the prototype of the new class constructor (that is, another function). All static properties and methods of the parent class are thus made accessible from the inheriting class. In the absence of the setPrototypeOf function setPrototypeOf Babel provides for direct recording of the prototype into the hidden __proto__ property - a technique that is not recommended, but suitable for extreme cases when using old browsers.

The very recording of methods, both static and dynamic, occurs separately from the _inherits call _inherits simply copying the references into the constructor or its prototype . When writing your own implementation of inheritance, you can use this example as a basis and add objects with dynamic and static fields to it as additional arguments to the _inherits function.

The “super” keyword is simply replaced by a direct prototype call when compiled. For example, the call to the parent constructor from the example above is replaced by the following line:

 return _possibleConstructorReturn(this, (DerivedClass.__proto__ || Object.getPrototypeOf(DerivedClass)).call(this, x)); 

Babel uses many helper functions that we will not cover here. The point is that in this call the interpreter receives the prototype of the constructor of the current class, which is just the constructor of the base class (see above), and calls it in the current context of this .

In our own implementation on pure ES5, the compilation stage is not available to us, so you can add _super fields to the constructor and its prototype to have a convenient reference to the parent class, for example:

 function extend(subClass, superClass) { // ... subClass._super = superClass; subClass.prototype._super = superClass.prototype; } 

The "extend" function in Backbone JS


Backbone JS provides the extend function for extending library classes: Model, View, Collection, etc. If you wish, you can borrow it for your own purposes. Below is the extend function code from Backbone 1.3.3.

 var extend = function(protoProps, staticProps) { var parent = this; var child; // The constructor function for the new subclass is either defined by you // (the "constructor" property in your `extend` definition), or defaulted // by us to simply call the parent constructor. if (protoProps && _.has(protoProps, 'constructor')) { child = protoProps.constructor; } else { child = function(){ return parent.apply(this, arguments); }; } // Add static properties to the constructor function, if supplied. _.extend(child, parent, staticProps); // Set the prototype chain to inherit from `parent`, without calling // `parent`'s constructor function and add the prototype properties. child.prototype = _.create(parent.prototype, protoProps); child.prototype.constructor = child; // Set a convenience property in case the parent's prototype is needed // later. child.__super__ = parent.prototype; return child; }; 

The usage example is as follows:

 var MyModel = Backbone.Model.extend({ constructor: function() { //   ;   , //   ,      Backbone.Model.apply(this, arguments); }, toJSON: function() { //  ,      «__super__» MyModel.__super__.toJSON.apply(this, arguments); } }, { staticMethod: function() {} }); 

This function implements the extension of the base class with the support of its own constructor and static fields. It returns the class constructor function. Actually inheritance is implemented by the following line, similar to the example from Babel:

 child.prototype = _.create(parent.prototype, protoProps); 

The _.create() function is an analogue of Object.create() from ES6, implemented by the Underscore JS library. Its second argument allows you to immediately write to the prototype the properties and methods of protoProps passed when you call the extend function.

Inheritance of static class fields is implemented by simply copying links (or values) from the parent class and an object with static fields, passed as the second argument to the extend function, to the created constructor:

 _.extend(child, parent, staticProps); 

Specifying a constructor is optional and is done inside the class declaration as a constructor method. When using it, it is necessary to call the parent class constructor (as in other languages), so instead, developers more often use the initialize method, which is called automatically from within the parent constructor.

The keyword "__super__" is just a handy addition, because the parent method call all the same happens with the indication of the name of the specific method and with the transfer of the context this . Without this, such a call would lead to looping in the case of a multilevel chain of inheritance. The superclass method, whose name is usually known in the current context, can also be invoked directly, so this keyword is only an abbreviation for:

 Backbone.Model.prototype.toJSON.apply(this, arguments); 

In terms of code, the extension classes in Backbone are pretty succinct. You do not have to manually create a class constructor and separately associate it with the parent class. This convenience comes at a price - debugging difficulties. In the browser debugger, all instances of classes inherited in this way have the same constructor name, declared within the extend function - “child”. This drawback may seem insignificant until you come across it in practice when debugging a chain of classes, when it becomes difficult to understand which class is the object and which class it inherits from:

image

This chain is much more convenient to be debugged using the inheritance from Babel:

image

Another disadvantage is that the constructor property is enumerable, i.e. enumerated when traversing an instance of a class in a for-in loop. Not significant, however, Babel took care of this, declaring the constructor with the enumeration of the necessary modifiers.

Link to the super class in Ember JS


Ember JS uses both the inherits function implemented by Babel and its own extend implementation, which is very complex and sophisticated, with support for mixins and so on. There is simply not enough room to bring the code of this function in this article, which already casts doubt on its performance when used for its own needs outside the framework.

What is of particular interest is the implementation of the “super” keyword in Ember. It allows you to call the parent method without specifying a specific method name, for example:

 var MyClass = MySuperClass.extend({ myMethod: function (x) { this._super(x); } }); 

Note: when calling the super-class method ( this._super(x) ) we do not specify the name of the method. And no code transformations occur during compilation.

How it works? How does Ember _super which method to call when accessing the _super universal property without code conversion? It is all about the complex work with classes and the clever function _wrap , the code of which is given below:

 function _wrap(func, superFunc) { function superWrapper() { var orig = this._super; this._super = superFunc; // <---   var ret = func.apply(this, arguments); this._super = orig; return ret; } //      return superWrapper; } 

When inheriting a class, Ember passes through all its methods and calls for each given wrapper function, replacing each original function with a superWrapper .

Pay attention to the line marked with a comment. In the _super property, a _super written to the parent method corresponding to the name of the method being called (the work on determining correspondences occurred at the stage of creating a class when invoking extend ). Next, the original function is called, from inside of which you can access _super as the parent method. Then the _super property _super assigned the original value, which allows its use in deep call chains.

The idea is undoubtedly interesting, and it can be applied in its implementation of inheritance. But it is important to note that all this has a negative impact on performance. Each class method (at least, of those that override the parent method), regardless of whether the _super property is _super in it, is wrapped in a separate function. Therefore, with a deep chain of calls to methods of the same class, a stack will grow. This is especially critical for methods that are called regularly in a loop or when drawing the user interface. Therefore, it can be said that this implementation is too cumbersome and does not justify the advantage obtained in the form of an abbreviated form of recording.

The most common mistake


One of the most common and dangerous mistakes in practice is to create an instance of the parent class when it is extended. Here is an example of such a code, the use of which should always be avoided:

 function BaseClass() { this.x = this.initializeX(); this.runSomeBulkyCode(); } // ...  BasicClass  ... function SubClass() { BaseClass.apply(this, arguments); this.y = this.initializeY(); } //   SubClass.prototype = new BaseClass(); SubClass.prototype.constructor = SubClass; // ...  SubClass  ... new SubClass(); //   

Noticed a mistake?

This code will work, it will allow the SubClass class to inherit the properties and methods of the parent class. However, during the class binding through prototype , an instance of the parent class is created, its constructor is called, which leads to unnecessary actions, especially if the constructor does a lot of work when creating the object ( runSomeBulkyCode ). So you can not do:

 SubClass.prototype = new BaseClass(); 


This can lead to hard-to-find errors when properties initialized in the parent constructor ( this.x ) are written not to the new instance, but to the prototype of all instances of the class SubClass . In addition, the same BaseClass constructor is then called again from the subclass constructor. If the parent constructor requires some parameters to be called, it is hard to make such an error, but if they are not available, it is quite possible.

Instead, create an empty object, the prototype of which is the prototype property of the parent class:

 SubClass.prototype = Object.create(BasicClass.prototype); 

Results


We gave examples of the implementation of pseudo-classical inheritance in the Babel compiler (ES6-to-ES5) and in the Backbone JS, Ember JS frameworks. Below is a comparative table of all three implementations by the criteria described earlier.

BabelBackbone jsEmber js
MemoryEquivalently
PerformanceHigherAverageLower
Static fields+ (only in ES6) *+- (except for the internal use of inheritance from Babel)
Superclass referencesuper.methodName() (only in ES6)Constructor.__super__.prototype
.methodName.apply(this)
this._super()
Cosmetic itemsIdeal with ES6;
needs some work in its own implementation under ES5
Convenience ads; debugging issuesDepends on the mode of inheritance; same debugging problems as in backbone
* - Babel is ideal when using ES6; if you write your own implementation based on it under ES5, static fields and the link to the superclass will have to be added independently.

The performance criterion was evaluated not in absolute values, but relative to the other implementations, based on the number of operations and cycles in each variant. In general, differences in performance are not significant, since the extension of classes usually occurs once at the initial stage of the application and is not called again.

All the above examples have their advantages and disadvantages, but the implementation of Babel can be considered the most practical. As mentioned above, if possible, use the inheritance specified in EcmaScript 6 with compilation in ES5. In the absence of such an opportunity, it is recommended to write your own implementation of the extend function based on an example from the Babel compiler, taking into account the comments and additions from other examples. So inheritance can be implemented in the most flexible and appropriate way for this project.

Sources


  1. JavaScript.ru: Inheritance
  2. David Shariff. JavaScript Inheritance Patterns
  3. Eric Elliott. 3 Different Kinds of Prototypal Inheritance: ES6 + Edition
  4. Wikipedia: Composition over inheritance
  5. Mozilla Developer Network: Object.prototype
  6. Backbone js
  7. Ember js
  8. Babel

Source: https://habr.com/ru/post/353778/


All Articles