in ECMAScript

Тонкости ECMA-262-3. Часть 6. Замыкания.

Read this article in: English, French.

Введение

В этой заметке мы поговорим об одной из наиболее обсуждаемых тем, связанных с JavaScript — о замыканиях. Тема, по сути, избита; существует немалое количество статей, посвящённых этой структуре (некоторые из них очень хорошие, например, статья R. Cornford-a, представленная в списке дополнительной литературы), однако мы постараемся разобрать её более с теоретической точки зрения, и посмотрим, как замыкания в ECMAScript устроены изнутри.

Как я уже отмечал в предыдущих статьях (и в комментариях к ним), данный цикл статей является зависимым от предыдущих частей, поэтому желательно, если есть необходимость, прочесть ранние части данного ряда. И, если необходимость всё же есть, то для полного понимания данной части обязательной к прочтению является часть 4 (Цепь областей видимости, Scope chain) и, возможно, связанная с ней ранняя статья – часть 2 (Объект переменных, Variable object).

Общая теория

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

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

Определения

Функциональный аргумент (Functional argument, “Funarg”, “Фунарг”) — аргумент, значением которого является функция.

Пример:

function exampleFunc(funArg) {
  funArg();
}

exampleFunc(function () {
  alert('funArg');
});

Фактическим параметром, сопоставленным с фунаргом, в данном случае является анонимная функция, переданная функции exampleFunc.

В свою очередь, функции, принимающие функциональные аргументы, называются функциями высшего порядка (ФВП, higher-order functions, HOF). Другое название таких функций — функционалы (functional) или, ближе к математике, операторы. В примере выше, exampleFunc является функционалом.

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

(function functionValued() {
  return function () {
    alert('returned function is called');
  };
})()();

Функциональные аргументы, функционалы и функции с функциональным значением, называются функциями первого класса (first-class functions) или более обще — объекты первого класса (first-class objects). В ECMAScript все функции являются функциями первого класса.

Функционал, который получает себя в качестве аргумента, называется автоаппликативной функцией (self-applicative, auto-applicative):

(function selfApplicative(funArg) {
  if (funArg && funArg === selfApplicative) {
    alert('self-applicative');
    return;
  }
  selfApplicative(selfApplicative);
})();

Функция, возвращающая сама себя, называется авторепликативной (self-replicative, auto-replicative). Иногда, в литературе фигурирует название – самовоспроизводящаяся функция (self-reproducing):

(function selfReplicative() {
  return selfReplicative;
})();

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

function testFn(funArg) {
  // активация фунарга, доступна
  // локальная переменная localVar
  funArg(10); // 20
  funArg(20); // 30
}

testFn(function (arg) {
  var localVar = 10;
  alert(arg + localVar);
});

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

Фунарг-проблема

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

Свободная переменная (Free variable) — переменная, используемая функцией, но не являющаяся ни параметром, ни локальной переменной этой функции.

Пример:

function testFn() {
  var localVar = 10;
  function innerFn(innerParam) {
    alert(innerParam + localVar);
  }
  return innerFn;
}

var someFn = testFn();
someFn(20); // 30

В данном случае, для функции “innerFn” свободной переменной является “localVar”.

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

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

var x = 10;
function someFn() {
  alert(x);
}
someFn(); // 10 - и при статической и при динамической Scope

(function () {
  var x = 20;
  someFn(); // 10 - при статической, 20 - при динамической Scope
})();

// аналогично, при передаче someFn
// в качестве параметра

(function (funArg) {
  var x = 30;
  funArg(); // 10 - при статической, 30 - при динамической Scope
})(someFn);

То есть, видим, что при организации системы с динамической областью видимости, разрешение имени переменной осуществляется в пределах динамического (активного) стека переменных (свободная переменная ищется в динамической цепи вызова, а не в статической цепи создания функции), что может привести к неоднозначности. При этом, даже, если “x” будет определена (в отличии от предыдущего примера, где локальные переменные удалились бы из стека и были бы не определены), возникнет вопрос, какое из значений “x” (точнее, “x” из какого контекста, какой области видимости) должно использоваться в различных вызовах функции “someFn”?

