Als Webtechnologie-Erklärbär genieße ich den zweifelhaften Luxus, vergleichsweise selten selbst Code schreiben zu dürfen (und stattdessen sehr viel über Code zu erzählen). Nur im Sommer oder zum Jahresende, wenn niemand eine Schulung haben will, komme ich mal dazu, etwas nicht völlig triviales zu programmieren. Dabei zeigt sich immer, was ich selbst bei meinen eigenen Schulungen gelernt habe. Dieses Jahr hat sich bei mir (d.h. in meinem Code) die Benutzung von Maps (und in geringerem Maße auch Sets) durchgesetzt und normale Objekte bzw. Objekt-Literals verwende ich nur für ausgesuchte Zwecke. Dieser Blogpost versucht euch zu überzeugen, es mir gleichzutun und Maps in euren aktiven JS-Wortschatz aufzunehmen.
Use Cases für Objekte in JS
Von null
und undefined
abgesehen ist alles in JavaScript entweder ein Objekt oder verhält sich wie eins. Objekte bündeln Primitives oder Unter-Objekte nebst zugehörigen Funktionen und füllen damit genau die Rolle aus, die Objekte in so ziemlich jeder Programmiersprache haben. Andere Programmiersprachen verwenden zur Definition von Objekten oft ein Klassensystem, bei den die Programmierer ein Template für eine Datenstruktur nebst Funktionalität definieren (sowie bei statisch typisierten Sprachen einen entsprechenden Typ).
Vergleichsweise ungewöhnlich an JavaScript ist, dass man mit Objekten recht rücksichtslos umgehen kann. Mit Objekt-Literalen wie { foo: 42 }
lassen sich beliebige Objekte aus dem Nichts erzeugen, eine Klasse oder ein Typ muss nicht deklariert werden. Und sofern keine speziellen Vorkehrungen getroffen werden kann das Objekt jederzeit manipuliert werden. Werte können überschrieben werden, Attribute können jederzeit gelöscht oder hinzugefügt werden.
Objekte werden aufgrund dieser großen Benutzerfreundlichkeit sowie aus Mangel an Alternativen für zwei sehr unterschiedliche Use Cases verwendet:
- Als eher statische Objekte in dem Sinne, wie sie auch in anderen Programmiersprachen vorkommen (Objekte/Structs), d.h. als in ihrer Struktur unveränderliche Bündelungen von Daten und Funktionalität.
- Als eher dynamische Key-Value-Datenstrukturen, in die jederzeit beliebige neue Einträge eingefügt oder aus denen Daten gelöscht werden.
Vom Grundprinzip her sind JS-Objekte also durchaus als eine Key-Value-Datenstruktur zu beschreiben, die beliebige Werte beliebigen String-Keys zuordnet:
// Speichert 42 unter dem Key "foo"
let x = { foo: 42 };
// Überschreibt 42 unter "foo" mit 23
x.foo = 23;
// Liest den unter "foo" gespeicherten Wert aus
let y = x.foo;
// Löscht den Eintrag "foo" nebst Daten aus dem Objekt
delete x.foo;
Diese Vermischung der Use Cases führt offensichtlich nicht zum Untergang des Abendlandes, aber optimal ist sie nicht. Objekte sind keine besonders gute Datenstruktur und mit Maps bietet uns modernes JS mit Maps eine hervorragende Alternative.
Objekte vs. Maps
Herkömmliche JavaScript-Objekte sind Key-Value-Paare, bei denen die Key immer Strings sind. Was kein String ist, aber als Objekt-Key verwendet wird, wird stringifiziert:
let o = {};
o.foo = 1; // Klappt
o[42] = 2; // Klappt
o[{}] = 3; // Klappt
const allKeys = Object.keys(o);
allKeys.every( x => typeof x === "string")
// > true
console.log(allKeys);
// > [ '42', 'foo', '[object Object]' ]
Bei Objekten fungieren also streng genommen nicht die Keys als Keys, sondern die String-Repräsentationen der Keys sind die Keys! Wenn zwei verschiedene Objekte die gleiche String-Repräsentationen haben, sind sie als Objekt-Key austauschbar:
let o = {};
const x = { a: 23 };
const y = { a: 42 };
o[x] = 1;
console.log(o[y]); // > 1
Die Objekte x
und y
sind völlig unterschiedlich, als Key aber austauschbar. Autsch! Bei Maps hingegen sind die Objekte selbst die Keys:
let m = new Map();
const x = { a: 23 };
const y = { a: 42 };
m.set(x, 1);
console.log(m.get(y)); // > undefined
Nur Objekte, die gemäß ===
gleich sind (mit wenigen Ausnahmen) werden von Maps als gleiche Keys betrachtet, was in so gut wie allen Fällen sinnvoller ist, als Key-Objekte stets und ständig zu stringifizieren.
Jedes JavaScript-Objekt hat seine Prototypen-Kette – Plain Objects ebenso wie Maps. Das Problem ist hier, dass diese Prototypen-Kette tatsächlich relevant wird, sobald man mit normalen Objekten und der Punkt/Eckklammer-Notation arbeitet:
let o = {}; // leeres Objekt... oder?
typeof o.toString; // > function! Ups...
Die Eigenschaft toString
ist eigentlich nicht wirklich auf dem Objekt vorhanden, dank der Vererbungs-Kette aber irgendwie doch schon. Dem Problem lässt sich in der Theorie entgegentreten, indem man Objektzugriff nur per hasOwnProperty()
(d.h. unter Umgehung der Prototypen-Kette) durchführt und/oder Objekte grundsätzlich per Object.create(null)
(d.h. ganz ohne Prototyp) anlegt. Sollte das auch nur einmal nicht passieren, kann durch das Überschreiben eines „magischen Keys“ wie toString
das Verhalten eines Objekts verändert werden. Beide o.g. Maßnahmen helfen im Übrigen nichts, wenn der fragliche magische Key __proto__
ist. Man stelle sich nur folgendes Szenario vor:
const wordCountForInput = {};
const getUserInput = () => "__proto__"; // Stub
const countWords = (x) => x.split(" ").length;
const userInput = getUserInput();
const wordsInUserInput = countWords(userInput);
wordCountForInput[userInput] = wordsInUserInput;
Hier wird mitnichten eine Zahl unter einem String-Key abgespeichert! Durch den magischen Key __proto__
wird vielmehr versucht, ein Number-Primitive als Prototyp des Objekts zu definieren. Das hat in, keinem modernen Browser einen Effekt, aber das bedeutet auch, dass keine Zahl abgespeichert wird. Wäre der Wert an dieser Stelle nicht eine Zahl sondern ein Objekt, könnte alles mögliche passieren – sobald irgendein Nutzer in irgendein Input __proto__
eingibt, kann man seinem Code nicht mehr über den Weg trauen. Das ist zugegebenermaßen ein wenig wahrscheinliches Szenario, aber warum würde man sich dieses Problem überhaupt ans Bein binden wollen, wenn doch Maps zur Verfügung stehen?
const m = new Map();
m.has("toString"); // > false - was auch sonst?
m.set("__proto__", 42); // klappt
m.get("__proto__"); // > 42
Maps haben neben weniger Edge Cases auch den großen Vorteil, Iterierbare Objekte zu sein, Iteratoren für Keys und Values zu bieten und darüber hinaus auch strikt die Insertion Order beizubehalten. Bedeutet:
- Über Maps kann ohne weiteres mit
for-of
-Schleifen iteriert werden
Map.prototype.keys()
liefert einen Iterator über alle Keys
Map.prototype.values()
liefert einen Iterator über alle Values
- In allen drei Fällen werden die Werte in exakt der Reihenfolge ausgespuckt, in der Sie in die Map eingefügt wurden
Somit ist der folgende mit Maps umgesetzte Code …
const m = new Map();
m.set("a", 1);
m.set("b", 2);
for (const [ key, value ] of m) {} // Klappt
const keys = m.keys(); // > Iterator "a", "b"
const vals = m.values(); // > Iterator 1, 2
const clone = new Map(m); // Maps können mit Iterables initialisiert werden
… zwar auch mit normalen Objekten machbar …
const o = {};
o.a = 1;
o.b = 2;
for (const [ key, value ] of o) {} // Klappt nicht
for (const key in o) {} // Bester Ersatz, aber bezieht Prototypen mit ein
const keys = Object.keys(o); // > Array "a", "b"
const vals = Object.values(o); // > Array 1, 2 (seit ES2017)
const clone = Object.assign({}, o);
… aber wieder mit Edge Cases und weniger Komfort. Bei for-in
ist zu beachten, dass auch über Prototyp-Properties iteriert wird und die Reihenfolge der Properties ist weder durch den Standard noch durch real existierende Implementierungen garantiert – die Keys und Values können also in jeder beliebigen Reihenfolge ausgegeben werden! Das ist natürlich in sehr vielen Fällen kein Problem, nur warum würde man all den Kleinigkeiten überhaupt die Chance geben wollen, zu einem Problem zu werden?
Kurz und gut: Maps können fast alles, was Plain Objects können, aber mit weniger Edge Cases und mehr eingebauten Features. Das bedeutet allerdings längst nicht, dass man Maps jetzt auch für wirklich alles benutzen sollte.
Was für Objekte spricht
Objekte haben gegenüber Maps auch zahlreiche Vorteile. Die Literal-Syntax ist im Vergleich zum komplizierten new Map()
natürlich unschlagbar einfach und auch einfache Wertzuweisung schlägt get()
und set()
im Benutzerfreundlichkeits-Wettkampf klar. Wenn man den ganzen Tag Code schreibt, ist das ein nicht zu vernachlässigender Bonus.
Außerdem ist es beileibe nicht immer wünschenswert, Objekte unstrinifiziert anhand von Objekt-Identität zu vergleichen. Ich persönlich hatte jüngst eine Map konstruiert, die als Keys x/y-Koordinaten-Objekte verwendete und diesen Koordinaten jeweils andere Objekte zuordnete. Mein Problem: viele hundert Zeilen später wollte ich für gegebene Koordinaten das passende Objekt aus der Map fischen, doch das dann dafür zur Verfügung stehende x/y-Koordinaten-Objekt war (bei gleichem Inhalt) eben ein anderes Objekt. In diesem Fall funktionieren Maps nicht:
const m = new Map();
const coords = { x: 1, y: 2 };
m.set(coords, 42);
const sameCoords = { x: 1, y: 2 };
m.has(sameCoords); // > false - logisch, wenn auch unpraktisch
Manchmal ist eben eine Stringifizierung doch das Mittel der Wahl für Vergleiche. Außerdem gibt es natürlich viele Objekt-Use-Cases, bei denen die Prototyp-Kette ein Feature und kein Hindernis darstellt. Hier haben Maps nichts zu suchen.
Wir stellen also fest: Maps können viele der Aufgaben erfüllen, die in JavaScript klassischerweise von Plain Objects übernommen werden. Es bleibt die Frage, ob und wann sie denn auch wirklich statt eines Plain Objects zum Einsatz kommen sollten.
Fazit und abschließende Empfehlungen
Maps haben einen Use Case, bei dem sie in modernem JS in allen Fällen normale Objekte verdrängen sollten: Key-Value-Datenstrukturen mit unbekannten Keys. Wann immer sich über die Programmlaufzeit ein Objekt mit Daten füllen soll, die zum Zeitpunkt des Programmierens noch nicht bekannt sind, sind Maps das Mittel der Wahl. Unter alle anderen Umständen sind weiterhin normale Objekte vorzuziehen, da sich dort die Nachteile nicht auswirken und das, was im Datenstruktur-Use-Case ein lästiger Edge Case ist (die Prototyp-Kette), ein nützliches Feature ist.
Für die folgenden Fälle sind und bleiben Plain Objects das Mittel der Wahl:
// Struct - Keys bekannt, Prototypen egal
const coords = { x: 1, y: 2 };
// Klasse (Bündel aus Daten und Funktionen)
// Prototypen sind hier ein wichtiges Feature!
class Car {
drive () {
console.log("Brumm");
}
}
// Funktionsparameter (wie Structs)
function foo (options) { ... }
foo({ bar: 23, baz: 42 });
In diesem Szenario sind wir mit einer Map besser beraten:
const userInput = "Hello Hello __proto__ toString";
const countWords = (str) => {
const counter = new Map();
const words = str.split(" ");
for (const word of words) {
if (counter.has(word)) {
counter.set(word, counter.get(word) + 1);
} else {
counter.set(word, 1);
}
}
return counter;
}
countWords(userInput);
// > Map { 'Hello' => 2, '__proto__' => 1, 'toString' => 1 }
Der Code verschluckt sich nicht an __proto__
, liefert garantiert die Wörter in der Reihenfolge, wie sie im Ursprungs-Input vorkamen und toString
steht im Output, weil das Wort im Input war – keine Prototyp-Kette der Welt kann dafür verantwortlich sein.
Der einzig gute Grund, auch im Jahr 2018 weiterhin Plain Objects als Key-Value-Datenstruktur zu verwenden, ist der gute alte Legacy-Code. Libraries wie Lo-Dash und viele Millionen Zeilen Bestandscode wurden konzipiert, bevor Maps in ECMAScript landeten. Ich hätte kein Problem damit, bestehende APIs mit guten alten Objekten zu füttern, denn wenn sie sich bis heute gehalten haben, kümmern sich sie bestimmt fein säuberlich um all die bisher aufgezählten Edge Cases und es kann eigentlich nichts schiefgehen. Richtig?