Классы

Немного о классах

Долгое время в JavaScript для создания однотипных объектов использовалась функция-конструктор. Функция-конструктор вызывается при помощи оператора new. Эта функция заполняет полями и методами какой-то объект (обычно this) и возвращает его (явно или не явно).

function Cat(name) {
    this.name = name;
    this.say = function() {
        console.log(this.name, "say:", "meow");
    }
}

const cat1 = new Cat("Tom");
const cat2 = new Cat("Lalaka");
cat1.say();
cat2.say();

В ES6 появился синтаксис, который позволяет объявлять классы. По сути класс - это функция-конструктор каких-то объектов с упрощенным синтаксисом наследования, объявления методов и еще некоторыми особенностями. Само понятие класса лучше отражает объектно-ориентированный подход разработки, чем использование функций-конструкторов и прототипов.

class Cat {
    constructor(name) {
        this.name = name;
    }

    say() {
        console.log(this.name, "say:", "meow");
    }
}

const cat1 = new Cat("Tom");
const cat2 = new Cat("Lalaka");
cat1.say();
cat2.say();

Когда объявляется класс, в память кладется некоторый объект, который умеет создавать новые объекты при помощи оператора new. Объект класса - это функция конструктор, в котором хранится логика по заполнению новых других объектов. Объекты, которые были созданы при помощи какого-то класса называются экзэмплярами или инстансами (instance) этого класса.

Важно научится четко различать, что такое "объект класса" и "инстанс класса". Выше сказано, что объект класса - это объект в памяти (это функция). Это означает, что с объектом класса можно работать как с обычным объектам: добавлять поля, присваивать в качестве значения переменной, передавать в параметры функции (и в параметры конструктора другого класса).

class Cat {
    constructor(name) {
        this.name = name;
    }
}

const obj = Cat;
const cat1 = new obj("Tom");

function create(classObj) {
    return new classObj("Tom");
}

const cat2 = create(Cat);

Cat.foo = function() {
    console.log("Функция у объекта класса");
}

Cat.foo();

Инстансы класса - это самостоятельные объекты, которые были сконструированы этого класса. Они не зависят друг от друга (если это не сделано специально) и с классом их связывает только то, что он являлся их создателем.

Объявление классов в TypeScript имеет ряд особенностей по сравнению с объявлением классов в JavaScript.

Поля классов

Класс может иметь поля. Для того, чтобы объявить поле, нужно указать его название и тип.

class A {
    b: number;
    c: string;
    e: { a: number; b: string };
}

Поля могут иметь модификатор доступа. В TypeScript существует 3 модификатора доступа:

  • public - поле является публичным. Читать и изменять значение этого поля можно в любом месте

  • private - поле является приватным. Читать и изменять значение этого поля можно только из методов самого класса

  • protected - поле является защищенным. Чистать и изменять значение этого поля можно из методов самого класса и его наследников

class A {
    public a: number;
    private b: string;
    protected c: boolean;
}

const a = new A();

a.a = 10;
a.b = "lalaka"; //Error: Property 'b' is private and only accessible within class 'A'.
a.c = true; //Error: Property 'c' is protected and only accessible within class 'A' and its subclasses.

const q1 = a.a;
const q2 = a.b; //Error: Property 'b' is private and only accessible within class 'A'.
const q3 = a.c; //Error: Property 'c' is protected and only accessible within class 'A' and its subclasses.

Модификаторы доступа полей нужны для того, чтобы правильно инкапсулировать логику внутри класса. private и protected поля хранят внутренне состояние класса. Это позволяет классу гарантировать, что значения в этих полях будут соответствовать внутренней логике класса (или потомка). Важно, что защита полей присутствует только на этапе компиляции. В JavaScript нет (пока) модификаторов доступа полей класса, поэтому в runtime значения приватных полей может быть изменено откуда угодно.

Если у поля не задан никакой модификатор доступа, то это поле public по умолчанию.

Для того, чтобы поле было доступно только на чтение, в TypeScript существует модификатор поля readonly. Он может применятся в месте с модификатором доступа. readonly поле должно быть инициализировано либо во время объявления, либо в конструкторе (об этом позже).

class A {
    public readonly a: number = 10;
    private readonly b: string;

    constructor() {
        this.b = "lalaka";
    }
}

const a = new A();

const q1 = a.a;
a.a = q1; //Error: Cannot assign to 'a' because it is a read-only property.

Поле класса может быть статичным. Статичное поле - это поле, значение которого хранится не в каждом конкретном инстансе класса (у каждого свое), а в самом объекте класса. Статичное поле помечается модификатором static. К статичному полю можно применять модификаторы доступа и модификатор readonly.

