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

В этой статье мы поговорим о наследовании классов в JavaScript. Большая часть этой заметки — перевод статьи Douglas Crockford «Classical Inheritance in JavaScript». Буду рад получить от вас замечания и предложения по улучшению! Итак, начнём!

В языке JavaScript классов нет. Это, как и многие другие, объектно-ориентированный язык, который использует наследование на прототипах вместо наследования на классах. Этот факт может вводить разработчиков, которые учились на общепринятых объектно-ориентированных языках, таких как C++, Java и т.д., в замешательство. У JavaScript’ового прототипного наследования, как мы дальше увидим, есть свои преимущества в выразительности.

Для начала давайте сравним языки JavaScript и, например, Java по их ООП-критериям:

Язык JavaScript Java
Контроль типов Слабо типизированный Сильно типизированный
Проверка типов Динамическая типизация Статическая типизация
Наследование Прототипы Классы
Представление класса Функции Классы
Конструкторы класса Функции Конструкторы
Методы объектов Функции Методы

Но перед этим давайте ответим на вопрос, зачем нам вообще это наследование? А наследование нам необходимо по двум причинам.

Во-первых, удобство типов. Мы хотим, чтоб язык внутри себя автоматически приводил ссылки на похожие классы. Скудная типобезопасность получается из системы типов, требующей рутинного явного приведения типов. Это очень важно в сильно типизированных языках, но не имеет никакого отношения к слабо типизированным (таким, как JS), где вообще нет необходимости приводить типы объектов.

Во-вторых, переиспользование кода. Распространённая практика — сделать множество объектов, реализующих одни и те же методы. Классы позволяют создать их всех из единого набора определений. Также часто создаются схожие объекты, отличающиеся лишь некоторыми методами или свойствами. Наследование классов полезно для этих целей, но(!) прототипное наследование ещё полезнее.

Для того, чтоб показать и доказать это, мы введём «сахар», который позволит нам писать в стиле, похожем на языки с классическим ООП. После этого мы посмотрим полезные паттерны, которые не получится использовать в классических языках. И в завершении статьи я дам разъяснения «сахару».

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

Сначала мы создадим класс Parenizor, у которого для его свойства будут set и get методы и toString метод, оборачивающий значение в скобки.

function Parenizor(value) {
    this.setValue(value);
}

Parenizor.method('setValue', function (value) {
    this.value = value;
    return this;
});

Parenizor.method('getValue', function () {
    return this.value;
});

Parenizor.method('toString', function () {
    return '(' + this.getValue() + ')';
});

Синтаксис немного необычен, но в нём легко узнать паттерн классов. Метод «method» принимает название метода и функцию, добавляя их к классу, как публичный метод.
Теперь мы можем делать так:

myParenizor = new Parenizor(0);
myString = myParenizor.toString();

Тут никаких неожиданностей. Переменная myString равна «(0)».

Теперь мы создадим другой класс, который будет наследоваться от Parenizor и отличаться от родительского реализацией метода toString.

function ZParenizor(value) {
    this.setValue(value);
}

ZParenizor.inherits(Parenizor);

ZParenizor.method('toString', function () {
    if (this.getValue()) {
        return this.uber('toString');
    }
    return "-0-";
});

Метод inherits похож на ключевое слово extends в Java. Метод uber похож на ключевое слово super в Java. Он позволяет вызвать метод из родительского класса.

Теперь мы можем написать:

myZParenizor = new ZParenizor(0);
myString = myZParenizor.toString();

На этот раз, myString равна «-0-«.

Несмотря на то, что JavaScript не имеет классов, мы можем писать код почти так, как если бы они были.

Множественное наследование

При помощи манипуляций объекта прототипа функции мы можем реализовать множественное наследование, позволяющее нам делать класс, состоящий из методов нескольких классов.

Реализация неразборчивого множественного наследования может быть сложной и потенциально подвержена коллизии названий методов. Мы могли бы реализовать неразборчивое множественное наследование в JavaScript, но для этого примера мы используем более строгую форму, называемую Швейцарское наследование (Swiss Inheritance).

Предположим, имеется класс NumberValue, у которого есть метод setValue, проверяющий числовое значение на попадание в заданный диапазон и кидающий исключение в случае ошибки в проверке. Мы хотим добавить методы setValue и setRange в наш класс ZParenizor. Но(!) при этом его метод toString нам не нужен.

В таком случае, мы пишем так:

ZParenizor.swiss(NumberValue, 'setValue', 'setRange');

Такое выражение добавляет только запрашиваемые методы в наш класс.

Паразитическое наследование

Есть ещё один способ написать ZParenizor. Вместо наследование от Parenizor, мы создаём конструктор, который вызывает родительский конструктор Parenizor, выдавая результат, как собственный. И вместо добавления публичных методов, конструктор создает привилегированные.

function ZParenizor2(value) {
    var that = new Parenizor(value);
    that.toString = function () {
        if (this.getValue()) {
            return this.uber('toString');
        }
        return "-0-"
    };
    return that;
}

