in ECMAScript

Тонкости ECMA-262-3. Часть 7.2. ООП: Реализация в ECMAScript.

Read this article in: English, French.

Введение

Данная статья является продолжением статьи об объектно-ориентированном программировании в ECMAScript. В первой части мы разбирали общую теорию и проводили параллели с ECMAScript. Перед прочтением текущей части, если есть необходимость, я рекомендую прочесть первую часть, поскольку в этой статьей мы будет активно опираться на пройденную терминологию. Желающие могут найти первую часть здесь: Тонкости ECMA-262-3. Часть 7.1. ООП: Общая теория.

Реализация ООП в ECMAScript

Ну вот, пройдя (возможно, немалый) путь основных моментов общей теории, мы, наконец, добрались до самого ECMAScript’a. Теперь, когда мы знаем его идеологию, дадим ещё раз чёткое определение относительно ООП, которым полноценно можно оперировать на форумах или в статьях:

ECMAScript — это объектно-ориентированный язык программирования, поддерживающий делегирующее наследование на базе прототипов.

Начнём мы анализ с типов данных. И для начала стоит отметить, что ECMAScript различает сущности на примитивные значения (primitive value) и объекты (object). Поэтому фраза “всё в JavaScript — объекты”, иногда возникающая в различных статьях, не точна (не полна). К примитивам относятся данные определённых типов, которые необходимо рассмотреть подробней.

Типы данных

Хоть ECMAScript и динамический, слабо типизированный язык — с “утиной” типизацией и автоматическим преобразованием типов, в нём всё же присутствуют определённые типы данных. То есть, в один момент времени, объект принадлежит конкретному типу.

Всего стандарт определяет девять типов, причём непосредственно доступны в ECMAScript программе, всего шесть:

  • Undefined
  • Null
  • Boolean
  • String
  • Number
  • Object

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

  • Reference
  • List
  • Completion

При этом (рассмотрим кратко): тип Reference используется для объяснения таких языковых конструкций как delete, typeof, this, др. и состоит из базового объекта и имени свойства.

Тип List описывает поведение списка аргументов (в выражениях new и вызове функций).

Тип Completion, в свою очередь, используется для объяснения поведения конструкций break, continue, return и throw.

Типы примитивных значений

Возвращаясь к шести типам, используемым ECMAScript программами, первые пять из них: Undefined, Null, Boolean, String и Number — являются типами примитивных значений.

Примеры примитивных значений:

var a = undefined; 
var b = null; 
var c = true;
var d = 'test';
var e = 10;

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

Здесь есть одна особенность и связана она с оператором typeof, который часто может сбивать с толку, если не знать, как он работает. В большей мере это относится к значению null: несмотря на то, что тип null‘a, по определению, Null, typeof для этого значения выдаёт "object":

console.log(typeof null); // "object"

А дело в том, что оператор typeof возвращает строковое значение, взятое из жёстко закреплённой таблицы, где прописано: “для null – возвращать "object".

Причины этого в стандарте не разъясняются, однако B. Eich (создатель JavaScript) отмечал, что null, в отличии от undefined (который означает неопределённость, “нет значения”), используется в большинстве случаев там, где фигурируют объекты, т.е. является некой сущностью, тесно связанной с объектами (конкретно, означающей нулевую ссылку на объект, “пустышку”, возможно, зарезервировавшую место для будущих целей). Но в некоторых черновиках (например, в невышедшем ECMAScript4 aka JavaScript 2.0) был заведён документ, где данный “феномен” описан как обычный баг. Такое поведение обсуждалось также в одном из баг-трекеров при участии B. Eich’a; в итоге было решено оставить typeof null, как есть (т.е. "object"), хотя сам стандарт ECMA-262-3 описывает тип null-a как Null.

Объектный тип

В свою очередь, тип Object (не путайте с конструктором Object, мы сейчас ведём речь лишь об абстрактных типах!) – единственный тип, представляющий объекты в ECMAScript.

Объект — это неупорядоченное множество пар ключ-значение.

Ключи объектов называются свойствами. Значениями свойств могут быть примитивы и другие объекты. В случае, когда “подтип” объектов — функция, такие свойства называются методами.

Пример:

var x = { // объект "x" с тремя свойствами: a, b, c
  a: 10, // примитив
  b: {z: 100}, // объект "b" со свойством z
  c: function () { // функция (метод)
    console.log('method x.c');
  } 
};

console.log(x.a); // 10
console.log(x.b); // [object Object]
console.log(x.b.z); // 100
x.c(); // 'method x.c'
Built-in, native и host объекты

Стоит также отметить, что стандарт различает нативные (родные, встроенные) объекты (native objects), непосредственно встроенные объекты (built-in objects) и объекты среды (host objects).

Built-in и native объекты являются объектами реализации, и разница между ними несущественная. Нативные объекты — это все объекты, предоставляемые ECMAScript (некоторые из них могут быть встроенными, некоторые — создаваться по мере запуска ECMAScript программы, например, обычные пользовательские объекты).

Встроенные объекты — это подтип нативных объектов, которые встроены в ECMAScript до начала запуска программы (например, parseInt, Math и т.д.).

К хост-объектам (объектам среды) относятся все остальные объекты (например, window, console.log и т.д.).

Boolean, String и Number объекты

Также спецификация определяет ряд объектов типа Object, соответствующих некоторым примитивным типам (т.е., по сути, являющихся объектным представлением примитива; условно в кавычках, эти объекты можно назвать “подтипами” типа Object). Это следующие объекты:

  • Boolean-объект
  • String-объект
  • Number-объект

Такие объекты создаются соответствующими встроенными (built-in) конструкторами и содержат одним из внутренних свойств корреспондирующее примитивное значение. Объектное представление может преобразовываться в примитивное (как правило, применением конструктора, как функции — без оператора new) и обратно.

Примеры объектных значений, соответствующих примитивным типам:

var c = new Boolean(true);
var d = new String('test');
var e = new Number(10);

// конвертация в примитивы
// преобразование: ToPrimitive
с = Boolean(c);
d = String(d);
e = Number(e);

// обратно - в объект
// преобразование: ToObject
с = Object(c);
d = Object(d);
e = Object(e);

Помимо объектов, связанных с примитивами, есть также объекты, порождаемые специальными встроенными конструкторами: Function (конструктор объектов-функций) Array (конструктор массивов), RegExp (конструктор регулярных выражений), Math (математический модуль), Date (конструктор дат), и др. Такие объекты также являются значениями типа Object; обособление их друг от друга происходит посредством наличия и значений определённых внутренних свойств, о которых будет сказано ниже.

Литеральные нотации

Для трёх объектных значений: объекта, массива и регулярного выражения предусмотрены сокращённые нотации, называющиеся соответственно, инициализатор объекта (object initialiser), инициализатор массива (array initialiser) и литерал регулярных выражений (regular expression literal):

// эквивалентно new Array(1, 2, 3);
// или array = new Array();
// array[0] = 1;
// array[1] = 2;
// array[2] = 3;
var array = [1, 2, 3]; 

// эквивалентно
// var object = new Object();
// object.a = 1;
// object.b = 2;
// object.c = 3;
var object = {a: 1, b: 2, c: 3};

// эквивалентно new RegExp("^\\d+$", "g")
var re = /^\d+$/g;

Обратите внимание, что в случае связывания имён конструкторов — Object, Array или RegExp с новыми объектами, семантика последующего использования литеральных нотаций может зависеть от реализации. Так, например, в текущей версии Rhino или в старой SpiderMonkey 1.7, соответствующая литеральная нотация создаст объект, связанный с новым значением конструктора. В других реализациях (включая текущую Spider/TraceMonkey) семантика литеральных нотаций не изменяется, даже, если имя конструктора связывается с другим объектом:

var getClass = Object.prototype.toString;

Object = Number;

var foo = new Object;
console.log([foo, getClass.call(foo)]); // 0, "[object Number]"

var bar = {};

// в Rhino, SpiderMonkey 1.7 - 0, "[object Number]"
// в других: "[object Object]", "[object Object]"
console.log([bar, getClass.call(bar)]);

// аналогично с именем Array
Array = Number;

foo = new Array;
console.log([foo, getClass.call(foo)]); // 0, "[object Number]"

bar = [];

// в Rhino, SpiderMonkey 1.7 - 0, "[object Number]"
// в других: still "", "[object Object]"
console.log([bar, getClass.call(bar)]);

// но для RegExp семантика литеральной
// нотации не меняется во всех протестированных реализациях

RegExp = Number;

foo = new RegExp;
console.log([foo, getClass.call(foo)]); // 0, "[object Number]"

bar = /(?!)/g;
console.log([bar, getClass.call(bar)]); // /(?!)/g, "[object RegExp]"
Литерал регулярных выражений и RegExp объекты

Обратите, однако, внимание, что в ES3 два последних случая с регулярными выражениями, хоть и эквиваленты по смыслу, они всё же отличаются. Литерал регулярного выражения существует в единственном экземпляре и создаётся ещё на этапе парсинга, тогда как конструктор RegExp каждый раз создаёт новый объект. Это может вызвать определённые проблемы, связанные, например, со свойством lastIndex объектов регулярного выражения, и в подобных случаях regexp-тест может не пройти проверку:

for (var k = 0; k < 4; k++) {
  var re = /ec/g;
  console.log(re.lastIndex); // 0, 2, 0, 2
  console.log(re.test("ecmascript")); // true, false, true, false
}

// в отличие от

for (var k = 0; k < 4; k++) {
  var re = new RegExp("ec", "g");
  console.log(re.lastIndex); // 0,0,0, 0
  console.log(re.test("ecmascript")); // true, true, true, true
}

В ES5 данная проблема была решена и regexp-литерал также всегда создаёт новый объект.

Ассоциативные массивы?

Часто в различных статьях и дискуссиях объекты JavaScript (причём, созданные, как правило, инициализатором объектов — {}) могут называть хэш-таблицами или просто — хэшами (термины из Ruby или Perl), ассоциативными массивами (термин из PHP), словарями (термин из Python’a) и т.д. Привлечение данной терминологии является привычкой к определённой технологии. Действительно, они достаточно схожи, и в определении пар “ключ-значение” могут полностью соответствовать теоретической структуре данных “хэш-таблица”. Более того, на уровне реализации именно эти хэш-таблицы могут быть и использованы.

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

var a = {x: 10};
a['y'] = 20;
a.z = 30;

var b = new Number(1);
b.x = 10;
b.y = 20;
b['z'] = 30;

var c = new Function('');
c.x = 10;
c.y = 20;
c['z'] = 30;

// и т.д. - с любым объектным "подтипом"

Более того, объекты в ECMAScript изначально, за счёт делегации, могут быть непустыми, поэтому термин “хэш” также может не подходить:

Object.prototype.x = 10;

var a = {}; // создали "пустой" "хэш"

console.log(a['x']); // 10, а он не пуст

a['y'] = 20; // добавили пару в "хэш"
console.log(a['y']); // 20

Object.prototype.y = 20; // и свойство в прототип

delete a['y']; // удалили
console.log(a['y']); // а ключ и значение всё равно здесь - 20

Также, некоторые свойства могут иметь специфичные геттеры/сеттеры, что также может сбивать с толку:

var a = new String("foo");
a['length'] = 10;
console.log(a['length']); // 3

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

Также в ECMAScript при делегации понятие “свойство” семантически не подразделяется на “ключ”, “индекс массива”, “метод” или “свойство”. Здесь все они являются свойствами, которые подчиняются единому закону чтения/записи с анализом цепи прототипов.

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

a = {}
a.class # Hash

a.length # 0

# новая пара "ключ-значение"
a['length'] = 10;

