Наследование классов, функция super

На предыдущем занятии мы с вами познакомились с классами 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:

console.log( l1 );

Но, какая структура наследования у нас в итоге получилась? Как мы помним, в 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 будет пустым:

console.log( 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 в него, как бы, «вшито».