Наследование и интерфейсы в TypeScript
- Зачем это нужно
- Наследование:
extendsиsuper - Переопределение и
override - Полиморфизм: один вызов — разное поведение
instanceofи почему им злоупотребляют зря- Абстрактный класс: чертёж с дырой
- Интерфейс: контракт без реализации
interface,typeиabstract class: что когда- Композиция вместо наследования
- Что под капотом: прототипы
- Структурная типизация: неожиданный сюрприз
- Типичные ошибки: текст, перевод, причина, починка
- Тренажёр: связи между классами вслепую
- Символы этой главы
- Что дальше
- Куда дальше: проверенные ресурсы
Зачем это нужно
Наследование и интерфейсы в Solidity уже были: is, virtual, override, договор без реализации. Здесь те же идеи записываются иначе — и ведут себя иначе в двух местах, которые стоит увидеть до того, как они укусят.
Первое: class в этом языке — надстройка над механизмом, который был в нём задолго до классов. Из этого следует и странное поведение instanceof, и то, что метод физически лежит в одном экземпляре на все объекты.
Второе: типы здесь сравниваются по форме, а не по имени. Контракт, который в Solidity нужно было объявить словом is, тут выполняется сам собой — если совпали поля и методы.
Наследование: extends и super
Наследование говорит: «этот класс — такой же, как тот, плюс кое-что своё».
class Tx {
constructor(id) { this.id = id; }
describe() { return `объект ${this.id}`; }
}
class Transfer extends Tx {
constructor(id, to) {
super(id); // сначала родитель
this.to = to; // потом своё
}
describe() {
return super.describe() + ` → ${this.to}`;
}
}
const tr = new Transfer('x9', 'Bob');
console.log(tr.describe()); // объект x9 → Bob
Живой вывод — объект x9 → Bob.
Порядок вызова super — не формальность. Забыли — получите ошибку, и вот её настоящий текст:
class Broken extends Tx {
constructor(id) { this.id = id; }
}
new Broken('z');
// ReferenceError: Must call super constructor in derived class
// before accessing 'this' or returning from derived constructor
Причина техническая: до super() объекта this ещё физически не существует — его создаёт конструктор родителя.
Переопределение и override
Метод с тем же именем в наследнике заменяет родительский. Работает это молча — и в этом опасность: опечатались в имени, и вместо переопределения получился новый метод, а старый продолжает вызываться.
TypeScript умеет ловить это с флагом noImplicitOverride:
class A {
greet(): string { return 'a'; }
}
class B extends A {
override gret(): string { return 'b'; }
}
// error TS4117: This member cannot have an 'override' modifier because
// it is not declared in the base class 'A'. Did you mean 'greet'?
Компилятор не только заметил, но и подсказал правильное имя. Ради одной этой проверки noImplicitOverride стоит включить в любом проекте с наследованием.
Второе, что проверяет компилятор, — совместимость сигнатур. Наследник не может требовать больше, чем родитель:
class Base { greet() { return 'a'; } }
class Sub extends Base {
greet(name: string) { return name; }
}
// error TS2416: Property 'greet' in type 'Sub' is not assignable
// to the same property in base type 'Base'.
// Target signature provides too few arguments. Expected 1 or more, but got 0.
Логика такая: любой код, работающий с Base, вправе вызвать greet() без аргументов. Если под видом Base подсунуть Sub, этот код сломается. Компилятор запрещает такую подмену заранее.
Полиморфизм: один вызов — разное поведение
Полиморфизм звучит страшно, а значит буквально следующее: вызывающий код пишет одну строку, а выполняется та версия метода, которая соответствует настоящему классу объекта.
const all = [new Tx('b1'), new Transfer('t1', 'Alice')];
console.log(all.map((x) => x.describe()));
// [ 'объект b1', 'объект t1 → Alice' ]
Строка x.describe() одна. Результаты разные, потому что объекты разных классов. Никаких if по типу писать не нужно — язык сам находит нужную версию.
Ради этого ООП и придумали. Сравните с тем, что было бы без него:
// плохо: каждый новый вид транзакции требует править эту функцию
function describe(x) {
if (x.kind === 'transfer') return `объект ${x.id} → ${x.to}`;
if (x.kind === 'issue') return `выпуск ${x.amount}`;
return `объект ${x.id}`;
}
Такой if расползается: сегодня он в одном месте, через месяц — в семи, и добавление нового вида транзакции требует найти их все. При полиморфизме новый вид — это новый класс со своим describe, и старый код трогать не нужно вовсе.
instanceof и почему им злоупотребляют зря
Проверить, объект какого класса перед нами:
console.log(tr instanceof Transfer); // true
console.log(tr instanceof Tx); // true — родитель тоже подходит
console.log(tr instanceof Array); // false
instanceof идёт вверх по цепочке наследования, поэтому объект наследника — это одновременно и объект родителя.
Законное применение — разбор ошибок:
try {
await sendTx(tx);
} catch (e) {
if (e instanceof InsufficientFunds) console.log('не хватает средств');
else if (e instanceof NetworkError) console.log('сеть недоступна, повторим');
else throw e;
}
Незаконное — подмена полиморфизма:
// плохо
function describe(x) {
if (x instanceof Transfer) return `${x.id} → ${x.to}`;
if (x instanceof Issue) return `выпуск ${x.amount}`;
return `объект ${x.id}`;
}
Это тот же самый расползающийся if, только записанный красивее. Признак, по которому его узнают: цепочка instanceof, в которую придётся дописывать строку при появлении нового класса. Если так получилось — метод просится в сам класс.
instanceof ломается там, где объект пришёл из другого «мира»: из другого окна браузера, из другого экземпляра библиотеки в node_modules, из JSON.parse. Классы там технически другие, и проверка даст false при внешне одинаковых объектах.
Устойчивая альтернатива — обычное поле-метка:
type Tx = { kind: 'transfer'; to: string } | { kind: 'issue'; amount: number };
TypeScript такой набор понимает особым образом: после проверки if (tx.kind === 'transfer') он сам знает, что поле to существует. Приём называется размеченным объединением, и в коде, работающем с данными по сети, он надёжнее классов.
Абстрактный класс: чертёж с дырой
Абстрактный класс описывает общее, но сам объектом стать не может: в нём намеренно оставлена дыра, которую обязан заполнить наследник.
abstract class Shape {
abstract area(): number; // дыра: тела нет
describe(): string { // общее: тело есть
return `площадь ${this.area()}`;
}
}
new Shape();
// error TS2511: Cannot create an instance of an abstract class.
class Square extends Shape {
side = 2;
}
// error TS2515: Non-abstract class 'Square' does not implement
// inherited abstract member area from class 'Shape'.
Обе ошибки — настоящий вывод компилятора. Первая говорит «чертёж с дырой изделием быть не может», вторая — «наследник дыру не заполнил».
Правильный наследник:
class Square extends Shape {
private side: number;
constructor(side: number) {
super();
this.side = side;
}
area(): number { return this.side ** 2; }
}
console.log(new Square(3).describe()); // площадь 9
Смысл конструкции: describe написан один раз в родителе и работает у всех наследников, хотя родитель понятия не имеет, как считается площадь. Это самый частый и самый полезный вид наследования — вынести общий алгоритм наверх, оставив вниз только различающийся шаг.
Интерфейс: контракт без реализации
Интерфейс — список того, что объект обязан уметь. Кода в нём нет вовсе.
interface Signer {
sign(data: string): string;
publicKey: string;
}
class Wallet implements Signer {
sign(data: string): string { return data; }
}
// error TS2420: Class 'Wallet' incorrectly implements interface 'Signer'.
// Property 'publicKey' is missing in type 'Wallet' but required in type 'Signer'.
Слово implements — заявка «мой класс соответствует этому контракту». Компилятор проверяет заявку и находит несоответствие сразу. Он же ловит и неверные типы аргументов:
class Wallet implements Signer {
sign(data: number): string { return String(data); }
}
// error TS2416: Property 'sign' in type 'Wallet' is not assignable
// to the same property in base type 'Signer'.
Зачем это нужно на практике: интерфейс позволяет писать код, которому всё равно, с каким именно кошельком он работает.
function send(tx: string, signer: Signer): string {
return signer.sign(tx);
}
Функция send работает с любым объектом, у которого есть sign и publicKey: с настоящим кошельком, с аппаратным ключом, с заглушкой в тестах. Ни одного из этих классов она не знает и знать не должна.
interface, type и abstract class: что когда
Три способа описать «форму» объекта. Различия — не в стиле, а в том, что каждый умеет.
| что даёт | остаётся в JS | множественность | |
|---|---|---|---|
interface | список обязательных полей и методов | нет | класс может реализовать несколько |
type | то же плюс объединения, пересечения, вычисляемые типы | нет | не про наследование вовсе |
abstract class | контракт и готовый общий код | да | родитель ровно один |
Практическое правило:
- Нужен только контракт —
interface. - Нужны объединения (
'low' | 'high'), пересечения, работа с типами —type. - Нужно вместе с контрактом отдать наследникам общий код —
abstract class.
Реализовать несколько интерфейсов класс может, а вот наследовать несколько классов — нет:
class Wallet implements Signer, Serializable { // так можно
// …
}
Это и есть обычный ответ на вопрос «почему в языке нет множественного наследования»: множественное наследование контрактов есть, а множественного наследования кода нет — именно оно и порождает неразрешимые конфликты.
Композиция вместо наследования
Наследование выглядит удобным, пока иерархия не выросла. Классическая ловушка:
class Tx { }
class SignedTx extends Tx { }
class SignedTimestampedTx extends SignedTx { }
class SignedTimestampedFeeTx extends SignedTimestampedTx { }
Каждое новое сочетание свойств требует нового класса, и число классов растёт быстрее, чем число свойств. Через полгода никто не помнит, что где переопределено, а изменение в корне ломает всё дерево разом.
Композиция решает ту же задачу через поля:
class Tx {
constructor(
private signer: Signer,
private clock: Clock,
private fees: FeePolicy,
) {}
async send(): Promise<string> {
const at = this.clock.now();
const fee = this.fees.calc(this);
return this.signer.sign(`${at}:${fee}`);
}
}
Класс не «является» подписывателем — он им пользуется. Сменить политику комиссий теперь значит передать другой объект в конструктор, а не переписывать дерево наследования. Для тестов туда же подставляют заглушку с фиксированным временем.
Практическое правило, которым пользуются повсеместно: «является» — наследование, «имеет» или «использует» — композиция. Transfer является транзакцией — наследование уместно. Транзакция использует подписыватель — тут композиция.
Что под капотом: прототипы
Классы появились в языке только в 2015 году. До этого те же задачи решали прототипами, и class — это удобная запись поверх них, а не новый механизм. Знать это нужно, потому что все сообщения об ошибках и весь старый код говорят на языке прототипов.
const tr = new Transfer('x9', 'Bob');
console.log(Object.getPrototypeOf(tr) === Transfer.prototype); // true
console.log(Object.getOwnPropertyNames(Transfer.prototype)); // [ 'constructor', 'describe' ]
console.log(tr.hasOwnProperty('describe')); // false
console.log('describe' in tr); // true
Живой запуск подтверждает всё сразу. Что здесь происходит при вызове tr.describe():
- Язык ищет поле
describeв самом объектеtr. Не находит. - Идёт в прототип — в
Transfer.prototype. Находит и вызывает. - Если бы не нашёл — пошёл бы в
Tx.prototype, затем вObject.prototype, затем вернул быundefined.
Эта цепочка и называется прототипной. Наследование классов — просто связывание прототипов в цепь.
Отсюда становятся понятны две вещи, которые раньше приходилось запоминать.
Почему методов нет в JSON.stringify и в Object.keys. Они не в объекте, а в его прототипе, а оба этих инструмента смотрят только на собственные поля.
Почему class — это функция.
console.log(typeof Tx); // function
console.log(Tx.name); // Tx
typeof даёт function — потому что под капотом класс и есть функция-конструктор с прикреплённым prototype. Единственное отличие от старых функций-конструкторов: класс запрещено вызывать без new и его объявление не поднимается вверх файла.
Структурная типизация: неожиданный сюрприз
Тем, кто раньше видел Java или C#, этот раздел нужен обязательно: в TypeScript типы сравниваются по форме, а не по имени.
class Open { constructor(public v: number) {} }
const a: Open = { v: 1 }; // компилируется! обычный объект подошёл под тип класса
class Dog { bark() {} }
class Cat { bark() {} }
const c: Dog = new Cat(); // компилируется! классы разные, форма одинаковая
Обе строки проходят проверку — это проверено компиляцией. Для TypeScript «тип Dog» означает «что-нибудь с методом bark», а не «объект, созданный из класса Dog».
Ломается это ровно в одном месте — при наличии приватного поля:
class Closed { constructor(private v: number) {} }
const b: Closed = { v: 1 };
// error TS2322: Type '{ v: number; }' is not assignable to type 'Closed'.
// Property 'v' is private in type 'Closed' but not in type '{ v: number; }'.
Приватное поле делает тип уникальным: подделать его снаружи нельзя, потому что никакой посторонний объект не может иметь то самое приватное поле. Этим иногда пользуются намеренно, когда нужен тип, который нельзя подменить похожим по форме объектом.
Типичные ошибки: текст, перевод, причина, починка
| Что видно | Перевод | Причина | Как чинить |
|---|---|---|---|
ReferenceError: Must call super constructor… | Нужно вызвать конструктор родителя | В конструкторе наследника нет super() или он после this | Поставить super(...) первой строкой |
error TS2511: Cannot create an instance of an abstract class | Абстрактный класс объектом не бывает | new от абстрактного класса | Создавать наследника |
error TS2515: …does not implement inherited abstract member | Наследник не заполнил дыру | Не реализован abstract-метод | Написать метод в наследнике |
error TS2420: Class incorrectly implements interface | Класс не соответствует контракту | Нет поля или метода из интерфейса | Дописать недостающее |
error TS4117: This member cannot have an 'override' modifier | Переопределять нечего | Опечатка в имени метода | Компилятор сам подсказывает верное имя |
error TS2416: Property … is not assignable to the same property in base type | Сигнатура наследника несовместима с родительской | Наследник требует больше аргументов, чем родитель | Сохранить совместимую сигнатуру |
error TS2322: Property 'v' is private in type 'Closed' but not in… | Подделать тип с приватным полем нельзя | Обычный объект подставлен вместо класса с приватным полем | Создавать объект через конструктор класса |
Тренажёр: связи между классами вслепую
Цель: скорость ≥ 110 зн/мин, точность ≥ 90%
class Transfer extends Tx { constructor(id: string, public to: string) { super(id); } }
Символы этой главы
| Знак или слово | Название | Что делает |
|---|---|---|
extends | наследование | связывает класс с родителем |
super(…) | конструктор родителя | обязателен до первого this в наследнике |
super.метод() | метод родителя | вызывает родительскую версию переопределённого метода |
abstract | абстрактный | класс без объектов, метод без тела |
implements | реализация контракта | заявка, что класс соответствует интерфейсу |
interface | интерфейс | список обязательных полей и методов; исчезает при компиляции |
override | явное переопределение | с noImplicitOverride ловит опечатку в имени метода |
instanceof | проверка происхождения | идёт вверх по цепочке прототипов |
.prototype | прототип класса | объект, где физически лежат методы |
Что дальше
Классы и связи между ними разобраны. Следующая глава — про то, чего в Solidity не было вовсе: свои типы, объединения и обобщения. Там выяснится, что систему типов можно заставить проверять не только «это число», но и «этот результат уже проверен на ошибку».
Углубиться
Порядок инициализации полей. Поля наследника заполняются после возврата из super(). Если конструктор родителя вызовет метод, переопределённый в наследнике, тот увидит поля наследника ещё незаполненными — undefined вместо значения. Вывод: не вызывайте переопределяемые методы из конструктора.
super в статике. У статических методов своя цепочка: статический метод наследуется от родителя, и внутри работает super.метод().
Наследование от Error. Свой класс ошибки требует одной дополнительной строки: this.name = 'InsufficientFunds', иначе в журнале будет просто Error.
Декораторы. Записи вида @logged перед классом или методом, которые оборачивают их дополнительным поведением. Мощная вещь, но начинать с неё не стоит: декоратор прячет происходящее, а отлаживать спрятанное сложнее.
Object.create. Прототип можно назначить напрямую, без классов. Так писали всё до 2015 года, и в старых библиотеках эта запись встречается постоянно.
Дальше по теме:
- MDN: прототипное наследование — что под капотом
- TypeScript Handbook: Type Compatibility — структурная типизация целиком
Сводная проверка по всей главе: 9 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- MDN: наследование и цепочка прототипов — что происходит под капотом.
- TypeScript Handbook: Classes — абстракции,
implements,override. - TypeScript Handbook: Type Compatibility — структурная типизация и её границы.
- TypeScript: tsconfig — strict — что включают строгие флаги, включая
noImplicitOverride. - Learn JavaScript: классы — русскоязычный раздел с задачами.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.