class A {
    static readonly a: { p: string } = { p: "lalaka" };
    private static readonly c: string = "lalaka";
}

const q1 = A.a; //q1 = { p: "lalaka" }
A.a.p = "malaka"; //A.a = { p: "malaka" }
q1.p; //q1 = { p: "malaka" }

Важно отметить, что к статичному полю через инстанс класса доступа нет.

new A().a //Error: Property 'a' is a static member of type 'A'

Из-за того, что статичные поля присваиваются только объекту самого класса, а не статичные - только инстансу класса, в TypeScript можно объявить у одного и того же класс статичное и не статичное поля с одинаковым именем:

class A {
    static a: number = 10;
    a: boolean = true;
}

A.a // 10
new A().a // true

Поля класса могут быть необязательными. Для этого после названия параметра нужно указать символ ? (как у объектов).

Конструктор

Выше уже было показано, как объявляется конструктор класса. Конструктор класса - это функция, которая вызывается для создания нового инстанса класса. Конструктор объявляется при помощи ключевого слова constructor. В конструкторе можно изменять значения полей класса (как публичных, так и приватных).

class A {
    private a: number;
    public b: string;
    constructor() {
        this.a = 10;
        this.b = "lalaka";
    }
}

Ключевое слово this ссылается на тот инстанс класса, который сейчас конструируется. Этот же инстанс класса вернется в результате команды new A(). Конструктор класса - это функция. Она может принимать параметры. Параметры конструктора типизируются также, как типизируются параметры обычной функции.

class A {
    private a: number;
    private b: string;
    private c?: boolean;
    constructor(a: number, b = "lalaka", c?: boolean) {
        this.a = a;
        this.b = b;
        this.c = c;
    }
}

const a1 = new A(10);
const a2 = new A(15, "malaka");
const a3 = new A(20, "balaka", false);

Выше также было указано, что поля класса можно инициализировать во время объявления (in-place).

class A {
    private a: number = 10;
}

При создании инстанса класса, in-place поля инициализируются до вызова конструктора. К их значениям можно получить доступ из конструктора. Также в конструкторе можно изменить in-place значение поля.

class A {
    private a: number = 10;
    public b: number = 100;

    constructor() {
        this.b = this.a
    }
}

new A().b // 10

Для удобства работы в TypeScript есть возможность объявлять поля сразу в конструкторе класса. Если параметру конструктора указать модификатор доступа, то у класс появится поле с именем и значением параметра и указанным модификатором доступа:

class A {
    constructor(private a: number, public b: string) {
    }
}

const q1 = new A(10, "lalaka");
q1.b // "lalaka"

Методы

Помимо полей, класс может иметь методы. Метод класс - это функция, которая существует в рамках инстанса класса (статичная в рамках объекта класса). Эта функция имеет доступ к внутреннему состоянию класса. Она, как и другие функции, может иметь или не иметь возвращаемое значение и параметры класса (объявлять generic-параметры).

class A {
    private q: number = 2;

    multiply(a: number): number {
        return this.q*a;
    }
}

const a = new A();
const q1 = a.multiply(15); // 30
const q2 = a.multiply(10); // 20

Методы класса могут иметь модификаторы доступа. Они такие же, как и модификаторы доступа полей. public методы могут быть вызваны, как внутри класса, так и снаружи; private - только внутри класса; protected - внутри класса или внутри класса наследника.

class A {
    public say() {
        console.log(this.getWord("Hello"));
    }

    private getWord(word: string): string {
        return word;
    }
}

const a = new A();
a.say();
a.getWord("lalaka"); //Error: Property 'getWord' is private and only accessible within class 'A'.

Модификатор readonly к методам не применим. А вот модификатор static применим и работает похожим образом, как и для полей. Метод присваивается не инстансам класса, а самому объекту класса. Соответственно, статичный метод не имеет доступа к полям инстансов класса, но имеет доступ к статичным полям и другим статичным методам.

class A {
    private static word: string = "Hello";

    public static say() {
        console.log(this.getWord())
    }

    private static getWord(): string {
        return this.word;
    }
}

A.say();

Generic-параметры

Классу можно указывать generic-параметры. Их нужно указывать в <> сразу после имени класса. Generic-и выводятся по тем же принципам, что и при вызовах функций.

class A<T> {
    public readonly a: T
    constructor(a: T) {
        this.a = a;
    }
}

const a1 = new A(10);
const a2 = new A("lalaka");
const a3 = new A<true>(true);

Методы класса могут объявлять собственные generic-и и использовать generic-и объявленные для класса.

class A<T> {
    public readonly a: T
    constructor(a: T) {
        this.a = a;
    }

    print<R>(b: R) {
        console.log(this.a, b);
    }
}