Описанные случаи – являются двумя разновидностями фунарг-проблемы – в зависимости от того, имеем ли мы дело с функциональным аргументом, передаваемым функционалу (downward funarg), или же с функциональным значением, возвращаемым из функции (upward funarg).

Для решения данной проблемы (и её подвидов) было предложено понятие замыкания.

Замыкание

Замыкание (Closure) или более полно — лексическое замыкание (Lexical closure) — это совокупность блока кода и данных того контекста, в котором этот блок порождён.

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

var a = 20;
function testFn() {
  alert(a); // свободная переменная "а" == 20
}

// Замыкание для testFn
closureForTestFn = {
  call: testFn // сама функция
  lexicalContext: {a: 20} // контекст для поиска свободных переменных
};

В примере выше, “closureForTestFn”, естественно, является псевдокодом, тогда как в ECMAScript сама функция testFn уже содержит свойством цепь областей видимости контекста, в котором она была порождена.

Слово “лексическое” часто опускается, т.к. подразумевается по-умолчанию, и, в данном случае, оно акцентирует внимание на том, что при создании замыкания, вместе с ним запоминаются данные из контекста, в котором блок кода порождается. При последующих активациях данного блока кода свободные переменные будут использоваться именно из этого запомненного (замкнутого) контекста, что и можно видеть в примерах выше, когда переменная “x” всегда имеет значение 10.

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

Что же касается реализаций, для хранения локальных переменных, после того, как контекст уничтожен, стековая организация уже не подходит (поскольку, это противоречит самой стековой структуре). Поэтому в данном случае, как правило, используется хранение данных лексического контекста в динамически распределяемой памяти (“куча”, “хип”, heap) с использованием сборщика мусора (Garbage collector, GC) и подсчётом ссылок, что является менее эффективным по быстродействию, чем стековая организация. Однако реализации всегда вправе сделать оптимизацию: на этапе парсинга кода определить, используются ли в функции свободные переменные, используются ли функции в качестве функциональных аргументов или функциональных значений, и, в зависимости от этого, решить — размещать ли данные в стеке или “куче”.

Реализация замыканий в ECMAScript

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

var a = 10;
function testFn() {
  alert(a);
}

(function (funArg) {
  var a = 20;
  // "a" для funArg запомнилось статически из
  // порождаемого её (лексического) контекста,
  // поэтому:
  funArg(); // 10, а не 20
})(testFn);

Технически, порождающий функцию лексический контекст запоминается внутренним свойством функции [[Scope]]. Если на данном этапе есть какие-нибудь неясности со свойством [[Scope]], я настоятельно рекомендую вернуться и прочесть часть 4, посвященную цепи областей видимости (Scope chain), где свойство [[Scope]] разбиралось подробно. По сути, если вы полностью разберётесь со [[Scope]] и Scope chain, вопрос о понимании замыканий в ECMAScript отпадёт сам собой.

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

var a = 10;
function testFn() {
  alert(a);
}

// testFn - замыкание
testFn: <FunctionObject> = {
  [[Call]]: <блок кода testFn>,
  [[Scope]]: [
    Global: {
      a: 10
    }
  ],
  ... // другие свойства
};

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

Некоторые реализации позволяют получить доступ к замкнутому контексту напрямую. К примеру в Rhino, свойству [[Scope]] функции, соответствует нестандартное свойство __parent__, которое мы разбирали в заметке об объекте переменных:

var global = this;
var x = 10;

var foo = (function () {
  var y = 20;
  return function () {
    alert(y);
  };
})();

foo(); // 20
alert(foo.__parent__.y); // 20
foo.__parent__.y = 30;
foo(); // 30

// можно двигаться дальше вверх по scope chain
alert(foo.__parent__.__parent__ === global); // true
alert(foo.__parent__.__parent__.x); // 10

Один [[Scope]] на “всех”

Стоит также отметить, что замкнутый [[Scope]] в ECMAScript является одним и тем же объектом для нескольких замыканий, порождённых в одном лексическом контексте. Это означает, что модификация замкнутой переменной одним замыканием, будет иметь эффект при использовании этой переменной другим замыканием:

