Наследование в javascript

6 сентября 2021 г. 9:27

Наследование в 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. Если вы собираетесь дальше погружаться в этот язык, то знания, полученные из этой заметки, вам пригодятся еще не раз. И пусть она будет вашим надежным помощником на этом непростом поприще

На этом все, всем удачи и до новых встреч!

admin
(ваш голос учтен)