Price composition in the Javascript world

    The idea that in the development of any more or less complex business logic, priority should be given to the composition of objects, rather than inheritance being popular among software developers of various types. On the next wave of popularity of the functional programming paradigm, launched by the success of ReactJS, the talk about the benefits of compositional solutions came to the front end. In this post there are some layouts on the shelves of the theory of the composition of objects in Javascript, a specific example, its analysis and the answer to the question how much semantic elegance costs to the user (spoiler: quite a lot).

    V. Kandinsky - Composition X
    Wassily Kandinsky - "Composition X"

    The years of successful development of an object-oriented approach to development, mostly in the academic field, have led to a noticeable imbalance in the mind of the average developer. Thus, in most cases, the first thought, if necessary, to generalize for a number of diverse entities any behavior is the creation of the parent class and the inheritance of this behavior. This approach, when abused, leads to several problems that complicate the project and inhibit development. 

    First, the overloaded base class becomes fragile. - a small change in its methods can fatally affect its derived classes. One way to circumvent this situation is to distribute the logic into several classes, forming a more complex inheritance hierarchy. In this case, the developer gets another problem - the logic of the parent classes is duplicated in the heirs by necessity, in cases of intersection between the functional of the parent classes, but, importantly, not complete. mail.mozilla.org/pipermail/es-discuss/2013-June/031614.html

    image


    Finally, creating a fairly deep hierarchy, the user, when using any entity, is forced to pull all its ancestors along with all their dependencies, regardless of whether he is going to use their functionality or not. This problem of over-dependence on the environment has been called the problem of the gorilla and banana by Joe Armstrong, the creator of Erlang:

    I think the languages ​​are not functional languages. This is why we’re carrying around. It is a gorilla holding a banana and the entire jungle.

    To solve all these problems, the composition of objects is intended as an alternative to class inheritance. The idea is not new, but does not find a complete understanding among the developers. The situation in the world of frontend is a little better, where the structure of software projects is often quite simple and does not stimulate the creation of a complex object-oriented relationship scheme. However, blindly following the precepts of the Gang of Four, recommending that composition be preferred to inheritance, can also play a cruel joke with a great developer inspired by wisdom.

    Transferring definitions from the “Design Patterns” to the dynamic world of Javascript, you can generally speak of three types of object composition: aggregation , concatenation and delegation. It should be said that this division and, in general, the concept of an object composition has a purely technical nature, while within the meaning of these terms have intersections, which causes confusion. For example, class inheritance in Javascript is implemented on the basis of delegation (prototype inheritance). Therefore, it is better to back up each of the cases with live code samples. 

    Aggregation is an enumerated union of objects, each of which can be obtained using a unique access identifier. Examples are arrays, trees, graphs. A good example from the world of web development is the DOM tree. The main quality of this type of composition and the reason for its creation is the possibility of conveniently applying some handler to each child element of the composition. 

    A synthetic example is an array of objects that in turn define a style for an arbitrary visual element.

    const styles = [
     { fontSize: '12px', fontFamily: 'Arial' },
     { fontFamily: 'Verdana', fontStyle: 'italic', fontWeight: 'bold' },
     { fontFamily: 'Tahoma', fontStyle: 'normal'}
    ];

    Each of the style objects can be extracted by its index without losing information. In addition, using Array.prototype.map () you can handle all stored values ​​in a specified way.

    const getFontFamily = s => s.fontFamily;
    styles.map(getFontFamily)
    //["Arial","Verdana","Tahoma"]

    Concatenation involves extending the functionality of an existing object by adding new properties to it. Thus, for example, state reducer work in Redux. The data received for updating are recorded in the state object, expanding it. Data on the current state of the object, in contrast to the aggregation, is lost if it is not saved. 

    Returning to the example, alternately applying the above settings to the visual element, you can form the final result by concatenating the parameters of the objects.

    const concatenate = (a, s) => ({…a, …s});
    styles.reduce(concatenate, {})
    //{fontSize:"12px",fontFamily:"Tahoma",fontStyle:"normal",fontWeight:"bold"}

    Values ​​of a more specific style will eventually overwrite previous states.
     
    When delegating , as you can easily guess, one object is delegated to another. Delegates, for example, are prototypes in Javascript. Instances of heir objects redirect calls to parent methods. If there is no required property or method in the array instance, it will redirect this call to Array.prototype, and if necessary, further to the Object.prototype. Thus, the inheritance mechanism in Javascript is built on the basis of the prototype delegation chain, which technically is (surprise) a composition option. 

    The union of an array of style objects through delegation can be done as follows.

    const delegate = (a, b) =>Object.assign(Object.create(a), b);
    styles.reduceRight(delegate, {})
    //{"fontSize":"12px","fontFamily":"Arial"}
    styles.reduceRight(delegate, {}).fontWeight
    //bold

    As you can see, the delegate properties are not accessible by enumeration (for example, using Object.keys ()), but are accessible only by explicit reference. The fact that it gives us - at the end of the post. 

    Now to the specifics. A good example of a case that encourages a developer to use composition instead of inheritance is in Michael Object ’s Object Composition in Javascript . Here the author considers the process of creating a hierarchy of role-playing characters. Initially, two types of characters are required - a warrior and a magician, each of which has a certain amount of health and has a name. These properties are common and can be moved to the Character class of the parent.

    classCharacter{
      constructor(name) {
        this.name = name;
        this.health = 100;
      }
    }

    The warrior is distinguished by the fact that he is able to strike, while spending his endurance, and the magician - the ability to cast spells, reducing the amount of mana.

    classFighterextendsCharacter{
      constructor(name) {
        super(name);
        this.stamina = 100;
      }
      fight() {
        console.log(`${this.name} takes a mighty swing!`);
        this.stamina  -  ;
      }
    }
    classMageextendsCharacter{
      constructor(name) {
        super(name);
        this.mana = 100;
      }
      cast() {
        console.log(`${this.name} casts a fireball!`);
        this.mana  -  ;
      }
    }

    Having created the classes Fighter and Mage, the heirs of Character, the developer faces an unexpected problem when the need arises to create the Paladin class. The new character is distinguished by an enviable ability to both fight and conjure. Offhand, I see a couple of solutions that differ in the same lack of grace.

    1. You can make Paladin the heir of Character and implement both the methods of fight () and cast () in it from scratch. In this case, the DRY principle is grossly violated, because each of the methods will be duplicated during creation and will subsequently need constant synchronization with the methods of the Mage and Fighter classes to track changes.
    2. The fight () and cast () methods can be implemented at the level of the Charater class so that all three types of characters have them. This is a slightly more pleasant solution, but in this case, the developer must override the fight () method for the mage and the cast () method for the warrior, replacing them with empty plugs.

    In any of the options, sooner or later you have to face the problems of inheritance, voiced at the beginning of the post. They can be solved with a functional approach to the implementation of the characters. It is enough to push off not from their types, but from their functions. In the end, we have two key features that determine the abilities of the characters - the ability to fight and the ability to conjure. These features can be set using factory functions that extend the state that defines the character (an example of composition is concatenation).

    const canCast = (state) => ({
      cast: (spell) => {
        console.log(`${state.name} casts ${spell}!`);
        state.mana  -  ;
      }
    }) 
    const canFight = (state) => ({
      fight: () => {
        console.log(`${state.name} slashes at the foe!`);
        state.stamina  -  ;
      }
    })

    Thus, a character is determined by a set of these features, and initial properties, both general (by name and health), and private (endurance and mana).

    const fighter = (name) => {
      let state = {
        name,
        health: 100,
        stamina: 100
      }
      returnObject.assign(state, canFight(state));
    } 
    const mage = (name) => {
      let state = {
        name,
        health: 100,
        mana: 100
      }
      returnObject.assign(state, canCast(state));
    }
    const paladin = (name) => {
      let state = {
        name,
        health: 100,
        mana: 100,
        stamina: 100
      }
      returnObject.assign(state, canCast(state), canFight(state));
    }

    Everything is beautiful - the action code is reused; you can easily add any new character without touching the previous ones and without inflating the functionality of any one object. To find a fly in the proposed solution, it suffices to compare the performance of the solution based on inheritance (read delegation) and decisions based on concatenation. Let's create a million army of copies of the created characters.

    var inheritanceArmy = [];
    for (var i = 0; i < 1000000; i++) { 
      inheritanceArmy.push(new Fighter('Fighter' + i)); 
      inheritanceArmy.push(new Mage('Mage' + i));
    }
    var compositionArmy = [];
    for (var i = 0; i < 1000000; i++) { 
      compositionArmy.push(fighter('Fighter' + i)); 
      compositionArmy.push(mage('Mage' + i));
    }

    And we compare the costs of memory and computation between inheritance and composition, which are necessary for creating objects.

    image

    On average, a solution using composition by concatenation requires 100–150% more resources. The presented results were obtained in the NodeJS environment; you can see the results for the browser engine by running this test .

    The advantage of the solution based on inheritance-delegation can be explained by saving memory due to the lack of implicit access to the delegate properties, as well as disabling some engine optimizations for dynamic delegates. In turn, a concatenation based solution uses the very expensive Object.assign () method, which strongly affects its performance. Interestingly, Firefox Quantum shows diametrically opposed Chromium results - the second solution works much faster in Gecko. 

    Of course, it is worth relying on the results of performance tests only when solving quite laborious tasks associated with creating a large number of complex infrastructure objects — for example, when working with a virtual element tree or developing a graphic library. In most cases, the structural beauty of the solution, its reliability and simplicity are more important, and a small difference in performance does not play a big role (operations with DOM elements will take much more resources).

    In conclusion, it is worth noting that the types of composition considered are not the only and mutually exclusive. Delegation can be implemented using aggregation, and class inheritance using delegation (as is done in JavaScript). In essence, any combination of objects will be one form or another of the composition, and ultimately only the simplicity and flexibility of the resulting solution is important.

    Also popular now: