Към съдържанието
wannacode

Reflection

Проблем

Полето, което toString забрави

toString на поръчката е писан на ръка. По-късно някой добавя поле за отстъпката:

class Order {
number = 1042;
customer = "Мария Петрова";
total = 86.40;
discount = 10; // добавено по-късно
toString() {
return `Order[number=${this.number}, customer=${this.customer}, total=${this.total}]`;
}
}
console.log(String(new Order()));
Изход
Order[number=1042, customer=Мария Петрова, total=86.4]

Отстъпката я няма в изхода, защото никой не се е сетил да обнови toString. При десет класа това се случва постоянно.

А console.log(new Order()), без String, показва всички полета, и discount, макар че никой не е писал код за това. Как? Така: програмата може да разглежда собствените си обекти и класове, докато работи.

Идеята

Класът като обект

В JavaScript класът е функция (typeof дава "function"), а методите му са в обекта prototype. Разглеждането на класове и обекти, докато програмата работи, се казва reflection (отражение: програмата се вижда като в огледало). Има три начина да вземеш класа:

  • Order, когато знаеш класа, докато пишеш кода;
  • obj.constructor, когато имаш обект;
  • globalThis["Map"] за вграден клас, когато имаш само името като текст. За свои класове такъв списък няма: пазиш ги сам, например в Map от име към клас.
const type = globalThis["Map"];
console.log(`Име: ${type.name}`);
console.log(`Вид: ${typeof type}`);
console.log(`Родител: ${Object.getPrototypeOf(type.prototype).constructor.name}`);
console.log(`Методи: ${Object.getOwnPropertyNames(type.prototype).sort().join(", ")}`);
Изход
Име: Map
Вид: function
Родител: Object
Методи: clear, constructor, delete, entries, forEach, get, has, keys, set, size, values
Родителят е класът на prototype-а над този на Map: Object.getPrototypeOf(type.prototype). Интерфейси в JavaScript няма. getOwnPropertyNames дава имената в prototype, а sort() ги подрежда, за да е изходът един и същ.

Виж го

Полетата на всеки обект

Object.entries(obj) дава полетата на обекта като двойки [име, стойност]. typeof value казва вида на стойността: в JavaScript полетата нямат обявен тип.
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) = Мария Петрова
total (number) = 86.4
discount (number) = 10
Room:
number (number) = 214
seaView (boolean) = true
Една функция отпечатва всеки обект, и новото поле discount вече е там. Полетата тук са публични. Полетата с # са истински частни: Object.entries не ги вижда, и няма начин да ги отвориш, като setAccessible в Java.

function dump(obj) {
console.log(`${obj.constructor.name}:`);
for (const [name, value] of Object.entries(obj)) {
console.log(` ${name} (${typeof value}) = ${value}`);
}
}
Стъпка по стъпка, с думи
  1. dump получава обект Order и не знае нищо за класа му.
  2. obj.constructor е класът Order. И той е обект.
  3. Първият ред е краткото име на класа.
  4. Object.entries(obj) дава 4 двойки: име и стойност.
  5. number идва със стойността си, 1042, а typeof казва вида ѝ: number.
  6. customer (string) = Мария Петрова.
  7. total (number) = 86.4.
  8. discount: полето, което toString забрави, излиза само, защото reflection взима всички полета.
  9. Функцията не знае нищо за Order. За Room печата number и seaView по същия начин.
По-точно, за любопитните
  • Object.entries и Object.keys дават само собствените полета на обекта, без методите (те са в prototype). Reflect.ownKeys(obj) дава и имената от вид Symbol.
  • Редът е редът, в който полетата са получили стойност, с едно изключение: имена като "1" и "2" (цели числа) идват първи, подредени като числа.
  • Полетата с # не се виждат от нито един от тези методи, нито от JSON.stringify.

Пробвай

Чий клас?

Какво ще отпечата програмата?

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);
Отговор

Виж го

Обект, метод и частно поле по име

През reflection можеш да създадеш обект и да извикаш метод, като знаеш само имената им. С частно поле не става:
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, ["Кафе", 10.0]);
const discount = Reflect.get(coffee, "applyDiscount");
Reflect.apply(discount, coffee, [20]);
console.log(`Полета: ${Reflect.ownKeys(coffee).join(", ")}`);
console.log(`#price отвън: ${Reflect.get(coffee, "#price")}`);
console.log(Reflect.get(coffee, "label").call(coffee));
Изход
Полета: name
#price отвън: undefined
Кафе: 8 €
  • Reflect.construct(клас, [аргументи]) прави обект, като new.
  • Reflect.get(coffee, "applyDiscount") дава метода, а Reflect.apply(метод, обект, [аргументи]) го вика върху coffee. Същото е и coffee["applyDiscount"](20).
  • Reflect.ownKeys вижда само name. #price не е име на поле, което може да се търси: Reflect.get(coffee, "#price") дава undefined. Цената може да се чете и променя само от методите на класа, затова тук е 8 след отстъпката.

Типове на параметрите не се подават: в JavaScript методите се намират само по име.

По-точно, за любопитните
Масив, създаден и попълнен, без в кода да има new Array или .push:
const list = Reflect.construct(globalThis["Array"], []);
const add = Reflect.get(list, "push");
Reflect.apply(add, list, ["Кафе"]);
Reflect.apply(add, list, ["Чай"]);
console.log(list);
Изход
[ 'Кафе', 'Чай' ]
Вградените класове като Array са полета на globalThis.

