Kompositionspreis in der Javascript-Welt

Die Idee, dass bei der Entwicklung einer mehr oder weniger komplexen Geschäftslogik der Zusammensetzung von Objekten Vorrang vor der Vererbung eingeräumt werden sollte, ist bei Softwareentwicklern verschiedener Typen beliebt. Bei der nächsten Welle der Popularität des funktionalen Programmierparadigmas, das durch den Erfolg von ReactJS ausgelöst wurde, wurde das Gespräch über die Vorteile kompositorischer Lösungen in den Vordergrund gerückt. In diesem Beitrag gibt es ein kleines Layout in den Regalen der Theorie der Komposition von Objekten in Javascript, ein spezifisches Beispiel, seine Analyse und die Antwort auf die Frage, wie viel semantische Eleganz den Benutzer kostet (Spoiler: viel).

V. Kandinsky - Zusammensetzung X.
Vasily Kandinsky - "Zusammensetzung X"

Die jahrelange erfolgreiche Entwicklung eines objektorientierten Entwicklungsansatzes, hauptsächlich im akademischen Bereich, hat zu einem spürbaren Ungleichgewicht im Kopf eines durchschnittlichen Entwicklers geführt. In den meisten Fällen besteht der erste Gedanke, falls erforderlich, um ein Verhalten für eine Reihe verschiedener Entitäten zu verallgemeinern, darin, eine übergeordnete Klasse zu erstellen und dieses Verhalten zu erben. Dieser Missbrauchsansatz führt zu mehreren Problemen, die das Design erschweren und die Entwicklung behindern.

Erstens wird die mit Logik überladene Basisklasse zerbrechlich - eine kleine Änderung ihrer Methoden kann abgeleitete Klassen tödlich beeinflussen. Eine Möglichkeit, diese Situation zu umgehen, besteht darin, die Logik auf mehrere Klassen zu verteilen und eine komplexere Vererbungshierarchie zu erstellen. In diesem Fall tritt beim Entwickler ein weiteres Problem auf: Die Logik der übergeordneten Klassen wird bei Bedarf in den Erben dupliziert, wenn sich die Funktionalität der übergeordneten Klassen überschneidet, aber vor allem nicht vollständig ist.

Bild
mail.mozilla.org/pipermail/es-discuss/2013-June/031614.html

Um eine ziemlich tiefe Hierarchie zu erstellen, muss der Benutzer bei Verwendung einer Entität alle seine Vorfahren zusammen mit all ihren Abhängigkeiten ziehen, unabhängig davon, ob er ihre Funktionalität verwenden wird oder nicht. Dieses Problem der übermäßigen Abhängigkeit von der Umwelt mit einer leichten Hand von Joe Armstrong, dem Schöpfer von Erlang, wurde das Problem des Gorillas und der Banane genannt:

Ich denke, der Mangel an Wiederverwendbarkeit liegt in objektorientierten Sprachen, nicht in funktionalen Sprachen. Weil das Problem mit objektorientierten Sprachen darin besteht, dass sie all diese implizite Umgebung haben, die sie mit sich herumtragen. Sie wollten eine Banane, aber Sie bekamen einen Gorilla, der die Banane und den gesamten Dschungel hielt.

Die Zusammensetzung von Objekten ist erforderlich, um all diese Probleme als Alternative zur Klassenvererbung zu lösen. Die Idee ist überhaupt nicht neu, findet aber unter Entwicklern kein vollständiges Verständnis. Die Situation in der Front-End-Welt ist etwas besser, wo die Struktur von Softwareprojekten oft recht einfach ist und nicht zur Schaffung eines komplexen objektorientierten Beziehungsschemas führt. Das blinde Befolgen der Bündnisse der Viererbande, die Empfehlung, die Komposition der Vererbung vorzuziehen, kann jedoch auch der inspirierenden Weisheit der großen Entwickler einen Streich spielen.

Durch die Übertragung von Definitionen aus „Entwurfsmustern“ in die dynamische Welt von Javascript können drei Arten der Objektzusammensetzung zusammengefasst werden: Aggregation , Verkettung und Delegierung . Es ist anzumerken, dass diese Trennung und das Konzept der Objektzusammensetzung im Allgemeinen rein technischer Natur sind, während die Bedeutung dieser Begriffe Überschneidungen aufweist, was zu Verwirrung führt. So wird beispielsweise die Klassenvererbung in Javascript auf der Basis der Delegierung implementiert (Prototypvererbung). Daher ist es in jedem Fall besser, mit Live-Codebeispielen zu sichern.

Die Aggregation ist eine aufzählbare Vereinigung von Objekten, von denen jedes unter Verwendung einer eindeutigen Zugriffskennung erhalten werden kann. Beispiele sind Arrays, Bäume, Diagramme. Ein gutes Beispiel aus der Welt der Webentwicklung ist der DOM-Baum. Die Hauptqualität dieser Art von Komposition und der Grund für ihre Erstellung ist die Fähigkeit, jedem Kind der Komposition bequem einen Handler zuzuweisen.

Ein synthetisches Beispiel ist ein Array von Objekten, die abwechselnd einen Stil für ein beliebiges visuelles Element festlegen.

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

Jedes der Stilobjekte kann ohne Informationsverlust durch seinen Index extrahiert werden. Darüber hinaus können Sie mit Array.prototype.map () alle gespeicherten Werte auf eine bestimmte Weise verarbeiten.

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

Bei der Verkettung wird die Funktionalität eines vorhandenen Objekts durch Hinzufügen neuer Eigenschaften erweitert. So arbeiten beispielsweise Zustandsreduzierer in Redux. Die zur Aktualisierung empfangenen Daten werden in das Statusobjekt geschrieben und erweitert. Im Gegensatz zur Aggregation gehen Daten zum aktuellen Status des Objekts verloren, wenn sie nicht gespeichert werden.

Wenn Sie zum Beispiel zurückkehren und die obigen Einstellungen abwechselnd auf das visuelle Element anwenden, können Sie das Endergebnis generieren, indem Sie die Parameter der Objekte verketten.

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

Werte eines spezifischeren Stils überschreiben möglicherweise frühere Zustände.

Wie Sie sich vorstellen können, wird beim Delegieren ein Objekt an ein anderes delegiert. Delegierte sind beispielsweise Prototypen in Javascript. Instanzen abgeleiteter Objekte leiten Aufrufe an übergeordnete Methoden um. Wenn in der Array-Instanz keine erforderliche Eigenschaft oder Methode erforderlich ist, wird dieser Aufruf an Array.prototype und gegebenenfalls weiter an Object.prototype umgeleitet. Daher basiert der Vererbungsmechanismus in Javascript auf der Prototyp-Delegierungskette, die technisch eine (Überraschungs-) Version der Komposition ist.

Das Kombinieren eines Arrays von Stilobjekten durch Delegierung kann wie folgt erfolgen.

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

Wie Sie sehen, kann auf die Delegateigenschaften nicht durch Aufzählung zugegriffen werden (z. B. mit Object.keys ()), sondern nur durch expliziten Zugriff. Die Tatsache, dass dies uns gibt, ist am Ende des Beitrags.

Nun zu den Einzelheiten. Ein gutes Beispiel für einen Fall, der einen Entwickler dazu ermutigt, Komposition anstelle von Vererbung zu verwenden, ist Michael Rises Objektkomposition in Javascript . Hier betrachtet der Autor den Prozess der Erstellung einer Hierarchie von Rollenspielcharakteren. Zunächst sind zwei Arten von Charakteren erforderlich - ein Krieger und ein Zauberer, von denen jeder eine bestimmte Gesundheitsreserve und einen Namen hat. Diese Eigenschaften sind häufig und können in die übergeordnete Klasse Character verschoben werden.

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

Der Krieger zeichnet sich dadurch aus, dass er weiß, wie man schlägt, während er seine Ausdauer verbraucht, und der Zauberer - die Fähigkeit, Zauber zu wirken und die Menge an Mana zu reduzieren.

 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 -  ; } } 

Nachdem der Entwickler die Klassen Fighter und Mage, die Nachkommen von Character, erstellt hat, steht er vor einem unerwarteten Problem, wenn die Paladin-Klasse erstellt werden muss. Der neue Charakter zeichnet sich durch die beneidenswerte Fähigkeit aus, sowohl zu kämpfen als auch zu zaubern. Auf Anhieb können Sie einige Lösungen sehen, die sich im gleichen Mangel an Anmut unterscheiden.

  1. Sie können Paladin zu einem Nachkommen des Charakters machen und sowohl den Kampf () als auch den Zauber () von Grund auf neu implementieren. In diesem Fall wird das DRY-Prinzip grob verletzt, da jede der Methoden während der Erstellung dupliziert wird und anschließend eine ständige Synchronisation mit den Methoden der Klassen Mage und Fighter erforderlich ist, um Änderungen zu verfolgen.
  2. Die Methoden Fight () und Cast () können auf der Ebene der Charater-Klasse implementiert werden, sodass alle drei Arten von Charakteren sie besitzen. Dies ist eine etwas angenehmere Lösung. In diesem Fall muss der Entwickler jedoch die Fight () - Methode für den Magier und die Cast () - Methode für den Krieger neu definieren und durch leere Stubs ersetzen.