var firstClosure;
var secondClosure;

function testFn() {
  var a = 1;
  firstClosure = function () { return ++a; };
  secondClosure = function () { return --a; };
  a = 2; // воздействие на VO["a"], который в [[Scope]] замыканий
  alert(firstClosure()); // 3, через firstClosure.[[Scope]]
}

testFn();
alert(firstClosure()); // 4
alert(secondClosure()); // 3

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

var data = [];
for (var k = 0; k < 3; k++) {
  data[k] = function () {
    alert(k);
  };
}

data[0](); // 3, а не 0
data[1](); // 3, а не 1
data[2](); // 3, а не 2

Предыдущий пример объясняет данное поведение – Scope порождающего контекста — один на три функции (каждая функция ссылается на него через [[Scope]]), и переменная “k” в нём может свободно меняться.

Схематично:

activeContext.Scope = [
  ... // вышестоящие объекты переменных
  {data: [...], k: 3} // объект переменных порождающего контекста
];

data[0].[[Scope]] === Scope;
data[1].[[Scope]] === Scope;
data[2].[[Scope]] === Scope;

Соответственно, на момент активации функций, выводится последнее присвоенное значение “k”, т.е. 3.

Исправить данное положение позволяет создание дополнительного объекта переменных в [[Scope]] функций, посредством создания ещё одной вложенной функции:

var data = [];
for (var k = 0; k < 3; k++) {
  data[k] = (function _helper(x) {
    return function () {
      alert(x);
    };
  })(k); // передаём "k"
}

// теперь всё в порядке
data[0](); // 0
data[1](); // 1
data[2](); // 2

В данном случае, функция “_helper” создаётся и тут же запускается с параметром “k”. Результатом функции “_helper” является также функция, и именно она записывается в соответствующий элемент массива “data”. Данная тактика приводит к следующему эффекту: активируясь, “_helper” каждый раз создаёт новый объект переменных, в котором присутствует переменная-параметр “x” (значением этого параметра является переданное значение “k”). Таким образом, [[Scope]] возвращаемых функций будут следующими:

data[0].[[Scope]] === [
  ... // вышестоящие объекты переменных
  VO контекста примера: {data: [...], k: 3},
  VO контекста _helper: {x: 0}
];

data[1].[[Scope]] === [
  ... // вышестоящие объекты переменных
  VO контекста примера: {data: [...], k: 3},
  VO контекста _helper: {x: 1}
];

data[2].[[Scope]] === [
  ... // вышестоящие объекты переменных
  VO контекста примера: {data: [...], k: 3},
  VO контекста _helper: {x: 2}
];

Видим, что теперь [[Scope]] функций имеют ссылку на нужное значение (посредством переменной “x”) и для этого нам понадобилось создать дополнительный объект переменных в [[Scope]] (из возвращённых функций также, естественно, можно обратиться и к переменной “k”, всё с тем же, правильным для всех функций, значением 3).

Кстати, часто в различных статьях о JavaScript замыканиями (неполно) называют лишь данную конструкцию (с созданием дополнительного VO в [[Scope]] порождаемых функций). Действительно, с прикладной точки зрения особенно важным является именно этот приём, однако, как уже было отмечено, фактически к замыканиям в ECMAScript можно отнести все функции.

Однако, данный приём не является единственным решением. Зафиксировать нужное значение “k”, например, можно было и так:

var data = [];
for (var k = 0; k < 3; k++) {
  (data[k] = function () {
    alert(arguments.callee.x);
  }).x = k; // запоминаем "k" свойством функции
}
 
// также, всё в порядке
data[0](); // 0
data[1](); // 1
data[2](); // 2

Фунарг и return

И ещё одна особенность — это возврат из замыканий. В ECMAScript инструкция return из замыкания возвращает управление вызывающему контексту. В других языках, например, в Ruby, возможны различные формы замыканий, по-разному обрабатывающие return: в некоторых случаях это может быть возврат в вызывающий контекст, в других — полный выход из активного контекста.

