Замыкания в Javascript [Часть 1]

в 7:37, , рубрики: Без рубрики

Перевод статьи Ричарда Корнфорда Javascript Closures.

  • Введение
  • Разрешение имен свойств объектов
    • Присваивание значений
    • Чтение значений

  • Разрешение имен идентификаторов, контексты исполнения и цепь областей видимости
    • Контекст исполнения
    • Цепь областей видимости и свойство [[scope]]
    • Разрешение имен идентификаторов

  • ...

Введение

Замыкание

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

Замыкания относятся к наиболее мощным особенностям ECMAScript (javascript), но они не могут быть применены должным образом без понимания. Несмотря на то, что их легко создать, даже случайно, их создание может иметь пагубные последствия, в частности, в некоторых относительно распространенных окружениях браузеров. Чтобы избежать случайных столкновений с недостатками и использовать преимущества замыканий, необходимо понимать их механизм. Это сильно зависит от роли цепи областей видимости в разрешении имен идентификаторов (identifier resolution) и от разрешения имен свойств в объектах.

Самое простое объяснение замыкания в том, что ECMAScript допускает вложенные функции, определения функций и функции-выражения (function expressions) внутри тел других функций. И эти вложенные функции имеют доступ ко всем локальным переменным, параметрам и функциям, находящихся внутри их внешней функции (внешних функций). Замыкание образуется, когда одна из этих вложенных функций становится доступной вне той функции, в которую она была включена, таким образом, она может быть выполнена после завершения внешней функции. В этот момент она все еще имеет доступ к локальным переменным, параметрам и внутренним декларациям функций (function declarations) своей внешней функции. Эти локальные переменные, параметры и декларации функций (изначально) имеют те же значения, которые были во время завершения внешней функции и могут взаимодействовать с внутренней функцией.

К сожалению, правильное понимание замыканий требует понимания механизмов, которые стоят за ними, и немало технических подробностей. Хотя некоторые из алгоритмов, определенных в ECMA 262, затронуты в начале последующего объяснения, большинство не могут быть опущены или просто приведены к упрощенному виду. Если вы знакомы с разрешением имен свойств объектов, то можете пропустить этот раздел, но только люди, уже знакомые с замыканиями, могут позволить себе пропустить последующие разделы и прямо сейчас перестать читать и вернуться к их использованию.

Разрешение имен свойств объектов

ECMAScript признает две категории объектов: нативные объекты “Native Object” и объекты среды “Host Object” и подкатегорию нативных объектов, которая называется встроенными объектами “Built-in Objects” (ECMA 262 3rd Ed Section 4.3). Нативные объекты принадлежат языку, а host-объекты предоставлены средой и могут быть, например, объектом document, DOM узлами и т.п.

Нативные объекты — это свободные и динамические контейнеры именованных свойств (некоторые реализации не настолько динамические, когда речь идет о подкатегории встроенных объектов, хотя обычно это не имеет значения). Определенные именованные свойства нативного объекта хранят значения, которые могут быть ссылкой на другой объект (функции также являются объектами в этом смысле) или элементарными значениями: String, Number, Boolean, Null или Undefined. Примитивный тип Undefined немного непривычный в том смысле, что можно присвоить свойству объекта значение undefined, но при этом свойство не удалится из объекта; оно так и останется именованным свойством объекта, которое просто хранит значение undefined.

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

Присваивание значений

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

var objectRef = new Object(); // создает обобщенный (generic) объект javascript.

Свойство с именем «testNumber» может быть создано так:

objectRef.testNumber = 5;
/*  или  */
objectRef["testNumber"] = 5;

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

objectRef.testNumber = 8;
/* или  */
objectRef["testNumber"] = 8;

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

Чтение значений

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

/* Присвоение значение именованному свойству. Если у объекта не было свойства
   с соответствующим именем до присваивания, то оно появится после
*/
objectRef.testNumber = 8;
/* Считываем значение из свойства */
var val = objectRef.testNumber;
/* и val теперь содержит значение 8, которое было только 
что присвоено именованному свойству объекта. */ 

Но все объекты могут иметь прототипы, а прототипы это объекты и, в свою очередь, могут иметь прототипы, которые могут иметь прототипы, и т.д, формируя то, что называется цепью прототипов. Цепь прототипов заканчивается, когда один из объектов в цепи имеет прототип null. Прототип, используемый по умолчанию с конструктором Object имеет прототип null, таким образом,

var objectRef = new Object(); // создает обобщенный (generic) объект javascript

создает объект с прототипом Object.prototype, который сам имеет прототип null. Тогда цепь прототипов объекта objectRef содержит только один объект: Object.prototype. Однако,

/* Функция “конструктор” для создания объектов типа MyObject1.*/
function MyObject1(formalParameter){
    /*Возьмем свойство сконструированного объекта testNumber и
      присвоим ему значение, переданное конструктору, как его первый
      аргумент 
    */
    this.testNumber = formalParameter;
}
/* Функция “конструктор” для создания объектов типа MyObject2 */ 
function MyObject2(formalParameter){
   /*Возьмем свойство testString сконструированного объекта и 
     присвоим ему значение, переданное конструктору, как его первый 
     аргумент
    */
    this.testString = formalParameter;
}
/*Следующая операция заменит прототип, созданный по умолчанию, 
  ассоциированный со всеми экземплярами объекта MyObject2, 
  экземпляром объекта MyObject1, 
  отправив аргумент 8 в конструктор MyObject1, 
  тогда у его свойства testNumber будет установлено это значение
*/
MyObject2.prototype = new MyObject1( 8 );
/*Наконец, создадим экземпляр функции MyObject2 и присвоим переменной objectRef
  ссылку на этот объект, передав в конструктор строку как первый аргумент
*/
var objectRef = new MyObject2( "String_Value" );

Экземпляр объекта MyObject2, на который ссылается переменная objectRef, имеет цепь прототипов. Первым объектом в данной цепи является экземпляр объекта MyObject1, который был создан и присвоен свойству prototype конструктора MyObject2. Экземпляр объекта MyObject1 имеет в качестве прототипа объект, который был присвоен свойству prototype функции MyObject1 по умолчанию. Данным прототипом является созданный по умолчанию прототип объекта Object, т.е. объект, на который ссылается Object.prototype. Object.prototype имеет прототип null, поэтому на этом месте цепь заканчивается.

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

var val = objectRef.testString;

— экземпляр MyObject2, доступный через objectRef, имеет свойство с именем testString, поэтому значение этого свойства установлено как String_Value, и оно присваивается переменной val.
Тем не менее,

var val = objectRef.testNumber;

не может прочитать свойство из самого экземпляра функции MyObject2, т.к. он не имеет такого свойства, но переменная val устанавливается в значение 8, а не undefined, потому что интерпретатор проверяет объект, который является его прототипом, из-за неудачного поиска соответствующего именованного свойства в самом объекте. Его прототип является экземпляром функции MyObject1, который был создан со свойством testNumber со значением 8, присвоенным этому свойству, таким образом, выражение доступа к свойству вычисляется как значение 8. Ни MyObject1, ни MyObject2 не определяют метод toString, но если выражение доступа к свойству попытается прочитать значение свойства toString из objectRef,

var val = objectRef.toString;

то переменной val присвоится ссылка на функцию. Данная функция — это свойство toString объекта Object.prototype и она возвращается в результате процесса проверки прототипов: осуществляется проверка объекта objectRef, после обнаружения в нем отсутствия свойства toString осуществляется проверка прототипа objectRef, и когда оказывается, что в нем нет этого свойства, в свою очередь проверяется его прототип. Его прототип — это Object.prototype, у которого есть метод toString и ссылка возвращается именно на эту функцию.

В заключение:

var val = objectRef.madeUpProperty;

— возвращает undefined, потому что процесс, обрабатывающий цепь прототипов, не находит свойств с именем madeUpProperty ни в одном из объектов, он в конечном счете доходит до прототипа объекта Object.prototype, т.е. null, и тогда процесс заканчивается, возвращая undefined.

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

