Глава 15. Классы в структурном мире
Глава 15. Классы в структурном мире
Экземпляр, который не членство
Объявим класс. Самый обыкновенный, с приватным полем, как нас учили: данные спрятаны, наружу торчит метод.
class Money {
private amount: number; // сумма в минимальных единицах валюты
constructor(amount: number) {
this.amount = amount;
}
format(): string {
return (this.amount / 100).toFixed(2);
}
}
const price = new Money(4999);
console.log(price.format()); // "49.99"
Всё ведёт себя так, как ждёшь. new Money(4999) строит экземпляр, format видит приватное поле, снаружи к amount через точку не подобраться — компилятор не даст: price.amount подсветится красным с «Property ‘amount’ is private». Картина мира цела.
Теперь два укола, после которых она треснет. Первый — функция, которой нужен Money. Передадим ей не Money, а голый объект подходящей формы:
function showPrice(m: Money): void {
console.log(m.format());
}
const fake = {
amount: 4999,
format: () => "49.99",
};
showPrice(fake); // ???
Рефлекс джависта говорит: не скомпилируется. fake создан литералом, через него не проходил ни один new Money, он не экземпляр класса Money — какое право у него подменять Money? И тем не менее: если у класса нет приватных членов, такой код проходит проверку. Объект той же формы — функция format, возвращающая строку, — присваивается параметру типа Money без всякого new, без extends, без какой-либо связи с классом. «Быть Money» оказалось вопросом формы, а не происхождения.
Приватное поле в нашем Money этот конкретный фокус ломает — и ниже мы увидим, почему именно оно и какой ценой. Но сначала второй укол, в саму приватность. Возьмём настоящий экземпляр и попробуем добраться до amount в обход точки:
const real = new Money(4999);
console.log((real as any)["amount"]); // 4999 — прочиталось
Поле, которое компилятор охранял как private, в рантайме лежит открытым. Скобочный доступ через any снимает его без малейшего сопротивления, потому что сопротивляться некому: к моменту запуска private стёрто, и amount — обычное свойство обычного объекта. Защита, на которую мы рассчитывали, существовала только во время проверки.
Соедините два наблюдения. «Экземпляр» класса — не членство в нём: объект той же формы сходит за экземпляр без new. А private — не приватность: поле читается в рантайме голыми руками. Оба столпа, на которых джавист держит понятие класса, здесь стоят не там, где он привык. Откуда такое поведение и что вообще такое класс в мире, где типы — стираемая форма, а не членский билет, — это и есть вопрос главы.
Класс — это конструктор и форма
Начнём с того, что отличает класс от всего, что мы типизировали до сих пор. type и interface — чисто проверяющие конструкции: они живут в проверяющей программе и стираются без остатка, в выходном .js от них не остаётся ни байта. Класс устроен иначе. Он переживает стирание — но переживает наполовину, и важно понять, какой именно половиной.
Прогоним Money через компилятор и посмотрим на выходной JavaScript:
// money.js — то, что реально исполняется
class Money {
constructor(amount) {
this.amount = amount;
}
format() {
return (this.amount / 100).toFixed(2);
}
}
Аннотация : number у amount исчезла, private исчезло, : string у format исчезло — всё проверяющее стёрлось, как и положено. Но сам class Money остался. Осталось рантайм-значение: функция-конструктор с прототипом, которую можно позвать через new. Одно объявление class породило две сущности, живущие в двух разных мирах:
- значение
Money— конструктор, существующий в рантайме; его можно передать, сохранить в переменную, вызвать черезnew; - тип
Money— форма экземпляра ({ amount: number; format(): string }), существующая только в проверяющей программе.
Имя одно, сущности две, и TypeScript держит их в раздельных пространствах имён — пространстве значений и пространстве типов. Поэтому одно и то же Money законно стоит и там, где ждут значение, и там, где ждут тип:
const ctor = Money; // Money как значение (конструктор)
const inst: Money = price; // Money как тип (форма экземпляра)
Эта двойственность — то, чего нет ни у type, ни у interface, и из неё вытекает свойство, которое делает класс уникальным гражданином среди всех типов: на классе работает instanceof.
if (price instanceof Money) {
// здесь price сужен до Money
console.log(price.format());
}
instanceof — оператор JavaScript: он берёт значение слева, конструктор справа и проверяет, есть ли прототип конструктора в цепочке прототипов значения. Справа должно стоять рантайм-значение — и Money им является, потому что класс не стёрся. Сравните с типом из type, который стирается целиком:
type Order = { id: string; total: number };
if (value instanceof Order) {} // ошибка: 'Order' — это тип, а не значение
Order после стирания не оставляет никакого конструктора, ставить справа от instanceof нечего, и компилятор это прямо говорит. Класс — единственная типовая конструкция, которую можно проверить в рантайме, ровно потому, что она наполовину рантаймовая. Это его сила, и к ней мы ещё вернёмся, когда будем говорить о том, где классы уместны.
Тип класса — это форма, а не происхождение
Класс породил тип. Теперь — главный вопрос: что это за тип? И ответ, который ломает модель Java: тип класса — это структурный тип, описывающий форму экземпляра, и ничего больше. Никакой пометки «произведено конструктором Money» в нём нет.
Вернёмся к версии без приватного поля, чтобы видеть механизм в чистоте:
class Point {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
function lengthOf(p: Point): number {
return Math.hypot(p.x, p.y);
}
Тип Point для проверяющей программы — это { x: number; y: number }. Всё. Когда lengthOf объявляет параметр p: Point, она требует не «объект, рождённый из new Point», а «объект формы { x: number; y: number }». И любой такой объект подойдёт:
lengthOf(new Point(3, 4)); // 5 — очевидно
lengthOf({ x: 3, y: 4 }); // 5 — тоже проходит, без всякого new
Второй вызов — не послабление и не дыра, а прямое следствие структурной типизации: совместимость определяется формой, а не родословной. { x: 3, y: 4 } имеет требуемую форму, значит он Point — настолько, насколько Point вообще что-то значит для типов. То же работает и через переменную, и в обе стороны:
const here: Point = { x: 0, y: 0 }; // литерал присваивается типу класса
Здесь нужно развести два слова, которые в Java сливались в одно. Быть экземпляром (p instanceof Point — true) и быть совместимым по типу (p присваивается параметру Point) — в TypeScript это разные, независимые свойства. new Point(3, 4) истинно и то и другое. А { x: 3, y: 4 } совместимо по типу, но instanceof Point для него вернёт false: в его цепочке прототипов нет Point.prototype. Тип проверяет форму, instanceof проверяет происхождение, и они расходятся.
В Java членство в классе и тип — одно и то же: значение имеет тип Point тогда и только тогда, когда оно произведено Point или его наследником. В TypeScript эти два вопроса разъехались. «Какой формы значение» спрашивает проверяющая программа на этапе компиляции. «Каким конструктором рождено значение» спрашивает instanceof в рантайме. Класс отвечает на оба — но ответы не обязаны совпадать.
Мягкий private и рантаймовый #private
Теперь вернёмся к приватному полю, которое в первой секции сломало структурное присваивание, и разберём, что оно делает на самом деле. В TypeScript есть два разных механизма приватности, и они не варианты одного — они принадлежат двум разным мирам.
Первый — ключевое слово private (и protected). Это конструкция проверяющей программы. Она запрещает доступ к члену снаружи класса во время проверки и стирается при компиляции. В рантайме поле остаётся обычным публичным свойством — мы это уже видели на (real as any)["amount"].
class Account {
private balance = 0; // приватно для проверяющего
deposit(sum: number): void {
this.balance += sum;
}
}
const acc = new Account();
acc.balance; // ошибка: Property 'balance' is private
console.log((acc as any).balance); // 0 — в рантайме читается свободно
Но private делает кое-что ещё, кроме запрета на доступ, — и это объясняет загадку из начала главы. Класс с приватным членом перестаёт быть чисто структурным типом. В него впрыскивается капля номинативности: проверяющая программа начинает требовать, чтобы совместимое значение происходило из этого самого объявления класса. Объект той же формы больше не подходит:
function useAccount(a: Account): void {}
useAccount({ balance: 0, deposit: () => {} });
// ошибка: тип литерала несовместим с 'Account' —
// в типе цели есть приватное свойство 'balance', которого нет в источнике
Литерал имеет ту же форму, но у него нет приватного balance, объявленного внутри Account, — а такое поле может появиться только у того, кто прошёл через объявление класса. Вот почему наш Money из первой секции не принимал самозванца, а Point без приватных полей — принимал. Одно приватное поле переключает тип класса со структурного на «структурный, но из этого класса».
Второй механизм — нативные приватные поля JavaScript, помеченные #. Это уже не проверяющая конструкция, а часть самого языка, переживающая стирание целиком:
class Wallet {
#balance = 0; // настоящее приватное поле рантайма
deposit(sum: number): void {
this.#balance += sum;
}
has(amount: number): boolean {
return this.#balance >= amount;
}
}
const w = new Wallet();
console.log((w as any)["#balance"]); // undefined — снаружи не достать
console.log((w as any).balance); // undefined — поля 'balance' нет вовсе
К #balance нельзя добраться извне класса никак: ни через точку, ни скобочным доступом, ни через any. Движок физически не отдаёт это поле коду за пределами тела класса — это рантайм-гарантия, а не обещание проверяющего. И вместе с настоящей инкапсуляцией # приносит настоящую номинативную метку, доживающую до выполнения. Проверить принадлежность можно прямо в рантайме:
class Wallet {
#balance = 0;
static isWallet(value: object): value is Wallet {
return #balance in value; // true только для настоящих Wallet
}
}
#balance in value — выражение JavaScript, которое отвечает «да» лишь для объектов, реально получивших это приватное поле, то есть прошедших через конструктор Wallet. Никакой самозванец той же формы тут не пройдёт: приватное поле невозможно подделать извне. Это та номинативность, которую джавист ждал от класса по умолчанию, — но в TypeScript она появляется только с #, и появляется честно, на рантайм-уровне, а не как страховка компилятора.
Отсюда современный уклон: если нужна инкапсуляция, тянутся к #. private остаётся там, где достаточно дисциплины проверяющего и не жаль её стираемости.
Остальной фасад
Прочие части классового синтаксиса знакомы по виду, но каждую стоит проверить одним вопросом: переживает она стирание или нет. Ответ делит фасад надвое.
Стираются — то есть существуют только в проверяющей программе и в .js не попадают:
abstract class Shape {
abstract area(): number; // abstract — стирается целиком
describe(): string { // обычный метод — остаётся
return `площадь ${this.area()}`;
}
}
class Circle extends Shape implements Comparable {
// implements только сверяет форму (см. главу 14) и стирается
constructor(private radius: number) { // параметр-свойство — НЕ стирается
super();
}
area(): number {
return Math.PI * this.radius ** 2;
}
}
abstract — чистая проверяющая конструкция: она запрещает new Shape() во время проверки и исчезает при компиляции, в рантайме Shape становится обычным конструктором. implements Comparable лишь поручает проверяющему сверить, что форма класса соответствует интерфейсу (структурная сверка из главы 14), и тоже стирается — никакого рантайм-следа, в отличие от Java, где реализованный интерфейс остаётся типом объекта.
А вот extends другого класса — настоящее прототипное наследование JavaScript: оно эмитит рантайм-код, выстраивает цепочку прототипов, и потому instanceof корректно работает вверх по иерархии. static-члены и аксессоры (get/set) — тоже реальный рантайм: статика становится свойством самого конструктора, аксессоры — свойствами прототипа с геттером и сеттером.
Особняком стоят параметры-свойства — то самое constructor(private radius: number). Это сокращение, которое разворачивается в объявление поля плюс присваивание this.radius = radius, и потому, в отличие от чистого private, оно не стирается: за ним стоит рантайм-код, генерация которого тянет за собой реальную работу компилятора. Та же история с декораторами. Это важная трещина: инструменты, которые умеют только отрывать типы (нативный стриппинг типов в Node, о котором шла речь в главах 3 и 5), на параметрах-свойствах и декораторах спотыкаются — отрывать типы недостаточно, тут надо генерировать код. Большинство классового синтаксиса стираемо; эти двое — нет, и за это приходится платить более тяжёлым тулчейном.
Глазами джависта
В Java класс ведёт себя ровно так, как подсказывает интуиция, и каждая из этих опор здесь либо проседает, либо стоит на другом фундаменте.
privateпринудителен. В Java приватное поле защищено рантаймом JVM (рефлексия — отдельный, явно запрашиваемый обход). В TypeScriptprivate— пометка для проверяющего, стираемая дотла; в рантайме поле публично и читается скобочным доступом. Настоящую инкапсуляцию даёт только#, и это уже не TypeScript, а сам JavaScript. Гарантия есть, но включается другим ключом.Класс номинативен. В Java значение имеет тип
Point, только если произведеноPointили наследником; экземпляр и есть членство. В TypeScript тип класса структурен: объект той же формы присваивается безnew.instanceof Pointи совместимость по типу — два разных, расходящихся вопроса. Приватный член впрыскивает каплю номинативности, но это побочный эффект, а не суть.
instanceofиgetClassнадёжны. В Java рантайм помнит класс каждого объекта намертво. В TypeScriptinstanceofработает на классах (они не стёрлись) — но не на интерфейсах иtype(стёрлись), а через границу реалмов (iframe, worker, см. главу 3) отказывает, потому что сравнивает прототипы из разных контекстов. Надёжен, но в более узких рамках.
abstract,static,finalреальны. В TypeScriptabstractстирается;staticреален;finalотсутствует вовсе — запретить переопределение или наследование на уровне типов нечем.Вывод неприятный, но необходимый: интуицию номинативного класса здесь надо разучить. В TypeScript класс — гость в структурном мире, а не его хозяин, как в Java.
Где классы кусаются
Двойственная природа класса — половина в проверяющей программе, половина в рантайме — порождает набор острых краёв. Перечислим их прямо.
private читается в рантайме. Если на стираемую приватность понадеялись как на меру безопасности — спрятать токен, ключ, внутреннее состояние от чужого кода в том же процессе, — это дыра. Поле лежит открытым, скобочный доступ его берёт. private защищает от случайной ошибки своего кода во время компиляции, но не от злого умысла и не от кода, который до проверяющего не дошёл. Для настоящего секрета — #.
Капля номинативности ломает то, что работало структурно. Приватный член делает класс номинативным «ровно настолько», чтобы перестали присваиваться объекты той же формы. Чаще всего это бьёт по тестам: мок, повторяющий публичную форму класса, внезапно не проходит проверку, потому что у него нет приватного поля, объявленного внутри оригинала. Структурное использование, на которое рассчитывали, обрывается из-за поля, которое к этому использованию отношения не имеет.
У #private свои причуды. Настоящая приватность не бесплатна. К #-полю нельзя обратиться динамически — obj["#x"] не работает, это всегда obj.#x с буквальным именем. Часть инструментов сериализации и инспекции его не видит. Зато #x in obj даёт удобный надёжный брендинг — иногда это причина выбрать класс там, где иначе хватило бы объектного типа.
Посторонний объект той же формы маскирует баги. Структурная совместимость работает и против вас: функция, ждущая Point, молча примет любой объект нужной формы, в том числе случайно подошедший, и instanceof Point внутри неё неожиданно вернёт false. Если логика опирается на instanceof, а на вход пришёл структурный двойник, проверка провалится там, где по типам всё сходилось.
Параметры-свойства и декораторы нестираемы. Как разобрано выше, они требуют генерации кода и потому несовместимы с чистым стриппингом типов. Команда, перешедшая на нативный запуск .ts, об эти конструкции спотыкается — это цена синтаксического удобства (см. главу 5).
Методы теряют this и бивариантны. Метод класса — это функция, и при отрыве от объекта (const f = obj.method; f()) он теряет привязку this ровно по правилам JavaScript; типы об этой потере не предупредят. Кроме того, параметры методов проверяются бивариантно — это сознательное послабление в пользу удобства, разобранное в главе 13, и здесь оно тоже действует.
instanceof отказывает между реалмами. Объект, пришедший из другого реалма (iframe, worker), имеет другой Money.prototype, и value instanceof Money вернёт false, хотя по форме и по смыслу это Money. Граница рантайма (глава 3) проходит и здесь: instanceof надёжен лишь внутри одного контекста исполнения.
Класс — гость в структурном мире
Вот сдвиг, который должен произойти. Перестаньте видеть в классе то, чем он был в Java, — номинальную капсулу, где членство, тип и инкапсуляция слиты в одно неделимое целое. В TypeScript класс — это две вещи, сшитые именем: рантайм-конструктор и структурный тип его экземпляра. Эти две вещи можно и нужно мысленно разнимать, потому что они расходятся: объект бывает совместим по типу, не будучи экземпляром, и наоборот.
Из этого следуют три рабочих правила. Первое: «экземпляр» не означает членства — если код полагается на происхождение значения, проверяйте его явно через instanceof или #field in obj, а не доверяйте тому, что параметр объявлен типом класса. Второе: для настоящей приватности берите #, а private считайте проверяемой документацией — обещанием проверяющего, не охраной рантайма. Третье, и главное про сам выбор: тянитесь к классу тогда, когда вам нужно именно то, что класс даёт сверх объектного типа, — рантайм-идентичность, работающий instanceof, прототипное наследование или конструктор с инвариантами. Если ничего из этого не нужно, направление волокон языка ведёт в другую сторону — к объектным типам и функциям, которые в структурном мире чувствуют себя дома, а класс в нём — гость.
Это не призыв избегать классов и не аргумент «предпочитай композицию наследованию» — тот разговор отдельный, он в главе 16. Это призыв не тащить в TypeScript номинативные интуиции Java целиком: они описывают мир, которого здесь нет. Класс остаётся полезным инструментом — но инструментом структурного, стираемого языка, а не номинативного.
Итог
Класс в TypeScript — это разом рантайм-конструктор и структурная форма его экземпляра, а его private — обещание проверяющего, стираемое перед запуском, если только это не #. Кто держит это в голове, тот не путает экземпляр с членством и не доверяет секреты полю, которое в рантайме лежит открытым.