# но семантика для точечной нотации
# осталась прежней и означает доступ
# к "свойству/методу", но не к "ключу"

a.length # 1

# тогда как скобочная нотация
# осуществляет доступ к "ключам" хэша

a['length'] # 10

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

class Hash
  def z
    100
  end
end

# доступно новое "свойство"

a.z # 100

# но не "ключ"

a['z'] # nil

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

Конвертация типов

За конвертацию объекта в примитив отвечает метод valueOf. Как уже было отмечено, вызов конструктора (для определённых типов) в качестве функции, т.е. без операции new осуществляет преобразование объектного типа к примитиву; данное преобразование и заключается в неявном вызове метода valueOf:

var a = new Number(1);
var primitiveA = Number(a); // неявный вызов .valueOf
var alsoPrimitiveA = a.valueOf(); // явный

console.log([
  typeof a, // "object"
  typeof primitiveA, // "number"
  typeof alsoPrimitiveA // "number"
]);

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

var a = new Number(1);
var b = new Number(2);

console.log(a + b); // 3

// или даже так

var c = {
  x: 10,
  y: 20,
  valueOf: function () {
    return this.x + this.y;
  }
};

var d = {
  x: 30,
  y: 40,
  // та же функциональность
  // .valueOf, что и у объекта "с",
  // заимствуем её:
  valueOf: c.valueOf
};

console.log(c + d); // 100

Значение, возвращаемое методом valueOf (если он не был переопределён) варьирует в зависимости от типа объекта. Для некоторых объектов он возвращает значение this (например, Object.prototype.valueOf() и все, кто наследует этот метод от него), для других — какое-то вычисленное значение (например, Date.prototype.valueOf() возвращающий число, являющееся временем даты)

var a = {};
console.log(a.valueOf() === a); // true, .valueOf() вернул this

var d = new Date();
console.log(d.valueOf()); // время
console.log(d.valueOf() === d.getTime()); // true

Также есть ещё одно примитивное представление объекта — строковое. Отвечает за это метод toString, который так же, как и valueOf, в некоторых операциях вызывается автоматически:

var a = {
  valueOf: function () {
    return 100;
  },
  toString: function () {
    return '__test';
  }
};

// в этой операции
// автоматом вызван
// метод toString
console.log(a); // "__test"

// а здесь, .valueOf()
console.log(a + 10); // 110

// однако, если метода
// valueOf не будет, он
// будет замещаться
// методом toString
delete a.valueOf;
console.log(a + 10); // "_test10"

Стоит отметить метод toString, определённый в Object.prototype: этот метод особенный и возвращает одно из внутренних свойств объекта, а именно – [[Class]]; но подробней об этом – ниже.

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

Один из явных способов вызвать ToObject — это использовать встроенный конструктор Object к аргументу в качестве функции (однако для некоторых типов возможно и использование Object с оператором new):

var n = Object(1); // [object Number]
var s = Object('test'); // [object String]

// также для конкретных типов
// можно вызвать Object и с new
var b = new Object(true); // [object Boolean]

// однако, применённый без параметров,
// new Object создаёт обычный объект
var o = new Object(); // [object Object]

// в том случае, если аргумент Object
// уже является объектным значением,
// оно просто возвращается
var a = [];
console.log(a === new Object(a)); // true
console.log(a === Object(a)); // true

Касательно же вызовов встроенных конструкторов с new и без new, здесь единого правила нет, и зависит это от самого конструктора. Так, например, конструкторы Array или Function выполняют идентичные действия и при вызове в качестве конструктора (с new), и при вызове в качестве функции (без new):

var a = Array(1, 2, 3); // [object Array]
var b = new Array(1, 2, 3); // [object Array]
var c = [1, 2, 3]; // [object Array]

var d = Function(''); // [object Function]
var e = new Function(''); // [object Function]

Помимо конвертации из объектного типа в примитивный и обратно, существуют явные и неявные конвертации и в рамках примитивных типов, например, из числа (number) в строку (string):

var a = 1;
var b = 2;

// неявные
var c = a + b; // 3, number
var d = a + b + '5' // "35", string

// явные
var e = '10'; // "10", string
var f = +e; // 10, number
var g = parseInt(e, 10); // 10, number

// и т.д.
Атрибуты свойств

Все свойства могут иметь ряд атрибутов:

  • {ReadOnly} — попытка записать значение в свойство игнорируется; однако, ReadOnly-свойства могут быть изменены действиями хост-среды, поэтому ReadOnly – не означает “константное значение”;
  • {DontEnum} — свойство не выводится в цикле for .. in;
  • {DontDelete} — игнорируется действие оператора delete, применённого к свойству;
  • {Internal} — свойство является внутренним, не имеет имени и используется исключительно реализацией; ECMAScript программе такие свойства не доступны напрямую.
Внутренние свойства и методы

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

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

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

  • [[Prototype]] — прототип объекта (подробней будет рассмотрен ниже);
  • [[Class]] — строковое представление типа объекта (например, “Object”, “Array”, “Function” и т.д); используется для разграничения объектов;
  • [[Get]] — метод получения значения свойства;
  • [[Put]] — метод записи значения в свойство;
  • [[CanPut]] — метод определения, возможна ли запись в свойство;
  • [[HasProperty]] — метод определения, присутствует ли уже данное свойство в объекте;
  • [[Delete]] — метод удаления свойства;
  • [[DefaultValue]] — метод, возвращающий примитивное значение, корреспондирующее с объектом (для получения данного значения вызывается метод valueOf; для некоторых объектов может выбрасываться исключение TypeError).

Получить свойство [[Class]] из ECMAScript программы можно косвенным (и единственным) способом — посредством вызова Object.prototype.toString(). Данный метод должен всегда возвращать строку следующего вида: “[object ” + [[Class]] + “]”. Например:

var getClass = Object.prototype.toString;