Стандартное поведение return в ECMAScript:

function getElement() {

  [1, 2, 3].forEach(function (element) {
    if (element % 2 == 0) {
      // возврат в функционал - .forEach,
      // но не выход из getElement
      alert('found: ' + element); // found: 2
      return element;
    }

  });

  return null;
}

alert(getElement()); // null, а не 2

Чтобы выйти из нужного контекста, в ECMAScript можно воспользоваться специальным “break”-исключением:

var $break = {};
 
function getElement() {
 
  try {
 
    [1, 2, 3].forEach(function (element) {
 
      if (element % 2 == 0) {
        // "return" из getElement
        alert('found: ' + element); // found: 2
        $break.data = element;
        throw $break;
      }
 
    });
 
  } catch (e) {
    if (e == $break) {
      return $break.data;
    }
  }
 
  return null;
}
 
alert(getElement()); // 2

Версии теории

Часто замыкания путают, либо неверно приравнивают (почему-то) к анонимным функциям. Это, не так, поскольку, как мы видели – все функции (не зависимо от вида: анонимная, именованная, FE, FD), в виду механизма Scope chain, являются замыканиями (исключение могут составлять функции, созданные с помощью конструктора Function, которые в качестве [[Scope]] имеют лишь глобальный объект); и, чтобы не было путаницы, выделим две версии теории замыканий в ECMAScript, которыми можно оперировать:

Замыканиями в ECMAScript являются:

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

Применение замыканий

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

[1, 2, 3].sort(function (a, b) {
  ... // условия сортировки
});

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

[1, 2, 3].map(function (element) {
  return element * 2;
}); // [2, 4, 6]

Часто, удобно организовывать функции поиска с помощью функциональных аргументов, определяющих практически неограниченные условия поиска:

someCollection.find(function (element) {
  return element.someProperty == 'searchCondition';
});

Также, можно отметить применяющие функционалы, как, например, метод .forEach, который применяет (apply) фунарг к элементам массива:

[1, 2, 3].forEach(function (element) {
  if (element % 2 != 0) {
    alert(element);
  }
}); // 1, 3

Кстати, методы объектов-функций .apply и .call, также уходят корнями в применяющие функционалы функционального программирования. Мы уже были знакомы с этими методами в заметке о this; здесь, мы видим их в роли применяющих функционалов – функция применяется к аргументам (к списку (массиву) аргументов – в apply, и к позиционным аргументам – в call):

(function () {
  alert([].join.call(arguments, ';')); // 1;2;3
}).apply(this, [1, 2, 3]);

Ещё одним важным примененим замыканий являются отложенные вызовы:

var a = 10;
setTimeout(function () {
  alert(a); // 10, через секунду
}, 1000);

Функции обратного вызова (callback):

...
var a = 10;
// только для примера
xmlHttpRequestObject.onreadystatechange = function () {
  // callback, который вызовется отложенно,
  // когда данные будут готовы;
  // переменная "а" здесь доступна,
  // даже несмотря на то, что контекст,
  // породивший "а" уже завершился
  alert(a); // 10
};
..

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

var testObject = {};

// инициализация
(function (object) {
  var a = 10;
  object.getA = function _getA() {
    return a;
  };
})(testObject);

alert(testObject.getA()); // получение замкнутой "а" - 10 

Заключение

Данная статья получилась скорее об общей теории, нежели конкретно о стандарте ECMA-262-3, однако я думаю, что эта общая теория позволила более детально приблизиться к понятию замыканий в ECMAScript. Если у вас возникнут вопросы, я с удовольствием отвечу на них в комментариях.

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

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

Write a Comment

Comment

