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, valuesprototype-а над този на 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) = 10Room: number (number) = 214 seaView (boolean) = truediscount вече е там. Полетата тук са публични. Полетата с # са истински частни: 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}`); }}Стъпка по стъпка, с думи
- dump получава обект Order и не знае нищо за класа му.
- obj.constructor е класът Order. И той е обект.
- Първият ред е краткото име на класа.
- Object.entries(obj) дава 4 двойки: име и стойност.
- number идва със стойността си, 1042, а typeof казва вида ѝ: number.
- customer (string) = Мария Петрова.
- total (number) = 86.4.
- discount: полето, което toString забрави, излиза само, защото reflection взима всички полета.
- Функцията не знае нищо за 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);doc е Invoice.prototype, а неговият constructor е Invoice. Над него е Document.prototype. Document не пише extends, затова над неговия prototype е Object.prototype, общият родител от модула за наследяването. Над Object.prototype е null: там няма нищо.Виж го
Обект, метод и частно поле по име
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 на третия ред. На кой ред е грешката?Избери реда с грешката.
Полето се казва name, а тук пише "nmae". Липсващо поле в JavaScript не е грешка: Reflect.get връща undefined и програмата продължава. Грешката излиза ред по-късно, далеч от причината:
TypeError: Cannot read properties of undefined (reading 'toUpperCase')Това е главната цена на reflection:
- никой не проверява предварително имената и типовете, а редакторът не ги намира при преименуване;
- кодът е по-бавен от обикновено извикване и по-труден за четене;
-
Reflect.setзаобикаля капсулацията на всяко поле без#: можеш да счупиш правило, което класът пази.
console.log, JSON.stringify, ORM като Sequelize. В обикновения код се пише рядко.Идеята
Етикети на полета
@ на полета и методи се казват анотации. В 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Гост: Иван ГеоргиевНощувки: 3static 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:
- взима имената в
prototypeна класа сgetOwnPropertyNames, пропуска тези безcheckотпред и ги подрежда, за да е редът един и същ; - прави нов обект за всеки тест, затова тестовете не си пречат;
- вика метода по име:
instance[name](). Ако тестът хвърли, грешката идва направо.
node:test от първия урок на модула работи иначе: там тестът се записва, като викаш test("име", функция), и reflection не трябва. assert.equal също хвърля грешка, когато стойностите се различават. Точно като expect тук.
Пробвай
Проверките, които изчезнаха
Какво ще отпечата runChecks?
class DiscountChecks { checkTenPercentOff = () => { ... };
checkExtraFivePercentFrom100 = () => { ... };}
runChecks(DiscountChecks);new, а не в prototype на класа. getOwnPropertyNames(type.prototype) не го вижда и нито една проверка не се пуска. Програмата работи, просто не прави нищо. Затова методите, които ще се търсят с reflection, се пишат като методи: checkTenPercentOff() { … }.Упражнение
Код, който работи с всеки обект
Реши задачите в редактора си.
1. Всички полета и веригата. НапишиprintFields(obj), който печата първо веригата от класове (от Object.getPrototypeOf(obj) нагоре до null, с constructor.name), после всяко поле като име = стойност. Полетата на родителя вече са на обекта и идват първи: класът родител ги слага, преди наследникът да сложи своите.class Vehicle { plate = "СА 1234 ВК"; year = 2019;}
class Truck extends Vehicle { loadTons = 12.5;}Класове: Truck → Vehicle → Objectplate = СА 1234 ВКyear = 2019loadTons = 12.5toJson(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;}{"seat": 14, "event": "Концерт", "price": 45, "vip": false}{"number": 214, "seaView": true}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Тест