L'idée selon laquelle dans le développement de toute logique métier plus ou moins complexe devrait être donnée la priorité à la composition des objets plutôt qu'à l'héritage est populaire parmi les développeurs de logiciels de différents types. Lors de la prochaine vague de popularité du paradigme de programmation fonctionnelle lancée par le succès de ReactJS, parler des avantages des solutions de composition est venu au premier plan. Dans cet article, il y a une petite mise en page sur les étagères de la théorie de la composition des objets en Javascript, un exemple spécifique, son analyse et la réponse à la question de combien coûte l'élégance sémantique pour l'utilisateur (spoiler: beaucoup).
Vasily Kandinsky - «Composition X»Des années de développement réussi d'une approche orientée objet du développement, principalement dans le domaine universitaire, ont conduit à un déséquilibre notable dans l'esprit du développeur moyen. Ainsi, dans la plupart des cas, la première pensée, si nécessaire, pour généraliser un comportement pour un certain nombre d'entités diverses est de créer une classe parente et d'hériter de ce comportement. Cette approche abusive conduit à plusieurs problèmes qui compliquent la conception et inhibent le développement.
Premièrement, la classe de base surchargée de logique devient
fragile - un petit changement dans ses méthodes peut affecter fatalement les classes dérivées. Une façon de contourner cette situation consiste à répartir la logique sur plusieurs classes, créant ainsi une hiérarchie d'héritage plus complexe. Dans ce cas, le développeur obtient un autre problème - la logique des classes parentes est dupliquée dans les héritiers si nécessaire, en cas d'intersection entre les fonctionnalités des classes parentes, mais, surtout, pas complète.
mail.mozilla.org/pipermail/es-discuss/2013-June/031614.htmlEt enfin, en créant une hiérarchie assez profonde, l'utilisateur, lorsqu'il utilise n'importe quelle entité, est obligé de faire glisser tous ses ancêtres avec toutes leurs dépendances, qu'il utilise ou non leurs fonctionnalités. Ce problème de dépendance excessive à l'environnement avec une main légère de Joe Armstrong, créateur d'Erlang, a été appelé le problème du gorille et de la banane:
Je pense que le manque de réutilisabilité vient des langages orientés objet, pas des langages fonctionnels. Parce que le problème avec les langages orientés objet, c'est qu'ils ont tout cet environnement implicite qu'ils emportent avec eux. Vous vouliez une banane mais ce que vous avez obtenu était un gorille tenant la banane et toute la jungle.
La composition des objets est appelée à résoudre tous ces problèmes comme alternative à l'héritage de classe. L'idée n'est pas nouvelle du tout, mais ne trouve pas une compréhension complète parmi les développeurs. La situation dans le monde frontal est légèrement meilleure, où la structure des projets logiciels est souvent assez simple et ne stimule pas la création d'un schéma de relation orienté objet complexe. Cependant, suivre aveuglément les alliances du Gang of Four, recommandant de préférer la composition à l'héritage, peut également jouer un tour à la sagesse inspirante des grands développeurs.
En transférant les définitions de «Design Patterns» vers le monde dynamique de Javascript, nous pouvons résumer trois types de composition d'objets:
agrégation ,
concaténation et
délégation . Il convient de dire que cette séparation et le concept de composition d'objet en général ont un caractère purement technique, tandis que le sens de ces termes a des intersections, ce qui introduit de la confusion. Ainsi, par exemple, l'héritage de classe en Javascript est implémenté sur la base de la délégation (héritage prototype). Par conséquent, chacun des cas est préférable de sauvegarder avec des exemples de code en direct.
L'agrégation est une union énumérable d'objets, chacun pouvant être obtenu à l'aide d'un identificateur d'accès unique. Les exemples incluent des tableaux, des arbres, des graphiques. L'arbre DOM est un bon exemple du monde du développement Web. La principale qualité de ce type de composition et la raison de sa création est la possibilité d'appliquer facilement un gestionnaire à chaque enfant de la composition.
Un exemple synthétique est un tableau d'objets qui à tour de rôle définit un style pour un élément visuel arbitraire.
const styles = [ { fontSize: '12px', fontFamily: 'Arial' }, { fontFamily: 'Verdana', fontStyle: 'italic', fontWeight: 'bold' }, { fontFamily: 'Tahoma', fontStyle: 'normal'} ];
Chacun des objets de style peut être extrait par son index sans perte d'informations. De plus, en utilisant Array.prototype.map (), vous pouvez traiter toutes les valeurs stockées d'une manière donnée.
const getFontFamily = s => s.fontFamily; styles.map(getFontFamily)
La concaténation consiste à étendre les fonctionnalités d'un objet existant en lui ajoutant de nouvelles propriétés. Ainsi, par exemple, les réducteurs d'état dans Redux fonctionnent. Les données reçues pour la mise à jour sont écrites dans l'objet d'état, en les développant. Contrairement à l'agrégation, les données sur l'état actuel de l'objet sont perdues si elles ne sont pas enregistrées.
Pour revenir à l'exemple, en appliquant alternativement les paramètres ci-dessus à l'élément visuel, vous pouvez générer le résultat final en concaténant les paramètres des objets.
const concatenate = (a, s) => ({…a, …s}); styles.reduce(concatenate, {})
Les valeurs d'un style plus spécifique finiront par écraser les états précédents.
Lors de la
délégation , comme vous pouvez le deviner, un objet est délégué à un autre. Les délégués, par exemple, sont des prototypes en Javascript. Les instances d'objets dérivés redirigent les appels vers les méthodes parentes. S'il n'y a pas de propriété ou de méthode requise dans l'instance de tableau, il redirigera cet appel vers Array.prototype et, si nécessaire, plus loin vers Object.prototype. Ainsi, le mécanisme d'héritage en Javascript est basé sur la chaîne de délégation de prototype, qui est techniquement une version (surprise) de la composition.
La combinaison d'un tableau d'objets de style par délégation peut être effectuée comme suit.
const delegate = (a, b) => Object.assign(Object.create(a), b); styles.reduceRight(delegate, {})
Comme vous pouvez le voir, les propriétés de délégué ne sont pas accessibles par énumération (par exemple, en utilisant Object.keys ()), mais ne sont accessibles que par un accès explicite. Le fait que cela nous donne est à la fin de l'article.
Passons maintenant aux détails. Un bon exemple d'un cas qui encourage un développeur à utiliser la composition au lieu de l'héritage est dans la
composition d'objet de Michael Rise
en Javascript . Ici, l'auteur considère le processus de création d'une hiérarchie de personnages jouant des rôles. Initialement, deux types de personnages sont requis - un guerrier et un magicien, chacun ayant une certaine réserve de santé et un nom. Ces propriétés sont courantes et peuvent être déplacées vers la classe parent Character.
class Character { constructor(name) { this.name = name; this.health = 100; } }
Le guerrier se distingue par le fait qu'il sait frapper, tout en dépensant son endurance, et le magicien - la capacité de lancer des sorts, réduisant la quantité de mana.
class Fighter extends Character { constructor(name) { super(name); this.stamina = 100; } fight() { console.log(`${this.name} takes a mighty swing!`); this.stamina - ; } } class Mage extends Character { constructor(name) { super(name); this.mana = 100; } cast() { console.log(`${this.name} casts a fireball!`); this.mana - ; } }
Après avoir créé les classes Fighter et Mage, les descendants de Character, le développeur est confronté à un problème inattendu lorsqu'il est nécessaire de créer la classe Paladin. Le nouveau personnage se distingue par sa capacité enviable à combattre et à conjurer. Offhand, vous pouvez voir quelques solutions qui diffèrent par le même manque de grâce.
- Vous pouvez faire de Paladin un descendant de personnage et y implémenter à la fois fight () et cast (). Dans ce cas, le principe DRY est gravement violé, car chacune des méthodes sera dupliquée lors de la création et nécessitera par la suite une synchronisation constante avec les méthodes des classes Mage et Fighter pour suivre les modifications.
- Les méthodes fight () et cast () peuvent être implémentées au niveau de la classe Charater afin que les trois types de personnages les possèdent. C'est une solution légèrement plus agréable, cependant, dans ce cas, le développeur doit redéfinir la méthode fight () pour le magicien et la méthode cast () pour le guerrier, en les remplaçant par des talons vides.
Dans chacune des options, tôt ou tard, il faut faire face aux problèmes d'héritage exprimés au début du message. Ils peuvent être résolus avec une approche fonctionnelle de l'implémentation des personnages. Il suffit de repousser non pas leurs types, mais leurs fonctions. En fin de compte, nous avons deux caractéristiques clés qui déterminent la capacité des personnages - la capacité de combattre et la capacité d'évoquer. Ces fonctionnalités peuvent être définies à l'aide de fonctions d'usine qui développent l'état qui définit le personnage (un exemple de composition est la concaténation).
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 - ; } })
Ainsi, le personnage est déterminé par l'ensemble de ces caractéristiques et les propriétés initiales, à la fois générales (nom et santé) et privées (endurance et mana).
const fighter = (name) => { let state = { name, health: 100, stamina: 100 } return Object.assign(state, canFight(state)); } const mage = (name) => { let state = { name, health: 100, mana: 100 } return Object.assign(state, canCast(state)); } const paladin = (name) => { let state = { name, health: 100, mana: 100, stamina: 100 } return Object.assign(state, canCast(state), canFight(state)); }
Tout est beau - le code d'action est réutilisé, vous pouvez ajouter n'importe quel nouveau personnage en toute simplicité, sans toucher aux précédents et sans gonfler les fonctionnalités d'un seul objet. Pour trouver une mouche dans la pommade dans la solution proposée, il suffit de comparer les performances de la solution basée sur l'héritage (lire délégation) et la solution basée sur la concaténation. Créez une millionième armée d'instances des personnages créés.
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)); }
Et comparez les coûts de mémoire et de calcul entre l'héritage et la composition nécessaires pour créer des objets.

