Deep JavaScript
Bitte unterstützen Sie dieses Buch: kaufen Sie es oder spenden Sie
(Werbung, bitte nicht blockieren.)

8 Die Probleme von gemeinsam genutztem veränderbarem Zustand und wie man sie vermeidet



Dieses Kapitel beantwortet die folgenden Fragen

8.1 Was ist gemeinsam genutzter veränderbarer Zustand und warum ist er problematisch?

Gemeinsam genutzter veränderbarer Zustand funktioniert wie folgt

Beachten Sie, dass diese Definition für Funktionsaufrufe, kooperatives Multitasking (z. B. asynchrone Funktionen in JavaScript) usw. gilt. Die Risiken sind in jedem Fall ähnlich.

Der folgende Code ist ein Beispiel. Das Beispiel ist nicht realistisch, aber es demonstriert die Risiken und ist leicht verständlich

function logElements(arr) {
  while (arr.length > 0) {
    console.log(arr.shift());
  }
}

function main() {
  const arr = ['banana', 'orange', 'apple'];

  console.log('Before sorting:');
  logElements(arr);

  arr.sort(); // changes arr

  console.log('After sorting:');
  logElements(arr); // (A)
}
main();

// Output:
// 'Before sorting:'
// 'banana'
// 'orange'
// 'apple'
// 'After sorting:'

In diesem Fall gibt es zwei unabhängige Parteien

logElements() beeinträchtigt main() und führt dazu, dass diese in Zeile A ein leeres Array protokolliert.

Im Rest dieses Kapitels betrachten wir drei Möglichkeiten, die Probleme von gemeinsam genutztem veränderbarem Zustand zu vermeiden

Insbesondere werden wir auf das gerade gesehene Beispiel zurückkommen und es beheben.

8.2 Vermeidung von Sharing durch Kopieren von Daten

Das Kopieren von Daten ist eine Möglichkeit, das Teilen davon zu vermeiden.

  Hintergrund

Für Hintergrundinformationen zum Kopieren von Daten in JavaScript verweisen wir auf die folgenden beiden Kapitel dieses Buches

8.2.1 Wie hilft das Kopieren bei gemeinsam genutztem veränderbarem Zustand?

Solange wir nur *lesen* aus gemeinsam genutztem Zustand, haben wir keine Probleme. Bevor wir ihn *modifizieren*, müssen wir ihn „ent-teilen“, indem wir ihn kopieren (so tief wie nötig).

Defensives Kopieren ist eine Technik, bei der immer kopiert wird, wenn Probleme *auftreten könnten*. Sein Ziel ist es, die aktuelle Entität (Funktion, Klasse usw.) zu schützen

Beachten Sie, dass diese Maßnahmen uns vor anderen Parteien schützen, aber sie schützen auch andere Parteien vor uns.

Die nächsten Abschnitte veranschaulichen beide Arten von defensivem Kopieren.

8.2.1.1 Gemeinsam genutzte Eingaben kopieren

Erinnern Sie sich, dass wir im motivierenden Beispiel am Anfang dieses Kapitels in Schwierigkeiten gerieten, weil logElements() seinen Parameter arr modifizierte

function logElements(arr) {
  while (arr.length > 0) {
    console.log(arr.shift());
  }
}

Fügen wir dieser Funktion defensives Kopieren hinzu

function logElements(arr) {
  arr = [...arr]; // defensive copy
  while (arr.length > 0) {
    console.log(arr.shift());
  }
}

Nun verursacht logElements() keine Probleme mehr, wenn sie innerhalb von main() aufgerufen wird

function main() {
  const arr = ['banana', 'orange', 'apple'];

  console.log('Before sorting:');
  logElements(arr);

  arr.sort(); // changes arr

  console.log('After sorting:');
  logElements(arr); // (A)
}
main();

// Output:
// 'Before sorting:'
// 'banana'
// 'orange'
// 'apple'
// 'After sorting:'
// 'apple'
// 'banana'
// 'orange'
8.2.1.2 Gemeinsam genutzte interne Daten kopieren

Beginnen wir mit einer Klasse StringBuilder, die interne Daten, die sie preisgibt, nicht kopiert (Zeile A)

class StringBuilder {
  _data = [];
  add(str) {
    this._data.push(str);
  }
  getParts() {
    // We expose internals without copying them:
    return this._data; // (A)
  }
  toString() {
    return this._data.join('');
  }
}

Solange .getParts() nicht verwendet wird, funktioniert alles gut

const sb1 = new StringBuilder();
sb1.add('Hello');
sb1.add(' world!');
assert.equal(sb1.toString(), 'Hello world!');

Wenn jedoch das Ergebnis von .getParts() geändert wird (Zeile A), funktioniert der StringBuilder nicht mehr richtig

const sb2 = new StringBuilder();
sb2.add('Hello');
sb2.add(' world!');
sb2.getParts().length = 0; // (A)
assert.equal(sb2.toString(), ''); // not OK

Die Lösung besteht darin, die internen ._data defensiv zu kopieren, bevor sie preisgegeben werden (Zeile A)

class StringBuilder {
  this._data = [];
  add(str) {
    this._data.push(str);
  }
  getParts() {
    // Copy defensively
    return [...this._data]; // (A)
  }
  toString() {
    return this._data.join('');
  }
}

