пятница, 20 февраля 2009 г.

Классическое наследование в JavaScript

Данная статья адресована, в первую очередь, программистам, пишущим на JavaScript и имеющим, как минимум, базовые знания в области объектов и наследования через прототипы. Поэтому, я не буду излагать азы (которые, при желании, можно найти по ссылкам в конце статьи [^]). Хочу просто показать, как можно реализовать классическое (классовое) наследование, используя стандартные возможности языка.

Поставим перед собой несколько целей:
  1. Наследование должно быть полноценным, т.е. конструкция object instanceof Class
    должна работать [^]
  2. Реализовать простую возможность вызова конструктора [^] или любого метода
    базового класса [^]
  3. Простота в использовании, стройность и лёгкость кода [^]
  4. По возможности, облегчить задачу современным IDE (в частности Spket) для корректного функционирования code complete по созданным объектам [^]
Итак, создадим базовый класс:

// Пишем конструктор базового класса
/**
 * Некий абстрактный человек, имеющий имя и
 * умеющий выполнять некоторые повседневные действия
 * @constructor Person
 * @param name {String} Имя человека
 */
var Person = function (name) {
    // Инициализируем public свойство name
    this.name = name;
    // Локальные переменные определённые внутри конструктора
    // можно использовать, при необходимости, как private члены класса
    // но только через специальные public методы (getter и/или setter),
    // определённые здесь же - в конструкторе
    //
    // var somePrivateVariable = {};
    // this._getSomePrivateVariable = function() {
    //     return somePrivateVariable;
    // }
}
// Описываем члены базового класса внутри wrapper-функции
// и сохраняем ссылку на неё в public static свойство $class
// для дальнейшего использования
// ПРИМЕЧАНИЕ:
//    Естественно, wrapper-функция может быть и анонимной.
//    В данном примере имя (совпадающее с именем класса) используется
//    только для того, чтобы получить более дружественный code complete в IDE

/**
 * @class Person
 */
Person.$class = function Person() {
    // Локальные переменные определённые в этой области
    // будут что-то вроде private static членов класса
    var $className = 'Person';
    // Описываем public свойства класса
    /**
    * Имя человека
    * @type {String}
    */
    this.name = '';
    // Описываем public методы класса
    /**
    * Возвращает список ежедневных действий человека
    * @returns {String}
    */
    this.performEverydayActions = function () {
        return '- Спит, ест, развлекается [as a ' + $className + ']';
    }
    /**
    * Возвращает имя человека
    * @returns {String}
    */
    this.getName = function () {
        return this.name;
    }
    this.toString = function () {
        return this.name;
    }
}
// "Компилируем" базовый класс, создавая прототип из wrapper-функции
Person.prototype = new Person.$class();

Далее, создадим производный класс:

// Пишем конструктор производного класса
/**
 * Некий абстрактный работник, имеющий имя (как и любой человек) и
 * умеющий выполнять какую-либо работу
 * @constructor Employee
 * @extends Person
 * @param name {String} Имя работника
 * @param job {String} Функция работника (или работа, которую он умеет выполнять)
 */
var Employee = function (name, job) {
    // Вызываем конструктор базового класса Person
    // (через ссылку $super, которую установим ниже),
    // чтобы проинициализировать имя при создании
    // объекта производного класса (Employee)
    arguments.callee.$super.call(this, name);
    // Инициализируем public свойство job
    this.job = job;
}
// Сохраняем ссылку на базовый класс для дальнейшего использования
Employee.$super = Person;
// Описываем члены производного класса внутри wrapper-функции
// и сохраняем ссылку на неё для дальнейшего использования
/**
 * @class Employee
 * @extends Person
 * @param $super {Object} Прототип базового класса
 */
Employee.$class = function Employee($super) {
    // Внутрь wrapper-функция производного класса будем передавать
    // в качестве агрумента ссылку на прототип базового класса.
    // Это существенно облегчит обращение к методам базового класса (см. ниже)

    var $className = 'Employee';

    // Описываем public свойства класса
    /**
     * Функция работника
     * @type {String}
     */
    this.job = '';
    
    // Описываем public методы класса
    /**
     * Возвращает функцию работника
     * @returns {String}
     */
    this.getJob = function () {
        return this.job;
    }
    // Переопределяем метод базового класса
    /**
     * Возвращает список ежедневных действий человека, включая его рабочие функции
     * @returns {String}
     */
    this.performEverydayActions = function () {
        // Вызываем метод базового класса
        return $super.performEverydayActions.call(this) +
                '\n- ' + this.job + ' [as an ' + $className + ']';
    }
}
// Устанавливаем наследование производного класса
// от базового класса через прототипы
Employee.$class.prototype = Employee.$super.prototype;
// "Компилируем" класс, создавая прототип из wrapper-функции
// и передавая в качестве аргумента прототип базового класса
Employee.prototype = new Employee.$class(Employee.$super.prototype);

Теперь, создадим класс производный от производного (именно на этом шаге многие попытки реализовать классовое наследование терпели неудачу):

// Пишем конструктор ещё одного производного класса
/**
 * Программист, который может писать код
 * @constructor Developer
 * @extends Employee
 * @param {String} name Имя
 */
var Developer = function (name) {
    // Вызываем конструктор базового класса (Employee)
    arguments.callee.$super.call(this, name, 'Пишет код');
}

// Т.к. класс Developer не имеет собственных членов, то wrapper - пустая функция 
/**
 * @class Developer
 * @extends Employee
 */