const a = new A(10);
a.print("lalaka");
a.print<true>(true);

Наследование

В TypeScript поддерживается возможность наследования классов. Наследование - одна из ключевых механик объектно-ориентированного программирования. При помощи наследования можно передать (унаследовать) поведение одного класса другому классу.

class Figure {
    print() {
        console.log("I'm a figure");
    }
}

class Circle extends Figure {
    radius: number = 15;
}

const q1 = new Circle();
q1.radius; // 15
q1.print();

В примере выше класс Figure содержит метод print. Класс Circle наследуется (расширяет) класс Figure. В этом случаи Circle является наследником класса Figure, а класс Figure - базовом классом (или родительским классом, или суперклассом (superclass)) для Circle. Все поведение, которое определяется в классе Figure (в нашем случаи это метод print) передается классу Circle. При этом сам класс Circle объявляет свое собственное специфичное поведение (поле radius) в дополнение к унаследованному. Поэтому инстанс класса Circle имеет и поле radius и метод print.

Если у базового класса объявлен конструктор с обязательными параметрами, то его наследник в своем конструкторе должен вызывать конструктор базового класса. Иначе будет ошибка компиляции.

class Rect {
    constructor(public width: number, public height: number) {
    }
}

class Square extends Rect {
    constructor(side: number) {
        super(side, side);
    }
}

const rect = new Rect(15, 10);
const square = new Square(5);

Ранее были рассмотрены модификаторы доступа полей и методов. Для того, чтобы сделать какое-то поле или метод доступным только внутри класса или внутри наследников этого класса, используется модификатор protected. private поля и методы базового класса доступны только внутри самого базового класса и наследникам не передаются.

class Rect {
    private width: number;
    private height: number;

    constructor(width: number, height: number) {
        this.width = width;
        this.height = height;
    }

    protected calcArea(): number {
        return this.width*this.height;
    }
}

class Square extends Rect {
    constructor(side: number) {
        super(side, side);
    }

    printArea() {
        const q =  this.width; //Error: Property 'width' is private and only accessible within class 'Rect'.
        console.log(this.calcArea());
    }
}

const square = new Square(5);
square.printArea();
square.calcArea(); //Error: Property 'calcArea' is protected and only accessible within class 'Rect' and its subclasses.

Так как наследник расширяет базовый класс - он является надмножеством над базовым классом. С точки зрения типов это означает, что инстансы наследников базового класса могут передаваться туда, где требуется значение с типом базового класса.

class Rect {
    constructor(public width: number, public height: number) {
    }
}

class Square extends Rect {
    constructor(side: number) {
        super(side, side);
    }

    onlySquareMethod() {
    }
}

const q1: Square = new Square(10);
const q2: Rect = q1;

q1.onlySquareMethod();
q2.onlySquareMethod(); //Error: Property 'onlySquareMethod' does not exist on type 'Rect'.

В коде выше переменная q2 имеет тип Rect, но ей присваивается значение q1: Square. Это возможно, потому что тип Square имеет все поля типа Rect. Однако, не смотря на то, что в q2 лежит инстанс класса Square, поля и методы этого типа использовать нельзя, потому что тип переменной q2: Rect и TypeScript в общем случаи не может гарантировать, что в значении этой переменной лежит Square.

Наследоваться сразу от двух классов нельзя. TypeScript (как JavaScript) не поддерживает множественное наследование. Можно наследовать один класс от второго, а второй от третьего.

В TypeScript класс может быть абстрактным. Абстрактный класс - это класс, от которого можно наследовать другие классы, но нельзя создавать инстансы этого класса. Абстрактный класс помечается ключевым словом abstract.

abstract class Figure {
    print() {
        console.log("I'm a figure");
    }
}

class Circle extends Figure {
}

const circle = new Circle();
const figure = new Figure(); //Error: Cannot create an instance of an abstract class.

Абстрактные классы могут иметь абстрактные методы. Абстрактные методы - это методы, у которых объявлена сигнатура, но не объявлено тело метода. Абстрактный метод помечается модификатором abstract. Наследник абстрактного класса обязан объявить у себя метод с такой же сигнатурой и определить реализацию этого метода. Если наследник не реализует абстрактные методы - будет ошибка компиляции.

abstract class A {
    abstract show(p: number): string;
}


//Error: Non-abstract class 'B1' does not implement inherited abstract member 'show' from class 'A'.
class B1 extends A {
}

Если наследником класса с абстрактными методами тоже является абстрактный класс, то абстрактные методы реализовывать не обязательно.

abstract class A {
    abstract show(p: number): string;
}

abstract class B2 extends A {
}

Абстрактные методы стоит воспринимать как интерфейс. Они гарантируют, что независимо от реализации абстрактного класса, они будут реализованы и иметь фиксированную сигнатуру.