Наследование классов похоже на отношения «есть a», а паразитическое наследование больше похоже на отношение «было a, а стало b». Теперь у конструктора роль в создании объекта увеличилась. Заметьте, что метод uber, показывающий родителя, также доступен для привилегированных методов.

Приращение классов

Как было сказано в начале статьи, JavaScript — язык с динамической типизацией. Этот факт позволяет нам добавлять или удалять методы существующих классов. Мы в любое время можем вызвать метод method, и у всех существующих и новых создаваемых экземпляров класса будет этот метод. Также можно менять класс литерально. Наследование будет работать «задним числом», т.е. если мы когда-то создали экземпляр какого-то класса, при последующем изменении класса будет меняться и его экземпляр. Поэтому мы называем это явление приращением класса, чтобы избежать путаницы с ключевым словом extends в Java, которое означает нечто иное.

Приращение объектов

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

Теперь, основываясь на вышенаписанном, можно вообще избавиться от ZParenizor. Для этого нужно изменить экземпляр класса Parenizor вот так:

myParenizor = new Parenizor(0);
myParenizor.toString = function () {
    if (this.getValue()) {
        return this.uber('toString');
    }
    return "-0-";
};

myString = myParenizor.toString();

Мы добавили метод toString в экземпляр нашего класса myParenizor без использования какой бы то ни было формы наследования. Мы можем видоизмениять отдельные объекты, потому что JS — язык без классов.

Сахар

Для того, чтоб работали примеры, написанные выше, были написаны четыре «сахарных» метода. Первый из них — метод method, добавляющий методы к классу.

Function.prototype.method = function (name, func) {
	if(typeof func == 'function') {
    	this.prototype[name] = func;
    } else {
    	throw new Error('Wrong method ' + name + ' value');
    }
    return this;
};

Данный код добавляет публичный метод method в Function.prototype, поэтому все функции получат к нему доступ через приращение классов. В самом методе происходит проверка корректности типа переданной функции (чтоб потом не пытаться вызвать, например, число) и добавление публичного метода в прототип функции.

Данный метод возвращает this. Возьмите на заметку полезную практику — если метод не должен ничего возвращать, возвращайте this. Это позволит писать код в каскадном стиле, создавая цепочки вызовов.

Далее идёт метод inherits, показывающий, что один класс наследуется от другого. Он должен быть вызван после того, как оба класса были определены, но перед тем, как добавляются методы наследующего класса.

Function.method('inherits', function (parent) {
    this.prototype = new parent();
    var d = {}, 
        p = this.prototype;
    this.prototype.constructor = parent; 
    this.method('uber', function uber(name) {
        if (!(name in d)) {
            d[name] = 0;
        }        
        var f, r, t = d[name], v = parent.prototype;
        if (t) {
            while (t) {
                v = v.constructor.prototype;
                t -= 1;
            }
            f = v[name];
        } else {
            f = p[name];
            if (f == this[name]) {
                f = v[name];
            }
        }
        d[name] += 1;
        r = f.apply(this, Array.prototype.slice.apply(arguments, [1]));
        d[name] -= 1;
        return r;
    });
    return this;
});

Вновь мы приращаем Function. Внутри метода создаётся экземпляр родительского класса, который используется в качестве нового прототипа. Вдобавок мы изменили поле конструктора и добавили метод uber в прототип.

Метод uber ищет именованный метод в своём прототипе. Это функция для вызова в случае паразитического наследования или приращения объекта. Если мы делаем наследование классов, тогда нам нужно искать функцию в родительском прототипе. Возвращаемое выражение использует метод функции apply для вызова функции с явным указанием контекста this и передачей массива параметров. Параметры (если они имеются) получаются из встроенного массива arguments. К сожалению, массив arguments не является экземпляром настоящего списка, поэтому мы используем метод apply ещё раз для вызова метода slice массива.

Наконец, мы делаем метод swiss.

Function.method('swiss', function (parent) {
    for (var i = 1; i < arguments.length; i += 1) {
        var name = arguments[i];
        this.prototype[name] = parent.prototype[name];
    }
    return this;
});

Метод swiss пробегает по всем элементам списка arguments. Для каждого имени он копирует значение из родительского прототипа в прототип нового класса.

Заключение

JavaScript может быть использован, как язык с классами, но у него также есть своя выразительность, достаточно уникальная. Мы взглянули на Классовое, Швейцарское и Паразитическое наследование, а также приращение классов и объектов. Этот набор паттернов переиспользования кода получается из языка, который считается меньше и проще Java.

Наследование классов - жёсткое. Единственный способ добавить новое свойство или метод к жёсткому объекту - создать новый класс. В JavaScript'е объекты мягкие. Новые методы и свойства могут быть добавлены мягкому объекту путём простого присваивания значения.

Из-за того, что объекты в JS являются такими гибкими, вы захотите иначе думать об иерархиях классов. Глубокие иерархии ни к чему, а мелкие иерархии - эффективные и выразительные.

За всё время работы с JavaScript мне никогда не приходилось использовать метод uber. Идея super достаточно важна для паттерна классов, но она, кажется, лишней в прототипном и функциональном паттерне.

Я рад, что ты дочитал статью! Если она тебе понравилась, делись ссылкой с друзьями. До новых встреч! :)