Nun stört die Änderung des Ergebnisses von .getParts() die Funktionsweise von sb nicht mehr

const sb = new StringBuilder();
sb.add('Hello');
sb.add(' world!');
sb.getParts().length = 0;
assert.equal(sb.toString(), 'Hello world!'); // OK

8.3 Vermeidung von Mutationen durch nicht-destruktive Aktualisierungen

Wir können Mutationen vermeiden, wenn wir Daten nur nicht-destruktiv aktualisieren.

  Hintergrund

Weitere Informationen zur Aktualisierung von Daten finden Sie unter §7 „Daten destruktiv und nicht-destruktiv aktualisieren“.

8.3.1 Wie hilft nicht-destruktive Aktualisierung bei gemeinsam genutztem veränderbarem Zustand?

Bei nicht-destruktiver Aktualisierung wird das Teilen von Daten unproblematisch, da wir die gemeinsam genutzten Daten nie verändern. (Dies funktioniert nur, wenn jeder, der auf die Daten zugreift, dies tut!)

Interessanterweise wird das Kopieren von Daten trivial einfach

const original = {city: 'Berlin', country: 'Germany'};
const copy = original;

Dies funktioniert, weil wir nur nicht-destruktive Änderungen vornehmen und daher Daten bei Bedarf kopieren.

8.4 Verhinderung von Mutationen durch Unveränderlichmachen von Daten

Wir können Mutationen von gemeinsam genutzten Daten verhindern, indem wir diese Daten unveränderlich machen.

  Hintergrund

Für Hintergrundinformationen zum Unveränderlichmachen von Daten in JavaScript verweisen wir auf die folgenden beiden Kapitel dieses Buches

8.4.1 Wie hilft Unveränderlichkeit bei gemeinsam genutztem veränderbarem Zustand?

Wenn Daten unveränderlich sind, können sie ohne Risiko geteilt werden. Insbesondere ist kein defensives Kopieren erforderlich.

  Nicht-destruktive Aktualisierung ist eine wichtige Ergänzung zu unveränderlichen Daten

Wenn wir beides kombinieren, werden unveränderliche Daten praktisch so vielseitig wie veränderliche Daten, aber ohne die damit verbundenen Risiken.

8.5 Bibliotheken zur Vermeidung von gemeinsam genutztem veränderbarem Zustand

Es gibt mehrere Bibliotheken für JavaScript, die unveränderliche Daten mit nicht-destruktiven Aktualisierungen unterstützen. Zwei beliebte sind

Diese Bibliotheken werden in den nächsten beiden Abschnitten detaillierter beschrieben.

8.5.1 Immutable.js

In seinem Repository wird die Bibliothek Immutable.js wie folgt beschrieben

Unveränderliche persistente Datensammlungen für JavaScript, die Effizienz und Einfachheit erhöhen.

Immutable.js bietet unveränderliche Datenstrukturen wie

Im folgenden Beispiel verwenden wir eine unveränderliche Map

import {Map} from 'immutable/dist/immutable.es.js';
const map0 = Map([
  [false, 'no'],
  [true, 'yes'],
]);

// We create a modified version of map0:
const map1 = map0.set(true, 'maybe');

// The modified version is different from the original:
assert.ok(map1 !== map0);
assert.equal(map1.equals(map0), false); // (A)

// We undo the change we just made:
const map2 = map1.set(true, 'yes');

// map2 is a different object than map0,
// but it has the same content
assert.ok(map2 !== map0);
assert.equal(map2.equals(map0), true); // (B)

Hinweise

8.5.2 Immer

In seinem Repository wird die Bibliothek Immer wie folgt beschrieben

Erstellen Sie den nächsten unveränderlichen Zustand, indem Sie den aktuellen modifizieren.

Immer hilft bei der nicht-destruktiven Aktualisierung von (potenziell verschachtelten) einfachen Objekten, Arrays, Sets und Maps. Das heißt, es sind keine benutzerdefinierten Datenstrukturen beteiligt.

So sieht die Verwendung von Immer aus

import {produce} from 'immer/dist/immer.module.js';

const people = [
  {name: 'Jane', work: {employer: 'Acme'}},
];

const modifiedPeople = produce(people, (draft) => {
  draft[0].work.employer = 'Cyberdyne';
  draft.push({name: 'John', work: {employer: 'Spectre'}});
});

assert.deepEqual(modifiedPeople, [
  {name: 'Jane', work: {employer: 'Cyberdyne'}},
  {name: 'John', work: {employer: 'Spectre'}},
]);
assert.deepEqual(people, [
  {name: 'Jane', work: {employer: 'Acme'}},
]);

Die ursprünglichen Daten sind in people gespeichert. produce() stellt uns eine Variable draft zur Verfügung. Wir tun so, als ob diese Variable people wäre und verwenden Operationen, mit denen wir normalerweise destruktive Änderungen vornehmen würden. Immer fängt diese Operationen ab. Anstatt draft zu modifizieren, ändert es people nicht-destruktiv. Das Ergebnis wird von modifiedPeople referenziert. Als Bonus ist es tief unveränderlich.

assert.deepEqual() funktioniert, weil Immer einfache Objekte und Arrays zurückgibt.