Pull to refresh

Замыкания и объекты JavaScript. Переизобретаем интерпретатор

Reading time 12 min
Views 25K
Обычно концепции или парадигмы программирования объясняют либо описательно — «разжёвывая» новые идеи простыми словами, либо метафорически — уподобляя их хорошо знакомым аудитории предметам и понятиям. Но ни первый, ни второй способ не дает такого точного и полного представления о предмете, как взгляд с точки зрения низкоуровневой реализации.

Когда в изучении языка доходишь до нетривиальных вещей, бывает полезно сместить уровень абстракции, чтобы понять, как на самом деле всё устроено. Ведь, по большому счету, любые конструкции языков сколь угодно высокого уровня сводятся к старому доброму машинному коду. Писать в объектно-ориентированном или функциональном стиле можно и на чистом C, и даже на ассемблере. Грубо говоря, любой высокоуровневый язык — это зафиксированный на уровне компилятора или интерпретатора набор синтаксических карамелек и шоколадок. Повышение уровня абстракции позволяет писать более сложные программы с меньшими усилиями, но вот понять в начале пути, что конкретно имеется в виду под наследованием или замыканием, как это всё работает и почему, гораздо легче, разобравшись, каким образом всё это реализовано.

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

Первоклассные функции


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

Рассмотрим следующий код:
//  Листинг 1
var x = 10;

var foo = function()  {
  var y = 20;
  var bar = function() {
    var z = 30;
    return x+y+z; 	
  }
  return bar;
}

var baz = foo();
console.log(baz());  // 60


Переменные x, foo и baz — глобальные, и поэтому доступны везде, независимо от глубины стека. В момент вызова foo, на вершине стека оказывается кадр с локальными переменными y и bar, затем, при выходе из foo, этот кадр теряется, а в момент вызова baz — в кадре активации только z. Откуда интерпретатору взять y? В языке Си (и всех его потомках) эта проблема решается весьма сурово — объявление вложенных функций запрещено. В Паскале — наоборот, вложенные функции есть, но возможность вернуть функцию на выходе отсутствует. Когда говорят, что функции в императивных языках не являются объектами первого класса, имеют в виду именно эту ситуацию. Функциональные же языки позволяют делать с функциями всё, что угодно (пример выше на настоящем JavaScript вполне работоспособен). Как им это удается?

При возврате вложенной функции bar из внешней функции foo, стековый кадр, созданный при вызове foo, сохраняется в переменной baz в составе контекста исполнения функции bar. Таким образом формируется цепочка областей видимости, совершенно не зависящая от стека вызовов. Конкретная реализация механизма формирования этой цепочки, конечно, может сильно отличаться от этого упрощенного описания, но главное то, что переменная y существует, пока существует baz, несмотря на то, что при выходе из foo ссылка на неё исчезает из стека.

Совокупность всех существующих в данный момент цепочек видимости (на каждую определенную в текущем контексте исполнения функцию — по одной цепочке) образует нечто вроде трехмерного дерева. Если мы возвращаем несколько вложенных функций, ветви дерева расходятся в стороны, совместно используя переменные внешней функции, а если вызываем внешнюю функцию несколько раз, ответвление идет вверх, создавая независимые копии свободных переменных:
//  Листинг 2
var x = 'I am global!';

var foo = function (y)  {
  var z = 'unchanged';
  var getXYZ = function ()  {
    return 'x: '+x+'  y: '+y+'  z: '+z;
  }
  var setZ = function(newZ)  {
    z = newZ;
  }
  return [getXYZ, setZ];  // Мы ведь договорились, что не знаем ничего про объекты, правда?
}

var a = foo('Alice');        // *1*
var b = foo('Bob');          // *2*

console.log(a[0]());         // x: I am global!  y: Alice  z: unchanged
a[1]('changed');             //                                          *3*
console.log(a[0]());         // x: I am global!  y: Alice  z: changed    *4*
console.log(b[0]());         // x: I am global!  y: Bob  z: unchanged 
x = 'Everybody can see me!'; //                                          *5*
console.log(a[0]());         // x: Everybody can see me!  y: Alice  z: changed 
console.log(b[0]());         // x: Everybody can see me!  y: Bob  z: unchanged


В точке *1* «дерево видимости» разветвляется в узле foo, так как мы возвратили две вложенные функции. Они могут сообщаться друг с другом через переменные y и z, что видно в точке *3*. В точке *2* мы входим в функцию foo во второй раз, и дерево растет вверх — создаются копии всех локальных переменных foo. «Серые» getXYZ и setZ так же сообщаются через y и z в составе «серого» узла foo, но они ничего не знают об y и z из «черного» узла foo, что хорошо видно в точке *4*. В то же время переменная x уровнем выше видна всем листьям дерева видимости (*5*).

