Перейти к основному содержимому
Блокчейн27ГЛАВА 27Наследование и интерфейсы в TypeScriptTxextends Tximplements Signerпо форме, не по имени

Наследование и интерфейсы в TypeScript

Прочитано0%
Квизы0/3
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

Наследование и интерфейсы в 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.

Нажми на часть выражения
НАСЛЕДНИК БЕРЁТ ВСЁ, ЗАМЕНЯЕТ ВЫБОРОЧНОclass Txid, amountdescribe(), hash()extendsclass Transferунаследовано без измененийhash()своё поле: toclass Issueпереопределено своимdescribe()внутри — super.describe()super()строгопервымдо this
Наследник получает все поля и методы родителя. Часть он оставляет как есть, часть заменяет своими — и внутри замены может позвать родительскую версию через super.

Порядок вызова 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: с настоящим кошельком, с аппаратным ключом, с заглушкой в тестах. Ни одного из этих классов она не знает и знать не должна.

ЧТО ПЕРЕЖИВЁТ КОМПИЛЯЦИЮфайл .tsinterface Signertype Levelimplements Signerprivate amountreadonly idclass Txstatic count#secrettscфайл .jsисчезли без следаclass Txstatic count#secretв итоге: id и amount сталиобычными полями объектатипы — леса на время стройки; # — настоящая стена, она остаётся
Интерфейсы, типы и модификаторы доступа существуют только до компиляции: в готовом JavaScript от них не остаётся ни строчки. Класс, static и поле с решёткой остаются.

interface, type и abstract class: что когда​

Три способа описать «форму» объекта. Различия — не в стиле, а в том, что каждый умеет.

что даётостаётся в JSмножественность
interfaceсписок обязательных полей и методовнеткласс может реализовать несколько
typeто же плюс объединения, пересечения, вычисляемые типынетне про наследование вовсе
abstract classконтракт и готовый общий коддародитель ровно один

Практическое правило:

  • Нужен только контракт — interface.
  • Нужны объединения ('low' | 'high'), пересечения, работа с типами — type.
  • Нужно вместе с контрактом отдать наследникам общий код — abstract class.

Реализовать несколько интерфейсов класс может, а вот наследовать несколько классов — нет:

class Wallet implements Signer, Serializable { // так можно
// …
}

Это и есть обычный ответ на вопрос «почему в языке нет множественного наследования»: множественное наследование контрактов есть, а множественного наследования кода нет — именно оно и порождает неразрешимые конфликты.

Отвечено вопросов: 0 из 6
Что произойдёт, если в конструкторе наследника не вызвать super()?
Зачем нужен флаг noImplicitOverride?
Что такое полиморфизм на практике?
Почему цепочку instanceof считают запахом кода?
Чем abstract class отличается от interface?
Сколько интерфейсов может реализовать один класс?
Отвечено верно: 0 из 6

Композиция вместо наследования​

Наследование выглядит удобным, пока иерархия не выросла. Классическая ловушка:

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():

  1. Язык ищет поле describe в самом объекте tr. Не находит.
  2. Идёт в прототип — в Transfer.prototype. Находит и вызывает.
  3. Если бы не нашёл — пошёл бы в 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; }'.

Приватное поле делает тип уникальным: подделать его снаружи нельзя, потому что никакой посторонний объект не может иметь то самое приватное поле. Этим иногда пользуются намеренно, когда нужен тип, который нельзя подменить похожим по форме объектом.

Отвечено вопросов: 0 из 4
Когда выбирают композицию вместо наследования?
Что вернёт typeof для объявленного класса?
Почему tr.hasOwnProperty("describe") даёт false, а "describe" in tr — true?
const c: Dog = new Cat() при одинаковых методах — что скажет компилятор?
Отвечено верно: 0 из 4

Типичные ошибки: текст, перевод, причина, починка​

Что видноПереводПричинаКак чинить
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); } }
клавиша ``клавиша 11клавиша 22клавиша 33клавиша 44клавиша 55клавиша 66клавиша 77клавиша 88клавиша 99клавиша 00клавиша --клавиша ==клавиша ⌫⌫клавиша TabTabклавиша QQклавиша WWклавиша EEклавиша RRклавиша TTклавиша YYклавиша UUклавиша IIклавиша OOклавиша PPклавиша [[клавиша ]]клавиша \\клавиша Caps LockCaps Lockклавиша AAклавиша SSклавиша DDклавиша FFклавиша GGклавиша HHклавиша JJклавиша KKклавиша LLклавиша ;;клавиша ''клавиша EnterEnterклавиша ShiftShiftклавиша ZZклавиша XXклавиша CCклавиша VVклавиша BBклавиша NNклавиша MMклавиша ,,клавиша ..клавиша //клавиша ShiftShiftклавиша CtrlCtrlклавиша WinWinклавиша AltAltклавиша ПробелПробелклавиша AltAltклавиша WinWinклавиша MenuMenuклавиша CtrlCtrl
следующая: C — средний левой руки

Символы этой главы​

Знак или словоНазваниеЧто делает
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 года, и в старых библиотеках эта запись встречается постоянно.

Дальше по теме:

Сводная проверка по всей главе: 9 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоTypeScript — НаследованиеArtsofte Education
ВидеоНаследование в TypeScript: полное руководствоWebDev с нуля. Канал Алекса Лущенко
ВидеоJavaScript ООП #5: наследование классов, переопределение методовselfedu
ВидеоЧто такое prototype: прототипы и прототипное наследованиеВасилий Муравьев
ВидеоTypeScript Tutorial #15 — InterfacesNet Ninja

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.