abstract class BasePrinter {
    abstract print(n: number): void;
}

class ConsolePrinter extends BasePrinter {
    print(n: number): void {
        console.log(n);
    }
}

class DomPrinter extends BasePrinter {
    print(n: number): void {
        const element = document.querySelector("#print");
        if (element != null) {
            element.textContent = n.toString();  
        }
    }
}

const printers: BasePrinter[] = [new ConsolePrinter(), new DomPrinter()];
printers.forEach(p => p.print(10));

Класс, как интерфейс

Инстанс класса - это объект. Как и любой другой объект в TypeScript, он имеет тип. Интерфейсом взаимодействия с инстансом класса из внешнего мира являются его публичные поля и методы. Поэтому, если тип объекта требует наличие каких-либо полей или методов у объекта, и тип класса имеет все эти поля и методы, то инстансы этого класса совместимы с типом объекта.

interface A {
    x: number;
}

class B {
    constructor(public x: number) {
    }
}

class C {
    constructor(public x: string) {
    }
}

const a1: A = new B(10);
const a2: A = new C("10"); /* Error:
Type 'C' is not assignable to type 'A'.
  Types of property 'x' are incompatible.
    Type 'string' is not assignable to type 'number'.
*/

Тип класса сам может выступать в роли интерфейса.

class A {
    public x: number = 15;
}

const a: A = { x: 40 };

В коде выше переменная a типизирована, как A. При этом A - это класс. Конструкция a: A не требует присвоения только инстансов класса A. Это конструкция требует любое совместимое с интерфейсом класса значение.

Для более строгой типизации TypeScript поддерживает реализацию интерфейса классом. Для этого используется ключевое слово implements.

interface Figure {
    print: () => void;
}

class Circle implements Figure {
    print() {
        console.log("I'm a circle");
    }
}

Если указано, что класс реализует какой-то интерфейс, но в классе (или его базовом классе) не объявлены какие-то поля или методы, указанные в интерфейсе, то TypeScript выдаст ошибку компиляцию

interface Figure {
    print: () => void;
}

class Circle implements Figure {
} /* Error:
Class 'Circle' incorrectly implements interface 'Figure'.
  Property 'print' is missing in type 'Circle' but required in type 'Figure'.
*/

Класс может реализовывать сразу несколько интерфейсов - их нужно перечислять через запятую после ключевого слова implements. Класс может одновременно наследоваться от другого класса и реализовывать множество интерфейсов.

interface A {
    a: number;
}

interface B {
    b: string;
}

class X {
    public a: number = 300;
}

class Y extends X implements A, B {
    constructor(public b: string) {
        super();
    }
}

Как было сказано ранее, классу Y не обязательно реализовывать интерфейсы A и B, для того, чтобы типы его инстансов были совместимы с этими интерфейсами. Реализация интерфейсов во многом нужна для более строгой типизации и облегчения работы в IDE.

Объект класса, как тип

Ранее уже говорилось, что объект класса - это своего рода функция, которая конструирует другие объекты (инстансы класса). Так как это объект, соответственно у него есть какой-то тип.

Объект класса можно вызывать как функцию с определенными параметрами, которая вернет определенное значение. Но есть одна важная особенность. Эту функцию-конструктор можно вызывать только с ключевым словом new. Поэтому тип объекта класса можно описать при помощи callable со следующей модификацией:

interface ClassType {
    new (a: number, b: string): {a: number, b: string, c: boolean}
}

Выше был объявлен тип, который описывает множество объектов, которые можно вызывать с оператором new и двумя параметрами с заданными типами, и в результате этого вызова вернется объект с заданным интерфейсом.

interface ClassType {
    new (a: number, b: string): {a: number, b: string, c: boolean}
}

class A {
    c: boolean = false;
    constructor(public a: number, public b: string) {
    }
}

class B {
    constructor(public a: number, public b: string) {
    }
}

const classType1: ClassType = A;
const classType2: ClassType = B; /* Error:
Type 'typeof B' is not assignable to type 'ClassType'.
  Property 'c' is missing in type 'B' but required in type '{ a: number; b: string; c: boolean; }'.
*/

Например, можно объявить тип-помощник, который будет возвращать тип объекта класса.

type TypeOf<T> = { new (...args: any[]): T }

class A {
}

class B {
    constructor(public b: string) {
    }
}

const classA: TypeOf<A> = A;
const classB: TypeOf<B> = B;
const wrong: TypeOf<A> = new A(); /* Error:
Type 'A' is not assignable to type 'TypeOf<A>'.
  Type 'A' provides no match for the signature 'new (...args: any[]): A'.
*/

Задания

Last updated