in ECMAScript

Тонкости ECMA-262-3. Часть 7.1. ООП: Общая теория.

Read this article in: English, French.

Введение

В данной статье мы рассмотрим ключевые моменты, касающиеся объектно-ориентированного программирования в ECMAScript. Чтобы данный материал не получился “очередным” (ведь подобная тема поднималась уже не раз в различных статьях), больше внимания будет уделено, опять же, теоретическим аспектам, чтобы увидеть эти процессы изнутри. В частности, мы рассмотрим алгоритмы конструирования объектов, увидим, как строятся связи между ними (в том числе, и основная связь – наследование), а также дадим чёткие определения, которыми можно оперировать (что, надеюсь, развеет какие-то терминологические и идеологические сомнения и путаницы, возникающие часто в статьях об ООП в JavaScript).

Общие положения, парадигмы и идеология

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

ECMAScript является мультипарадигменным языком программирования, в числе которых: структурная, объектно-ориентированная, функциональная, императивная и, в определённых моментах, аспектно-ориентированная; но, поскольку статья посвящена ООП, дадим определение ECMAScript относительно этой сущности:

ECMAScript – это объектно-ориентированный язык программирования с прототипной организацией.

Прототипная организация ООП имеет ряд отличий от статично-классовой парадигмы. Рассмотрим их подробней.

Особенности классовой и прототипной организаций

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

Этот момент выделен, поскольку довольно часто в различных статьях и на форумах, называя JavaScript “другим”, основным моментом противопоставляют пару: “класс vs. прототип”, тогда как наличие лишь данной разницы в некоторых реализациях (например, в динамично-классовых Python или Ruby) – не столь существенно (и, при соблюдении определённых условий, JavaScript становится не таким уж и “другим”, хотя, отличия в определённых идеологических моментах, всё же, имеются). Наиболее же существенным, является противопоставление пар “статика + классы vs. динамика + прототипы” – именно совокупность статики и классов (примеры: C++, Java), а также, сопутствующие им механизмы разрешения свойств/методов, позволяют чётко увидеть разницу от прототипной реализации.

Но, давайте по порядку. Рассмотрим общую теорию и ключевые моменты этих парадигм.

Статическая классовая организация

В классовой организации присутствует понятие класса (class) и сущности (instance), принадлежащей данной классификации. Сущности класса также часто называют объектами (object) или экземплярами.

Классы и объекты

Класс представляет собой формальное абстрактное множество обобщённых характеристик сущности (знаний об объектах).

Понятие множество в этом отношении более близко к математике, однако, можно говорить о типе или классификации.

Пример (здесь и ниже – примеры будут псевдокодом):

C = Class {a, b, c} // класс C, с характеристиками a, b, c

К характеристикам сущностей относятся свойства (описание объекта) и методы (активность объекта).

Сами характеристики, также могут быть представлены объектами: т.е. является ли свойство изменяемым, удаляемым, активным (геттер/сеттер) и т.д.

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

C = Class {a, b, c, method1, method2}

c1 = {a: 10, b: 20, c: 30} // объект с1 класса С
c2 = {a: 50, b: 60, c: 70} // объект с2 со своим состоянием, того же класса С
Иерархическое наследование

Для улучшения повторного использования кода (code reuse), классы могут расширять (extend) другие классы, внося необходимые дополнения. Этот механизм называется (иерархическим) наследованием.

D = Class extends C = {d, e} // {a, b, c, d, e} 
d1 = {a: 10, b: 20, c: 30, d: 40, e: 50}

Разрешение имён методов при вызове методов из экземпляра осуществляется жёстким, неизменным, последовательным опросом класса, на наличие того или иного метода; если метод не найден в родном классе, продолжается поиск в классе-предке, в предке класса-предка и т.д. – в жёсткой иерархической цепи. Если, по достижении самого базового звена наследования, метод остаётся неопределённым (неразрешённым), делается вывод: объект не обладает (в своём множестве и иерархической цепи) подобным поведением, и получить желаемый результат невозможно.

d1.method1() // D.method1 (нет) -> C.method1 (да)
d1.method5() // D.method5 (нет) -> C.method5 (нет) -> нет результата

В отличии от методов, которые не копируются при наследовании, а образуют иерархию, свойства всегда копируются в класс-потомок. Мы можем видеть это на примере класса D, предком которого является класс С: свойства a, b и c cкопированы. В итоге, структура D следующая: {a, b, c, d, e}. Однако методы {method1, method2} не были скопированы, но унаследованы. Поэтому, расход памяти в этом отношении, прямо пропорционален глубине иерархии. Основной недостаток здесь тот, что, даже, если объекту на третьем уровне вложенности не нужны какие-то свойства, они всё равно будут в нём присутствовать.

Ключевые моменты классовой модели

Итак, имеем следующие ключевые моменты:

  • чтобы породить объект, нужно перед этим обязательно описать его класс;
  • при этом, объект будет создан по “образу и подобию” (структуре и поведению) своей классификации;
  • разрешение методов осуществляется в жёсткой цепи наследования;
  • классы-потомки (и соответственно, порождаемые от них объекты) содержат все свойства цепи наследования (даже, если какие-то из этих свойств не нужны конкретному унаследованному классу);
  • будучи порождённым, класс не может (в виду статической организации) изменить набор характеристик (ни свойств, ни методов) своих экземпляров;
  • экземпляры (вновь, в виду жёсткой статической организации) не могут обладать ни дополнительным (своим уникальным) поведением, ни дополнительными свойствами, отличными от структуры и поведения своего класса.

Посмотрим, что предлагает альтернативная ООП организация, на базе прототипов.

Прототипная организация

Здесь основным понятием являются динамические изменяемые (мутируемые) объекты (dynamic mutable objects).

Мутации (полная изменяемость: не только значений, но и всех характеристик) непосредственно связаны с динамикой языка.

Такие объекты могут самостоятельно хранить все свои характеристики (свойства, методы) и в классе не нуждаются.

object = {a: 10, b: 20, c: 30, method: fn};
object.a; // 10
object.c; // 30
object.method();

Более того, в виду динамики, они могут свободно изменять (добавлять, удалять, модифицировать) свои характеристики:

object.method5 = function () {...}; // добавили новый метод
object.d = 40; // добавили новое свойство "d"
delete object.c; // удалили свойство "с"
object.a = 100; // модифицировали свойство "а"

// в итоге: object: {a: 100, b: 20, d: 40, method: fn, method5: fn};

То есть, при присвоении, если определённая характеристика не существует в объекте, она создаётся и инициализируется переданным значением; если существует, – производится её модификация.

Повторное использование кода в данном случае достигается не за счёт расширения классов (обратите внимание, ни о каких классах, как о множествах жёстких характеристик, речи не идёт; здесь их вообще нет), а посредством обращения к, так называемому, прототипу.

Прототип (Prototype) — это объект, служащий либо прообразом для других объектов, либо вспомогательным объектом (делегатом), к характеристикам которого может обратиться оригинальный объект, в случае, если сам оригинальный объект не обладает нужной характеристикой.

Делегирующая модель

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

Я напомню, мы сейчас ведём разговор об общей теории, мало касаясь реализаций; когда будем разбирать конкретные реализации (и, в частности, ECMAScript), увидим ряд своих особенностей.

Пример (псевдокод):

x = {a: 10, b: 20};
y = {a: 40, c: 50};
y.[[Prototype]] = x; // x – прототип y

y.a; // 40, собственная характеристика
y.c; // 50, тоже собственная
y.b; // 20 – полученная из прототипа: y.b (нет) -> y.[[Prototype]].b (да): 20

delete y.a; // удалили собственную "а"
y.a; // 10 – получена из прототипа

z = {a: 100, e: 50}
y.[[Prototype]] = z; // изменили прототип y на z
y.a; // 100 – получена из прототипа
y.e // 50, тоже – получена из прототипа

z.q = 200 // добавили новое свойство в прототип
y.q // изменения отобразились и на y

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

Этот механизм называется делегацией (delegation), а связанная с ним прототипная модель, – делегирующим прототипированием.

Обращение к характеристикам в данном случае называется посылкой сообщения объекту. Т.е., когда объект не может ответить на сообщение самостоятельно, он обращается к своему прототипу (делегирует ему полномочия за ответ).

Повторное использование кода в данном случае называется делегирующим наследованием (delegation based inheritance) или наследованием, основанным на прототипах (prototype based inheritance).

Поскольку прототипом может быть любой объект, соответственно, и у прототипов, могут быть свои прототипы. Данная комбинация связанных между собой прототипных объектов образует, так называемую, цепь прототипов (prototype chain). Она, так же, как и в статичных классах, иерархична, однако, в виду мутаций может свободно перегруппировываться, изменяя иерархию и состав.

x = {a: 10}

y = {b: 20}
y.[[Prototype]] = x

z = {c: 30}
z.[[Prototype]] = y

z.a // 10

// z.a найдено по цепи прототипов:
// z.a (нет) ->
// z.[[Prototype]].a (нет) ->
// z.[[Prototype]].[[Prototype]].a (да): 10

Если объект и его цепь прототипов, не смогли ответить на сообщение, объект может активировать соответствующий служебный сигнал, отреагировав на который, можно продолжить диспетчеризацию и делегирование по другой цепи.

Данный служебный сигнал доступен во многих реализациях, включая классовые динамические: это и #doesNotUnderstand – в SmallTalk, и method_missing – в Ruby, и __getattr__ – в Python, и __call – в PHP, и __noSuchMethod__ – в одной из реализаций ECMAScript, и др.

Пример (ECMAScript реализации SpiderMonkey):

var object = {
  
  // реагируем на служебный сигнал
  // о невозможности обработать сообщение
  __noSuchMethod__: function (name, args) {
    alert([name, args]);
    if (name == 'test') {
        return '.test() method handled';
    }
    return delegate[name].apply(this, args);
  }

};

var delegate = {
  square: function (a) {
    return a * a;
  }
};

alert(object.square(10)); // 100
alert(object.test()); // .test() method handled

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

Касательно ECMAScript, здесь используется именно эта реализация – делегирующее прототипирование. Однако, как мы увидим, на уровне стандарта и реализаций есть и свои особенности.

Каскадная модель

Но, справедливости ради, стоит сказать несколько слов и о другом случае из определения (хоть он и не используется в ECMAScript), когда прототип представляет собой прообраз для оригинального объекта.

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

Данный вид прототипирования называется каскадным (concatenative).

“Отпочковавшись” от прототипа и скопировав в себя все его характеристики, объект в дальнейшем может свободно менять свои свойства и методы, равно как и прототип может менять свои (и это не отобразится на вновь созданном объекте, как это было бы при изменении характеристик прототипа в делегирующей модели). Достоинством такого подхода является снижение затрат на диспетчеризацию и делегирование, основным недостатком – больший расход памяти.

“Утиная” типизация

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

Пример:

// в статично-классовой организации
if (object instanceof SomeClass) { 
  // позволены какие-то действия
}

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

if (object.test) // ECMAScript

if object.respond_to?(:test) // Ruby

if hasattr(object, 'test'): // Python

На жаргоне, это называется утиная типизация (duck typing). То есть, объекты могут идентифицироваться по совокупности их характеристик, присутствующих на момент проверки, а не местом объектов в иерархии или принадлежности их к какому-либо конкретному типу.

Ключевые особенности прототипной модели

Итак, выделим ключевые моменты данной организации:

  • основным понятием является объект;
  • объекты полностью динамичны и изменяемы (и в теории, могут полностью мутировать из одного вида в другой);
  • у объектов нет жёстких классов, задающих их структуру и поведение; объекты не нуждаются в классах;
  • однако, не имея классов, объекты могут иметь прототипы, к которым можно делегировать, если сами объекты не в состоянии ответить на посланное им сообщение;
  • прототип объекта может быть изменён в любое время программы;
  • в делегирующей прототипной модели, изменение характеристик прототипа отображается на всех объектах, связанных с этим прототипом;
  • каскадная же прототипная модель, служит прообразом, с которого порождаемые объекты снимают точную копию и дальше становятся полностью самостоятельными; изменение прототипа в данной модели уже не влияет на клонируемые от него объекты;
  • если сообщение обработать не удаётся, можно сигнализировать об этом вызывающей стороне, которая может предпринять дополнительные меры (например, изменить диспетчеризацию);
  • идентификация объектов может производиться не по их иерархии и принадлежности к конкретному типу, а по текущему набору характеристик.

Однако, есть и ещё одна модель, на которую стоит обратить внимание.

Динамическая классовая организация

Данную модель мы рассмотрим, чтобы показать на примере то, о чём было сказано в начале – разница лишь “класс vs. прототип” не столь существенна (особенно, если цепь прототипов неизменна; для более чёткого различия, нужно задействовать ещё и статику в классах). В качестве примера можно использовать, например, Python или Ruby (или другие схожие языки). Оба эти языка являются динамическими с классовой парадигмой. Однако, в определённой позиции, в них могут прослеживаться некоторые аспекты из прототипной организации (но, естественно, с определёнными допущениями).

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

# Python

class A(object):
    
    def __init__(self, a):
        self.a = a

    def square(self):
        return self.a * self.a

a = A(10) # создаём экземпляр
print(a.a) # 10

A.b = 20 # новое свойство класса
print(a.b) # 20 - доступно через "делегирование" объекту "а"

a.b = 30 # создали родное свойство
print(a.b) # 30

del a.b # удалили родное свойство
print(a.b) # 20 - снова берётся из класса (прототипа)

# так же, как в прототипировании, можно
# менять "прототип" по ходу программы:

class B(object): # "пустой" класс B
    pass

b = B() # экземпляр класса B

b.__class__ = A # меняем динамически класс (прототип)

b.a = 10 # создаём новое свойство
print(b.square()) # 100 - доступен метод класса А

# можно удалить явные ссылки на классы
del A
del B

# но объекту всё равно доступны методы
# класса и сам класс за счёт своей внутренней ссылки
print(b.square()) # 100

# однако, сменить класс на один из встроенных
# уже не получится (в данной версии), и это - особенности реализации
b.__class__ = dict # ошибка

В Ruby картина похожа: там так же используются полностью динамические классы (кстати, в текущих версиях Python, в отличии от Ruby и ECMAScript, нельзя расширять стандартные классы/прототипы), можно полностью менять характеристики объектов и классов (добавлять методы/свойства в класс, и эти изменения отобразятся на уже порождённых объектах); однако, например, нельзя изменять класс объекта динамически.

Впрочем данная статья посвящена всё-таки не Python-у и не Ruby, а поэтому на этом мы сравнение с ними завершаем и плавно переходим непосредственно к ECMAScript.

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

Этот же раздел, я повторю, был приведён исключительно для того, чтобы отметить неверность возникающих утверждений вроде “JavaScript – другой, там прототипы, а не классы”. Нужно иметь в виду — не все классовые организации категорично другие. Если и есть смысл говорить, что “JavaScript – другой”, то помимо понятия класс нужно учитывать ещё и все сопутствующие характеристики.

Дополнительные сущности различных реализаций ООП

Здесь мы кратко рассмотрим дополнительные характеристики и вариации повторного использования кода (code reuse) различных реализаций ООП, проводя параллель с реализацией ООП в ECMAScript. Причина: в появляющихся статьях о JavaScript, понятие ООП ограничивается какой-либо одной из реализаций, не учитывая, что реализаций может быть множество, главное, чтобы они были технологически и идеологически обоснованы. Не находя сходство с каким-либо “синтаксическим сахаром” из одной (привычной) реализации ООП, JavaScript поспешно могут называть “не чистым ООП языком”, что является неверным утверждением.

Полиморфизм

Объекты ECMAScript – полиморфны во многих отношениях.

К примеру, одна функция может быть применена к разным объектам, как, если бы, она являлась родной характеристикой объекта (в виду определения this на этапе вызова):

function test() {
  alert([this.a, this.b]);
}

test.call({a: 10, b: 20}); // 10, 20
test.call({a: 100, b: 200}); // 100, 200

var a = 1;
var b = 2;

test(); // 1, 2

Однако, в этом отношении есть исключения: к примеру, метод Date.prototype.getTime() по стандарту в качестве значения this всегда должен иметь объект даты; в противном случае будет выброшено исключение:

alert(Date.prototype.getTime.call(new Date())); // время
alert(Date.prototype.getTime.call(new String(''))); // TypeError

Или так называемый параметрический полиморфизм, когда функция определена одинаково для всех типов данных, однако, принимает полиморфный функциональный аргумент (примером может служить метод сортировки массивов .sort и его параметр – полиморфная функция-условие для сортировки). Кстати, пример выше тоже можно отнести к параметрическому полиморфизму.

Либо же в прототипе метод может быть описан пустым, а все порождаемые объекты должны переопределять (реализовывать) этот метод (т.е. “один интерфейс, много реализаций”).

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

Инкапсуляция

С этой идеей часто возникает путаница и ошибки в восприятии. В данном случае мы будем рассматривать один из удобных “сахаров” некоторых реализаций ООП, — всем известные модификаторы: private, protected и public, которые также называются уровнями (или модификаторами) доступа к характеристикам объектов.

Я хочу выделить и напомнить самый главный момент, касающийся сущности инкапсуляции: инкапсуляция — это усиление абстракции, но никак не параноидальное сокрытие от “злых хакеров”, которые “то и норовят, что-то записать напрямую в поля ваших классов”.

Это самая большая (и распространённая) ошибка — использовать сокрытие ради сокрытия.

Уровни же доступа — private, protected и public — были введены в ряде реализаций ООП исключительно для удобства самого программиста (и действительно, являются достаточно удобным “сахаром” — поэтому и были введены), чтобы более абстрактно представлять и описывать систему.

Наиболее хорошо, это можно видеть в некоторых реализациях (опять же, в тех же Python’e и Ruby). С одной стороны (в Python), это __private и _protected свойства (определяются именованием через ведущие подчёркивания) и не доступны снаружи. С другой стороны, Python просто переименовывает такие поля определённым образом (_ИмяКласса__имя_поля), и под этим именем они уже доступны снаружи.

