Рубріки: Теория

Как понять замыкание в JavaScript: детальный разбор на примерах

Семен Гринштейн

Позвольте пропустить дежурные фразы о популярности JavaScript, о его мультипарадигменности и гибкости, о растущем количестве нововведений в стандарте ECMAScript (нужное подчеркнуть). Да и о том, что замыкания — это «мощный инструмент», который не все умеют эффективно использовать, тоже писать не будем. Итак, меньше слов — больше кода:

function sayHello() {
  var name = 'John'; // name — локальная переменная, объявленная и проинициализированная внутри функции sayHello()
  function say() { // say() — функция, использующая переменную из внешней функции sayHello()
    console.log('Hello, ' + name); 
  }
  say();
}
sayHello();

Заметьте, что здесь внутри sayHello() вызывается функция say().

Программа выдает строку:

Hello, John

Теперь внесем небольшие изменения в код:

function makeFunc() {
  var name = 'John';
  function say() {
    console.log('Hello, ' + name);
  }
  return say;   // мы не вызываем, а просто возвращаем функцию
}

var myFunc = makeFunc();
myFunc();

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

Функция say() вызывается из другого блока кода, но использует переменную name из внешней функции myFunc(), которая к этому моменту уже завершила свою работу. Будет ли этот пример работать корректно?

Мы прощаемся с myFunc() и вроде бы должны проститься с переменной name. Она объявлена как var, а это значит, что name живет только внутри этой функции. Но нет, она будет вечно жить в наших сердцах!

Тем не менее, этот код выдает тот же результат, что и в предыдущем примере.

Пример работает корректно: программа выдает строку:

Hello, John

Возможно, кому-то этот код покажется немного запутанным. Но в JS это работает. И виновато в этом не искривление пространства и времени, а более предсказуемое «явление» — замыкание.

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

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

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

Как и почему это работает

Лексическое окружение

Лексическое окружение — это объект, в котором хранятся:

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

Скрипт — это блок кода для глобальных переменных:

Пример скрипта

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

В этом случае мы находимся в глобальном лексическом окружении (внутри скрипта нет функций и блоков кода), и свойство «внешнее лексическое окружение» ссылается на null.

Вот что произойдет с нашим объектом лексического окружения при запуске кода:

  • переменная phrase еще не объявлена, но объект с помощью лексического анализатора прочитал код и заранее «знает» о ней;
  • в момент объявления переменной phraseее свойство примет значение undefined;
  • свойство phrase примет значение Hello.
  • свойство phrase примет значение Bye.

Пока все очень просто.

Объявление функции

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

Пример с функцией say(name)

 

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

Внутреннее и внешнее лексическое окружение

И что же произойдет, когда мы вызовем нашу функцию, например, с аргументом John?

Внутреннее и внешнее лексическое окружение

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

Внутри блока есть alert, который использует две переменные — name и phrase. С переменной name все просто: она находится в лексическом окружении нашего блока (тела функции), которое мы назвали внутренним. Но переменной phrase в этом окружении нет.

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

Так и работает алгоритм поиска переменных:

1. ищет в текущем окружении (в нашем примере оно внутреннее — это тело функции say);

2. если находит:

а) прекращает искать;
б) использует переменную;

3. если не находит:

а) по ссылке перемещается во внешнее окружение (делает его текущим);
б) переходит к пункту 1.

4. Если ничего не находит ни в одном окружении, то в зависимости от режима — либо выдает ошибку ( в режиме strict), либо создает новую глобальную переменную (в обычном режиме).

Возврат функции из функции

Мы уже использовали это выражение выше. Настало время объяснить подробнее. Взгляните на код и схему:

Два лексических окружения

В этом случае будет минимум два лексических окружения — внешнее и внутреннее. Это понятно по прошлому примеру. Но здесь еще есть безымянная (правильнее ее называть «анонимной») функция, которую возвращает makeCounter(). При этом она не будет запущена до тех пор, пока мы не запишем ее в переменную и не сделаем вызов явно. В этом примере это можно сделать так:

let counter = makeCounter();
alert( counter() );

Чтобы продвинуться дальше, мы должны знать, что все функции (включая анонимные) сохраняют свое внутреннее лексическое окружение в скрытом свойстве [[Environment]].

Вооружившись этой информацией, перерисуем схему:

Объект для хранения окружений

