ООП в JavaScript. Часть 2
Воскресенье, 21 апреля 2013

Часть 1

private

Теперь, когда мы разобрались с наследованием, пришла пора посмотреть и на другие концепции из объектно ориентированного программирования. И начнём с ограничения доступа. Вероятно, вы догадались, что полноценных приватных методов и полей в JS нет и не планировалось. Но есть два различных подхода, чтобы ими обзавестись.

Замыкания

Я надеюсь, рассказывать, что такое замыкания, не требуется. В крайнем случае можете почитать какой-нибудь умный учебник.

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

var SomeClass = function (value) {
  var someVar = value;

  this.getSomeVar = function () {
    return someVar;
  }
};

Помните наш первый медленный вариант наследования? Это как раз он. Медленно, зато работает.

Аннотации

Второй подход активно используется, например, в Google Closure. Он не требует каких-то особых ухищрений, просто небольшая договорённость. Например, пусть все приватные поля и методы начинаются с подчёркивания (или заканчиваются подчёркиванием). И больше ничего, доступ к этим методам будет на нашей совести. Никаких предосторожностей, зато будет быстро работать.

Этот метод отлично работает со статическим анализатором кода, который, например, есть в Google Closure Compiler. Помечаем метод приватным — и компилятор будет выдавать предупреждения при попытке обратиться из-вне. Для этой цели используется специальные JSDoc-аннотации. Предыдущий пример с учётом всего сказанного будет выглядеть так:

/**
 * @param {number} value
 * @constructor
 */
var SomeClass = function (value) {
  this._someVar = value;
};

/**
 * @type {number}
 * @private
 */
SomeClass.prototype._someVar = 0;

/**
 * @returns {number}
 */
SomeClass.prototype.getSomeVar = function () {
  return this._someVar;
};

static

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

var SomeClass = function () {};
SomeClass.staticVar = 0;
SomeClass.staticMethod = function () {};

Так как данные поля принадлежат объекту конструктора, их можно считать статическими. Таким образом, получается вот такая структура:

Обращаться к таким полям/методам придётся тоже через имя конструктора: SomeClass.staticMethod().

Однако это ещё не всё. JavaScript достаточно гибок, чтобы дать нам возможность самим организовать позднее статическое связывание (late static binding). Это, конечно, не так легко, но мы справимся.

Для начала определимся, что такое позднее статическое связывание. Поправьте меня, если я ошибаюсь, но с таким определением сталкиваются в основном программисты на PHP: в других языках подобная функциональность была заложена ещё в самом начале.

Итак, допустим, у нас есть два класса: ParentClass и ChildClass. В ParentClass объявлено статическое свойство NAME, содержащее имя класса, и статический метод getName(), который это имя возвращает. В ChildClass мы переопределяем свойство NAME и присваиваем ему другое имя.

Так вот, в случае раннего связывания ChildClass.getName() будет возвращать ParentClass.NAME, потому что в этом методе стоит явная ссылка на класс, которому это свойство принадлежит. Т.е. getName() определена как-то так:

ParentClass.getName = function () {
  return ParentClass.NAME;
};

А в случае позднего статического связывания класс, который вызвал данный метод, определяется на этапе выполнения функции и поэтому ChildClass.getName() вернет ChildClass.NAME.

ParentClass.getName = function () {
  var ctor = <каким-то образом определенный класс>;
  return ctor.NAME;
};

А теперь попробуем позднее связывание реализовать.

Вариант 1

Первый вариант будет работать, если у нас доступно свойство __proto__.

var ParentClass = function () {};
ParentClass.NAME = 'ParentClass';
ParentClass.getClassName = function () {
  return this.NAME;
}

var ChildClass = function () {};
ChildClass.NAME = 'ChildClass';
ChildClass.__proto__ = ParentClass;

Когда мы вызываем ChildClass.getClassName(), интерпретатор ищет в дереве наследования getClassName и выполняет этот метод в контексте ChildClass. Т.е. this.NAME == ChildClass.NAME, что и требовалось получить. Если бы NAME был не определён, то интерпретатор искал бы и его в дереве наследования.

Вариант 2

В случае, если __proto__ не доступно, придётся воспользоваться более сложными методами. В таком случае можно вынести все статические свойства в отдельный объект и наследовать их параллельно.

var ParentClass = function () {};
ParentClass.static = {};
ParentClass.static.NAME = 'ParentClass';
ParentClass.static.getClassName = function () {
  return this.NAME;
};
ParentClass.prototype.static = ParentClass.static;