getClass.call({}); // [object Object]
getClass.call([]); // [object Array]
getClass.call(new Number(1)); // [object Number]
// и т.д.

Данную особенность часто используют для проверок типа объекта, однако стоит учитывать, что по стандарту внутреннее свойство [[Class]] хост-объектов может быть любым (включая значения свойства [[Class]] встроенных объектов) что, в теории, делает данные проверки не 100% надёжными. К примеру, свойство [[Class]] метода document.childNodes.item(…) в старых IE выдаёт “String” (в других реализациях, выдаётся “Function”):

// в старых IE - "String", в остальных - "Function"
console.log(getClass.call(document.childNodes.item));

Конструктор

Итак, как было упомянуто выше, объекты в ECMAScript порождаются так называемыми конструкторами.

Конструктор (Constructor) — это объект-функция, создающая объект посредством вызова внутреннего метода [[Construct]] (общий конструктор всех объектов) и инициализирующая созданный объект посредством вызова внутреннего метода [[Call]].

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

F = new NativeObject();

F.[[Class]] = "Function"

.... // другие свойства

F.[[Call]] = callObject // объект вызова

F.[[Construct]] = internalConstructor // общий встроенный конструктор

.... // другие свойства

// прототип порождаемых от F объектов
__objectPrototype = {};
__objectPrototype.constructor = F // {DontEnum}
F.prototype = __objectPrototype

При этом [[Call]], помимо свойства [[Class]] (которое равно “Function”), является основным в плане обособления и разграничения объектов. Поэтому объекты, имеющие внутреннее свойство [[Call]], называются функциями; оператор typeof для таких объектов возвращает значение “function”.

Однако, это в большей мере касается native-объектов. В случае же вызываемых (callable) host-объектов оператор typeof (равно как и свойство [[Class]]) некоторых реализаций может выдавать другое значение: например, window.console.log(…) в IE:

// в IE - "Object", "object", в остальных - "Function", "function"
console.log(Object.prototype.toString.call(window.console.log));
console.log(typeof window.console.log); // "Object"

Внутренний метод [[Construct]] активируется посредством оператора new применённого к функции-конструктору. Именно этот метод отвечает за выделение памяти под объект и его создание. Если список инициализирующих параметров пустой, скобки вызова в функции-конструкторе можно опускать:

function A(x) { // конструктор А
  this.x = x || 10;
}

// без параметров, скобки
// вызова можно не использовать
var a = new A; // или new A();
console.log(a.x); // 10

// явная передача
// значения параметра x
var b = new A(20);
console.log(b.x); // 20

Как уже отмечалось в заметке о this, данное ключевое слово внутри конструктора (при инициализации) указывает на вновь создаваемый объект.

Алгоритм создания объектов

Описать псевдокодом действие внутреннего метода [[Construct]] функции “F” можно следующим образом:

F.[[Construct]](initialParameters):

O = new NativeObject();

// свойство [[Class]] - "Object", т.е. обычный объект
O.[[Class]] = "Object"

// получить объект, на который
// указывает в данный момент F.prototype
var __objectPrototype = F.prototype;

// если __objectPrototype - объект, то:
O.[[Prototype]] = __objectPrototype
// иначе:
O.[[Prototype]] = Object.prototype;
// где O.[[Prototype]] - прототип объекта

// инициализация созданного объекта
// посредством вызова F.[[Call]]; передаются:
// в качестве this - созданный объект - O,
// параметры - те же, что и initialParameters F  
R = F.[[Call]](initialParameters); this === O;
// где R - результат работы [[Call]]
// в JS виде, это выглядит так:
// R = F.apply(O, initialParameters);

// если R - объект
return R
// иначе
return O

Обратите внимание на два ключевых момента: во-первых, прототип порождаемого объекта (подробнее о прототипе – ниже) берётся из свойства функции prototype на текущий момент (это означает, что прототип двух порождённых объектов от одного конструктора, может варьировать, т.к. само свойство .prototype функции может свободно меняться); во-вторых, если при инициализации объекта вызов [[Call]] вернул объект (любой объект), именно он будет являться результатом выражения new:

function A() {}
A.prototype.x = 10;

var a = new A();
console.log(a.x); // 10 - делегацией, из прототипа

// устанавливаем свойство .prototype
// функции на новый объект; зачем
// явно определяется свойство .constructor,
// будет рассмотрено ниже
A.prototype = {
  constructor: A,
  y: 100
};

var b = new A();
// объект "b" имеет новый прототип
console.log(b.x); // undefined
console.log(b.y); // 100 - делегацией, из прототипа

// однако, прототип объекта "a"
// остался прежним (почему, - увидим ниже)
console.log(a.x); // 10 - делегацией, из прототипа

function B() {
  this.x = 10;
  return new Array();
}

// если бы в конструкторе "B" не было
// return'a (или return this), использовался бы
// this-объект, в данном же случае - массив
var b = new B();
console.log(b.x); // undefined
console.log(Object.prototype.toString.call(b)); // [object Array]

Рассмотрим прототип объекта подробней.

Прототип

Каждый объект имеет прототип (исключения могут составлять некоторые системные объекты); связь с прототипом осуществляется посредством внутреннего, недоступного напрямую, свойства [[Prototype]]. Прототипом может быть либо объект, либо null.

Свойство “constructor”

В примере выше можно также выделить два важных момента. Первый из них касается свойства constructor свойства функции prototype. Как можно видеть в алгоритме создания объектов-функций, рассмотренном выше, свойство constructor записывается в свойство функции prototype при создании; значением данного свойства является ссылка на саму функцию:

function A() {}
var a = new A();
console.log(a.constructor); // function A() {}, через делегирование
console.log(a.constructor === A); // true

Часто в данном моменте бывает путаница — свойство constructor неверно считают родным свойством порождённого объекта. Как видим (в алгоритме создания функций), данное свойство является свойством прототипа, и доступно объекту посредством делегирования.

