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

Абстрактные классы (abstract classes)

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

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

В 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 {}

let v0: SuperAbstractClass = new SuperAbstractClass() // Error, нельзя создавать экземпляры абстрактного класса
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 должен состоять из более специфичных (более конкретных) интерфейсов, на которых и будет завязана программа. А это, в свою очередь, означает, что экземпляр базового класса, который реализует интерфейс IAnimal и у которого отсутствует основная логика, может быть использован в тех частях программы, в которых предполагается использовать экземпляры более специфичных типов, принадлежащих к типам этих интерфейсов (проще говоря, потомков базового типа).

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

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

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

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

Бывает так, что при проектировании компонента программы разработчик уже на стадии проектирования знает, что их функционал будет расширен в будущем. И может показаться, что точка сопряжения, в данной ситуации, кандидат на использование абстрактного класса вместо интерфейса. С одной стороны, да. Если заменить тип интерфейса на тип, представляемый абстрактным классом, то в дальнейшем, при расширении его логики, можно воспользоваться механизмом наследования (extends), который для этого и предназначен. Но, с другой стороны, существует такое устоявшееся правило, которое гласит — “Предпочитайте композицию наследованию класса” (“Банда Четырех”), что склоняет к использованию интерфейса. Тем не менее выбор не заключается между лучшим и худшим. В конкретном случае разработчику просто предстоит выбрать один из двух вариантов. И если вы относитесь к тем, кто предпочитает композицию наследованию, то обязательно должны знать, что существует случай, который в общей практике и является исключительным, но в контексте TypeScript, его, скорее всего, правильно причислить к “щекотливым”.

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

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

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

Хотя интерфейсы в TypeScript могут иметь необязательные члены (глава “Операторы - Optional, Not-Null, Not-Undefined, Definite Assignment Assertion”), что, в некоторой степени, сводит на нет преимущество абстрактных классов в ситуациях, когда точки соприкосновения подвержены добавлению новых членов.

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