class A(object):

    def __init__(self):
      self.public = 10
      self.__private = 20

    def get_private(self):
        return self.__private

# снаружи:

a = A() # объект класса A

print(a.public) # OK, 10
print(a.get_private()) # OK, 20
print(a.__private) # ошибка, данное имя доступно только внутри описания класса А

# но Python просто переименовывает такие 
# свойства в _ClassName__property_name
# и, используя это имя, мы можем обратиться
# к "private" данным

print(a._A__private) # OK, 20

Или в Ruby: с одной стороны, есть возможность определять private и protected характеристики, с другой стороны, так же есть ряд методов (например, instance_variable_get, instance_variable_set, send и т.д.), позволяющих получить доступ к инкапсулированным данным.

class A

  def initialize
    @a = 10
  end

  def public_method
    private_method(20)
  end

private

  def private_method(b)
    return @a + b
  end

end

a = A.new # объект класса A

a.public_method # OK, 30

a.a # ошибка, @a - private instance-переменная без геттера "a"

# ошибка "private_method" является private и
# доступен только внутри класса A

a.private_method # Error

# Но, используя специальные мета-методы, мы
# имеем доступ к инкапсулированным данным:

a.send(:private_method, 20) # OK, 30
a.instance_variable_get(:@a) # OK, 10

Основная причина: программист сам, сознательно хочет получить доступ к инкапсулированным (обратите внимание, я специально не использую термин “скрытым”) данным. А значит, если они будут как-то неверно изменены или будет какая-то ошибка, то в ответе за это полностью и осознанно программист, а не просто “опечатка” или “кто-то случайно изменил какое-то поле”. Однако в частых подобных случаях, всё-таки, есть смысл говорить о плохом стиле программирования.

Основной же ролью инкапсуляции, повторим, является абстрагирование от пользователя вспомогательных сущностей и никак не “способ обезопасить объект от хакеров”. Для безопасности программного обеспечения применяются куда более серьёзные меры, нежели модификатор “private”.

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

Так же важной задачей сеттера является абстрагирование сложных вычислений. Например, сеттер element.innerHTML — мы просто абстрактно говорим — “теперь html этого элемента будет таким”, в то время, как внутри функции-сеттера для свойства innerHTML будут сложные вычисления и проверки. Здесь речь идёт об абстракции, но инкапсуляция, как усиление её — имеет место быть.

Само же понятие инкапсуляции может и не быть связано с ООП. Так, например, в качестве инкапсулирующей сущности может выступать и обычная функция, которая инкапсулирует в себе различные вычисления, делая использование её абстрактным (пользователю не важно знать, как реализована, к примеру, функция Math.round(…), он просто её вызывает). Это — есть инкапсуляция реализации и, обратите внимание, ни о каких private, protected, public в этом моменте речи не идёт.

ECMAScript в текущей версии стандарта не определяет модификаторов private, protected и public.

Однако на практике можно видеть то, что называют “имитацией инкапсуляции в JS”. Используется для этого обрамляющий контекст (как правило, сам конструктор) и особенности свойства [[Scope]] порождаемых в этом контексте функции (замыканий). К сожалению, используя данную “имитацию”, программисты иногда плодят “геттеры/сеттеры” для совсем неабстрактных сущностей (повторю, неверно трактуя саму инкапсуляцию):

function A() {
  
  var _a; // "private" a
  
  this.getA = function _getA() {
    return _a;
  };

  this.setA = function _setA(a) {
    _a = a;
  };

}

var a = new A();

a.setA(10);
alert(a._a); // undefined, "private"
alert(a.getA()); // 10

При этом все осознают, что для каждого порождённого объекта будет создана своя пара методов “getA/setA”, что повлечёт за собой расход памяти прямо пропорциональный количеству объектов (в отличии, если бы методы были определены в прототипе). Хотя, в первом случае, теоретически могла бы быть оптимизация с объединёнными объектами.

Также в различных статьях о JavaScript фигурирует название для подобных методов, как “привилегированные методы”. Чтобы уточнить, отметим, – ECMA-262-3 никакого понятия “привилегированный метод” не определяет.

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

Более того, касательно JavaScript, подобные “скрытые” “private” var-ы всё равно до конца не скрыты (если инкапсуляция всё ещё воспринимается неправильно, как защита от “злого хакера”, который хочет записать значение в определённое поле напрямую, а не через сеттер): в некоторых реализациях можно обратиться к нужной цепи областей видимости (и, соответственно, всем объектам переменных в ней) посредством передачи eval-у вызывающего контекста (можно потестировать в SpiderMonkey до версии 1.7):