En moyenne, une solution utilisant une composition par concaténation nécessite 100 à 150% de ressources supplémentaires. Les résultats présentés ont été obtenus dans l'environnement NodeJS, vous pouvez voir les résultats pour le moteur de navigation en exécutant ce
test .
L'avantage de la solution basée sur la délégation d'héritage peut s'expliquer par l'économie de mémoire en raison du manque d'accès implicite aux propriétés du délégué, ainsi que par la désactivation de certaines optimisations de moteur pour les délégués dynamiques. À son tour, la solution basée sur la concaténation utilise la méthode Object.assign () très coûteuse, qui affecte considérablement ses performances. Fait intéressant, Firefox Quantum affiche des résultats Chromium diamétralement opposés - la deuxième solution fonctionne beaucoup plus rapidement dans Gecko.
Bien sûr, il vaut la peine de s'appuyer sur les résultats des tests de performances uniquement pour résoudre des tâches assez laborieuses associées à la création d'un grand nombre d'objets d'infrastructure complexe - par exemple, lorsque vous travaillez avec une arborescence virtuelle d'éléments ou que vous développez une bibliothèque graphique. Dans la plupart des cas, la beauté structurelle d'une solution, sa fiabilité et sa simplicité s'avèrent plus importantes, et une petite différence de performances ne joue pas un grand rôle (les opérations avec des éléments DOM prendront beaucoup plus de ressources).
En conclusion, il convient de noter que les types de composition considérés ne sont pas uniques et s'excluent mutuellement. La délégation peut être implémentée en utilisant l'agrégation et l'héritage de classe en utilisant la délégation (comme cela se fait en JavaScript). À la base, toute combinaison d'objets aura une forme ou une autre de composition, et, finalement, seule la simplicité et la flexibilité de la solution résultante comptent.