Это означает, что если значение было присвоено objectRef.testNumber = 3, то свойство testNumber будет создано в самом экземпляре функции MyObject2 и последующие попытки считать значение приведут к возвращению значения, которое установлено в объекте. Теперь для выполнения выражения доступа к свойству обработка цепи прототипов больше не требуется, но при этом экземпляр объекта MyObject1 cо значением 8, присвоенным свойству testNumber, не изменен. Присвоение значения объекту objectRef просто скрывает соответствующее свойство в его цепи прототипов.

Отметим, что ECMAScript определяет внутреннее свойство [[prototype]] внутреннего типа Object. Это свойство не доступно напрямую скриптам, но есть цепь объектов, на которую ссылается внутреннее свойство [[prototype]], которое используется в разрешении выражений свойств доступа; эта цепь является цепью прототипов объекта. Открытое свойство prototype существует для присвоения значений, определения и манипуляции прототипами совместно с внутренним свойством [[prototype]]. Детали взаимосвязи меду этими двумя свойствами описаны в ECMA 262 (3rd edition) и не входят в данное обсуждение.

Разрешение имен идентификаторов, контексты исполнения и цепь областей видимости

Контекст исполнения

Контекст исполнения — это абстрактное понятие, которое используется в спецификации ECMAScript (ECMA 262 3rd edition), чтобы определить поведение, требуемое от реализаций ECMAScript. Спецификация ничего не говорит о том, как контекст исполнения должен быть реализован, но контексты исполнения имеют ассоциативные атрибуты, которые ссылаются на определенные в спецификации структуры, поэтому могут быть задуманы (или даже реализованы) как объекты со свойствами, хотя и закрытыми.

Весь код javascript исполняется в контексте исполнения. Глобальный код (встроенный код в html странице или в файле JS или выполняемый после загрузки страницы (loads)) исполняется в глобальном контексте исполнения и каждый вызов функции (возможно, как конструктор) имеет соответствующий контекст исполнения. Код, выполняемый с помощью функции eval, также получает определенный контекст исполнения, но так как eval обычно не используется javascript программистами, то он не будет здесь обсуждаться. Специфицированные подробности контекстов исполнения можно найти в разделе 10.2 ECMA 262 (3rd edition).

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

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

Следующим шагом создания контекста исполнения для вызова функции является создание объекта arguments, это массивоподобный объект с элементами, индексированными целыми числами, которые соответствуют аргументам, отправленным функции в данном порядке. Также у него есть свойства length и callee (которое не имеют отношения к нашему обсуждению, подробности в спецификации). Создается свойство объекта активации с именем arguments, которому присваивается ссылка на объект arguments.

Затем контекст исполнения присваивает область видимости. Область видимости состоит из списка (или цепи) объектов. Каждый объект функции имеет внутреннее свойство [[scope]] (которое мы вскоре рассмотрим подробней), которое также состоит из списка (или цепи) объектов. Область видимости, присвоенная контексту исполнения вызова функции, состоит из списка, на который ссылается свойство [[scope]] соответствующего объекта функции, и из объекта активации, добавленного в начало цепи (или на вершину этого списка).

Потом происходит процесс создания переменных (variable instantiation) с помощью объекта, который определяется в ECMA 262 как объект переменных (Variable object). В то же время, объект активации используется как объект переменных (отметим, что это один и тот же объект, это важно). Именованные свойства объекта переменных создаются для каждого формального параметра функции, и если аргументы при вызове функции соответствуют этим параметрам, то аргументы присваиваются этим свойствам (иначе присваивается undefined). Определение внутренних функций используется для создания объектов функций, присвоенных свойствам объекта переменных с именами, соответствующими именам функций, использованных в декларациях функций. Последней стадией создания переменных является создание именованных свойств объекта переменных, которые соответствуют всем локальным переменным, определенным внутри функции.

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

На самом деле, объект активации со свойством arguments и объект переменных с именованными свойствами, соответствующими локальным переменным функции, — это один и тот же объект, который позволяет рассматривать идентификатор arguments как локальную переменную функции.

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

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

Глобальный контекст исполнения также использует ссылку на глобальный объект как значение this.

Цепь областей видимости и свойство [[scope]]

Цепь областей видимости контекста исполнения вызова функции собирается путем добавления объекта активации (объекта переменных) в начало цепи областей видимости, содержащейся в свойстве [[scope]] объекта функции, поэтому важно понимать, как определяется внутреннее свойство [[scope]].

