Перейти к содержанию

Абстрактные классы

Если у всех начинающих разработчиков при размышлениях об интерфейсах возникают вопросы "когда и зачем их использовать", то при размышлении об абстрактных классах к ним добавляются "чем они отличаются от интерфейсов и когда та или иная конструкция предпочтительней". Ответы на эти вопросы вы найдете в данной главе, но для начала стоит рассмотреть общие характеристики.

Общие характеристики

В TypeScript объявление абстрактного класса отличается от объявления обычного только добавлением ключевого слова abstract перед ключевым словом class.

abstract class Identifier {}

Абстрактные классы также, как и обычные классы, могут расширять другие обычные и абстрактные классы и реализовывать интерфейсы.

interface IInterface {}

class StandardClass {}

// абстрактный класс расширяет обычный класс и реализует интерфейс
abstract class SuperAbstractClass
  extends StandardClass
  implements IInterface {}

// абстрактный класс расширяет другой абстрактный класс
abstract class SubAbstractClass extends SuperAbstractClass {}

Несмотря на то, что абстрактный класс — все же класс, главное его отличие от обычного класса заключается в отсутствии возможности создания его экземпляров. Другими словами, нельзя создать экземпляр абстрактного класса.

abstract class SuperAbstractClass {}
class SubStandartClass extends SuperAbstractClass {}
// Error, нельзя создавать экземпляры абстрактного класса
let v0: SuperAbstractClass = new SuperAbstractClass();
let v1: SuperAbstractClass = new SubStandartClass(); // Ok
let v2: SubStandartClass = new SubStandartClass(); // Ok

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

abstract class Identifier {
  public abstract field: string = 'default value'; // реализация допустима
  public abstract get prop(): string; // реализация не допустима
  public abstract set prop(value: string); // реализация не допустима

  public abstract method(): void; // реализация не допустима
}

Абстрактный класс расширяющий другой абстрактный класс не обязан переопределять все абстрактные члены своего суперкласса. В отличие от абстрактных классов, обычные классы расширяющие абстрактные классы, обязанные переопределить все поля, свойства и методы, находящиеся в иерархической цепочке и помеченные ключевым словом abstract, если они не были реализованы предками ранее.

abstract class SuperAbstractClass {
  public abstract field: string; // объявление абстрактного поля
}

// в абстрактных потомках допускается не переопределять абстрактные члены предков
abstract class SubAbstractClass extends SuperAbstractClass {}

class SubConcreteClass extends SubAbstractClass {
  // конкретный подкласс обязан переопределять абстрактные члены, если они...
  public field: string;
}

// ... если они не были переопределены в классах-предках
class SubSubConcreteClass extends SubConcreteClass {}

Как было сказано ранее, абстрактным полям может быть задано значение по умолчанию, но в этом случае обратится к нему могут только абстрактные классы в иерархии наследования.

abstract class SuperAbstractClass {
  // объявление абстрактного поля со значением по-умолчанию
  public abstract field0: string = 'default value';
  public abstract field1: string;
  public abstract field2: string;
}

abstract class SubAbstractClass extends SuperAbstractClass {
  // переопределение абстрактного поля и инициализация его
  // значением абстрактного поля, которому было присвоено
  // значение по умолчанию в абстрактном предке
  public field1: string = this.field0;
}

class SuboncreteClass extends SubAbstractClass {
  // конкретному классу необходимо переопределить два абстрактных поля,
  // так как в предках было переопределено только один член
  public field0: string;
  public field2: string;
}

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

interface IInterface {
  field: string;
  method(): void;
}

abstract class AbstractSuperClass implements IInterface {
  // абстрактный класс декларирует реализацию интерфейса
  public abstract field: string; // поле без реализации...
  public abstract method(): void; // ...метод без реализации. Тем не менее ошибки не возникает
}

Кроме абстрактных членов, абстрактные классы могут содержать обычные члены обращение к которым ничем не отличается от членов объявленных в обычных классах.