Это специальный объект, созданный специально для хранения окружений. В нашем примере [[Environment]] нашей безымянной функции почти пуст (<empty>). У него есть лишь ссылка на ее внешнее лексическое окружение.

Теперь, вызывая нашу функцию в последней строке, изменим значение переменной count:

Меняем значение переменной count

Вызывая функцию counter() и многократно выполняя count++ внутри нее, мы каждый раз будем получать новое значение — 1, 2, 3, 4 и так далее.

Состояние переменной count каждый раз обновляется и хранится в объекте внешнего лексического окружения нашей безымянной функции. А получить к нему доступ и модифицировать состояние переменной она может благодаря ссылке, хранящейся в ее объекте [[Environment]].

Вложенные функции

Скорее всего, вам уже и так понятно, что «вложенными» бывают не только лексические окружения, но и функции.

function sayHiBye(firstName, lastName) {

  /* Ниже две вспомогательные функции.
  В них заданы способы приветствия и прощания */  
  function getFirstName() {
    return firstName;
  }
  
  function getLastName() {
    return lastName;
  }

  console.log( "Hello, " + getFirstName());
  console.log( "Bye, " + getLastName() );

}

sayHiBye("John", "Petrov");

Пример выше показывает, что внутри одной функции можно реализовать несколько вложенных функций.

Результат работы будет таким:

"Hello, John"
"Bye, Petrov"

В следующем примере мы повысили вложенность:

  • изменили название и поведение функции getLastName() ;
  • вложили в нее новую функцию addMr().
function sayHiBye(firstName, lastName) {

  /* Ниже две вспомогательные функции.
  В них заданы способы приветствия и прощания */  
  function getFirstName() {
    return firstName;
  }
  
  function getRespect(lName) {
  /* эта функция вложена еще глубже, она вспомогательная для вспомогательной */   function addMr() {
     return "Mr. " + lName;
    }
  
   var mrLastName = addMr();
    return mrLastName;
  }

  console.log( "Hello, " + getFirstName());
  console.log( "Bye, " + getRespect(lastName) );

}

sayHiBye("John", "Petrov");

Результат, который у нас получится:

"Hello, John"
"Bye, Mr, Petrov"

Но будет ли работать код в следующем примере? Теперь дважды вложенная функция addMr() должна где-то найти переменную lastName. Ведь мы больше не передаем ее в качестве параметра из функции getRespect().

function sayHiBye(firstName, lastName) {

  /* Ниже две вспомогательные функции.
  В них заданы способы приветствия и прощания */  
  function getFirstName() {
    return firstName;
  }
  
  function getRespect() {
  /* эта функция вложена еще глубже, она вспомогательная для вспомогательной */   function addMr() {
     return "Mr. " + lastName;
    }
  
   var mrLastName = addMr();
    return mrLastName;
  }

  console.log( "Hello, " + getFirstName() );
  console.log( "Bye, " + getRespect() );

}

sayHiBye("John", "Petrov");

Если код все-таки выполнится корректно, то подумайте, пожалуйста, самостоятельно: есть ли в этом коде замыкания?

Более сложные примеры замыканий

Использование тройного замыкания и «приватная» функция

var makeCounter = function() {
  var privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment: function() {
      changeBy(1);
    },

    decrement: function() {
      changeBy(-1);
    },

    value: function() {
      return privateCounter;
    }
  }
};

var counter = makeCounter();

alert(counter.value());  // 0.

counter.increment();
counter.increment();
alert(counter.value()); // 2.

counter.decrement();
alert(counter.value()); // 1.


В этом примере можно наблюдать интересный эффект: попытка выполнить counter.changeBy(2) приводит к ошибке.

Выражаясь языком ООП, функция changeBy(val) является как бы приватной (private). Это значит, что к ней нельзя получить доступ за пределами ее внешней функции makeCounter().

А три функции counter.increment, counter.decrement и counter.value наоборот являются как бы публичными (public). И более того: они имеют общее лексическое окружение, заключенное в блоке после ключевого слова return. Да, и так тоже можно!

Убедитесь в этом сами и попробуйте в конце добавить counter.changeBy(2).

Обработка Ajax-запроса

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

Рассмотрим пример:

function sendAjax(){ 
    var outerVar = "abc"; 
    $.ajax({ 
       cache : ..., 
       url : ..., 
       type : ..., 
       crossDomain: ..., 
       data : ...., 
       contentType : ..., 
       timeout : 20000, 
       dataType : ..., 
       success : function(response){ 
            console.log(outerVar);  
        }, 
       error : function(jXhr, err){ 
            console.log(outerVar);  
        }, 
       xhrFields: { 
           withCredentials: true 
       } 
  }); 
} 

Здесь sendAjax() — та самая внешняя функция. Внутри нее определена функция, которая запускается, если процесс отправки Ajax-запроса завершился успешно (success). Эта callback-функция использует внешнюю переменную outerVar:

console.log(outerVar);

Что и требовалось доказать.

Работа с DOM

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

Допустим, нужно устанавливать новый размер шрифта по нажатию кнопок, расположенных на HTML-странице:

Работа с DOM

Напишем функцию makeSizer(size), которая будет реагировать на соответствующие события для каждой кнопки:

function makeSizer(size) {
  return function() {
    document.body.style.fontSize = size + 'px';
  };
}

Внутри нее реализуем замыкание. В данном случае это анонимная функция, которая для изменения размера шрифта использует внешнюю переменную size. И будем возвращать эту анонимную функцию как результат работы внешней функции makeSizer(size):

var size12 = makeSizer(12);
var size14 = makeSizer(14);
var size16 = makeSizer(16);

Тогда size12, size14 и size16 — это отдельные функции, которые устанавливают размер шрифта страницы равным 12, 14 и 16 пикселей соответственно.

Если хотите поиграться с кодом и программой, заходите сюда.

Вместо заключения: убираем за собой

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

В JS, как и во многих других языках, очисткой памяти занимается Сборщик мусора. Как и когда удаляются из памяти переменные внешней функции в нашей ситуации?

function f() {
  let value = 123;

  return function() {
    alert(value);
  }
}

let g = f(); // g.[[Environment]] хранит ссылку на объект лексического окружения функции f()

В приведенном выше примере в g.[[Environment]] сохраняется переменная value и остается там даже после того, как мы вызовем функцию f() и она завершит работу. Хотя известно, что переменные, объявленные как let, должны уничтожаться при выходе из блока. Должны, да не обязаны.

Более сложный пример лучше показывает масштаб бедствия: все в три раза хуже. У нас теперь появляются целых три копии переменной value. Каждый раз при вызове функции f() выделяется и не очищается память под новую копию лексического окружения (читай: новый объект [[Environment]]):

function f() {
  let value = Math.random();

  return function() { alert(value); };
}

/* Создадим массив из трех функций f(). В этом случае каждый экземпляр функции (напоминаю, в JS функция — это тоже объект) хранит свою копию объекта лексического окружения */
let arr = [f(), f(), f()];

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

g = null;

В принципе, все. Только не забудьте сделать так, чтобы эта очистка памяти происходила в нужном месте и в нужное время.

function f() {
  let value = 123;

  return function() {
    alert(value);
  }
}

let g = f(); // пока в переменной g хранится функция f() — переменная value существует

g = null; // упс… уже не существует

Останні статті

Обучение Power BI – какие онлайн курсы аналитики выбрать

Сегодня мы поговорим о том, как выбрать лучшие курсы Power BI в Украине, особенно для…

13.01.2024

Work.ua назвал самые конкурентные вакансии в IТ за 2023 год

В 2023 году во всех крупнейших регионах конкуренция за вакансию выросла на 5–12%. Не исключением…

08.12.2023

Украинская IT-рекрутерка создала бесплатный трекер поиска работы

Unicorn Hunter/Talent Manager Лина Калиш создала бесплатный трекер поиска работы в Notion, систематизирующий все этапы…

07.12.2023

Mate academy отправит работников в 10-дневный оплачиваемый отпуск

Edtech-стартап Mate academy принял решение отправить своих работников в десятидневный отпуск – с 25 декабря…

07.12.2023

Переписки, фото, история браузера: киевский программист зарабатывал на шпионаже

Служба безопасности Украины задержала в Киеве 46-летнего программиста, который за деньги устанавливал шпионские программы и…

07.12.2023

Как вырасти до сеньйора? Девелопер создал популярную подборку на Github

IT-специалист Джордан Катлер создал и выложил на Github подборку разнообразных ресурсов, которые помогут достичь уровня…

07.12.2023