eval('_a = 100', a.getA); // или a.setA, т.к. "_a" - в [[Scope]] обоих функций
a.getA(); // 100

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

// Rhino
var foo = (function () {
  var x = 10; // "private"
  return function () {
    print(x);
  };
})();
foo(); // 10
foo.__parent__.x = 20;
foo(); // 20

Иногда, в качестве организационных мер (которые также могут являться инкапсуляцией), “private” и “protected” данные в JavaScript обозначают ведущим подчёркиванием (только, в отличии от того же Python-a, здесь это просто соглашение об именовании):

var _myPrivateData = 'testString';

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

(function () {
  
  // инициализирующее пространство

})();

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

Множественное наследование — удобный “сахар” для улучшения повторного использования кода (действительно, если научились наследовать от одного класса, почему бы не наследовать сразу от десяти?). Однако этот подход имеет ряд недостатков, в виду чего особо в реализациях не прижился.

ECMAScript не поддерживает множественного наследования (т.е. в качестве непосредственного прототипа, может быть указан лишь один объект), несмотря на то, что его предок — язык программирования Self — такую возможность имел. Но в некоторых реализациях, например, всё в том же SpiderMonkey, обработав __noSuchMethod__, можно осуществить диспетчеризацию и делегацию по альтернативным цепям прототипов.

Примеси

Примесь (mixin) также является удобным способом повторного использования кода. Примеси были предложены как альтернатива множественному наследованию. Это независимые элементы, которые могут быть подмешаны в любой из объектов, расширяя его функциональность (при этом объект может подмешивать несколько примесей). Стандарт ECMA-262-3 не описывает понятия “примесь”, однако, согласно определению примеси, и в виду динамических изменяемых объектов ECMAScript, ничто не мешает подмешать любой объект в другой, просто расширив его своими характеристиками.

Классический пример:

// helper для расширения объектов
Object.extend = function (destination, source) {
  for (property in source) if (source.hasOwnProperty(property)) {
    destination[property] = source[property];
  }
  return destination;
};

var X = {a: 10, b: 20};
var Y = {c: 30, d: 40};

Object.extend(X, Y); // "подмешиваем" Y к X
alert([X.a, X.b, X.c, X.d]); 10, 20, 30, 40

Обратите внимание, я беру эти определения (“примесь”, “подмешиваем”) в кавычки, поскольку, повторю, ECMA-262-3 таких понятий не определят и, более того, это, всё же, не подмешивание, а обычное расширение объекта новыми характеристиками (в то время как, например, в Ruby, где понятие примеси является официальным, подмешивание создаёт ссылку на модуль-примесь (т.е. по сути – создаёт ещё один объект (“прототип”) для делегирования), а не просто копирует все свойства модуля в объект).

Штрихи

Более “тонким” вариантом примесей являются, так называемые, штрихи (traits), которые схожи с примесями, однако имеют ряд своих особенностей (основное из которых – штрихи, по определению, не должны иметь состояния, которое может породить конфликт имён при подмешивании, как это возможно с примесью). В русскоязычной литературе, также фигурирует определение – типаж. Касательно ECMAScript, штрихи имитируются по тому же принципу, что и примеси; стандарт явного понятия “штрих” не определяет.

Интерфейсы

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

Интерфейсы можно представить в виде полностью абстрактных классов. Однако, в отличии от абстрактных классов, (которые часть методов могут реализовывать сами, а часть – представлять в качестве сигнатур), при одиночном наследовании, класс может реализовывать несколько интерфейсов; именно поэтому интерфейсы так же (как и примеси) позиционируются альтернативой множественному наследованию.

Стандарт ECMA-262-3 ни понятия “интерфейс”, ни понятия “абстрактный класс” (равно как и данных синтаксических конструкции), не определяет. Однако в качестве имитации можно использовать расширение объектов объектами, имеющими “пустые” методы (или, выбрасывающими в методах исключение, с сообщением о том, что данный метод необходимо переопределить).

Композиция

Композиция (или агрегирование) – тоже одна из методик динамического повторного использования кода. Агрегирование отличается от наследования большей гибкостью и реализует ничто иное, как делегацию к динамически изменяемым делегатам. Ну а это, в свою очередь, заложено в основу делегирующего прототипирования. Помимо динамически изменяемого прототипа, объект может агрегировать объект для делегации (в итоге создав композицию), и далее, при посылке определённого сообщения объекту, передавать полномочия за его выполнение этому делегату. Делегатов может быть несколько; их также, в виду динамики, свободно можно менять по ходу выполнения программы.

