Статьи

Подсказка: мастер замыкает, переопределяя их с нуля

Эта статья была рецензирована Тимом Севериеном и Микаэлой Лер . Спасибо всем рецензентам SitePoint за то, что сделали контент SitePoint как можно лучше!

Сказать, что есть много статей о замыканиях, было бы преуменьшением. Большинство объяснит определение замыкания, которое обычно сводится к простому предложению: замыкание — это функция, которая запоминает среду, в которой оно было создано. Но как это запомнить? И почему замыкание может использовать локальные переменные после того, как эти переменные вышли из области видимости? Чтобы поднять завесу магии вокруг замыканий, я собираюсь притвориться, что JavaScript не имеет замыканий и не может вкладывать функции, а затем мы собираемся заново реализовать замыкания с нуля. При этом мы узнаем, что на самом деле представляют собой крышки и как они работают под капотом.

Для этого упражнения мне также нужно сделать вид, что у JavaScript есть одна особенность, которой он на самом деле не имеет. Мне нужно сделать вид, что обычный объект может быть вызван, как если бы это была функция. Возможно, вы уже видели эту функцию на других языках. Python позволяет вам определять метод __call__ , а в PHP есть специальный метод __invoke , и именно эти методы выполняются, когда объект вызывается так, как если бы он был функцией. Если мы притворимся, что JavaScript также имеет эту функцию, вот как это может выглядеть:

 // An otherwise ordinary object with a "__call__" method let o = { n: 42, __call__() { return this.n; } }; // Call object as if it were a function o(); // 42 

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

Теперь давайте посмотрим на простой пример закрытия.

 function f() { // This variable is local to "f" // Normally it would be destroyed when we leave "f"'s scope let n = 42; // An inner function that references "n" function g() { return n; } return g; } // Get the "g" function created by "f" let g = f(); // The variable "n" should be destroyed by now, right? // After all, "f" is done executing and we've left its scope // So how can "g" still reference a freed variable? g(); // 42 

Здесь у нас есть внешняя функция f с локальной переменной и внутренняя функция g которая ссылается на локальную переменную f . Затем мы возвращаем внутреннюю функцию g и выполняем ее вне области действия f . Но если f завершается, тогда как g может использовать переменные, которые вышли из области видимости?

Вот магический трюк: закрытие не просто функция. Это объект с конструктором и закрытыми данными, который мы можем вызвать, как если бы это была функция. Если в JavaScript не было замыканий, и мы должны были реализовать их сами, вот как это будет выглядеть.

 class G { // An instance of "G" will be constructed with a value "n", // and it stores that value in its private data constructor(n) { this._n = n; } // When we call an instance of "G", it returns the value from its private data __call__() { return this._n; } } function f() { let n = 42; // This is the closure // Our inner function isn't really a function // It's a callable object, and we pass "n" to its constructor let g = new G(n); return g; } // Get the "g" callable object created by "f" let g = f(); // It's okay if the original variable "n" from "f"'s scope is destroyed now // The callable object "g" is actually referencing its own private data g(); // 42 

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

Продолжая

Проницательный читатель заметит, что есть какое-то поведение, которое мы еще не учли. Давайте посмотрим на другой пример закрытия.

 function f() { let n = 42; // An inner function that references "n" function get() { return n; } // Another inner function that also references "n" function next() { n++; } return {get, next}; } let o = f(); o.get(); // 42 o.next(); o.get(); // 43 

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

 class Get { constructor(n) { this._n = n; } __call__() { return this._n; } } class Next { constructor(n) { this._n = n; } __call__() { this._n++; } } function f() { let n = 42; // These are the closures // They're callable objects that privately store the values // passed through their constructors let get = new Get(n); let next = new Next(n); return {get, next}; } let o = f(); o.get(); // 42 o.next(); o.get(); // 42 

Как и прежде, мы заменили внутренние функции get и next на экземпляры классов Get и Next , и они f локальную переменную f , передав ее конструкторам и сохранив это значение в личных данных каждого экземпляра. Но обратите внимание, что манипуляция n одним вызываемым объектом с n не влияет на значение другого вызываемого объекта. Это произошло потому, что они не захватили ссылку на n ; они захватили копию значения n .

Чтобы объяснить, почему замыкания JavaScript будут ссылаться на один и тот же n , нам нужно объяснить сами переменные. Под капотом локальные переменные JavaScript на самом деле не являются локальными в традиционном смысле. Вместо этого они являются свойствами динамически размещаемого объекта и объекта с подсчетом ссылок, называемого объектом «LexicalEnvironment», а замыкания JavaScript захватывают ссылку на всю эту среду, а не на какую-либо конкретную переменную.

Давайте изменим нашу реализацию вызываемого объекта, чтобы захватить лексическую среду, а не n .

 class Get { constructor(lexicalEnvironment) { this._lexicalEnvironment = lexicalEnvironment; } __call__() { return this._lexicalEnvironment.n; } } class Next { constructor(lexicalEnvironment) { this._lexicalEnvironment = lexicalEnvironment; } __call__() { this._lexicalEnvironment.n++; } } function f() { let lexicalEnvironment = { n: 42 }; // These callable objects capture a reference to the lexical environment, // so they will share a reference to the same "n" let get = new Get(lexicalEnvironment); let next = new Next(lexicalEnvironment); return {get, next}; } let o = f(); // Now our callable objects exhibit the same behavior as JavaScript's functions o.get(); // 42 o.next(); o.get(); // 43 

Здесь мы заменили локальную переменную n на объект lexicalEnvironment который имеет свойство n . И замыкания — вызываемые экземпляры классов Get и Next захватывают ссылку на объект лексической среды, а не на значение n . И поскольку они теперь имеют ссылку на один и тот же n , манипулирование одним вызываемым объектом над n влияет на значение другого вызываемого объекта.

Вывод

Замыкания — это объекты, которые мы можем вызывать, как если бы они были функциями. Каждая функция в JavaScript на самом деле является вызываемым объектом, также называемым «функциональным объектом» или «функтором», который создается и хранит в частном порядке объект лексической среды, даже если это самая внешняя глобальная лексическая среда. В JavaScript функция не создает замыкание; функция закрытие

Этот пост помог вам понять замыкания? Буду рад услышать ваши мысли или вопросы в комментариях ниже.