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

Оглавление

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

Наследование - это один из способов повторного использования кода. Оно бывает двух типов: на основе классов (class-based) и прототипное (prototypal). В любом случае, так как речь про объектно-ориентированное программирование, наследование - это про создание множества объектов, обладающих общими свойствами, на основе уже существующих.

Рассмотрим пример на некотором вымышленном языке программирования удивительно похожем на JavaScript:

// Простейший способ создать объект
var greeterInstance = {
    person: null,
    greeting: function() { return 'Hello ' + this.person; }
};
greeterInstance.person = 'Alice';

// Некоторый код, который использует greeterInstance ...

// А здесь нам нужен аналогичный объект, но для Bob'а.
var greeterInstance2 = {
    person: null,
    greeting: function() { return 'Hello ' + this.person; }
};
greeterInstance2.person = 'Bob';
// ...

Упс, похоже на copy/paste! Такой код будет трудно поддерживать, так как изменения придется вносить сразу во все места, где создается instance. Попробуем улучшить:

function createGreeter(person) {
    return {
        person: person,
        greeting: function() { return 'Hello ' +  this.person; }
    };
}

var aliceGreeter = createGreeter('Alice');

// Некоторый код, который использует aliceGreeter ...

var bobGreeter = createGreeter('Bob');
// ...

Отлично, теперь мы можем создавать множество похожих объектов, используя createGreeter(), и мы избавились от дублирования кода. Это уже наследование? Нет, так как никто ни от кого ничего не наследует. Это способ повторного использования кода - да.

function createGreeter(person) {
    return {
        person: person,
        greeting: function() { return 'Hello ' +  this.person; }
    };
}

function createGateKeeper(person) {
    var keeper = {
        opened: false,
        open: function() {
            this.opened = true;
            console.log(this.greeting());
        }
    };

    var greeter = createGreeter(person);
    for (var k in keeper) {
        greeter[k] = keeper[k];
    }
    return greeter;
}

var gateKeeper = createGateKeeper('Alice');
gateKeeper.open();

А вот это уже больше похоже на наследование, так как с помощью createGateKeeper() мы можем создавать множество похожих объектов, каждый из которых основывается на объектах типа Greeter.

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

Рассмотрим аналогию из реального мира. Класс можно воспринимать, как чертеж, по которому создаются изделия на заводе (объекты). Чертеж изделия != самому изделию. Это лишь информация о том, как построить изделие (создать объект). В основанном на классах наследовании, одни чертежи наследуют общие свойства от других чертежей. А затем, объекты, созданные на основе таких чертежей, обладают свойствами, определенными как в первых, так и во вторых. Рассмотрим пример на Python 3:

class Greeter:
    def __init__(self, person):
        self.person = person

    def greeting(self):
        return 'Hello ' + self.person

aliceGreeter = Greeter('Alice')  
# Во многих языках (Java, C++, C#, etc) принято писать aliceGreeter = new Greeter(...). 
# Обратите внимание на ключевое слово new. Такую реализацию наследования
# принято называть "классической".

print(aliceGreeter.greeting())

# "Чертеж" для gate keeper'ов построен на основе чертежа для greeter'ов.
# Т.е. наследует все его свойства. А также добавляет собственные.
class GateKeeper(Greeter):
    def __init__(self, person):
        super().__init__(person)
        self.opened = False     

    def open(self):
        self.opened = True
        print(self.greeting()) 

gateKeeper = GateKeeper('Alice')      
gateKeeper.open()

Прототипное наследование

В прототипном наследовании отсутствует понятие чертежа (класса). Тут речь скорее идет о некотором первородном объекте-образце. Этот объект используется для создания множества других, идентичных ему объектов, расширяющих его дополнительными свойствами. Т.е. это похоже на завод, где нет чертежей изделия, которое нужно производить, но есть образец в единственном экземпляре. Задача инженеров на таком заводе научиться воспроизводить копии такого изделия по образцу и встраивать в них новые функции. И да, само по себе изделие №0 также является полноправным изделием (вспомним, что класс != объект этого класса).

Прототипное наследование можно реализовать минимум двумя разными способами:

  • копированием всех свойств основного объекта в создаваемый на этапе его построения
  • делегированием обращений к свойтвам, не заданным в создаваемом объекте, базовому объекту

Оба способа имеют свои плюсы и минусы. Рассмотрим реализацию первого способа:

function createBaseObject() {
    return {
        foo: 'bar',
        method1: function() { return 42; }
    };
}

var base = createBaseObject();

function createChildObject() {
    var child = {
        baz: 9001,
        method2: function() { return 43; }
    };

    for (var k in base) {
        child[k] = base[k];
    }
    return child;
}

Минусы:

  • Так как каждый child объект содержит копию свойств base требуется дополнительная память.
  • Дополнительное время на копирование свойств base в child при создании.

Плюсы:

  • Скорость обращения к свойствам child'ов не страдает за счет делегирования (см. ниже).
  • Изменение base объекта после создания child'ов не влияет на уже созданные объекты (это может быть как плюсом, так и минусом).

