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. В настоящее время стараюсь глубже разобраться в ООП, но не могу определиться с выбором подхода в создании Fluent interface (цепочки объектов). Такой подход применяется в библиотеке jQuery, но я пока не знаю на сколько он корректен чтобы отталкиваться от этой реализации?

    var $ = function(selector) {
        if(this.$) {
            return new $(selector);
        }
        if(typeof selector == "string") {
            this.init = document.getElementById(selector);
        }
    };
    
    $.prototype = {
        text: function(text) {
            if(!text){
               this.init.innerHTML;
            }
            this.init.innerHTML = text;
            return this;
        },
        css: function(style) {
            for(var i in style){
               this.init.style[i] = style[i];
            }
            return this;
        }
    };
    
    $('div').text('div').css({color: "red"});
    
    
  2. @Фелекс

    но я пока не знаю на сколько он корректен

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

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

    ({foo: function () {
      return "foo";
    }})
      .foo() // "foo"
      .toUpperCase(); // "FOO"
  3. Здравствуйте, Дмитрий ! У меня следующий вопрос.
    Не могли бы вы немного подробнее рассказать о различия между двумя способами создания объектов:

    1. Конструктор;
    2. Literal notation;

    Спасибо!

  4. @Сергей

    Собственно, если речь идет лишь о конструкторе Object, то инициализатор объекта (или литеральная нотация объекта) — всего лишь синтаксический сахар для new Object:

    var foo = {x: 10, y: 20};

    то же самое, что:

    var foo = new Object;
    foo.x = 10;
    foo.y = 20;
    

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

    С другой стороны, метод с конструктором позволяет создавать свойства, имена которых определяются в рантайме, т.е. не известны на этапе компиляции:

    var foo = new Object;
    
    for (var k = 3; k--;) {
      foo["slot" + k] = k;
    }
    
    console.log(foo); // {slot3: 3, slot2: 2, ...}

    Прототипом в обоих случаях — и с {}, и с new Object, будет Object.prototype.

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

  5. function foo (x){
    var x = x || 10;
    ...}

    Насколько я понимаю преобразуется в:

    var x = (typeof x !== 'undefined') ? x : {};

    Несколько запутанная нотация, код несомненно короче, но первая мысль которая приходит на ум, когда ее видишь – это возвращение true/false из x||10.

  6. @Сергей

    Насколько я понимаю преобразуется в:

    var x = (typeof x !== 'undefined') ? x : {};

    (Опечатка с {}? Дефолтное значение 10 у Вас в примере). Здесь используется преобразование ToBoolean:

    var x = (Boolean(x) === true) ? x : 10;

    Несколько запутанная нотация, код несомненно короче, но первая мысль которая приходит на ум, когда ее видишь – это возвращение true/false из x||10

    Это может быть вызвано привычкой, выработанной изначально в языке с другой семантикой (например, Си). Здесь же, JS возвращает сам операнд, а не boolean значение всей операции (хоть и использует boolean-преобразование для теста). Такая же семантика, например, в Python.

  7. Да, вы правы опечатка, исходя из примера должны быть 10

  8. Дмитрий, здравствуйте. Читаю ваши статьи с огромным упоением: наконец-то я нашёл действительно глубокий материал по JavaScript.

    Собственно, у меня к вам пара вопросов по данной статье. Во-первых, не очень понятно, когда у создаваемого объекта появляется свойство constructor: в момент присвоения A.prototype = new A()? Было бы здорово, если бы вы подробнее описали это место. Во-вторых, по поводу ООП-обёртки. Можно ли использовать ваш код для написания своей :)? Какие ещё обёртки вы бы посоветовали, в чём их преимущество?

  9. @Alexander Belov

    Спасибо; приятно видеть программистов, интересующихся глубоким JS.

    Во-первых, не очень понятно, когда у создаваемого объекта появляется свойство constructor: в момент присвоения A.prototype = new A()?

    Свойство constructor у объектов является не родным, а унаследованным. Это свойство лежит в прототипе:

    function A() {}
    
    var a = new A();
    
    console.log(a.constructor); // A

    Но — отчетливо видно, что это неродное свойство:

    console.log(a.hasOwnProperty("constructor")); // false
    console.log(a.__proto__.hasOwnProperty("constructor")); // true
    
    console.log(A.prototype.hasOwnProperty("constructor")); // true
    
    console.log(a.__proto__ === A.prototype); // true

    А в A.prototype свойство constructor появляется при создании функции. И это есть рекурсивная ссылка функции на себя:

    console.log(A.prototype.constructor === A); // true

    Во-вторых, по поводу ООП-обёртки. Можно ли использовать ваш код для написания своей 🙂 ? Какие ещё обёртки вы бы посоветовали, в чём их преимущество?

    Конечно используйте 😉 К тому же, это довольно распространенный на сегодня вариант для ES3. В ES5 появилась уже стандартная обертка — Object.create, которая делает почти то же самое внутри.

    Оберток много, реализации есть во многих библиотеках. Наиболее интересны обертки с удобным super-вызовами, т.е. вызовы методов родительских классов без “хардкода” имен классов. Вот несколько из моих наработок.

  10. Вижу множество отличных наработок на любой вкус. И вы, похоже, как и я любите Ruby :). Возможно, вместо написания своей, лучше использовать вашу, не изобретать велосипед, — с теорией знаком да и ладно :). В любом случае, ещё раз огромное спасибо за ваши статьи, продолжайте в том же духе!

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

  11. @Alexander Belov

    И вы, похоже, как и я любите Ruby 🙂

    Точно 😉 Да и вообще я люблю языки (Erlang, Python, CoffeeScript, Lua и другие).

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

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

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

    Да, мне уже говорили об этом. Этот блок меню писал не я. Если есть какие-нибудь предложения, как это можно лучше реализовать, пришлите мне на почту, я с удовольствием рассмотрю и встрою.

  12. Дмитрий !

    Мне непонятен вот этотм момент

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

    Зачем восстанивливать ссылку на конструктор если мы меняем прототип функции-конструктора. Почему всегда идет пара, н-р:

    Author.prototype = new Person(); // Set up the prototype chain.
    
    Author.prototype.constructor = Author; // Set the constructor attribute to Author.

    Чем чревато если мы скипаем восстановление ссылки на конструктор ?

  13. @денис

    Чревато тем, что, если захочется проверить свойство constructor у инстанса (порожденного от Author), то оно будет указываться на Person, а не на Author.

    Author.prototype = new Person(); // Set up the prototype chain.
    
    var author = new Author('Charles');
    console.log(author.constructor); // Person
  14. Ок – понятно. Спасибо.
    Статьи просто шикарные – решил глубоко разобраться с джава скриптами и обнаружилось, что при всей визуальной простоте языка возникает масса вопросов – а почему имеено так – откуда у этого растут ноги ?

    Книги же просто обходят серьезные вопросы стороной – я чесно говоря был в бешенстве. А потом произошло чудо – буржуй поясняя работу прототипов сослался на твой блог (я такого вообще никогда не встречал).

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

    Успехов !

  15. C интересом прочитал цикл ваших статей по ECMA-262-3. Удалось разобраться во всем, о чем Вы пишете, хотя я и новичок в JS. Материал, бесспорно, качественный и полезный. Хотел бы спросить следующее:

    1.

    var i = 123;
    alert(typeof(i)); // вернет: number
    var j = "123";
    alert(typeof(j)); // вернет: string
    

    Вопрос: откуда typeof знает, что в переменной i – число, а в переменной j – строка?

    2.
    Второй вопрос больше на мое собственное понимание механизмов JS.

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

    Если я правильно понимаю, в первом варианте функция F создается всякий раз, когда вызывается inherit(), но после отработки кода inherit() – уничтожается, т.к. на неё не остается ни одной ссылки во внешних переменных – F.prototype, вместе со свойством “constructor”, переписывается, и больше ссылок нигде нет. То есть, большую часть времени в системе будет существовать одна функция – inherit(). Во втором варианте функция F, за счет замыкания, будет жить постоянно, будучи “привязанной” к inherit(). Значит, будут существовать две функции, а не одна, как в первом варианте. Получается, первый вариант более оптимален с точки зрения использования памяти. И еще – поиск функции F по Scope chain. В первом варианте функция будет найдена в самом первом элементе Scope chain – в VO функции inherit(), а во втором – во втором элементе Scope chain – в VO анонимной функции, возвращающей будущее значение переменной-функции inherit. Строго говоря, в первом варианте поиск F по Scope chain пройдет быстрее.

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

  16. @Алексей

    откуда typeof знает, что в переменной i – число, а в переменной j – строка?

    На уровне имплементации, операторы (включая typeof) могут определить тип сущностей, над которыми они работают. Т.е. ответ — знает, по-определению.

    Во втором варианте функция F, за счет замыкания, будет жить постоянно, будучи “привязанной” к inherit(). Значит, будут существовать две функции, а не одна, как в первом варианте. Получается, первый вариант более оптимален с точки зрения использования памяти.

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

    И еще – поиск функции F по Scope chain. В первом варианте функция будет найдена в самом первом элементе Scope chain – в VO функции inherit(), а во втором – во втором элементе Scope chain – в VO анонимной функции

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

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

  17. С повторным использованием функции понятно – оптимальность зависит от ситуации, в которой применен тот или иной вариант.

    Про typeof хотел бы уточнить, почему я собственно озадачится эти вопросом. Вы тоже затрагивали тему работы typeof, когда рассказывали о внутреннем свойстве [[Call]]: “оператор typeof для таких объектов возвращает значение “function””. Я и задумался, на основании чего он тогда возвращает “number” и “string”? Спецификация ECMA-262, как я понял, на эту тему ничего не говорит. А об одном из способов реализации я читал. Там говорится, что при хранении переменных и свойств объектов, кроме имени и значения, используется еще одно числовое значение, обозначающее тип. По нему ориентируется typeof. Если это число “0” – тип “object”, другое число – тип “string”, еще какое-то число – тип “undefined”. Таким способом реализации объясняется и поведение typeof для значений null. Когда переменной присваивается null (i = null), в числовое значение, обозначающее тип, прописывается “0” – объект. typeof(i) смотрит на это значение и возвращает “object”, думая, что в переменной объект. Но, наверное, возможны и другие реализации…

  18. @Алексей

    Да, действительно, ECMA-262 оставляет сами механизмы получения типа сущности (да и формат на низком уровне сущностей) за кадром.

    По поводу [[Call]] — это вспомогательный признак для typeof, т.е., когда он уже определил, что тип сущности — Object, то: если есть [[Call]], возвращаем "function", иначе — возвращаем "object".

    Насчет представления данных и их типов на низком уровне — да, как Вы правильно отметили, возможна имплементация с “битом типа” (иногда в литературе используется “type-tag”). С этой точки зрения, любая сущность есть объект: {typeTag: type, data: data}. Но, стоит еще раз отметить, что это абстрактное рассуждение о реализациях (и реализациях даже не конкретно JS, а какого-нибудь языка программирования в целом), ECMA-262 ничего про это не говорит.

    Реализаций может быть много, они могут быть написаны на языках и более высокого уровня и использовать более простые представления и проверки (реализация может быть написана и на самом JS — тогда чтобы проверить результат typeof при интерпретации/компиляции, можно использовать сам typeof!).

  19. Да, действительно, ECMA-262 оставляет сами механизмы получения типа сущности (да и формат на низком уровне сущностей) за кадром.

    Интересно, почему? Вопрос, вроде бы, не такой простой: типов много, у каждого свои особенности.

  20. Дмитрий, исправьте пожалуйста опечатку в слове ptototype(C.ptototype.foo) на prototype

  21. @Александр, ага, спасибо, исправил (включая английскую версию — кстати, в ней больше дополнений; надо будет русскую тоже дополнить).

  22. Спасибо огромное за эти уроки!! у меня есть вопрос

    function A(){}
    A.prototype.x = 1;
    var y = new A();
    A.prototype = {
        x : 2
    }
    alert(y.x) // 1;
    function B(){}
    B.prototype.x = 1;
    var b = new B();
    B.prototype.x = 2;
    alert(b.x) // 2

    почему в первом варианте алертится 1, а во втором 2? в чем разница?

  23. @Вадим

    Первый случай.

    В этом фрагменте кода, A.prototype и y[[prototype]] ссылаются на один объект.

    var prototypeObject = {};
    
    function A(){}
    A.prototype = prototypeObject;
    A.prototype.x = 1;
    
    var y = new A();
    
    A.prototype === prototypeObject; // true
    y.__proto__ === prototypeObject; // y[[prototype]]
    y.__proto__ === A.prototype; // true
    

    После того как вы присвоели A.prototype ссылку на новый объект, A.prototype и у[[prototype]] стали ссылаться на разные объекты.

    A.prototype = {} 
    
    //теперь A.prototype ссылается на новый объект. Который никак не будет влиять на уже созданные объекты.
    A.prototype === prototypeObject; // false
    y.__proto__ === prototypeObject; // а у[[prototype]] все еще на старый.
    
    y.__proto__ === A.prototype // false
    

    Во втором случае вы не меняете ссылку на объект, а лишь изменяете значение одного из свойств этого объекта.

    var prototypeObject = {};
    
    function A(){}
    A.prototype = prototypeObject;
    A.prototype.x = 1;
    
    var y = new A();
    
    A.prototype === prototypeObject; // true
    y.__proto__ === prototypeObject; // y[[prototype]]
    y.__proto__ === A.prototype; // true
    
    // Меняем значение свойства prototypeObject
    A.prototype.x = 2; // эта запись равносильна записи prototypeObject.x = 2
    // Мы помеянли лишь значение свойства объекта prototypeObject, а не сам объект поэтому
    
    y.__proto__ === A.prototype; // все еще ссылаются на один и тот же объект
    // поэтому
    у.x === 2;
    

    Надеюсь я хоть как-то помог.

  24. Спасибо, maksimr! вроде бы все понятно

  25. @Дмитрий
    Это ценнейший материал, который я находил на просторах рунета. За этот цикл статей(за 5 дней) я понял раз в 10 больше чем за предыдущие пору месяцев попыток изучения javascript и стараний вникнуть в this, в замыкания и т.д. А самое ценное это то, как Вы выделяете важность терминов, и как затем вводите их в общую теорию. Спасибо Вам! )

  26. Дмитрий, спасибо за статью. Это хороший материал для понимания особенностей ECMAScript. Но возник вопрос, как вы думаете, а наследование через создание объекта не проще ли, чем способом, который придумали Lasse Reichstein Nielsen и Douglas Crockford?
    Например, такой код:

    
            function A(a) {
                this.a = a || 'init class A';
                document.writeln(this.a + '');
            }
            A.prototype.constructor = A;
            A.prototype.a1 = function (n) {
                document.writeln(n)
            }
            A.prototype.a2 = function (a) {
                document.writeln(a)
            }
    
            function B(b) {
                this.b = b || 'init class B';
                document.writeln(this.b + '');
            }
            B.prototype.constructor = B;
            B.prototype.b1 = function (c) {
                document.writeln(c)
            }
    
            function C(c) {
                document.writeln(c);
                this.a = new A();
                this.b = new B();
                this.c_value = 'method c4';
            }
            C.prototype.constructor = C;
            C.prototype.c1 = function (c) {
                this.a.a1(c);
            }
            C.prototype.c2 = function (c) {
                this.a.a2(c);
            }
            C.prototype.c3 = function (c) {
                this.b.b1(c);
            }
            C.prototype.c4 = function () {
                document.writeln(this.c_value)
            }
            var c = new C('init class C');
            c.c1('method c1');
            c.c2('method c2');
            c.c3('method c3');
            c.c4();
    

    по сути реализует множественное наследование. Плюс, не происходит “холостого выстрела” при объявлении наследования. Или я в чем то не прав?

  27. @Владимир

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

    Т.е. методы-обертки как c1, c2 и c3 всего лишь проксируют вызовы нужных методов в a и b.

    Это тоже можно назвать “наследованием”, но косвенным — через агрегацию. Основной недостаток — то, что Вы все-таки явно создаете собственные методы для класса c (пусть даже и только обертки). Само наследование (реюз кода) подразумевает неявную делегацию к наследуемым методам.

    Преимущество же агрегации — например, Вы можете более гибко в рантайме менять поведение методов c1, c2, c3, просто меняя объекты a и b — обертки будут уже делегировать к новым методам.

    P.S.: кстати, англоязычный вариант этой статьи более полный.

  28. Дмитрий, спасибо за статью, отличный материал! Хотел бы указать на опечатку/неточность, которая меня смутила:

    Оператор instanceof лишь берёт прототип объекта — ‘someObject.[[Prototype]]’ и проверяет его наличие в цепи прототипов, начиная от ‘SomeConstructor.prototype’

    Насколько я понимаю, someObject и SomeConstructor надо поменять местами, то есть instanceof проверяет наличие прототипа SomeConsructor в цепочке прототипов someObject.

  29. @Alexander

    Да, действительно. Спасибо, хорошее замечание; исправил.

  30. Дмитрий, Вам бы книгу написать! “Essential JavaScript”. Кмк, будет не менее популярной Рихторовских “Window via C++” или “CLR via C#”.(Книги из другой темы, но отличаются именно глубокой подачей материала, как Ваши статьи)

  31. @Konstantin

    Спасибо, рад, что материал полезен. Да, идея про книгу была, возможно восстановлю ее — скоро как раз выйдет ES6, там будет много нового материала.

  32. Ну наконец! Нашел нормальную статью про ООП в JavaScript!

    В своем проекте использую вот эту ООП-обёртку http://qooxdoo.org Серверную часть, кроме ООП, там куча других плющек.

  33. Здравствуйте, Дмитрий. Спасибо за ваш труд.
    Возник небольшой вопрос:
    при использовании описанной вами обертки

    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;
    }
    

    как быть если нужно при создании потомка отдать какие-то параметры в родительский конструктор?

  34. @Rinat

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

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

    function A(x) {
      this.x = x;
    }
    
    function B(x, y) {
      B.superproto.constructor.call(this, x);
      this.y = y;
    }
    
    inherit(B, A);
    
    B.prototype.getValues = function() {
      return [this.x, this.y];
    };
    
    var b = new B(10, 20);
    b.getValues(); // [10, 20]
    

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

    В новом коде скоро можно будет просто писать:

    class B extends A {
      constructor(x, y) {
        super(x);
        this.y = y;
      }
    
      getValues() {
        return [this.x, this.y];
      }
    }
  35. @Dmitry Soshnikov

    Благодарю за развернутый ответ.
    Хотел уточнить:
    1. На сегодняшний день вы не рекомендуете использовать эту низкоуровневую обертку?
    2. Стандарт ECMAScript 6 утвердили?
    3. Когда можно будет пользоваться его новыми возможностями и стоит ли вообще их использовать в боевом коде в настоящее время?

  36. @Rinat

    Работа над спецификацией ES6 движется к завершению, надеюсь, будет к концу года готовый стандарт.

    Но и уже сейчас есть множество инструментов, которые позволят использовать ES6 в старых браузерах.

    Один из таких инструментов — Traceur: транслятор из ES6-кода в ES5-код.

    В тестовой консоли можно, например, посмотреть, как выглядит компиляция классов.

    Если Вам нужно поддерживать и совсем старые браузеры, то можно поискать инструменты, которые компилируют в ES3 код. Один из таких — jstransform, над которым я работаю в том числе (хоть там и написано ES5, основные трансформации совершаются в ES3). Или, также esnext. Но Traceur на сегодня имеет наиболее полную поддержку ES6, так что, его есть смысл поизучать, чтобы понять, каким JS будет в скором будущем.

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

    Для промежуточной компиляции в Вашем проекте должна быть какая-то build-система, конечно, которая делает сборку и транслирует ES6-файлы. А страницы уже подключают ES3-скомпилированный код.

  37. Читал про 4 способа наследования в javascript, но так доступно только здесь описано! Спасибо за отличные статьи!

  38. Спасибо за такую исщерпывающую статью.

    Разбираю объектную модель в Ember.js и не могу понять до конца их CoreObject, его код на github

    CoreObject.extend = function(options) {
      var constructor = this;
    
      function Class() {
        var length = arguments.length;
    
        if (length === 0)      this.init();
        else if (length === 1) this.init(arguments[0]);
        else                   this.init.apply(this, arguments);
      }
    
      Class.__proto__ = CoreObject;
    
      Class.prototype = Object.create(constructor.prototype);
      if (options) assignProperties(Class.prototype, options);
    
      return Class;
    };
    

    Из Вашей статьи я понял, что в __proto__ отображает внутринее свойство [[Prototype]], то есть в Class.__proto__ по умолчанию записывается Object.prototype, а если бы мы сделали a = new Class(), то в a.__proto__ было бы Class.prototype, правильно ли я понимаю?
    При помощи строчки Class.prototype = Object.create(constructor.prototype); копируется текущий прототип, потому и не теряется предок?
    После чего в Class.prototype примешивается новый объект.
    Выходит это реализация примисей.
    Но полностью завела в тупик конструция в assignProperties

    function giveMethodSuper(superclass, name, fn) {
      var superFn = superclass[name];
    
      if (typeof superFn !== 'function') {
        superFn = function() {};
      }
    
    //.....
    
      return function() {
        var previous = this._super;
        this._super = superFn;
        var ret = fn.apply(this, arguments);
        this._super = previous;
        return ret;
      };
    }
    

    Тоесть эта функция принимает Class.prototype, название метода в объекте, который хотим примешать, и сам метода.
    В тупик вводит, то что возвращает эта функция.

  39. @Дмитрий

    Из Вашей статьи я понял, что в __proto__ отображает внутринее свойство [[Prototype]]

    Верно.

    то есть в Class.__proto__ по умолчанию записывается Object.prototype

    Поскольку Class — это функция, то ее __proto__ (т.е. [[Prototype]]), это Function.prototype (который является также функцией — “пустой функцией”).

    function Class() {}
    Class.__proto__ === Function.prototype; // true
    Class.__proto__; // function Empty() {}
    

    а если бы мы сделали a = new Class(), то в a.__proto__ было бы Class.prototype, правильно ли я понимаю?

    Да, здесь все верно.

    При помощи строчки Class.prototype = Object.create(constructor.prototype); копируется текущий прототип, потому и не теряется предок?

    Функция Object.create(proto), создает объект, который наследует от proto. Т.к. переменная constructor — это CoreObject, то значением Class.prototype будет объект, который наследует от CoreObject.prototype.

    После чего в Class.prototype примешивается новый объект.
    Выходит это реализация примисей.

    Да, все верно — как только мы унаследовали от CoreObject.prototype, можно подмешать новые методы в Class.prototype.

    Но полностью завела в тупик конструция в assignProperties

    Это делается для удобных вызовов super-методов.

    Т.е. чтобы писать так:

    this._super(...);
    

    А не так:

    Class.superclass.prototype.foo.call(this, ...);
    

    Проблемы начнутся, когда у Вас будет больше трех уровней наследования. Если _super, реализован как this.class.superclass.prototype.foo.call(this, ...);, то this каждый раз будет один и тот же, и мы получим бесконечную рекурсию на третьем уровне наследования, т.к. this.class всегда будет указывать на второй уровень (можете попробовать реализовать, чтобы убедиться).

    Поэтому это переопределение this._super = superFn; при каждом вызове подставляет правильное значение super-функции (взятое непосредственно из прототипа родительского класса), и потом (после выполнения, восстанавливает его). Значение ret в данном случае — это то, что вернула родительская функция.

    Данный код (реализация удобных super-вызовов) один из самых сложные при наследовании в JS, поэтому немудрено, что вызывает вопросы. Я думаю, если Вы попробуете реализовать, как было (ошибочно) показано выше, и увидите бесконечную рекурсию, он станет более понятным 😉

  40. @Dmitry, спасибо за разъяснения.

    Попробовал написать свой extend, получилось вот так

    function Klass() {}
    
    Klass.prototype.init = function () {};
    
    Klass.prototype.extend = Klass.extend = function (obj) {
        for (var k in obj) {
            if (obj.hasOwnProperty(k)) {
                if (Klass.prototype.hasOwnProperty(k) && typeof obj[k] === 'function') {
                    var base = Klass.prototype[k];
                    Klass.prototype[k] = obj[k];
    		Klass.prototype[k].prototype._base = base;
    	    } else {
                    Klass.prototype[k] = obj[k];
                }
            }
        }
    
        var klass = new Klass();
        return function () { klass.init.apply(klass,arguments); return klass; };
    };
    

    Код для теста

    Ничего ли я не забыл, думаю типизацию можно реализовать через переопределения метода toString?

  41. @Дмитрий

    Решение интересное, но, увы, нерабочее. Поскольку в строке 10 вы перезаписываете каждый раз базовое значение Klass.prototype[k], то могут возникнуть проблемы с переопределением. Увидеть проблему можно, вызвав Person.grow() после Worker.grow() — метод grow уже будет не из Person. Пример с base тоже интересен, но также громоздкий при вызовах. Все же, классический подход (в частности, описанный в этой статье) подойдет больше 🙂

  42. Да это я так пишу в чисто образовательных целях, пока сам не напишешь не поймешь, что к чему:)
    Вот попробовал исправить

    function Klass() {}
    
    Klass.prototype.extend = Klass.extend = function(obj) {
        var klass = new Klass();
        klass.init = function() {};
        for (var k in obj) {
            if (obj.hasOwnProperty(k)) {
                if (this.hasOwnProperty(k) && typeof obj[k] === 'function') {
                    var base = this[k];
                    klass[k] = obj[k];
                    klass[k]._base = base;
                } else {
                    klass[k] = obj[k];
                }
            }
        }
    
        return function() {
            klass.init.apply(klass, arguments);
            return klass;
        };
    };
    

    Теперь получается вызывать Person.grow() и чуть сократил вызов _base this.grow._base.call(this);

  43. @Дмитрий, ну вот, уже лучше 😉 Только все равно есть недостатки: каждый раз создается объект одного и того класса var klass = new Klass(), каждый раз init — это новая функция, свойства из obj просто копируются в новый объект (вместо наследования). Но в целом, в образовательных целях — неплохо.

  44. @Dmitry, да этого самого смущает. Ещё нужно подумать над другими вариантами.

    А ещё вопрос слегка оффтопик, как по мне принцип все по ссылки в JS экономит память, но когда смотришь чужой код довольно тяжело его понимать и что-то не сломать. Потому вместо ООП подхода может целесообразнее использовать подходы схожие с подходами в Erlang?

  45. @Дмитрий, в JS больше подойдет описание “все по значению”, включая объекты, которые передают значение адреса — ссылку. Также эта стратегия известна как “по разделению” (by sharing). В части 8 это подробно описано. Erlang хорош, но в JS изначально уже есть концепция изменяемого состояния.

  46. @Дмитрий, да, генераторы вносят кооперативную многозадачность (но внутри одного треда). У меня был пример реализации планировщика (еще с генераторами из SpiderMonkey, до ES6).


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

    Проверил в IE11, там уже тоже [object Function] возвращается.

  48. @Сергей, спасибо, да, в новых версия IE уже пофиксили. Добавил “в старых IE”.

  49. И все-таки не совсем понятно, почему в нижеследующем коде синтаксическая ошибка:

    10.toString();

    Оттого, что в JavaScript все числа вещественные?