14 Comments

  1. Это очень крутой цикл статей! Дмитрий, вы молодец!
    С новым годом вас.

  2. Соглашусь с похвалами! Вы делаете огромное дело! Спасибо Вам огромное!

  3. Отличный цикл статей. Спасибо, Дмитрий.

  4. Здравствуйте, Дмитрий. Оч полезная глубокая серия.

    Прочитал все статьи, и пришел к выводу что наличие в EcmaScript`е концепций Объект, Замыкание, Прототип целиком вытекает из особенности языка иметь функции объектами первого класса (functions – are first-class objects).

    Объясню почему я так думаю.
    У нас есть функциональный язык, и мы даём возможность присваивать, передавать и возвращать функции (т.е. делаем их объектами первого класса).
    Для этого мы должны решить проблему сохранения внешнего контекста функции (FunArg Problem) в некотором ‘контейнере’, который бы содержал переменные/функции этого контекста (VO/AO). Но такой ‘контейнер’ контекста с переменными/функциями не что иное как Объект с свойствами/методами. Значит достаточно дать юзеру синтаксис ручного определения таких ‘контейнеров’ и мы уже имеем концепт Объекта.
    Также, саму связку [текущий контекст функции – внешний (parent) контекст] мы уже можем назвать Замыканием.
    А поняв что вложенность функций неограниченна, мы должны прийти к концепту цепочки таких контекстов – Scope Chain (через свойство __parent__ в VO/AO).
    Но т.к. SC задается статично, через вложенность блоков в исходном тексте, мы дополнительно даём юзеру возможность ручного гибкого определения цепочек через Прототипы – Prototype Chain (через свойство __proto__ в объектах). Принципиально это таже самая цепочка объектов, которая используется при разрешении идентификатора, только теперь весь resolve происходит в 2D (для каждого объекта в цепочке контекстов (кроме AO), мы должны просмотреть цепочку его прототипов).

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

    Что Вы думаете насчёт такого анализа?

  5. @Сергей, совершенно верный анализ. Т.е. все вытекало из каких-то практических “неудобст”/”сложностей”, которые нужно было решить (сделали возможность передавать функции – что делать со свободными переменными? – надо придумать замыкание, и т.д.). И, решая эти “неудобвства”, появлялись те или иные конструкции в дизайне языка. Стоит отметить, что они появились задолго до JavaScript 🙂 — во времена Scheme, и еще раньше в 60-ых.

    P.S.: единственное, что прототипы напрямую не связаны со scope chain, но концепция – та же: “наследуемая” цепь объектов, которая по сути является обычным связным списком, который так же появился задолго JS 😉 Я, кстати, тоже использовал цепь прототипов для реализации scope chain: https://github.com/DmitrySoshnikov/Essentials-of-interpretation/blob/master/src/lesson-5.js#L269-L289

  6. Дмитрий, мне не совсем понятно для какой функции вызывается ‘call’ в следующем коде?:

    (function () {
      alert([].join.call(arguments, ';'));
    }).apply(this, [1, 2, 3]);
  7. @Сергей, это потому, что объект arguments не является массивом, и, соответственно, не имеет метода join. Поэтому мы заимствуем этот метод у (пустого) массива, и вызываем его в контексте объекта arguments.

    Надо сказать, что в ES6 появились rest-параметры, которые являются массивом:

    (function (...args) {
      console.log(args.join(';'));
    }).apply(this, [1, 2, 3]);
    
  8. Дмитрий, и все-таки как будет правильней отвечать, если на интервью по JS спросят: “Что такое замыкания?”?

  9. Дмитрий, объясните, пожалуйста, для чего в данном коде нужен оператор группировки?

    var data = [];
    for (var k = 0; k &lt; 3; k++) {
        (data[k] = function () {
            alert(arguments.callee.x);
        }).x = k;
    }

    Вот этот кусок кода интересует:

    (data[k] = function () {
       alert(arguments.callee.x);
    }).x = k;

    Как я понял из статьи про this — для получения значения типа Function, объекту которому присваивается свойство x.

  10. @Константин, в самом простом определении:

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

    Подробней: http://dmitrysoshnikov.com/ecmascript/es5-chapter-3-1-lexical-environments-common-theory/

  11. @Александр

    для получения значения типа Function, объекту которому присваивается свойство x

    Да, все верно. Нам нужно сохранить x куда-то, и мы сохраняем в саму функцию. Результатом data[k] также является функция (если поставить оператор группировки в другом месте или не поставить его вообще, будет не функция — поэкспериментируйте ;)).