В качестве примера можно использовать уже рассмотренный выше пример с описанием __noSuchMethod__, но и, также, покажем, как можно использовать делегатов явно:

Пример:

var _delegate = {
  foo: function () {
    alert('_delegate.foo');
  }
};

var agregate = {
  
  delegate: _delegate,
  
  foo: function () {
    return this.delegate.foo.call(this);
  }

};

agregate.foo(); // _delegate.foo

agregate.delegate = {
  foo: function () {
    alert('foo from new delegate');
  }
};

agregate.foo(); // foo from new delegate 

Данное отношение объектов называется “has-a”, т.е. “имеет внутри себя” – в отличии от наследования – “is-a”“является (наследником)”.

Недостатком явного агрегирования (наряду с его гибкостью относительно наследования) может являться увеличение промежуточного кода.

Элементы АОП

Элементом аспектно-ориентированного программирования (АОП) в ECMAScript можно выделить декорирование функций. Стандарт ECMA-262-3 не описывает явного понятия “декоратор функции” (в отличии, скажем, от Python-a, где этот термин является официальным), однако, посредством функциональных аргументов, функцию можно легко декорировать и выполнить в некотором аспекте (применив, так называемый, совет):

Пример простейшего декоратора:

function checkDecorator(originalFunction) {
  return function () {
    if (fooBar != 'test') {
      alert('wrong parameter');
      return false;
    }
    return originalFunction();
  };
}

function test() {
  alert('test function');
}

var testWithCheck = checkDecorator(test);
var fooBar = false;

test(); // 'test function'
testWithCheck(); // 'wrong parameter'

fooBar = 'test';
test(); // 'test function'
testWithCheck(); // 'test function'

Заключение

На этом мы ознакомление с общей теорией завершаем (надеюсь, материал её оказался для вас полезным) и переходим к части 7.2 – Реализация ООП в ECMAScript.

Дополнительная литература

Автор: Dmitry A. Soshnikov
Дата публикации: 12.09.2009

Write a Comment