В ECMAScript функции — это объекты, они создаются во время создания переменных из деклараций функций, во время выполнения функции-выражения (Function Expression) или при вызове конструктора Function.

Объекты функций, созданные конструктором Function, всегда имеют свойство [[scope]], ссылающееся на цепь областей видимости, которая содержит только глобальный объект.

Объекты функций, созданные декларацией функции или функцией-выражением, имеют область видимости контекста исполнения, в котором они созданы, которая присваивается к их внутреннему свойству [[scope]].

В самом простом случае декларации глобальной функции, таком как

function exampleFunction(formalParameter){
    ...   // код тела функции
}

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

Таким образом, этому созданному объекту функции, на который ссылается свойство глобального объекта exampleFunction, присваивается внутреннее свойство [[scope]], ссылающееся на цепь областей видимости, содержащую только глобальный объект.

Аналогичная цепь областей видимости будет присвоена во врем выполнения функции-выражения в глобальном контексте

var exampleFuncRef = function(){
    ...   // код тела функции
}

за исключением того, что в этом случае именованное свойство глобального объекта создается во время создания переменных глобального контекста исполнения, но объект функции не создастся, и ссылка на него не будет присвоена именованному свойству глобального объекта, пока не выполнится выражение присваивания. Но создание функции все же происходит в глобальном контексте исполнения, поэтому свойство [[scope]] созданного объекта функции содержит только глобальный объект в присвоенной цепи областей видимости.

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

function exampleOuterFunction(formalParameter){
    function exampleInnerFuncitonDec(){
        ... // тело внутренней функции
    }
    ...  // остальная часть тела внешней функции
}
exampleOuterFunction( 5 );

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

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

До этого момента все автоматически выполняется и контролируется структурой и исполнением исходного кода. Цепь областей видимости контекста исполнения определяет свойства [[scope]] для созданных объектов функций и эти свойства объектов функций [[scope]] определяют область видимости для своего контекста исполнения (вместе с соответствующим объектом активации). Но ECMAScript поддерживает оператор with как средство изменения области видимости.

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

Оператор with не может повлиять на декларацию функции, так как создание объектов функций происходит во время создания переменных, но функции-выражения могут быть выполнены внутри оператора with

/* создадим глобальную переменную y, которая ссылается на объект */
var y = {x:5}; // объектный литерал со свойством x
function exampleFuncWith(){
    var z;
    /* Поставим объект, на который ссылается переменная y,
        в начало цепи областей видимости
    */
    with(y){
        /* вычислим функцию-выражение, чтобы создать объект функции,
           и присвоим ссылку на этот объект функции локальной
           переменной z
        */
        z = function(){
            ... // тело внутренней функции-выражения;
        }
    }
    ... 
}
/* выполним функцию exampleFuncWith */
exampleFuncWith();

Когда вызывается exampleFuncWith, то ее контекст исполнения имеет цепь областей видимости, состоящую из объекта активации этой функции и следующего за ним глобального объекта. Выполнение выражения with добавляет объект, на который ссылается глобальная переменная y, в начало этой цепи областей видимости на время, пока выполняется эта функция-выражение. Свойству объекта функции [[scope]], созданного выполнением функции-выражения, присваивается значение, которое соответствует области видимости контекста исполнения, в котором этот объект был создан. Область видимости состоит из объекта y, за которым следует объект активации из контекста исполнения внешней функции, за которым следует глобальный объект.

Когда блочное выражение оператора with завершается, область видимости контекста исполнения восстанавливается (объект y удаляется), но объект функции был создан в этот момент и его свойству [[scope]] присвоилась ссылка на цепь областей видимости с объектом y на ее вершине.

Разрешение имен идентификаторов

Поиск идентификаторов осуществляется через цепь областей видимости. ECMA 262 определяет this скорее как ключевое слово, а не идентификатор, что не является необоснованным, т.к. оно определяется в зависимости от того значения this, которое находится в контексте исполнения, в котором оно используется, не обращаясь к цепи областей видимости.

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

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

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

В переводе также участвовала Людмила Лиховид.

Автор: lx_kov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js