Developer.$class = function () {};
// Сохраняем ссылку на базовый класс
Developer.$super = Employee;
// Устанавливаем наследование
Developer.$class.prototype = Developer.$super.prototype;
// "Компилируем" класс
Developer.prototype = new Developer.$class(Developer.$super.prototype);

Ещё немного усложним - создадим третий производный класс:

// Пишем конструктор последнего производного класса
/**
 * Ведущий инженер-программист, который ещё и командой разработчиков управляет
 * @constructor LeadDeveloper
 * @extends Developer
 * @param {String} name Имя
 * @param {Developer[]} team Команда программистов
 */
var LeadDeveloper = function(name, team) {
    // Вызываем конструктор базового класса (Developer)
    arguments.callee.$super.call(this, name);
    // Инициализируем public свойство team
    this.team = team;
}
// Сохраняем ссылку на базовый класс
LeadDeveloper.$super = Developer;
// Описываем члены класса
/**
 * @class LeadDeveloper
 * @extends Developer
 * @param $super {Object} Прототип базового класса
 */
LeadDeveloper.$class = function LeadDeveloper($super) {

    var $className = 'LeadDeveloper';

    // Описываем public свойства класса
    /**
     * Команда программистов, которой руководит данный Lead
     * @type {Developer[]}
     */
    this.team = [];
    
    // Описываем public методы класса
    /**
     * Возвращает подчиненных данного Lead-а
     * @returns {Developer[]}
     */
    this.getTeam = function () {
        return this.team;
    }
    // Переопределяем метод базового класса
    /**
     * Возвращает список ежедневных действий человека, включая его рабочие функции
     * @returns {String}
     */
    this.performEverydayActions = function () {
        // Вызываем метод базового класса
        return $super.performEverydayActions.call(this) +
                '\n- Управляет командой: [as a ' +
                $className + ']\n    ' + this.team.join('\n    ');
    }
}
// Устанавливаем наследование
LeadDeveloper.$class.prototype = LeadDeveloper.$super.prototype;
// "Компилируем" класс
LeadDeveloper.prototype = new LeadDeveloper.$class(LeadDeveloper.$super.prototype);

Теперь проверим, что получилось:

var ivanov = new Person('Иванов Василий');
alert(ivanov.getName() + ':\n' + ivanov.performEverydayActions());
// Иванов Василий:
// - Спит, ест, развлекается [as a Person]

var petrova = new Employee('Петрова Мария','Доит коров');
alert(petrova.getName() + ':\n' + petrova.performEverydayActions());
// Петрова Мария:
// - Спит, ест, развлекается [as a Person]
// - Доит коров [as an Employee]

var sidorov = new Developer('Сидоров Алексей');
var pupkin = new Developer('Пупкин Иван');
var shishkin = new Developer('Шишкин Пётр');
alert(sidorov.getName() + ':\n' + sidorov.performEverydayActions());
// Сидоров Алексей:
// - Спит, ест, развлекается [as a Person]
// - Пишет код [as an Employee]

var myshkin = new LeadDeveloper('Мышкин Фёдор', [sidorov, pupkin, shishkin]);
alert(myshkin.getName() + ':\n' + myshkin.performEverydayActions());
// Мышкин Фёдор:
// - Спит, ест, развлекается [as a Person]
// - Пишет код [as an Employee]
// - Управляет командой: [as a LeadDeveloper]
//     Сидоров Алексей
//     Пупкин Иван
//     Шишкин Пётр 

alert(myshkin instanceof Person);
// true 
alert(myshkin instanceof Employee);
// true 
alert(myshkin instanceof Developer);
// true 
alert(myshkin instanceof LeadDeveloper);
// true 

[Весь предыдущий код одним файлом: classes-start.js]



Всё отлично работает. Code complete в Spket без проблем выдаёт список свойств и методов объекта myshkin, причем по каждому из них указан класс, который их содержит:


Осталось сделать красивую "обёртку":

Function.prototype.$extends = function ($super) { 
    this.$super = $super;
    return this;
}
Function.prototype.$class = function ($class) {
    if ($class == null) {
        $class = function () {};
    }
    this.$class = $class;
    if (this.$super != null) this.$class.prototype = this.$super.prototype;
    this.prototype = new $class(this.$super!=null ? this.$super.prototype : null);
    return this;
}

И "упаковать" (для примера, только два последних класса, чтобы не повторять опять гору кода):

var Developer = function (name) {

    arguments.callee.$super.call(this, name, 'Пишет код');

}.$extends( Employee ).$class();


var LeadDeveloper = function(name, team) {

    arguments.callee.$super.call(this, name);
    this.team = team;

}.$extends( Developer ).$class( function ($super) {

    var $className = 'LeadDeveloper';

    this.team = [];
    
    this.getTeam = function () {
        return this.team;
    }
    this.performEverydayActions = function () {
        return $super.performEverydayActions.call(this) +
                '\n- Управляет командой: [as a ' +
                $className + ']\n    ' + this.team.join('\n    ');
    }

});

[Финальная реализация одним файлом: classes-final.js]

К сожалению, данная конструкция уже не по зубам IDE и code complete в финальной варианте всё-таки работать не будет. Однако, есть некоторые хитрости, которые позволят извлечь максимум пользы из всего вышеизложенного на различных фазах девелопмента.

Но об этом как-нибудь в другой раз...


Ссылки по теме: