На предыдущем
занятии мы с вами познакомились с классами JavaScript. А раз есть
классы, то, конечно же, должно быть и наследование классов. Эта фундаментальная
концепция ООП не обошла язык JavaScript стороной и сейчас мы посмотрим
как происходит расширение функционала базового класса при помощи механизма
наследования.
Для тех, кто
только к нам присоединился и задумывается: а что такое вообще наследование и
зачем оно нужно? В двух словах поясню. Сейчас мы будем рассматривать пример, в
котором создадим некий начальный (базовый) класс для описания свойств
геометрических фигур. И
назовем его prop:
class Prop {
constructor(width, color) {
this.width = width;
this.color = color;
}
}
Затем, создадим
связь между этим классом и новым (дочерним) для представления линии на
плоскости. Пусть класс называется Line и без связки
выглядит так:
class Line {
constructor(sp, ep, width, color) {
this.sp = sp;
this.ep = ep;
this.width = width;
this.color = color;
}
draw() {
console.log("Линия: "+this.sp.x+", "+this.sp.y+
", "+this.ep.x+", "+this.ep.y);
}
}
И вот здесь мы
бы хотели, чтобы свойства width и color формировались
через базовый класс Prop и, вообще, весь функционал с ними связанный,
находился бы в этом классе. Для этого, как раз, и используется механизм
наследования, который позволяет расширять функционал базового класса за счет
дочернего. Синтаксически, базовый класс указывается с помощью ключевого слова
extends <имя
базового класса>
например, так:
class Line extends Prop { … }
и в конструкторе
уберем формирование свойств width и color:
constructor(sp, ep, width, color) {
this.sp = sp;
this.ep = ep;
}
Если сейчас
попробовать сформировать объект Line:
let l1 = new Line({x: 0, y: 0}, {x: 10, y: 20}, 1, 'red');
то произойдет
ошибка. Ошибка, связанная с особенностью работы конструктора в наследуемом
классе Line. Дело в том,
что JavaScript формирует
указатель this только после
выполнения конструктора базового класса. Но мы здесь нигде его не вызываем и
автоматически он тоже не вызывается. Из-за этого и происходит данная ошибка.
Поправим это. Для вызова конструктора базового класса существует специальная функция:
super([параметры])
которой мы и
воспользуемся:
class Line extends Prop {
constructor(sp, ep, width, color) {
super(width, color);
this.sp = sp;
this.ep = ep;
}
…
Причем, обратите
внимание, функция super в конструкторе должна вызываться до использования
объекта this, так как он
формируется только после ее вызова.
Если теперь
обновить страницу браузера, то никаких ошибок не возникнет. В результате, будет
создан объект l1 с указанными
свойствами и методом draw:
Но, какая структура
наследования у нас в итоге получилась? Как мы помним, в JavaScript используется
прототипное наследование, а классы – это практически синтаксический сахар над
всем этим механизмом. И, действительно, результат может показаться, на первый
взгляд, несколько необычным:
Смотрите,
сначала формируется объект l1 с базовым Line.prototype, а затем, уже
идет объект Prop.prototype. Кроме того,
все свойства формируются в дочернем объекте l1, так как
именно на него указывает this. Поэтому, когда в базовом классе Prop выполняется
конструктор:
constructor(width, color) {
this.width = width;
this.color = color;
}
то width и color записываются в
дочерний объект l1. Если добавить в базовый класс метод, например:
getColor() { return this.color; }
то он будет
храниться именно в нем:
Переопределение методов
При
необходимости, методы базовых классов можно переопределять в дочерних.
Например, переопределим метод getColor в классе Line:
getColor() {
return '['+this.color+']';
}
И, если теперь
его вызвать:
console.log( l1.getColor() );
то в консоли
увидим строчку:
[red]
Если же
закомментировать этот метод в дочернем классе и повторить вызов, то увидим
просто слово red. То есть, вот
так, довольно просто можно переопределять методы базового класса. Однако, часто
нужно сохранить функциональность метода из базового класса и расширить ее в
дочернем. Для этого можно вызвать переопределяемый метод из базового класса,
используя специальный объект super:
getColor() {
let color = super.getColor();
return '['+color+']';
}
Смотрите, мы
здесь сначала берем значение цвета через геттер базового класса, а, затем,
добавляем квадратные скобки. Это более гибкий подход, т.к. цвет может
возвращаться и в виде строки и в виде числа или в еще каком-либо другом виде. И
мы, благодаря обращению к методу базового класса, сохраняем эту
функциональность.
Однако, обратите
внимание, контекст super на базовый класс может легко
потеряться, если мы обратимся к нему, например, из анонимной функции:
showColor() {
setTimeout(function() {
console.log( super.getColor() );
}, 0);
}
Произойдет
ошибка. А вот так, все будет работать:
showColor() {
console.log( super.getColor() );
}
или, так:
showColor() {
setTimeout(() => {
console.log( super.getColor() );
}, 0);
}
Мы здесь
используем стрелочную функцию, а как известно, они «прозрачные» и не образуют
собственного контекста.
Конструктор по умолчанию
В объявляемых
классах не обязательно прописывать конструктор. Например, если их убрать, то
ошибок при создании объекта не будет:
class Prop {
getColor() { return this.color; }
}
class Line extends Prop {
draw() {
console.log("Линия: "+this.sp.x+", "+this.sp.y+
", "+this.ep.x+", "+this.ep.y);
}
}
let l1 = new Line({x: 0, y: 0}, {x: 10, y: 20}, 1, 'red');
При этом будет
создана полная иерархия объектов. Правда сам объект l1 будет пустым:
Но как это все
работает без конструкторов? В действительности, в дочерних классах они всегда
есть и по умолчанию, если мы не определяем своих, принимают вид:
constructor(...args) {
super(...args);
}
То есть,
дочерние классы по умолчанию вызывают конструкторы базовых, передавая им все
принятые аргументы args. В этом легко убедиться, если в базовом классе Prop вернуть
конструктор для инициализации двух параметров:
class Prop {
constructor(width, color) {
this.width = width;
this.color = color;
}
getColor() { return this.color; }
}
Выполняя
программу, в консоли увидим объект с этими двумя свойствами. Это как раз
произошло, благодаря вызову конструктора по умолчанию в дочернем классе Line.
Особенности работы extends
Последнее, о чем
мы поговорим на этом занятии, это возможность динамической генерации базового
класса. Дело в том, что после ключевого слова extends можно указывать
любой исполняемое выражение:
extends <исполняемое
выражение>
например,
функцию, которая бы возвращала класс. Давайте сначала объявим такую функцию:
function getExtends(type) {
return class {
constructor(width, color) {
this.type = type;
this.width = width;
this.color = color;
}
};
}
И, затем,
вызовем ее после extends:
class Line extends getExtends("2D") {
constructor(sp, ep, width, color) {
super(width, color);
this.sp = sp;
this.ep = ep;
}
draw() {
console.log("Линия: "+this.sp.x+", "+this.sp.y+
", "+this.ep.x+", "+this.ep.y);
}
getType() { return this.type; }
}
Возможно, это
несколько необычно выглядит. Зато, бывает весьма эффективно. В частности,
здесь, мы указываем тип фигуры «2D» - двумерная, который записывается в
свойстве type создаваемого
объекта. Причем, далее, по программе нам нет необходимости определять это
свойство. Оно автоматически будет создаваться с этим значением, т.к. мы
сгенерировали именно такой класс: значение type: 2D в него, как бы,
«вшито».