var ChildClass = function () {};

// Главная цепочка наследования
var tempCtor = function () {};
tempCtor.prototype = ParentClass.prototype;
ChildClass.prototype = new tempCtor();

// Наследование статических свойств
tempCtor.prototype = ParentClass.static;
ChildClass.static = new tempCtor();
ChildClass.prototype.static = ChildClass.static;

ChildClass.static.NAME = 'ChildClass';

Попробуем разобрать с той «простыней», которая у нас получилась.

Получаются 2 цепочки прототипов: одна для объектов, другая для статических полей и методов. Временная переменная tempCtop используется для тех же целей, что и в методе наследования с временным конструктором.

Получить доступ к статическим свойствам можно, например, так: ChildClass.static.NAME. Если нужно получить доступ из какого-либо метода класса, то это можно сделать через this.static (именно для этого у нас есть конструкция ChildClass.prototype.static = ChildClass.static). И, наконец, внутри статического метода все остальные статические методы и поля будут доступны просто через this, например, this.NAME. Весьма неплохо, как мне кажется.

private static

Лучший способ получить private static свойство — замкнуть переменную внутри анонимной функции. Хотя никто не мешает использовать способ с аннотациями и Google Closure Compiler. Покажу на примере синглтона:

var getClassInstance = function () {
  var instance;

  var Class = function () {
  };
  Class.prototype.method = function () {};

  return function () {
    if (!instance) {
      instance = new Class();
    }

    return instance;
  }
}();

superclass

Со ссылкой на родительский класс вообще всё просто — её нет. Поэтому, если вам нужна такая, добавьте её самостоятельно. Расширим пример с правильным вариантом наследования ещё одной строчкой:

var tempCtor = function () {};
tempCtor.prototype = ParentClass.prototype;
ChildClass.prototype = new tempCtor();
ChildClass.prototype.superclass = ParentClass;

Результат будет выглядеть так:

Вызвать метод предка можно так: this.superclass.prototype.method.call(this, arg0, arg1, ...).

Есть ещё один вариант, который больше подходит для использования в какой-нибудь библиотеке. Заключается он в том, чтобы добавить в прототип класса метод, скажем, inherited, который будет смотреть, кто его вызвал, а затем искать в дереве наследования метод с таким же именем и вызывать его. Работать такой метод, несомненно, будет медленнее, но и избавит от необходимости писать много букв и помнить, к каком классу принадлежит перекрываемый метод. Достаточно будет просто написать this.inherited(arg0, arg1, ...). В простом варианте данный метод будет выглядеть, например, так:

Object.prototype.inherited = function () {
    var proto = this.constructor.prototype;
    var method;
    var caller = arguments.callee.caller; 
    for (var i in proto) {
        if (proto[i] === caller) {
            method = i;
            break;
        }
    }

    if (method) {
        if (proto.superclass && 
            proto.superclass.prototype[method] !== undefined) {
            return proto.superclass.prototype[method].apply(this, arguments);
        }    
    }
};

Обратите внимание на переменную proto. Чтобы данная функция заработала, нужно самим добавлять в прототип объекта свойство constructor, указывающее на класс, к которому принадлежит данный объект. Так ведь свойство constructor уже есть, можете сказать вы. Но нужно помнить, что не все браузеры его поддерживают, поэтому лучше перестраховаться.

А если у нас новый браузер или node.js, то можно воспользоваться свойством __proto__.

Ключевое слово this

С ним всё на самом деле просто. В отличии от всех обычных объектно ориентированных языков, в которых this является указателем на текущий экземпляр класса, в JavaScript this указывает на контекст вызова функции. Т.е. если мы написали obj.method(), то внутри функции method значение this = obj. А если мы скопировали функцию в какой-нибудь другой объект (obj2.method = obj.method) и вызвали obj2.method(), то внутри this == obj2.

А что получится, если мы вызываем просто функцию, не принадлежащую никакому объекту? В этом случае this будет равен корневому объекту. Это тот же объект, в который попадают все наши глобальные переменные, и он называется window в том случае, когда речь заходит о браузерном JavaScript.

У объекта функции есть 2 стандартных метода, которые позволяют изменить контекст вызова: call и apply. Вы видели пример использования call, когда речь шла о вызове метода предка. Эти два метода различаются только способом передачи параметров: call ожидает первым параметром новый контекст, а потом все необходимые для передачи параметры. Для вызова же apply нужно 2 параметра: новый контекст и массив с параметрами, передаваемыми в функцию.

