Reflection
Kurz gesagt: Reflection heißt, die eigenen Objekte und Klassen zu betrachten, während das Programm läuft: constructor, Object.entries und Reflect liefern die Felder und Methoden über ihren Namen.
Problem
Das Feld, das toString vergessen hat
toString der Bestellung ist von Hand geschrieben. Später fügt jemand ein Feld für den Rabatt hinzu:
class Order { number = 1042; customer = "Maria Becker"; total = 86.40; discount = 10; // später hinzugefügt
toString() { return `Order[number=${this.number}, customer=${this.customer}, total=${this.total}]`; }}
console.log(String(new Order()));Order[number=1042, customer=Maria Becker, total=86.4]Der Rabatt fehlt in der Ausgabe, weil niemand daran gedacht hat, toString anzupassen. Bei zehn Klassen passiert das ständig.
console.log(new Order()), ohne String, zeigt alle Felder, auch discount, obwohl niemand Code dafür geschrieben hat. Wie? So: Ein Programm kann seine eigenen Objekte und Klassen betrachten, während es läuft.Die Idee
Die Klasse als Objekt
In JavaScript ist eine Klasse eine Funktion (typeof liefert "function"), und ihre Methoden liegen im Objekt prototype. Klassen und Objekte zu betrachten, während das Programm läuft, heißt Reflection (Spiegelung: Das Programm sieht sich wie in einem Spiegel). Es gibt drei Wege zur Klasse:
Order, wenn du die Klasse schon beim Schreiben des Codes kennst;obj.constructor, wenn du ein Objekt hast;globalThis["Map"]für eine eingebaute Klasse, wenn du nur den Namen als Text hast. Für deine eigenen Klassen gibt es so eine Liste nicht: Du führst sie selbst, zum Beispiel in einerMapvom Namen zur Klasse.
const type = globalThis["Map"];console.log(`Name: ${type.name}`);console.log(`Art: ${typeof type}`);console.log(`Basisklasse: ${Object.getPrototypeOf(type.prototype).constructor.name}`);console.log(`Methoden: ${Object.getOwnPropertyNames(type.prototype).sort().join(", ")}`);Name: MapArt: functionBasisklasse: ObjectMethoden: clear, constructor, delete, entries, forEach, get, has, keys, set, size, valuesprototype über dem von Map: Object.getPrototypeOf(type.prototype). Interfaces gibt es in JavaScript nicht. getOwnPropertyNames liefert die Namen im prototype, und sort() sortiert sie, damit die Ausgabe immer gleich ist.Sieh es dir an
Die Felder jedes Objekts
Object.entries(obj) liefert die Felder des Objekts als Paare [Name, Wert]. typeof value sagt dir, welche Art von Wert es ist: In JavaScript haben Felder keinen deklarierten Typ.function dump(obj) { console.log(`${obj.constructor.name}:`); for (const [name, value] of Object.entries(obj)) { console.log(` ${name} (${typeof value}) = ${value}`); }}class Room { number = 214; seaView = true;}
dump(new Order());dump(new Room());Order: number (number) = 1042 customer (string) = Maria Becker total (number) = 86.4 discount (number) = 10Room: number (number) = 214 seaView (boolean) = truediscount ist schon dabei. Die Felder hier sind öffentlich. Felder mit # sind wirklich privat: Object.entries sieht sie nicht, und es gibt keinen Weg, sie zu öffnen, wie setAccessible in Java.function dump(obj) { console.log(`${obj.constructor.name}:`); for (const [name, value] of Object.entries(obj)) { console.log(` ${name} (${typeof value}) = ${value}`); }}Schritt für Schritt, in Worten
- dump bekommt ein Order-Objekt und weiß nichts über seine Klasse.
- obj.constructor ist die Klasse Order. Auch sie ist ein Objekt.
- Die erste Zeile ist der kurze Name der Klasse.
- Object.entries(obj) liefert 4 Paare: Name und Wert.
- number kommt mit seinem Wert, 1042, und typeof sagt, welche Art von Wert es ist: number.
- customer (string) = Maria Becker.
- total (number) = 86.4.
- discount: Das Feld, das toString vergessen hat, erscheint von selbst, weil Reflection alle Felder holt.
- Die Funktion weiß nichts über Order. Für Room gibt sie number und seaView genauso aus.
Genauer, für Neugierige
Object.entriesundObject.keysliefern nur die eigenen Felder des Objekts, ohne die Methoden (die liegen imprototype).Reflect.ownKeys(obj)liefert auch die Namen vom TypSymbol.- Die Reihenfolge ist die, in der die Felder einen Wert bekommen haben, mit einer Ausnahme: Namen wie
"1"und"2"(ganze Zahlen) kommen zuerst, als Zahlen sortiert. - Felder mit
#sieht keine dieser Methoden, und auchJSON.stringifynicht.
Probier es aus
Wessen Klasse?
Was gibt das Programm aus?
class Document {}
class Invoice extends Document {}
const doc = new Invoice();const proto = Object.getPrototypeOf(doc);console.log(proto.constructor.name);console.log(Object.getPrototypeOf(proto).constructor.name);console.log(Object.getPrototypeOf(Document.prototype).constructor.name);doc ist Invoice.prototype, und sein constructor ist Invoice. Darüber liegt Document.prototype. Document schreibt kein extends, also liegt über seinem prototype Object.prototype, die gemeinsame Basisklasse aus dem Modul „Vererbung und Polymorphie“. Über Object.prototype liegt null: Dort gibt es nichts.Sieh es dir an
Objekt, Methode und privates Feld über den Namen
class Product { #price;
constructor(name, price) { this.name = name; this.#price = price; }
applyDiscount(percent) { this.#price = (this.#price * (100 - percent)) / 100; }
label() { return `${this.name}: ${this.#price} €`; }}const type = Product;const coffee = Reflect.construct(type, ["Kaffee", 10.0]);
const discount = Reflect.get(coffee, "applyDiscount");Reflect.apply(discount, coffee, [20]);
console.log(`Felder: ${Reflect.ownKeys(coffee).join(", ")}`);console.log(`#price von außen: ${Reflect.get(coffee, "#price")}`);console.log(Reflect.get(coffee, "label").call(coffee));Felder: name#price von außen: undefinedKaffee: 8 €Reflect.construct(Klasse, [Argumente])erzeugt ein Objekt, wienew.Reflect.get(coffee, "applyDiscount")liefert die Methode, undReflect.apply(Methode, Objekt, [Argumente])ruft sie aufcoffeeauf.coffee["applyDiscount"](20)macht dasselbe.Reflect.ownKeyssieht nurname.#priceist kein Feldname, den man nachschlagen kann:Reflect.get(coffee, "#price")liefertundefined. Nur die Methoden der Klasse können den Preis lesen und ändern, deshalb ist er hier nach dem Rabatt8.
Die Typen der Parameter gibst du nicht an: In JavaScript werden Methoden nur über den Namen gefunden.
Genauer, für Neugierige
new Array oder .push im Code steht:const list = Reflect.construct(globalThis["Array"], []);const add = Reflect.get(list, "push");Reflect.apply(add, list, ["Kaffee"]);Reflect.apply(add, list, ["Tee"]);console.log(list);[ 'Kaffee', 'Tee' ]Array sind Felder von globalThis.Probier es aus
Der Preis, den es nicht gibt
TypeError ab. In welcher Zeile ist der Fehler?Wähle die Zeile mit dem Fehler.
Das Feld heißt name, aber hier steht "nmae". Ein fehlendes Feld ist in JavaScript kein Fehler: Reflect.get gibt undefined zurück, und das Programm läuft weiter. Der Fehler zeigt sich eine Zeile später, weit weg von der Ursache:
TypeError: Cannot read properties of undefined (reading 'toUpperCase')Das ist der wichtigste Preis von Reflection:
- niemand prüft vorher die Namen und Typen nicht, und der Editor findet sie nicht, wenn du etwas umbenennst;
- der Code ist langsamer als ein gewöhnlicher Aufruf und schwerer zu lesen;
-
Reflect.setumgeht die Kapselung jedes Felds ohne#: Du kannst eine Regel brechen, die die Klasse schützt.
console.log, JSON.stringify, ORMs wie Sequelize. In gewöhnlichem Code schreibst du sie selten.Die Idee
Etiketten an Feldern
@ an Feldern und Methoden Annotationen. JavaScript hat keine. Dekoratoren (@label) gibt es in TypeScript, und für die Sprache selbst sind sie ein Vorschlag, aber Node 22 unterstützt sie nicht: @label vor einer Methode ist ein SyntaxError. Deshalb stehen die Etiketten hier in einem statischen Feld der Klasse, und die Funktion gibt nur die Felder mit Etikett aus:class Booking { static labels = { number: "Nummer", guest: "Gast", nights: "Nächte" };
number = 501; guest = "Jonas Weber"; nights = 3; internalNote = "VIP";}function printLabeled(obj) { const labels = obj.constructor.labels ?? {}; for (const [name, value] of Object.entries(obj)) { if (Object.hasOwn(labels, name)) { console.log(`${labels[name]}: ${value}`); } }}Nummer: 501Gast: Jonas WeberNächte: 3static labelsist ein Feld der Klasse selbst, nicht jedes Objekts. Du kommst mitobj.constructor.labelsdaran.Object.hasOwn(labels, name)prüft, ob das Feld ein Etikett hat.internalNotehat keins und wird nicht ausgegeben.- Die Etiketten sind von den Feldern getrennt: Wenn du ein Feld umbenennst, musst du auch den Namen in
labelsändern.
Genauer, für Neugierige
- In TypeScript schreibt man einen Dekorator an einem Feld so:
@label("Gast") guest = "…";. Bibliotheken wie TypeORM benutzen genau solche Dekoratoren.
Sieh es dir an
Wie Tests gefunden werden
check beginnen. Die Testklasse prüft den Rabatt aus der ersten Lektion des Moduls (mit dem alten Fehler price > 100):class DiscountChecks { checkTenPercentOff() { this.expect(45.0, applyDiscount(50, 10)); }
checkExtraFivePercentFrom100() { this.expect(85.0, applyDiscount(100, 10)); }
expect(expected, actual) { if (expected !== actual) { throw new Error(`erwartet ${expected}, ist aber ${actual}`); } }}function runChecks(type) { const names = Object.getOwnPropertyNames(type.prototype).filter((name) => name.startsWith("check")).sort(); let passed = 0; let failed = 0; for (const name of names) { const instance = new type(); try { instance[name](); console.log(`OK ${name}`); passed++; } catch (error) { console.log(`FEHLER ${name}: ${error.message}`); failed++; } } console.log(`Prüfungen: ${passed + failed}, bestanden: ${passed}, fehlgeschlagen: ${failed}`);}FEHLER checkExtraFivePercentFrom100: erwartet 85, ist aber 90OK checkTenPercentOffPrüfungen: 2, bestanden: 1, fehlgeschlagen: 1So haben JUnit 3 (Methoden test…) und unittest in Python gearbeitet:
- Es holt mit
getOwnPropertyNamesdie Namen imprototypeder Klasse, überspringt die ohnecheckvorne und sortiert sie, damit die Reihenfolge immer gleich ist; - es erzeugt für jeden Test ein neues Objekt, damit sich die Tests nicht gegenseitig stören;
- es ruft die Methode über ihren Namen auf:
instance[name](). Wenn der Test wirft, kommt der Fehler direkt durch.
node:test aus der ersten Lektion des Moduls funktioniert anders: Dort meldest du einen Test an, indem du test("Name", Funktion) aufrufst, und Reflection ist nicht nötig. assert.equal wirft auch einen Fehler, wenn sich die Werte unterscheiden. Genau wie expect hier.
Probier es aus
Die Prüfungen, die verschwanden
Was gibt runChecks aus?
class DiscountChecks { checkTenPercentOff = () => { ... };
checkExtraFivePercentFrom100 = () => { ... };}
runChecks(DiscountChecks);new auf jedes Objekt gesetzt, nicht in den prototype der Klasse. getOwnPropertyNames(type.prototype) sieht es nicht, und keine einzige Prüfung wird ausgeführt. Das Programm läuft, es tut nur nichts. Deshalb schreibt man Methoden, die mit Reflection gesucht werden, als Methoden: checkTenPercentOff() { … }.Übung
Code, der mit jedem Objekt funktioniert
Löse die Aufgaben in deinem Editor.
1. Alle Felder und die Kette. SchreibprintFields(obj), das zuerst die Kette der Klassen ausgibt (von Object.getPrototypeOf(obj) nach oben bis null, mit constructor.name), dann jedes Feld als Name = Wert. Die Felder der Basisklasse sind schon auf dem Objekt und kommen zuerst: Die Basisklasse setzt sie, bevor die Unterklasse ihre eigenen setzt.class Vehicle { plate = "M-AB 1234"; year = 2019;}
class Truck extends Vehicle { loadTons = 12.5;}Klassen: Truck → Vehicle → Objectplate = M-AB 1234year = 2019loadTons = 12.5toJson(obj), das die Felder als JSON zurückgibt: die Namen in Anführungszeichen, Text in Anführungszeichen, Zahlen und true/false ohne Anführungszeichen. Benutze Object.entries und typeof value === "string". 45.0 ist in JavaScript einfach 45. (Die fertige Version ist JSON.stringify(obj), nur ohne die Leerzeichen.)class Ticket { seat = 14; event = "Konzert"; price = 45.0; vip = false;}{"seat": 14, "event": "Konzert", "price": 45, "vip": false}{"number": 214, "seaView": true}run(type), der die Tests über den Namen findet: die Methoden, deren Namen mit test beginnen, und eine Methode beforeEach, falls es sie gibt. Für jeden Test: ein neues Objekt, dann beforeEach, dann der Test. Wenn der Test wirft, gib die Art (error.name) und die Meldung des Fehlers aus. Probier ihn mit diesen Tests aus:class Account { #balance;
constructor(balance) { this.#balance = balance; }
get balance() { return this.#balance; }
withdraw(amount) { if (amount > this.#balance) { throw new RangeError(`Es fehlen ${amount - this.#balance} €`); } this.#balance -= amount; }}
class AccountChecks { beforeEach() { this.account = new Account(100); }
testWithdraw40() { this.account.withdraw(40); if (this.account.balance !== 60) throw new Error(`erwartet 60, ist aber ${this.account.balance}`); }
testWithdraw70() { this.account.withdraw(70); if (this.account.balance !== 30) throw new Error(`erwartet 30, ist aber ${this.account.balance}`); }
testWithdrawTooMuch() { this.account.withdraw(130); }}OK testWithdraw40OK testWithdraw70FEHLER testWithdrawTooMuch: RangeError: Es fehlen 30 €Tests: 3, bestanden: 2, fehlgeschlagen: 1Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Arrays: push, splice und sort · Klassen und Daten in Klassen · Pfeilfunktionen · Tests mit node:test
- Aufgaben zum Thema: Programme in der echten Welt