Zum Inhalt
wannacode

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()));
Ausgabe
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.

Und 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 einer Map vom 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(", ")}`);
Ausgabe
Name: Map
Art: function
Basisklasse: Object
Methoden: clear, constructor, delete, entries, forEach, get, has, keys, set, size, values
Die Basisklasse ist die Klasse des prototype ü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());
Ausgabe
Order:
number (number) = 1042
customer (string) = Maria Becker
total (number) = 86.4
discount (number) = 10
Room:
number (number) = 214
seaView (boolean) = true
Eine Funktion gibt jedes Objekt aus, und das neue Feld discount 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
  1. dump bekommt ein Order-Objekt und weiß nichts über seine Klasse.
  2. obj.constructor ist die Klasse Order. Auch sie ist ein Objekt.
  3. Die erste Zeile ist der kurze Name der Klasse.
  4. Object.entries(obj) liefert 4 Paare: Name und Wert.
  5. number kommt mit seinem Wert, 1042, und typeof sagt, welche Art von Wert es ist: number.
  6. customer (string) = Maria Becker.
  7. total (number) = 86.4.
  8. discount: Das Feld, das toString vergessen hat, erscheint von selbst, weil Reflection alle Felder holt.
  9. Die Funktion weiß nichts über Order. Für Room gibt sie number und seaView genauso aus.
Genauer, für Neugierige
  • Object.entries und Object.keys liefern nur die eigenen Felder des Objekts, ohne die Methoden (die liegen im prototype). Reflect.ownKeys(obj) liefert auch die Namen vom Typ Symbol.
  • 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 auch JSON.stringify nicht.

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

Sieh es dir an

Objekt, Methode und privates Feld über den Namen

Mit Reflection kannst du ein Objekt erzeugen und eine Methode aufrufen, wenn du nur ihre Namen kennst. Mit einem privaten Feld geht es nicht:
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));
Ausgabe
Felder: name
#price von außen: undefined
Kaffee: 8 €
  • Reflect.construct(Klasse, [Argumente]) erzeugt ein Objekt, wie new.
  • Reflect.get(coffee, "applyDiscount") liefert die Methode, und Reflect.apply(Methode, Objekt, [Argumente]) ruft sie auf coffee auf. coffee["applyDiscount"](20) macht dasselbe.
  • Reflect.ownKeys sieht nur name. #price ist kein Feldname, den man nachschlagen kann: Reflect.get(coffee, "#price") liefert undefined. Nur die Methoden der Klasse können den Preis lesen und ändern, deshalb ist er hier nach dem Rabatt 8.

Die Typen der Parameter gibst du nicht an: In JavaScript werden Methoden nur über den Namen gefunden.

Genauer, für Neugierige
Ein Array, erzeugt und gefüllt, ohne dass 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);
Ausgabe
[ 'Kaffee', 'Tee' ]
Eingebaute Klassen wie Array sind Felder von globalThis.

Probier es aus

Der Preis, den es nicht gibt

Das Programm startet, aber es bricht in der dritten Zeile mit einem TypeError ab. In welcher Zeile ist der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Etiketten an Feldern

In Java heißen Etiketten mit @ 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}`);
}
}
}
Ausgabe
Nummer: 501
Gast: Jonas Weber
Nächte: 3
  • static labels ist ein Feld der Klasse selbst, nicht jedes Objekts. Du kommst mit obj.constructor.labels daran.
  • Object.hasOwn(labels, name) prüft, ob das Feld ein Etikett hat. internalNote hat 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

Hier ist ein winziger Test-Runner. Marker gibt es nicht, deshalb werden die Prüfungen über den Namen gefunden: die Methoden, deren Namen mit 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}`);
}
Ausgabe
FEHLER checkExtraFivePercentFrom100: erwartet 85, ist aber 90
OK checkTenPercentOff
Prüfungen: 2, bestanden: 1, fehlgeschlagen: 1

So haben JUnit 3 (Methoden test…) und unittest in Python gearbeitet:

  1. Es holt mit getOwnPropertyNames die Namen im prototype der Klasse, überspringt die ohne check vorne und sortiert sie, damit die Reihenfolge immer gleich ist;
  2. es erzeugt für jeden Test ein neues Objekt, damit sich die Tests nicht gegenseitig stören;
  3. 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

Die Prüfungen sind als Felder mit Pfeilfunktionen geschrieben, alles andere ist gleich:

Was gibt runChecks aus?

class DiscountChecks {
checkTenPercentOff = () => { ... };
checkExtraFivePercentFrom100 = () => { ... };
}
runChecks(DiscountChecks);
Antwort

Übung

Code, der mit jedem Objekt funktioniert

Löse die Aufgaben in deinem Editor.

1. Alle Felder und die Kette. Schreib printFields(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;
}
Ausgabe für new Truck()
Klassen: Truck → Vehicle → Object
plate = M-AB 1234
year = 2019
loadTons = 12.5
2. Mini-JSON. Schreib toJson(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;
}
Ausgabe für new Ticket() und new Room()
{"seat": 14, "event": "Konzert", "price": 45, "vip": false}
{"number": 214, "seaView": true}
3. Dein eigener Test-Runner. Mach einen Runner 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);
}
}
Ausgabe
OK testWithdraw40
OK testWithdraw70
FEHLER testWithdrawTooMuch: RangeError: Es fehlen 30 €
Tests: 3, bestanden: 2, fehlgeschlagen: 1

Test

Teste dich

1. Was liefert obj.constructor?
2. Kann Reflection ein Feld #price lesen?
3. In Reflect.get(tea, "nmae") ist ein Tippfehler. Was passiert?
4. Warum werden die Prüfungen, die als checkX = () => { … } geschrieben sind, nicht gefunden?
5. Kannst du in Node 22 eine Annotation mit @ an eine Methode setzen, wie in Java?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·