Рассмотрим реализацию на основе делегирования (обращаю внимание, что это не JavaScript, а некоторый вымышленный язык удивительно на него похожий):

function createBaseObject() {
    return {
        foo: 'bar',
        method1: function() { return 42; }
    };
}

var base = createBaseObject();

function createChildObject() {
    return {
        baz: 9001,
        method2: function() { return 43; },
        __get__: function(prop) {
            assert !this.hasOwnProperty(prop)
            return base[prop];  // Делегирование обращения базовому объекту
        }
    };
}

Предположим, что "магический" метод __get__ переопределяет поведение при обращении к свойтвам, которые не заданы у самого объекта. Т.е. внутри __get__ вызов this.hasOwnProperty(prop) всегда возращает false.

Минусы:

  • Скорость такого кода должна быть ниже (если не рассматривать различные оптимизации под капотом языка) за счет дополнительного уровня косвенности, вводимого методом __get__.

Плюсы:

  • Создание объектов происходит быстрее.
  • Требуется меньше дополнительной памяти.

Прототипное наследование в JavaScript

JavaScript - весьма гибкий язык. Прототипное наследование в нем можно реализовать обоими способами. На самом деле, код из примера реализации прототипного наследования копированием свойств базового объекта (см. выше) является рабочим в JavaScript. Однако, JavaScript из коробки реализует функциональность, схожую с методом __get__ из второго примера. Использование родных для языка механизмов, на мой взгляд, является предпочтительным для реализации наследования, потому что они потенциально могут быть оптимизорованы движком языка.

Для ссылки на базовый объект при обращении к свойствам, не заданным у текущего объекта, используется свойство [[Prototype]]. Т.е., для того, чтобы унаследовать один объект от другого, нужно каким-либо способом задать для наследника [[Prototype]] равным ссылке на базовый объект. Простейший (но не стандартизованный до ES6 и не самый быстрый при этом) способ - это использовать свойство __proto__:

var base = {
    foo: 'bar'
};

var child = {
    baz: 42,
    __proto__: base
};

console.log(child.baz);
console.log(child.foo);  // Делегирование обращения базовому объекту

До ES6 было как минимум два "законных" способа сделать это. Первый и не самый прямолинейный - это использование ключевого слова new. Поговорим о нем чуть позже. Второй же - изобретенная Дугласом Крокфордом функция Object.create() (ссылка), которая в итоге была добавлена в сам язык.

Код из примера выше можно переписать следующим образом:

var base = {
    foo: 'bar'
};

var child = Object.create(base);  // создает новый объект с заданным прототипом
child.baz = 42;

console.log(child.baz);
console.log(child.foo);

Обернув две строки создание child в функцияю createChild() мы создадим удобную реализацию прототипного наследования от base.

Запутывающая всех конструкция new

Основная причина, усложняющая понимание реализации наследования в JavaScript - это конструкция new, добавленная в язык с целью популяризовать его, сделав похожим на языки с "классической" схемой наследования.

Как уже было сказано выше, с помощью new можно создать объект с заданным прототипом. Для этого нам понадобится функция.

var base = {
    greeting: function() { return 'Hello ' + this.person; }
};

function Greeter(person) {
    this.person = person;
}

Greeter.prototype = base;

var greeter = new Greeter('Alice');
console.log(greeter.greeting());          // prints "Hello Alice"
console.log(greeter.__proto__ === base);  // prints "true"

Функции наподобие Greeter в JavaScript называются конструкторами (а иногда не совсем корректно - классами). При вызове new Greeter() создается новый объект, this внутри конструктора ссылается на этот объект. А в качестве прототипа этого объекта задается объект Greeter.prototype. Таким образом, вводится дополнительный уровень косвенности.

This indirection was intended to make the language seem more familiar to classically trained programmers, but failed to do that, as we can see from the very low opinion Java programmers have of JavaScript. JavaScript’s constructor pattern did not appeal to the classical crowd. It also obscured JavaScript’s true prototypal nature. As a result, there are very few programmers who know how to use the language effectively. (с) Дуглас Крокфорд

Теперь, обладая этими знаниями, мы можем без труда понять первоначальную реализацию Object.create():

Object.create = function(o) {
    function F() {}
    F.prototype = o;
    return new F();
};

В результате вызова Object.create() будет создан новый пустой объект (new F()), прототипом которого будет объект o. И достигается это за счет описанной выше особенности JavaScript.

Заключение

Если подходить к вопросу наследования в JavaScript с "правильной" стороны, все оказывается достаточно просто и прозрачно. Как это зачастую и бывает в Computer Science, под капотом все просто и строится на базовых понятиях. В данном случае - на делегировании. С другой стороны, JavaScript позволяет организовать наследование множеством способов, каждый из которых по-своему "правильный", обладает определенными достоинствами и недостатками. Осознание этого может вызывать затрудненния.

Полезные ссыкли по теме