Таким образом, в момент написания программы мы определяем структуру будущих деревьев видимости, в момент фактического создания ветвей дерева можем повлиять на некоторые переменные внутри него и оставить их зафиксированными (после вызва foo('Alice') или foo('Bob') нет никакого способа изменить значение переменной y снаружи) и, пока ветви дерева существует, можем управлять его состоянием лишь постольку, поскольку это позволяют листья.

Необходимость в этих дополнительных структурах данных интерпретатора — одно из главных отличий внутреннего устройства функциональных языков от императивных. Так как такие структуры создаются неявно, без непосредственного участия программиста, то и освобождать память от них может только сам интерпретатор. Поэтому функциональные языки не могут существовать без сборщиков мусора, а императивные — могут. Между прочим, первый сборщик мусора был написан аж в 1959 году для языка Лисп. Ах да, чуть не забыл — ссылка на функцию вместе с её цепочкой областей видимости называется замыканием.

Инкапсуляция и наследование


Итак, слегка доработав интерпретатор, мы превратили примитивный процедурный язык в полноценный функциональный и поняли, как устроены замыкания. Теперь неплохо бы научить его работать с объектами. Хотя само по себе ООП знакомо большинству лучше, чем замыкания, проблем с ним возникает гораздо больше. Дело в том, что если в остальных языках существует жестко зафиксированный набор конструкций, однозначно задающий стиль и оттенки реализации объектной парадигмы в этом конкретном языке, то в JavaScript можно городить что угодно и как угодно. И городят… Любой уважающий себя автор книги о JavaScript считает своим долгом привести не меньше четырех разных способов организовать иерархии объектов, чтобы продемонстрировать читателю “мощь и выразительность языка”. Это конечно круто, но привычный к Java, Ruby или C# мозг закипает от такой анархии. Пока я не столкнулся с JavaScript, я не испытывал никакой потребности разобраться, как именно работают все эти объектные штуки — они просто работали, как написано в книжке. С JavaScript такой номер не проходит.

«Забудем» о том, что в JavaScript объекты уже есть, и будем называть их структурами, как в Си, а свойства объектов — членами структур. Так же пока откажемся от точечной нотации и будем везде обращаться к членам структуры через квадратные скобки. Так как у нас уже есть функции первого класса, с которыми можно обращаться так же вольно, как с любыми переменными, сконструировать структуру, содержащую как данные, так и функцию для их обработки, проще простого:
//  Листинг 3
var obj = {
  x: 10,
  y: 20,
  foo: function () {return x + y;}
};

console.log(obj['foo']());  //  Ошибка!


На самом деле всё немножко сложней. Этот пример неработоспособен, так как в объекте активации (так принято называть стековый кадр в JavaScript) нашей функции foo() нет никаких x и y. Нет их и выше по цепочке, там есть только переменная obj. Чтобы таки до них добраться, нам придется обращаться к ним, как к obj['x'] и obj['y']:
//  Листинг 4
var obj = {
  x: 10,
  y: 20,
  foo: function () {return obj['x'] + obj['y'];}
}

console.log(obj['foo']());  //  30


Заработало! Мы поместили данные и функцию, которая их обрабатывает, в одну структуру. Но очень часто нам бывает нужно несколько объектов с таким же устройством, теми же функциями, но разными значениями переменных. Создадим функцию, порождающую такие структуры:
//  Листинг 5
function createObj(x, y)  {
  var obj = {};
  obj['x'] = x,
  obj['y'] = y,
  obj['foo'] = function () {return obj['x'] + obj['y'];}
  return obj;
}  

var obj1 = createObj(1, 2);
var obj2 = createObj(3, 4);

console.log(obj1['foo']());  //  3
console.log(obj2['foo']());  //  7


Так как функция createObj() возвращает вложенную функцию в составе объекта obj, при каждом её вызове создается замыкание, содержащее независимые друг от друга копии x, y и foo (дерево областей видимости растет вверх).То, что у нас получилось, уже очень похоже на полноценный объект. Чтобы это дело отметить, в последующих листингах перейдем на более лаконичную точечную нотацию. Но ООП — не ООП без наследования. Как его организовать? Мы могли бы написать функцию, которая бы копировала все свойства объекта-родителя в объект-потомок. Такое наследование называют каскадным, но, строго говоря, это скорее клонирование, чем наследование. Изменения в реализации родителя никак не скажутся на потомке, кроме того, если каждый потомок будет содержать копии методов родителя, то это приведет к лишнему расходу памяти. Пожалуй, лучше просто хранить в одном из свойств потомка ссылку на родителя. Ещё нам понадобится функция для поиска свойств вверх по цепочке наследования:
//  Листинг 6
function createObj(x, y)  {
  var obj = {};
  obj.x = x,
  obj.y = y,
  obj.foo = function () {return obj.x + obj.y;}
  return obj;
}  