Bei jeder der Optionen muss man sich früher oder später mit den Problemen der Vererbung auseinandersetzen, die zu Beginn des Beitrags geäußert wurden. Sie können mit einem funktionalen Ansatz zur Implementierung von Zeichen gelöst werden. Es reicht aus, sich nicht von ihren Typen, sondern von ihren Funktionen abzuwenden. Unter dem Strich haben wir zwei Hauptmerkmale, die die Fähigkeit von Charakteren bestimmen - die Fähigkeit zu kämpfen und die Fähigkeit zu zaubern. Diese Funktionen können mithilfe von Factory-Funktionen festgelegt werden, die den Status erweitern, der das Zeichen definiert (ein Beispiel für die Komposition ist die Verkettung).

 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 -  ; } }) 

Somit wird der Charakter durch die Menge dieser Merkmale und die anfänglichen Eigenschaften bestimmt, sowohl allgemein (Name und Gesundheit) als auch privat (Ausdauer und 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)); } 

Alles ist schön - der Aktionscode wird wiederverwendet, Sie können problemlos jeden neuen Charakter hinzufügen, ohne die vorherigen zu berühren und ohne die Funktionalität eines Objekts zu erhöhen. Um eine Fliege in der Salbe in der vorgeschlagenen Lösung zu finden, reicht es aus, die Leistung der Lösung basierend auf der Vererbung (Lesedelegation) und die Lösung basierend auf der Verkettung zu vergleichen. Erstellen Sie eine millionste Armee von Instanzen der erstellten Charaktere.

 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)); } 

Vergleichen Sie die Speicher- und Rechenkosten zwischen Vererbung und Komposition, die zum Erstellen von Objekten erforderlich sind.

Bild

Im Durchschnitt erfordert eine Lösung, die eine Komposition durch Verkettung verwendet, 100–150% mehr Ressourcen. Die dargestellten Ergebnisse wurden in einer NodeJS-Umgebung erhalten. Sie können die Ergebnisse für die Browser-Engine anzeigen, indem Sie diesen Test ausführen.

Der Vorteil der auf Vererbungsdelegierung basierenden Lösung kann durch Speichern von Speicher aufgrund des fehlenden impliziten Zugriffs auf die Delegateigenschaften sowie durch Deaktivieren einiger Engine-Optimierungen für dynamische Delegaten erklärt werden. Die verkettungsbasierte Lösung verwendet wiederum die sehr teure Object.assign () -Methode, die sich stark auf die Leistung auswirkt. Interessanterweise zeigt Firefox Quantum diametral entgegengesetzte Chrom-Ergebnisse - die zweite Lösung arbeitet in Gecko viel schneller.

Natürlich lohnt es sich, sich nur dann auf die Ergebnisse von Leistungstests zu verlassen, wenn Sie mühsame Aufgaben im Zusammenhang mit der Erstellung einer großen Anzahl von Objekten mit komplexer Infrastruktur lösen - beispielsweise wenn Sie mit einem virtuellen Elementbaum arbeiten oder eine Grafikbibliothek entwickeln. In den meisten Fällen erweisen sich die strukturelle Schönheit einer Lösung, ihre Zuverlässigkeit und Einfachheit als wichtiger, und ein kleiner Leistungsunterschied spielt keine große Rolle (Operationen mit DOM-Elementen beanspruchen viel mehr Ressourcen).

Zusammenfassend ist anzumerken, dass die betrachteten Arten der Komposition nicht eindeutig sind und sich gegenseitig ausschließen. Die Delegierung kann mithilfe der Aggregation und die Klassenvererbung mithilfe der Delegierung implementiert werden (wie in JavaScript). Im Kern wird jede Kombination von Objekten die eine oder andere Form der Zusammensetzung sein, und letztendlich ist nur die Einfachheit und Flexibilität der resultierenden Lösung von Bedeutung.

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


All Articles