Пробвай

Цената, която я няма

Програмата тръгва, но спира с TypeError на третия ред. На кой ред е грешката?

Избери реда с грешката.

Идеята

Етикети на полета

В Java етикетите с @ на полета и методи се казват анотации. В JavaScript такива няма. Декораторите (@label) има в TypeScript и са предложение за самия език, но Node 22 не ги поддържа: @label пред метод е SyntaxError. Затова етикетите тук са в статично поле на класа, а функцията печата само полетата с етикет:
class Booking {
static labels = { number: "Номер", guest: "Гост", nights: "Нощувки" };
number = 501;
guest = "Иван Георгиев";
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}`);
}
}
}
Изход
Номер: 501
Гост: Иван Георгиев
Нощувки: 3
  • static labels е поле на самия клас, а не на всеки обект. До него се стига с obj.constructor.labels.
  • Object.hasOwn(labels, name) проверява дали полето има етикет. internalNote няма и не се печата.
  • Етикетите са отделно от полетата: ако преименуваш поле, трябва да смениш и името в labels.
По-точно, за любопитните
  • В TypeScript декоратор на поле се пише @label("Гост") guest = "…";. Библиотеки като TypeORM ползват точно такива декоратори.

Виж го

Как се намират тестовете

Ето малък пускач на тестове. Маркери няма, затова проверките се намират по името: методите, които започват с check. Тестовият клас проверява отстъпката от първия урок на модула (със старата грешка 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(`очаквах ${expected}, а е ${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(`ОК ${name}`);
passed++;
} catch (error) {
console.log(`ПАДА ${name}: ${error.message}`);
failed++;
}
}
console.log(`Проверки: ${passed + failed}, минали: ${passed}, паднали: ${failed}`);
}
Изход
ПАДА checkExtraFivePercentFrom100: очаквах 85, а е 90
ОК checkTenPercentOff
Проверки: 2, минали: 1, паднали: 1

Така са правили JUnit 3 (методи test…) и unittest в Python:

  1. взима имената в prototype на класа с getOwnPropertyNames, пропуска тези без check отпред и ги подрежда, за да е редът един и същ;
  2. прави нов обект за всеки тест, затова тестовете не си пречат;
  3. вика метода по име: instance[name](). Ако тестът хвърли, грешката идва направо.

node:test от първия урок на модула работи иначе: там тестът се записва, като викаш test("име", функция), и reflection не трябва. assert.equal също хвърля грешка, когато стойностите се различават. Точно като expect тук.

Пробвай

Проверките, които изчезнаха

Проверките са написани като полета със стрелкови функции, всичко останало е същото:

Какво ще отпечата runChecks?

class DiscountChecks {
checkTenPercentOff = () => { ... };
checkExtraFivePercentFrom100 = () => { ... };
}
runChecks(DiscountChecks);
Отговор

Упражнение

Код, който работи с всеки обект

Реши задачите в редактора си.

1. Всички полета и веригата. Напиши printFields(obj), който печата първо веригата от класове (от Object.getPrototypeOf(obj) нагоре до null, с constructor.name), после всяко поле като име = стойност. Полетата на родителя вече са на обекта и идват първи: класът родител ги слага, преди наследникът да сложи своите.
class Vehicle {
plate = "СА 1234 ВК";
year = 2019;
}
class Truck extends Vehicle {
loadTons = 12.5;
}
Изход за new Truck()
Класове: Truck → Vehicle → Object
plate = СА 1234 ВК
year = 2019
loadTons = 12.5
2. Мини JSON. Напиши toJson(obj), който връща полетата като JSON: имената в кавички, текстът в кавички, числата и true/false без кавички. Ползвай Object.entries и typeof value === "string". 45.0 в JavaScript е просто 45. (Готовото е JSON.stringify(obj), само без интервалите.)
class Ticket {
seat = 14;
event = "Концерт";
price = 45.0;
vip = false;
}
Изход за new Ticket() и new Room()
{"seat": 14, "event": "Концерт", "price": 45, "vip": false}
{"number": 214, "seaView": true}
3. Свой пускач на тестове. Направи пускач run(type), който намира тестовете по името: методите, които започват с test, и метод beforeEach, ако го има. За всеки тест: нов обект, после beforeEach, после тестът. Ако тестът хвърли, отпечатай вида (error.name) и съобщението на грешката. Пробвай го с тези тестове:
class Account {
#balance;
constructor(balance) {
this.#balance = balance;
}
get balance() {
return this.#balance;
}
withdraw(amount) {
if (amount > this.#balance) {
throw new RangeError(`Не стигат ${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(`очаквах 60, а е ${this.account.balance}`);
}
testWithdraw70() {
this.account.withdraw(70);
if (this.account.balance !== 30) throw new Error(`очаквах 30, а е ${this.account.balance}`);
}
testWithdrawTooMuch() {
this.account.withdraw(130);
}
}
Изход
ОК testWithdraw40
ОК testWithdraw70
ПАДА testWithdrawTooMuch: RangeError: Не стигат 30 €
Тестове: 3, минали: 2, паднали: 1

Тест

Провери се

1. Какво дава obj.constructor?
2. Може ли reflection да прочете поле #price?
3. В Reflect.get(tea, "nmae") има печатна грешка. Какво става?
4. Защо проверките, написани като checkX = () => { … }, не се намират?
5. Може ли в Node 22 да сложиш анотация с @ на метод, както в Java?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·