function createChild (parent)  {
  var child = {};
  child.__parent__ = parent;
  return child; 
}

function lookupProperty (obj, prop)  {
  if (prop in obj)
    return obj[prop];
  else if (obj.__parent__)
    return lookupProperty (obj.__parent__, prop);
 }
 
var a = createObj(1, 2);
var b = createChild (a);

console.log(lookupProperty(b, 'y'));      //  2
console.log(lookupProperty(b, 'foo')());  //  3


Вроде бы порядок, но если мы изменим объект b, например так: b.x = 10, то увидим, что на самом деле ничего не работает. Метод foo() по-прежнему обращается к свойствам своего объекта, а не объекта-потомка. Если мы хотим повторно использовать методы при наследовании, необходимо научить их работать со свойствами чужих объектов. Можно передавать методу аргумент, который указывает на текущий объект. Также необходимо использовать функцию lookupProperty() внутри метода, потому что мы не знаем заранее, определены ли в текущем объекте свойства x и y, или их придется искать вверх по цепочке наследования. Сами функции createChild() и lookupProperty() остаются без изменений:
//  Листинг 7
function createObj(x, y)  {
  var obj = {};
  obj.x = x,
  obj.y = y,
  obj.foo = function (currentObj) {
    return lookupProperty(currentObj, 'x') + lookupProperty(currentObj, 'y');
  }
  return obj;
}  

function createChild (parent)  {
  var child = {};
  child.__parent__ = parent;
  return child; 
}

function lookupProperty (obj, prop)  {
  if (prop in obj)
    return obj[prop];
  else if (obj.__parent__)
    return lookupProperty (obj.__parent__, prop);
 }
 
var a = createObj(1, 2);
var b = createChild (a);
b.x = 10;

console.log(lookupProperty(b, 'y'));      // 2
console.log(lookupProperty(b, 'foo')(b)); // 12


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

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

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

Наконец, чтобы при каждом вызове метода не передавать через аргументы текущий объект, будем автоматически предоставлять ссылку на объект, которому принадлежит метод. Назовем её this, в соответствии с традицией ООП:
//  Листинг 8
var a = Object.create(null, {
  x: {value: 1},
  y: {value: 2},
  foo: {value: function() {
    return this.x + this.y;
    }
  }
});    

var b = Object.create(a, {x: {value: 10}});

console.log(b.x+', '+b.y+', '+b.foo());  //  10, 2, 12


Мы привели наследование в соответствие со стандартом EcmaScript 5. К сожалению, новый стандарт работает ещё не везде и не очень быстро. Кроме того, уже написаны миллионы строк кода, в котором наследование сделано по-старинке, через new. Эта схема предполагает принудительное использование конструкторов и прототипов. Говорят, Брендан Айк ввёл её в язык, чтобы не шокировать привычных к классическому наследованию программистов простотой и прямолинейностью описанной выше схемы. Пожалуй, из Айка вышел бы неплохой контрабандист — ему удалось протащить функциональный язык в мейнстрим программирования, где до этого правили бал императивные языки, замаскировав его Си-подобным синтаксисом, и прототипное наследование, запутав его и сделав похожим на классическое.

Для классического ООП характерно наличие жесткой границы между классами и экземплярами. В прототипном ООП её нет вообще, так как нет классов и любой объект может служить прототипом. Чтобы смягчить это различие, был создан особый тип функций — конструкторы. Иерархия конструкторов существует параллельно иерархии объектов, каждый конструктор «висит» над своим объектом, как класс над экземпляром. Конструктор не является прототипом объекта, а прототип конструктора не имеет никакого отношения к прототипу создаваемых этим конструкторам объектов.