Пространства имён

Как таковых пространств имён в JavaScript нет. Но многие библиотеки применяют некую эмуляцию. Для этого просто используются глобальные объекты, в которые уже помещают необходимые классы. При этом обращение выглядит приблизительно так: some.namespace.SomeClass. Понятно, что объявить конструктор прямо так не получится:

some.namespace.SomeClass = function () {};

А не получится это потому, что some может быть не определено или some.namespace может не существовать. Значит, перед использованием пространства имен его нужно определить. Обычно это выглядит так:

some = some || {};
some.namespace = some.namespace || {};

Или же можно использовать какую-нибудь специальную функцию, которая сделает это за нас. Пример из Ext.js:

Ext.ns(‘some.namespace’);

Или вот пример из Google Closure Library:

goog.provide(‘some.namespace’);

Пример библиотеки

Чтобы всё обобщить попробуем создать свою небольшую библиотеку для работы с классами. Посмотреть конечный результат можно на GitHub. Сразу оговорюсь, что данная библиотека не предназначена для использования в реальных системах, а служит скорее примером, как можно собрать все те данные, что я привёл, в единое целое. Если увидите там неточность, скажите мне, я поправлю.

Расширение стандартных объектов

Расскажу про ещё одну интересную возможность JavaScript, которую трудно встретить в каком-нибудь другом нединамическом языке — расширение стандартных объектов. Связана она с тем, что прототип любого объекта — тоже объект, а значит его можно изменять. Т.е. когда нам нужен ещё один метод в классе Array, то мы можем его легко добавить:

Array.prototype.doNothing = function () {};

Эта конструкция добавит doNothing во всё массивы, включая уже созданные. А все потому, что прототип массива — это ссылка на Array.prototype. Даже больше, если мы создадим наследника от Array, то его экземпляры тоже получат изменения.

На этом эффекте основаны различные так называемые shim-библиотеки. Они добавляют в прототипы классов методы, которых почему-то там не хватает.

if (!Array.prototype.indexOf) {
    Array.prototype.indexOf = function () {...};
}

Google Closure

И напоследок хочу сказать пару слов о таком наборе инструментов от компании Google, как Google Closure, и в первую очередь о компиляторе. Дело в том, что он выполняет статическую проверку типов и позволяет на этапе компиляции увидеть проблемы кода. Так же работают компиляторы статических языков вроде Java. Например, он может следить, чтобы вы не пользовались приватными членами класса ниоткуда, кроме как из самого класса. Или же смотреть, чтобы вы не смогли передать параметры неверных типов в функцию. Конечно же это ограничивает вас в использовании языка, но вместе с тем уберегает от множества ошибок.

Ещё одна немаловажная часть Google Closure — это огромная библиотека. Если кого-то отпугивает её размер и сложность, учтите, что она разрабатывалась для написания очень больших JavaScript-приложений. На её основе сделаны большинство сервисов Google, в том числе Gmail, Google Reader и Google+1. Ещё кому-то библиотека может показаться слишком многословной, но не забываем, что написано так для того, чтобы Closure Compiler генерировал наиболее компактный и быстрый код.

Так вот, чтобы более эффективно использовать Google Closure Compiler, нам будет достаточно всего одного файла из библиотеки — base.js. В нем описаны функции для наследования, вызова предков, управления зависимостями между js-файлами, а также некоторое количество дополнительных вспомогательных функций, большую часть из которых следует воспринимать скорее как инструкции для компилятора.

В любом случае попробуйте. Если вы любите красивый код и у вас есть страсть к статической типизации, то это для вас.

Вместо заключения

Каждый для себя решает: использовать ли ему объектно-ориентированное программирование или нет. Конечно, если вы создаёте небольшую страничку с парой несложных эффектов, то лучше будет воспользоваться jQuery и не заморачиваться. Но если вы под действием новостей пишете замену медленно уходящему на покой Google Reader, то без объектно-ориентированного подхода будет трудно. Я же в свою очередь всего лишь показал вам способ, как можно перенять лучшие практики вроде шаблонов проектирования и начать, наконец, писать понятный и структурированный код.


  1. Информация с официального сайта библиотеки.

← ООП в JavaScript. Часть 1Регистрация на централизованное тестирование →

Хочется что-то добавить или сказать? Я всегда рад обсудить. Пишите на me@dikmax.name.