Через свойство constructor (если оно всё ещё указывает на конструктор, а свойство prototype конструктора, в свою очередь, всё ещё указывает на первоначальный прототип) косвенно можно получить ссылку на прототип объекта:

function A() {}
A.prototype.x = new Number(10);

var a = new A();
console.log(a.constructor.prototype); // [object Object]

console.log(a.x); // 10, делегированием
// равносильно a.[[Prototype]].x
console.log(a.constructor.prototype.x); // 10

console.log(a.constructor.prototype.x === a.x); // true

Если мы дописываем что-то в прототип через свойство конструктора prototype, ничего страшного не происходит, просто создаётся новое или модифицируется существующее свойство (в примере выше создаётся новое свойство “x”).

Однако, если мы меняем свойство конструктора prototype полностью, ссылка на оригинальный конструктор (равно как и на оригинальный прототип) будет утеряна (поскольку мы создаём новый объект, не имеющий свойства constructor):

function A() {}
A.prototype = {
  x: 10
};

var a = new A();
console.log(a.x); // 10
console.log(a.constructor === A); // false!

Поэтому данную ссылку необходимо восстановить вручную:

function A() {}
A.prototype = {
  constructor: A,
  x: 10
};

var a = new A();
console.log(a.x); // 10
console.log(a.constructor === A); // true

Но стоит учитывать, что восстановленное вручную свойство constructor, в отличие от утерянного оригинального, не будет обладать атрибутом {DontEnum} и, как следствие, будет выводиться в цикле for..in по A.prototype.

Prototype и [[Prototype]]

С прототипом объекта также иногда бывает путаница — в качестве него могут неверно считать явную ссылку конструктораprototype. Да, действительно, эта ссылка указывает на тот же объект, что и свойство [[Prototype]] объекта:

a.[[Prototype]] ----> Прототип <---- A.prototype

Более того, как мы видели в алгоритме создания объектов, [[Prototype]] черпает информацию из prototype. Однако, изменение свойства prototype в конструкторе, никак не влияет на прототип уже порождённых объектов. Меняется только (и именно) свойство prototype конструктора. Это означает, что новые порождаемые объекты, будут иметь новый прототип. Порождённые же уже (до смены свойства prototype) объекты, имеют связь со своим старым прототипом, и эта связь уже неизменна:

a.[[Prototype]] ----> Прототип <---- A.prototype
A.prototype ----> Новый прототип // новые объекты будут иметь этот прототип
a.[[Prototype]] ----> Прототип // ссылка на старый прототип

Пример:

function A() {}
A.prototype.x = 10;

var a = new A();
console.log(a.x); // 10

A.prototype = {
  constructor: A,
  x: 20
  y: 30
};

// объект "а" ссылается на
// старый прототип, посредством
// внутренней ссылки [[Prototype]]
console.log(a.x); // 10
console.log(a.y) // undefined

var b = new A();

// новые же объекты, при создании
// получают ссылку на новый прототип
console.log(b.x); // 20
console.log(b.y) // 30

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

Основное правило здесь звучит следующим образом: прототип объекта задаётся в момент создания объекта и в дальнейшем явно изменён (полностью на новый объект) быть не может. Посредством же явной ссылки prototype в конструкторе, если она не менялась и всё ещё ссылается на тот же объект, можно дописывать новые свойства в прототип объекта.

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

Нестандартное свойство __proto__

Однако, некоторые реализации, например, SpiderMonkey, предоставляют явную ссылку на прототип объекта, – посредством свойства объекта __proto__ (аналогия с со свойством __class__ в Python):

function A() {}
A.prototype.x = 10;

var a = new A();
console.log(a.x); // 10

var __newPrototype = {
  constructor: A,
  x: 20,
  y: 30
};

// ссылка на новый объект
A.prototype = __newPrototype;

var b = new A();
console.log(b.x); // 20
console.log(b.y); // 30

// "a" пока ссылается на
// старый объект
console.log(a.x); // 10
console.log(a.y); // undefined

// меняем прототип явно
a.__proto__ = __newPrototype;

// теперь и "а" ссылается
// на новый объект
console.log(a.x); // 20
console.log(a.y); // 30

Более того, в новой версии стандарта (ECMA-262-5), введён метод Object.getPrototypeOf(O), который, так же, как и, в данный момент, __proto__ возвращает ссылку на свойство [[Prototype]] — оригинальный прототип объекта.

Объект не зависит от своего конструктора

Поскольку прототип объекта не зависит (в плане полного изменения или удаления) от конструктора и его свойства prototype, теоретически и практически конструктор после своей главной цели — порождения объекта — может быть удалён; объект же продолжит существовать, ссылаясь через [[Prototype]] на свойства и методы, добавляемые в прототип косвенно, через свойство prototype конструктора:

function A() {}
A.prototype.x = 10;

var a = new A();
console.log(a.x); // 10

// об-null-или явную ссылку
// на конструктор "А"
A = null;

// но, можно создавать
// объекты за счёт косвенной ссылки
// из другого объекта, если
// свойство .constructor не менялось
var b = new a.constructor();
console.log(b.x); // 10

// удалим косвенную ссылку
// после этого `a.constructor` и `b.constructor`
// будут указывать на дефолтную функцию Object, а не на `A`
delete a.constructor.prototype.constructor;

// больше объектов конструктора "А"
// не породить, однако, всё ещё
// существуют два таких объекта и
// им доступен прототип
console.log(a.x); // 10
console.log(b.x); // 10

Особенность оператора instanceof

Кстати, с явной ссылкой на прототип (через свойство prototype конструктора) связана работа оператора instanceof. Да, именно так (уточняю, поскольку и в этом моменте бывает часто путаница) — данный оператор работает именно с цепью прототипов объекта. То есть, когда имеет место быть следующая проверка:

if (someObject instanceof SomeConstructor) {
  ...
}