Вернемся к листингу 7. Чтобы вписать конструкторы в схему наследования, вспомним, что в JavaScript всё, в том числе и функции, является объектом. То есть мы можем добавлять свойства к функциям, как к обычным объектам. Добавим свойство prototype, которое будет указывать на прототип создаваемого объекта в случае вызова функции в качестве конструктора. Затем, как и при переходе к листингу 8, переименуем временные переменные obj, child и currentObj в this, спрячем их объявление и возврат внутрь интерпретатора, туда же уберем lookupProperty(). Так как каждый конструктор создает один определенный тип объектов, использование универсального метода вроде Object.create() лишено смысла, поэтому будем называть конструкторы по типу объектов, которые они создают, но с прописной буквы, чтобы не путать с обычными функциями. Чтобы интерпретатор знал, что мы хотим вызвать функцию в качестве конструктора, добавим ключевое слово new перед её именем. Вот что у нас получится:
//  Листинг 9
function A(x, y)  {
  this.x = x,
  this.y = y,
  this.foo = function () {
    return this.x + this.y;
  }
}; 

function B () {};
B.prototype = new A(1, 2);

var b = new B();

console.log(b.x+', '+b.y+', '+b.foo());  //  1, 2, 3


Пара конструктор+прототип (в этом примере: B()+B.prototype) играет ту же роль, что класс в классическом ООП. Обратите внимание, что в листинге 8 объект a служит исключительно для того, чтобы унаследовать от него b, а в листинге 9 мы вообще избавились от переменной a, значит и конструктор A() нам не нужен. Свойства x и y, разные для каждого объекта, можно задать в конструкторе B, а общий для всех метод foo() — в прототипе:
//  Листинг 10
function B (x, y) {
  this.x = x,
  this.y = y
};

B.prototype.foo = function () {
  return this.x + this.y;
}

var b = new B(1, 2);

console.log(b.x+', '+b.y+', '+b.foo());  //  1, 2, 3


То же самое для Object.create():
// Листинг 11
var B = {
foo: function() {
  return this.x + this.y;
  }
};

var b = Object.create(B, {x: {value: 10}, y: {value: 20}});

console.log(b.x+', '+b.y+', '+b.foo());  //  10, 20, 30


Заключение


1. Выполнение программы на JavaScript обеспечивают три основные структуры данных интерпретатора — стек вызовов, цепочка областей видимости и цепочка наследования. Стек — самая древняя и примитивная структура. Он строго линеен (хотя возможны варианты...), зато быстр и прост. Две последние структуры скорее похожи на деревья, чем на цепочки, но так как в каждый момент времени выполняется только одна функция, и она может перемещаться по этим деревьям только вверх, с её точки зрения это именно цепочки.

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

3. Дерево наследования растет из объекта Object (UPD: на самом деле оно растет из null. Так как этот объект, мягко говоря, не слишком содержательный, обычно его не принимают во внимание. Спасибо azproduction за поправку). Основное назначение дерева наследования — поиск свойств вверх по цепочке прототипов, если свойство отсутствует в самом объекте — то, что делала функция lookupProperty() в наших примерах. В стандарте EcmaScript это метод [[get]]. Ссылка на родительский объект (__parent__ в листингах 6 и 7) во многих реализациях называется __proto__ и доступна программисту. Но её использование считается плохим тоном. Стандарт языка не предусматривает возможности менять родителей, как в нашей самодельной реализации наследования. Object.prototype и Object.__proto__ — совершенно разные вещи. Object.prototype используется только при вызове функции в качестве конструктора и задает прототип возвращаемого объекта.

4. В JavaScript нет модификаторов private, protected или public для сокрытия реализации объекта. Тем не менее такое сокрытие можно реализовать с помощью замыканий. Вот так или даже так. Однако это довольно сомнительная практика — читать и тестировать такой код сложнее. В большинстве современных динамических языков private — это всего лишь соглашение, и к частным свойствам при желании можно обращаться извне. В JavaScript принято обозначать частные свойства подчеркиванием: _private. Кроме того, часто используются модули. Это очень удобная и уже практически стандартная альтернатива частным методам и свойствам.

5. Ключевое слово this указывает на текущий объект, что довольно очевидно в случае конструкторов и методов объектов. В случае вызова функции не как метода объекта, this по-умолчанию указывает на глобальный объект. Хотя создание, передача и возврат объектов, на которые указывает this, спрятаны внутрь интерпретатора, в методах call() и apply() остались торчать уши переменной currentObj из листинга 7: первый аргумент этих методов будет виден внутри вызываемой функции, как this.

Список дополнительной литературы


  1. Javascript Closures
  2. JavaScript. Ядро
  3. Тонкости ECMA-262-3. Замыкания.
  4. Тонкости использования this
  5. ECMA-262-5 in detail. Lexical environments: Common Theory
  6. Learning Javascript with Object Graphs: часть 1, часть2, часть3
  7. Объектно-ориентированный Си (pdf)
  8. Основы и заблуждения насчет JavaScript
Tags:
Hubs:
+112
Comments 30
Comments Comments 30

Articles