Наследование в javascript
Наследование в javascript несомненно является одной из непростых тем для ученика. Во многом это связано с историей языка. Дело в том, что изначально в javascript была поддержка только прототипного наследования. Чтобы не путаться в разных видах наследования, рассмотрим сперва его:
Прототипное наследование
Рассмотрим следующий пример:
(function(){ const a = { a: 1 } const b = { b: 2, __proto__: a } console.log(b.a, b.b); // 1 2 console.log(b); // {b: 2} })()
Здесь мы объявили два объекта: объект a и наследуемый от него b. Еще раз отмечу, что здесь речь идет именно о прототипном наследовании.
Мы видим, что объект b унаследовал свойство a объекта a. Можем увидеть более наглядно, если развернем объект в консоли:
Обратите внимание, что на последнем скрине прототип объекта a остается свернутым. Мы вернемся к нему позже. А пока рассмотрим некоторые (не всегда очевидные) особенности прототипного наследования:
- Особенность первая: горячая подмена свойств в дереве базовых объектов. Для демонстрации немного изменим код из примера выше:
(function(){ const a = { a: 1 } const b = { b: 2, __proto__: a } a.a = 0; console.log(b.a, b.b); console.log(b); })() // результат: 0 2
Мы видим, что изменение свойства базового объекта привело к изменению одноименного свойства в дочернем. Однако это не работает обратно: изменение/назначение свойств в дочернем объекте не изменяет прототип, а добавляет их непосредственно в дочерний объект, перекрывая свойства базового
- Особенность вторая: горячая подмена базового объекта. Рассмотрим следующий пример кода:
(function(){ const a = { a: 1 } const b = { b: 2, __proto__: a } const c = { c: 3 } b.__proto__ = c; console.log(b.a, b.b); // undefined 2 console.log(b); // см скрин })()
Как мы видим, свойства a больше не существует, поскольку прототип был подменен.
Примечание автора: знать об этих особенностях необходимо, но тем не менее автор не рекомендует ими злоупотреблять в виду их неочевидностей
- Особенность третья: изнутри дочернего объекта нельзя обратиться непосредственно к базовому объекту (ключевое слово super не поддерживается в прототипном наследовании). Но можно обратиться посредством ключевого слова this, если свойство базового класса не перекрыто (можно проверить через hasOwnProperty). Например так:
const e = { e: 2 } const c = { __proto__: e, a: 1, b() { console.log(this.e) }, // 2 c: () => console.log(this.e), // !undefined (не используйте для этого случая стрелочные функции) d: function() { // console.log(super.e); // !error console.log(this.e) // 2 } }
При чем this всегда указывает на текущий объект, даже если сама функция объявлена в базовом объекте (т.е при изменении свойств через this прототип меняться не будет)
Надеюсь, общее представление о прототипном наследовании у вас сложилось. Перейдем к повышению градуса:
Прототипное наследование в функциях-конструкторах
Чтобы понять, почему (и главное, зачем) в javascript наследование такое, какое оно есть, погрузимся немного в исторический экскурс. Изначально в javascript не было классов. Их роль по сути выполняли так называемые функции-конструкторы. Они позволяли создавать множество однотипных независимых (в отличие от прототипного наследования через Object.create) объектов, тех самых, которые в классическом программировании принято называть экземплярами классов. Тогда как прототипное наследование в javascript скорее напоминало (и напоминает) наследование статических классов в традиционном ООП с некоторыми отличиями (в частности, возможностью смены прототипа).
Все экземпляры функции-конструктора по дефолту имели в качестве прототипа обычный объект вида, который не несет никакого особого смысла кроме метаданных о самой функции:
{constructor: Function}где Function - это и есть сама функция-конструктор. Рассмотрим пример:
function Base(b) { this.b = b; } let instance = new Base(2); console.log(instance.__proto__.constructor === Base); // true console.log(instance); // {b: 2} // см скрин ниже
Однако функции-конструкторы с самого начала имели возможность прототипного наследования для своих экземпляров с помощью свойства prototype:
let a = { a: 1 } function Base(b) { this.b = b; } Base.prototype = a; let instance = new Base(2); console.log(instance.__proto__.constructor === Base); // false console.log(instance) // {b: 2} // см скрин ниже
Если вернуться к аналогии с классическим наследованием, то такая конструкция напоминает наследование нестатических полей класса от полей статического класса (в общем-то в большинстве языков такой возможности вообще нет: при подобном наследовании, как правило, статические поля родительского класса остаются статическими и для дочернего), что в общем-то трудно назвать полноценным наследованием. Но тем не менее, решение было:
Имитация классического наследования
Это решение было в имитации классического наследования с подменой контекста:
function A() { this.a = 1; this.func = function(){} } function B(b) { A.call(this); // отнаследовать this.b = b this.bFunc = function(){} } var b = new B(2); console.log(b); // B {a: 1, b: 2, func: ƒ, bFunc: ƒ}
И хотя сейчас этот способ используется все реже, его все еще часто можно встретить во многих долгоживущих проектах, и не только легаси. Дело в том, что с таким способом можно имитировать множественное наследование, которого нет (и не планируется) в реализации языка. Этот лайфхак открывал множество возможностей. Из минусов разработчики выделяли то, что при создании такого экземпляра вместо одной аллокации происходило несколько (каждый конструктор в цепочке наследования вызывался независимо. впрочем это не было строго критично), ну и на этом пожалуй все (на самом деле нет, но мы к этому вернемся).
Наследование классов
С приходом ES6 в js появились классы. Многие разработчики сразу окрестили их "сахаром" для функций-конструкторов. И на первый взгляд это действительно так, однако не совсем:
class A{ a = 1 } class B extends A{ b = 2 } let b = new B() console.log(b); // B {a: 1, b: 2}
Мы видим, что оба класса находятся в дереве наследования в отличие от случая с имитацией наследования. И это отличие ключевое, поскольку без использования классов в javascript невозможно сымитировать такое дерево наследования. Кроме того, обратите внимание, что все простые поля экземпляра принадлежат непосредственно экземпляру, а не его прототипам, как при прототипном наследовании. Здесь стоит так же упомянуть, что объекты класса не поддерживают прототипное наследование в отличие от функций-конструкторов. То есть нельзя сделать так:
B.prototype = {c: 3} // ни к чему не приведет
Однако и на этом особенности наследования классов не заканчиваются: немного усложним предыдущий пример, добавив функции и геттер:
class A{ a = 1 func(){} get aGetter(){ return 0; } } class B extends A{ b = 2 bFunc(){} get bGetter(){ return 1; } } let b = new B() console.log(b); // B {a: 1, b: 2}
На скрине мы видим интересную картину: обратите внимание на функции func и bFunc. В отличие от "имитации" они не принадлежат экземпляру: они принадлежат прототипам, в которых были объявлены (!). Но еще интереснее, что свойство aGetter, который мы объявили в базовом классе A, принадлежит каждому прототипу в дереве наследования, в том числе и самому экземпляру, а вот функция-геттер - только тому прототипу, в котором он был объявлен
Теперь вернемся к самому началу статьи и развернем прототип класса B:
Прототипом класса B является Object - функция-конструктор, которая является неотъемлемой частью стандарта. Этот конструктор является прототипом по умолчанию для любых объектов (и функций, поскольку функция - это объект) javascript
Возможно ли сымитировать наследование классов ES6?
Это хороший вопрос. Попытки провернуть подобное с веб-компонентами оказались тщетны. Вопрос остался открытым: если ваш ментор или project-менеджер уверены, что классы ES6 - это всего лишь сахар для функций-конструкторов, можете предложить им так же попытать удачи переписать этот пример без классов, как это делал я (возможно, им повезет больше).
Я же оставил немного времени, чтобы разобраться, в причинах фиаско:
Как было уже писано выше при создании экземпляра из функции-конструктора в javascript свойство Prototype экземпляра содержит объект
{constructor: Function} // Function - функция-конструктор
Однако, как мы выяснили, при классическом наследовании этот объект так же может содержать функции класса, а так же поля с геттерами/сеттерами базовых классов. Доступ к ним мы имеем, как через свойство [[Prototype]], так и непосредственно через сам экземпляр. Теоретически для достижения желаемого мы могли бы попробовать явно указать прототипу конструктор:
function A(){ this.a = 1 } function B(){ this.b = 2 } B.prototype.__proto__.constructor = A // либо так: B.prototype.__proto__ = {constructor: A} let b = new B() console.log(b.a); // undefined
Но в этом примере b.a выдаст undefined, вероятно, как писалось выше, по той причине, что значение поля constructor несет всего лишь роль информационную (возможно, этому есть более грамотное объяснение в стандарте). Если у вас есть ответ получше, прошу поделиться им в комментариях. Буду рад дискуссии на тему.
Вместо заключения
Примечание: теперь вы имеете общее представление о наследовании в javascript. Если вы собираетесь дальше погружаться в этот язык, то знания, полученные из этой заметки, вам пригодятся еще не раз. И пусть она будет вашим надежным помощником на этом непростом поприще