это не означает проверку, что объект someObject порождён конструктором SomeConstructor. Оператор instanceof лишь берёт значение свойства SomeConstructor.prototype и проверяет его наличие в цепи прототипа объекта, начиная с someObject.[[Prototype]] (вызывается внутренний метод [[HasInstance]] конструктора). Продемонстрировать можно на примере:

function A() {}
A.prototype.x = 10;

var a = new A();
console.log(a.x); // 10

console.log(a instanceof A); // true

// если присвоить null
// A.prototype...
A.prototype = null;

// ...то объекту "а"
// всё ещё будет доступен
// прототип - через a.[[Prototype]]
console.log(a.x); // 10

// однако, оператор instanceof
// уже не сможет работать, т.к.
// начинает свой анализ со
// свойства .prototype конструктора
console.log(a instanceof A); // ошибка, A.prototype – не объект

С другой стороны, можно породить объект от одного конструктора, но instanceof, будет выдавать true при проверке с другим конструктором: всего-то нужно установить свойство [[Prototype]] объекта и свойство prototype конструктора на один и тот же объект:

function B() {}
var b = new B();

console.log(b instanceof B); // true

function C() {}

var __proto = {
  constructor: C
};

C.prototype = __proto;
b.__proto__ = __proto;

console.log(b instanceof C); // true
console.log(b instanceof B); // false

Поэтому оператор instanceof, особенно с учётом утиной типизации, может оказаться не столь эффективным.

Хранилище методов и разделяемых свойств

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

function A(x) {
  this.x = x || 100;
}

A.prototype = (function () {

  // инициализирующее пространство,
  // используются вспомогательные сущности
  
  var _someSharedVar = 500;
  
  function _someHelper() {
    console.log('internal helper: ' + _someSharedVar);
  }

  function method1() {
    console.log('method1: ' + this.x);
  }

  function method2() {
    console.log('method2: ' + this.x);
    _someHelper();
  }
  
  // непосредственно, прототип
  return {
    constructor: A,
    method1: method1,
    method2: method2
  };

})();

var a = new A(10);
var b = new A(20);

a.method1(); // method1: 10
a.method2(); // method2: 10, internal helper: 500

b.method1(); // method1: 20
b.method2(); // method2: 20, internal helper: 500

// на оба объекта используются
// одни и те же методы, из одного и
// того же прототипа
console.log(a.method1 === b.method1); // true
console.log(a.method2 === b.method2); // true

Чтение/Запись свойств

Как было отмечено выше, за чтение и запись свойств отвечают, соответственно, внутренние методы [[Get]] и [[Put]].

Метод [[Get]] при этом анализирует цепь прототипов объекта, поэтому свойства прототипа (любого из звеньев цепи прототипов) доступны для объекта как свои собственные (что и является делегацией).

Метод [[Put]], в свою очередь, записывает родное (own) свойство в объект. Последующий вызов [[Get]], даже если определённое свойство присутствует в прототипе, вернёт значение этого родного свойства, поскольку оно будет найдено сразу в объекте.

Для наглядности обозначим работу этих методов псевдокодом.

Метод [[Get]]

[[Get]]:

O.[[Get]](P):

// если есть родное свойство,
// возвращаем его значение
if (O.hasOwnProperty(P)) {
  return O.P;
}

// иначе, анализируем прототип
var __proto = O.[[Prototype]];

// если прототипа нет (возможно,
// например, в конечном звене
// цепи - Object.prototype.[[Prototype]],
// который равен null),
// возвращаем undefined;
if (__proto === null) {
  return undefined;
}

// иначе, вызываем [[Get]] рекурсивно -
// уже для прототипа; т.е. обходим цепь
// прототипов: пытаемся найти свойство
// в прототипе, далее - в прототипе
// прототипа и т.д. до тех пор, пока
// [[Prorotype]] не будет равен null
return __proto.[[Get]](P)

Обратите внимание, именно за счёт того, что метод [[Get]] в одном из случаев может вернуть undefined, нам доступны проверки на наличие переменной, вроде следующей:

if (window.someObject) {
  ...
}

Здесь свойство “someObject” не находится в самом “window”, затем и в прототипе, прототипе прототипа и т.д. — будет проанализирована вся цепь, пока она не прервётся (т.е. один из прототипов не будет равен null; в данном случае, это будет Object.prototype.[[Prototype]]); и в этом случае будет возращёно, согласно алгоритму, значение undefined.

Однако, для проверки выше, лучше отдать предпочтение оператору in, который так же анализирует цепь прототипов:

if ('someObject' in window) {
  ...
}

Это предостережёт от случаев, когда, например, “someObject” может быть равен false и первая проверка не пройдёт, даже, если “someObject” существует.

Метод [[Put]]

[[Put]]:

O.[[Put]](P, V):

// если запись данного свойства
// невозможна, выходим
if (!O.[[CanPut]](P)) {
  return;
}

// если данного родного свойства,
// нет в объекте, создаём его; все атрибуты
// при этом выставляются пустыми (false)
if (!O.hasOwnProperty(P)) {
  createNewProperty(O, P, attributes: {
    ReadOnly: false,
    DontEnum: false,
    DontDelete: false,
    Internal: false
  });
}

// присваиваем значение;
// если свойство существовало,
// атрибуты его остаются неизменными
O.P = V

return;

Выражение доступа к свойствам

Методы [[Get]] и [[Put]] активируются выражением доступа к свойствам, которое в ECMAScript осуществляется либо через точечную нотацию (dot notation), либо через скобочную (bracket notation). Точечная нотация используется, когда имя свойства является правильным идентификатором и заранее известно, скобочная – позволяет задавать динамически формируемые имена свойств.

var a = {testProperty: 10};

console.log(a.testProperty); // 10, точечная нотация
console.log(a['testProperty']); // 10, скобочная нотация

var propertyName = 'Property';
console.log(a['test' + propertyName]); // 10, скобочная, динамическая