Как правило, абстрактные классы реализуют только ту логику, которая не будет ни при каких обстоятельствах противоречить логике своих подклассов.

abstract class AbstractSuperClass {
  abstract name: string = 'AbstractSuperClass';

  public toString(): string {
    // реализация общего не абстрактного метода
    return `[object ${this.name}]`;
  }
}

class FirstConcreteSubClass extends AbstractSuperClass {
  public name: string = 'T2'; // реализуем абстрактное поле
}

class SecondConcreteSubClass extends AbstractSuperClass {
  public name: string = 'T2'; // реализуем абстрактное поле
}

let first: FirstConcreteSubClass = new FirstConcreteSubClass();
let second: SecondConcreteSubClass = new SecondConcreteSubClass();

first.toString(); // [object FirstConcreteSubClass] реализация в абстрактном предке
second.toString(); // [object SecondConcreteSubClass] реализация в абстрактном предке

Теория

Пришло время разобраться в теории абстрактных классов, а именно ответить на вопросы, которые могут возникнуть при разработке программ.

Интерфейс или абстрактный класс — частый вопрос, ответ на который не всегда очевиден. В действительности, это абсолютно разные конструкции, как с точки зрения реализации, так и идеологии. Интерфейсы предназначены для описания публичного api, которое служит для сопряжения с программой. Кроме того, они не должны, а в TypeScript и не могут реализовывать бизнес логику той части, которую представляют. Они — идеальные кандидаты для реализации слабой связанности (low coupling). При проектировании программ упор должен делаться именно на интерфейсы.

Абстрактные классы, при необходимости, должны реализовывать интерфейсы в той же степени и для тех же целей, что и обычные классы. Их однозначно нужно использовать в качестве базового типа тогда, когда множество логически связанных классов имеет общую для всех логику использование которой в чистом виде не имеет смысла. Другими словами, если логика размещенная в классе не может или не должна выполнятся отдельно от потомков, то необходимо запретить создание экземпляров подобных классов.

К примеру, абстрактный класс Animal, реализующий интерфейс IAnimal с двумя членами: свойством isAlive и методом voice, может и должен реализовать свойство isAlive, так как это свойство имеет заранее известное количество состояний (жив или мертв) и не может отличаться в зависимости от потомка. В то время как метод voice (подать голос) как раз таки будет иметь разную реализацию, в зависимости от потомков, ведь коты мяукают, а вороны каркают.

Тем не менее, резонно может возникнуть вопрос, а почему бы не вынести этот функционал в обычный, базовый класс?

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

Еще раз тоже самое, но другими словами. Поскольку базовый класс будет реализовывать логику предполагаемую интерфейсами разбитыми по принципу разделения интерфейсов с помощью которых и будет происходить сопряжение с остальными частями программы, то существует возможность попадания его экземпляра в места предполагающие логику отсутствующую в нем. То есть высокоуровневая логика, присущая только потомкам, может быть сокрыта за менее специфичным интерфейсом реализуемым самим базовым классом. Чтобы избежать подобных сценариев допускающих возникновение ошибок во время выполнения, необходимо запретить создание экземпляров подобных классов. (Принцип разделения интерфейсов рассматривается в главе Interface)

Кроме того, абстрактный класс с помощью абстрактных членов не даст разработчику забыть реализовать необходимую логику в потомках.

Но и это ещё не все. Интерфейс IAnimal в реальности будет составным типом. То есть, он будет принадлежать к типу ILiveable, описывающему свойство isAlive и типу IVoiceable, описывающему метод voice. Реализовать подобное с помощью абстрактного класса не получится, так как класс может расширять только один другой класс, в то время как интерфейсы могут расширять множество других интерфейсов, и следовательно, принадлежит ко множеству типов данных одновременно. Как раз это и демонстрирует интерфейс IAnimal расширяя интерфейсы ILiveable и IVoiceable.

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