Comment

  1. Спасибо за статью. Сложновата для восприятия, но всеравно полезна.

    По сайту: выпадающее меню справа не вмещается по высоте в окне браузера, при разрешении 1360 на 768, что затрудняет доступ к нижним пунктам в этом меню.

  2. Отличная, а главное нужная статья.

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

    В языках использующих компиляцию трудно создать динамику объекта , а вот в языках интерпретируещего типа (JavaScript) да еще со слабой типизацией , динамика объекта возникает из самой сути языка.

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

  3. arbeiter

    В языках использующих компиляцию трудно создать динамику объекта , а вот в языках интерпретируещего типа (JavaScript) да еще со слабой типизацией , динамика объекта возникает из самой сути языка.

    Тут я вам порекомендую взглянуть в сторону Node.JS, перед исполнением скрипт компилируется, но этот тот же JavaScript.

  4. arbeiter

    В языках использующих компиляцию трудно создать динамику объекта , а вот в языках интерпретируещего типа (JavaScript) да еще со слабой типизацией , динамика объекта возникает из самой сути языка.

    Тут я вам порекомендую взглянуть в сторону Node.JS, перед исполнением скрипт компилируется, но этот тот же JavaScript.

    ^^^ Выполняется JIT компиляция, точно такая-же как и в браузере. Так-что различий особых нету.

  5. Огромное спасибо за статью. Возник вопрос:

    function printAllProperties(o)
    {
        var objectToInspect;
        var level = 1;
    
        for (objectToInspect = o; objectToInspect !== null; objectToInspect = Object.getPrototypeOf(objectToInspect)) {
            console.log(level + ". Inspecting object " + objectToInspect.name + ":");
            console.log("which is of type " + typeof objectToInspect + " and constructor is " + objectToInspect.constructor);
            console.log(Object.getOwnPropertyNames(objectToInspect));
            level++;
        }
    }
    
    function Cat(name) {
        this.name = name;
    }
    Cat.prototype = null;
    
    var thomas = new Cat('Thomas');
    printAllProperties(thomas);
    

    Почему нельзя отвязать прототип от объекта? При выполнении приведенного кода у объекта всегда устанавливается прототип по умолчанию:

    1. Inspecting object Thomas:
    which is of type object and constructor is function Object() { [native code] }
    [ 'name' ]
    2. Inspecting object undefined:
    which is of type object and constructor is function Object() { [native code] }
    [ 'propertyIsEnumerable',
      'toLocaleString',
      '__lookupSetter__',
      'hasOwnProperty',
      '__defineSetter__',
      '__lookupGetter__',
      'valueOf',
      'toString',
      '__defineGetter__',
      'constructor',
      'isPrototypeOf' ]
    

    Дело в том, что для функционирования любого объекта нужна эта базовая функциональность, обеспечиваемая прототипом?

  6. Еще один вопрос. Выполняю такой код:

    function listKeys(obj)
    {
        console.log("Inspecting object " + obj.name + ":");
        for (var key in obj) {
    	console.log("\tProperty " + key + " = " + obj[key] );
        }
    }
    
    var Person = function (name) {
        this.name = name;
        this.age  = 12;
        this.old  = true;
    }
    
    Person.prototype = {
        proto_prop1: 22,
        walk: function () {
    	console.log(this.name  + ' is walking');
        }
    }
    
    var walter = new Person('Walter');
    walter.proto_prop1 = 33;
    
    listKeys(walter);
    
    var harry = new Person("Harry");
    listKeys(harry);
    

    Получаю:

    Inspecting object Walter:
            Property name = Walter
            Property age = 12
            Property old = true
            Property proto_prop1 = 33
            Property walk = function () {
            console.log(this.name  + ' is walking');
        }
    
    Inspecting object Harry:
            Property name = Harry
            Property age = 12
            Property old = true
            Property proto_prop1 = 22
            Property walk = function () {
            console.log(this.name  + ' is walking');
        }
    

    То есть свойство proto_prop1 прототипа имеет разные значения у объектов walter и harry. Выходит, что у каждого объекта есть своя копия прототипа?! Но это уже каскадная, а не делегирующая модель!

  7. @Ilya

    Почему нельзя отвязать прототип от объекта? При выполнении приведенного кода у объекта всегда устанавливается прототип по умолчанию

    Да, совершенно верно, при создании через конструктор, если свойство prototype функции — не объект, берется прототип по умолчанию, т.е. Object.prototype. Более подробно это описано — здесь.

    В ES5 все же можно создать объект без прототипа — через Object.create(null):

    var foo = Object.create(null);
    alert(foo); // Error, т.к. нет даже метода toString из Object.prototype
    

    То есть свойство proto_prop1 прототипа имеет разные значения у объектов walter и harry. Выходит, что у каждого объекта есть своя копия прототипа?!

    Нет, Вы же присвоили родное свойство proto_prop1 объекту walter. Присвоение (в случае обычных свойств) всегда создает/модифицирует родное свойство. Подробней — в части 7.2. (В случае, если же свойство является аксессором, то вызовится геттер/сеттер именно на прототипе вместо создания родного свойства, подробней — здесь):

  8. class A(object):

    def __init__(self):
    self.public = 10
    self.__private = 20

    def get_private(self):
    return self.__private

    # снаружи:

    a = A() # объект класса A

    print(a.public) # OK, 30 10
    print(a.get_private()) # OK, 20
    print(a.__private) # ошибка, данное имя доступно только внутри описания класса А

    # но Python просто переименовывает такие
    # свойства в _ClassName__property_name
    # и, используя это имя, мы можем обратиться
    # к “private” данным

    print(a._A__private) # OK, 20

    Дмитрий, небольшая описка.

    Огромное спасибо за статью – всё передано и разжёвано. При непонимании нужно просто прочесть пару раз – и доходит.

    P.s.
    Не хватает разрешения редактирования собственных постов.

  9. @Artyom Pokatilov, ОК, спасибо, исправил. Рад, что материал оказался полезным.

  10. // helper для расширения объектов
    Object.extend = function (destination, source) {
      for (property in source) if (source.hasOwnProperty(property)) {
        destination[property] = source[property];
      }
      return destination;
    };
     
    var X = {a: 10, b: 20};
    var Y = {c: 30, d: 40};
     
    Object.extend(X, Y); // "подмешиваем" Y к X
    alert([X.a, X.b, X.c, X.d]); 10, 20, 30, 40
    

    можно упростить/улучшить до

            // helper для расширения объектов
            Object.extend = function (destination, source) {
                for (property in source) if (typeof destination[property] == "undefined") {
                    destination[property] = source[property];
                }
    //            return destination;
            };
    
            var X = {a: 10, b: 20, c : 100};
            var Y = {c: 30, d: 40};
    
            Object.extend(X, Y); // "подмешиваем" Y к X
            alert([X.a, X.b, X.c, X.d]);    //  10, 20, 30, 40
    

    и тогда совпадающие свойства в destination-объекте не будут перекрываться…. =)