Здесь есть один важный нюанс — выражение доступа к свойствам всегда вызывает преобразование ToObject для объекта, стоящего до точки. Именно за счёт этой неявной конвертации, образно можно говорить, что “всё в JavaScript – объекты” (однако, как мы уже знаем – конечно, не всё, т.к. есть и примитивы).

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

Пример:

var a = 10; // примитив

// однако, ему доступны методы,
// как будто он объект
console.log(a.toString()); // "10"

// более того, мы даже можем
// (попытаться) создать новое
// свойство в примитиве "а", вызвав [[Put]]
a.test = 100; // вроде, даже работает

// но, [[Get]] данного свойства
// не возвращает; возвращает по
// алгоритму - undefined
console.log(a.test); // undefined

Итак, почему же в данном примере “примитивному” значению “а” доступен метод toString, но не доступно новое свойство test? Ответ прост: во-первых, как мы выяснили, после действия выражения доступа к свойству, рабочим объектом уже будет не примитив “а”, а промежуточный созданный объект new Number(a) (который посредством делегирования найдёт метод toString в своём прототипе):

a.toString() ---->
wrapper = new Number(a); ---->
wrapper.toString() // "10"

Во-вторых, [[Put]] для свойства test вызывается также относительно промежуточного wrapper-a:

a.test = 100 ---->
wrapper = new Number(a); ---->
wrapper.test = 100

Далее, как было сказано, wrapper после отработки удаляется и, вместе с ним — записанное в него свойство test.

Затем вновь происходит [[Get]], выражение доступа перед которым, создаёт новый wrapper, естественно, не имеющий никакого свойства test:

console.log(a.test); ---->
wrapper = new Number(a); ---->
console.log(wrapper.test); // undefined

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

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

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

Объединяясь, прототипы порождают уже упоминавшуюся цепь прототипов (prototype chain).

Собственно, вся работа по делегации и анализу цепи прототипов, сводится к работе метода [[Get]], рассмотренном нами выше. Если вы полностью разберётесь с несложным алгоритмом метода [[Get]], вопрос о наследовании в JavaScript отпадёт сам собой, а ответ на него станет прозрачным и ясным.

Часто на форумах, когда речь заходит о наследовании в JavaScript, я привожу примером одну единственную строчку ECMAScript программы, которая очень точно описывает объектную структуру языка и показывает делегирующее наследование. В сущности, ещё мы не создали ни одного своего конструкта или объекта, а весь язык уже “пронизан насквозь” наследованием. Данная строка очень проста:

console.log(1..toString()); // "1"

Теперь, когда мы точно знаем, как работают алгоритм [[Get]] и выражение доступа к свойствам (в данном случае точечная нотация), мы можем прочесть эту строчку, описав объектную сущность ECMAScript: “из примитива 1 создаётся wrapper-объект new Number(1) и у этого wrapper-a вызывается унаследованный метод toString Почему унаследованный? Потому, что объекты в ECMAScript могут иметь собственные (own) свойства, а wrapper-объект, в данном случае, не имеет собственного метода toString, соответственно, он наследует его от прототипа, на который, если ничего не менялось, указывает Number.prototype”.

Обратите внимание, две точки в примере выше — это не ошибка, просто первая точка распознаётся как отделение дробной части числа, а вторая — уже, как выражение доступа к свойству.

Стоит разобрать, как создавать эти цепи прототипов для пользовательских объектов. Здесь довольно всё просто:

function A() {
  console.log('A.[[Call]] activated');
  this.x = 10;
}
A.prototype.y = 20;

var a = new A();
console.log([a.x, a.y]); // 10 (родное), 20 (унаследованное)

function B() {}

// самый простой вариант
// связки - установить прототип
// наследника на новый объект,
// порождённый от конструктора-предка
B.prototype = new A(); 

// поправим .constructor, иначе был бы А
B.prototype.constructor = B;

var b = new B();
console.log([b.x, b.y]); // 10, 20, оба унаследованные

// [[Get]] b.x:
// b.x (нет) -->
// b.[[Prototype]].x (да) - 10

// [[Get]] b.y
// b.y (нет) -->
// b.[[Prototype]].y (нет) -->
// b.[[Prototype]].[[Prototype]].y (да) - 20

// где b.[[Prototype]] === B.prototype,
// а b.[[Prototype]].[[Prototype]] === A.prototype

Однако, данный способ имеет две особенности. Во-первых, B.prototype будет содержать свойство “x”. Кажется, что здесь что-то неправильно, свойство “x” описано в “A” как родное и ожидается в объектах конструктора “B” также, как родное. Однако в случае прототипного программирования — это норма, т.к. объект-предок, по идеологии, делегирует за неимеющимися свойствами к прототипу (возможно, объектам конструктора “B” и не понадобится свойство “x”) в отличии от классовой модели, где, повторим, все свойства копируются в класс-потомок. С другой стороны, если, всё же, хочется, чтобы свойство “x” было родным для объектов, порождённых от “B”, для этого есть решения, одно из которых мы рассмотрим чуть ниже.

Во-вторых, что является уже не особенностью, а недостатком — если в конструкторе будет выдано какое-то сообщение (в примере выше, в конструкторе “А” выдаётся сообщение “A.[[Call]] activated”), то данное сообщение “выстрелит вхолостую” в тот момент, когда объект, порождаемый конструктором “A”, используется в качестве B.prototype (можно видеть, что данное сообщение выдалось дважды – при создании объекта “а” и при определении B.prototype).

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

function A(param) {
  if (!param) {
    throw 'Param required';
  }
  this.param = param;
}
A.prototype.x = 10;

var a = new A(20);
console.log([a.x, a.param]); // 10, 20

function B() {}
B.prototype = new A(); // Ошибка

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

Для решения этих “особенностей/проблем” сегодня используют стандартный шаблон для связки прототипов, предложенный, по некоторым данным, Lasse Reichstein Nielsen (хотя, бытует мнение, что данный паттерн предложил Douglas Crockford). Суть данного трюка заключается в создании промежуточного конструктора, который и связывает свойства prototype нужных конструкторов. Помимо этого, можно добавить некоторый “синтаксический сахар” для удобного доступа к конструктору-предку:

function A() {
  console.log('A.[[Call]] activated');
  this.x = 10;
}
A.prototype.y = 20;

var a = new A();
console.log([a.x, a.y]); // 10 (родное), 20 (унаследованное)

function B() {
  // или просто A.apply(this, arguments);
  B.superproto.constructor.apply(this, arguments);
}

// наследование: связка прототипов
// посредством создания пустого промежуточного конструктора
var F = function () {};
F.prototype = A.prototype; // связка, ссылка
B.prototype = new F();
B.superproto = A.prototype; // явная ссылка на родительский прототип, "сахар"

// поправим .constructor, иначе был бы A
B.prototype.constructor = B;

var b = new B();
console.log([b.x, b.y]); // 10 (родное), 20 (унаследованное)

Теперь свойство “x” для объекта “b” — родное. Добились мы этого явным вызовом в конструкторе B, родительского конструктора А (через B.superproto.constructor) с передачей this как объекта конструктора B — B.superproto.constructor.apply(this, arguments).

Более того, теперь сообщение “A.[[Call]] activated” из конструктора A не “стреляет вхолостую” при задании B.prototype (поскольку B.prototype теперь – это объект, порождённый от пустого конструктора F, и прототипом этого объекта является A.prototype). Однако, сообщение всё ещё выводится два раза, но сейчас, второй раз — уже ожидаемо — мы явно вызываем родительский конструктор А, чтобы создать own-свойство “x” в объектах, порожденных конструктором B. Кстати, ничто не мешало нам вызвать конструктор А в конструкторе B для порождения own-свойства “x” и в первом варианте – тогда бы сообщение выдалось три раза — два ожидаемо, один — “вхолостую”.

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

function inherit(child, parent) {
  var F = function () {};
  F.prototype = parent.prototype
  child.prototype = new F();
  child.prototype.constructor = child;
  child.superproto = parent.prototype;
  return child;
}

Соответственно, наследование:

function A() {}
A.prototype.x = 10;

function B() {}
inherit(B, A); // связка прототипов

var b = new B();
console.log(b.x); // 10, найдено в A.prototype

Вариаций данных обёрток (в плане синтаксиса) существует немало, однако все они сводятся к действиям, описанным выше.

К примеру, мы можем оптимизировать предыдущую обёртку, если вынесем промежуточный конструктор наружу (таким образом, будет создана всего одна функция), тем самым, повторно используя его:

var inherit = (function(){
  function F() {}
  return function (child, parent) {
    F.prototype = parent.prototype;
    child.prototype = new F;
    child.prototype.constructor = child;
    child.superproto = parent.prototype;
    return child;
  };
})();

Поскольку настоящий прототип объекта — это свойство [[Prototype]], F.prototype может свободно меняться, т.к. child.prototype, порождаясь от new F, запомнит текущий прототип внутренним свойством [[Prototype]]:

function A() {}
A.prototype.x = 10;

function B() {}
inherit(B, A);

B.prototype.y = 20;

B.prototype.foo = function () {
  console.log("B#foo");
};

var b = new B();
console.log(b.x); // 10, найдено в A.prototype

function C() {}
inherit(C, B);

// и используя наш "superproto" сахар мы можем
// вызывать родительские методы с таким же именем

C.prototype.foo = function () {
  C.superproto.foo.call(this);
  console.log("C#foo");
};

var c = new C();
console.log([c.x, c.y]); // 10, 20

c.foo(); // B#foo, C#foo

Также, именно на этом принципе основаны все существующие вариации имитаций “классического наследования в JS”. Почему “классического”? Первоначальная причина – проекция на ООП со статично-классовой (более привычной) парадигмой. Однако, как мы видели, динамическая классовая парадигма в некоторых вариациях может быть и так схожа с прототипной. А во-вторых (что можно технически оправдать и приписать к классовой парадигме) — условие, что цепь прототипов будет жёсткой и неизменной (при этом, сама цепь физически, естественно, изменяема). Также, можно выделить создание унаследованных own-свойств в “классах”-потомках.

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

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

// ES6
class Foo {
  constructor(name) {
    this._name = name;
  }

  getName() {
    return this._name;
  }
}

class Bar extends Foo {
  getName() {
    return super.getName() + ' Doe';
  }
}

var bar = new Bar('John');
console.log(bar.getName()); // John Doe

Заключение

Данная статья получилась достаточно обширной и подробной. Надеюсь, что материал её оказался полезным и развеял некоторые идеологические сомнения относительно ECMAScript-a. Если у вас возникли какие-либо вопросы или дополнения, их, как всегда, можно обсудить в комментариях.

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

ECMAScript:

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

Write a Comment

Comment

53 Comments

  1. @Костантин, да, верно. Парсер попытается распознать число после точки (или “пустоту”, или доступ к свойству), но не букву. Поэтому следующие варианты верны:

    10.
    10..toString;
    
  2. function A() {
      console.log('A.[[Call]] activated');
      this.x = 10;
    }
    A.prototype.y = 20;
     
    function B() {
      B.superproto.constructor.apply(this, arguments);
    }
     
    var F = function () {};
    F.prototype = A.prototype;
    B.prototype = new F();
    B.superproto = A.prototype;
     
    B.prototype.constructor = B;

    Поясните пожалуйста, почему B.prototype выводит в консоль A {}, а не F{}.
    Спасибо.

  3. @shtephan

    Поясните пожалуйста, почему B.prototype выводит в консоль A {}, а не F{}

    Потому что консоль Chrome выводит имя конструктора, взятое из <объект>.constructor.name. Поскольку мы установили F.prototype = A.prototype;, F.prototype.constructor === A.

    Т.е.

    console.log(new F().constructor.name); // "A"