Замикання

Замикання – поєднання функції, зчепленої вкупі з (замкненої на) посиланням на стан, що її оточує (лексичне оточення). Інакше кажучи, замикання надає з внутрішньої функції доступ до області видимості зовнішньої функції. У JavaScript замикання породжуються щоразу, коли створюється функція, під час її створення.

Лексичне охоплення

Для прикладу – наступний код:

function init() {
  var name = "ВебДоки"; // name – локальна змінна, створена init
  function displayName() {
    // displayName() – внутрішня функція, що утворює замикання
    console.log(name); // застосування змінної, оголошеної в батьківській функції
  }
  displayName();
}
init();

init() створює локальну змінну, що зветься name, і функцію, що зветься displayName(). Функція displayName() – внутрішня функція, визначена всередині init(), і доступна лише всередині тіла функції init(). Зверніть увагу, що функція displayName() не має власних локальних змінних. Проте оскільки внутрішні функції мають доступ до змінних зовнішніх функцій, displayName() має доступ до змінної name, оголошеної у батьківській функції, init().

Запустіть цей код за допомогою цього посилання на JSFiddle і зауважте, що інструкція console.log() всередині функції displayName() успішно демонструє значення змінної name, котра оголошена в її батьківській функції. Це приклад лексичного охоплення, котрий показує, як розбирач розпізнає імена змінних, коли функції вкладені одна в одну. Слово лексичне вказує на факт того, що лексичне охоплення використовує місце, в якому змінна оголошена у вихідному коді, для з'ясування, де ця змінна доступна. Вкладені функції мають доступ до змінних, оголошених у зовнішній щодо себе області.

В цьому конкретному прикладі область видимості зветься областю функції, тому що змінна доступна виключно всередині тіла функції, в якій її оголосили.

Охоплення з let і const

Звичаєво (до ES6) JavaScript мав лише два види областей видимості: область функції та глобальну область. Змінні, оголошені з var, обмежені або функцією, або глобальною областю, залежно від того, де вони оголошені: всередині функції чи поза функцією. Це може бути мудрованим, адже блоки з фігурними дужками не породжують областей видимості:

if (Math.random() > 0.5) {
  var x = 1;
} else {
  var x = 2;
}
console.log(x);

Люди з інших мов (наприклад, C, Java), де блоки породжують області видимості, могли б подумати, що код вище повинен викинути помилку на рядку console.log, адже цей рядок лежить поза областями видимості x обох блоків. Проте завдяки тому, що блоки не породжують областей видимості для var, інструкції var тут насправді створюють глобальну змінну. Також нижче є практичний приклад, котрий демонструє, як це може у поєднанні з замиканнями призводити до реальних проблем.

В ES6 JavaScript запровадив оголошення let і const, котрі, серед інших речей, як то темпоральні мертві зони, дають змогу створювати змінні з блоковою областю видимості.

if (Math.random() > 0.5) {
  const x = 1;
} else {
  const x = 2;
}
console.log(x); // ReferenceError: x is not defined

По суті в ES6 блоки нарешті обробляються як області видимості, але лише за умови оголошення змінних з let чи const. На додачу ES6 запровадив модулі, що привнесли іще один різновид областей видимості. Замикання можуть захоплювати змінні в усіх цих областях, що буде показано згодом.

Замикання

Для прикладу – наступний код:

function makeFunc() {
  const name = "ВебДоки";
  function displayName() {
    console.log(name);
  }
  return displayName;
}

const myFunc = makeFunc();
myFunc();

Виконання цього коду має точно такий само ефект, як попередній приклад з функцією init() вище. Що відрізняється (і що цікаво) – те, що внутрішня функція displayName() повертається з зовнішньої функції до власного виконання.

На перший погляд, може здатись неінтуїтивним, що цей код працює. В деяких мовах програмування локальні змінні всередині функції існують лише протягом виконання такої функції. Оскільки makeFunc() завершила виконання, можна було б очікувати, що змінна name більше не доступна. Проте, оскільки код і далі працює як очікувалося, в JavaScript це очевидно не так.

Причина цього в тому, що функції в JavaScript утворюють замикання. Замикання – це поєднання функції й лексичного оточення, всередині якого ця функція оголошена. Таке оточення складається з усіх змінних, що були в поточній області видимості, коли створювалось замикання. В конкретному випадку myFunc є посиланням на примірник функції displayName, що створюється, коли запускається makeFunc. Примірник displayName зберігає посилання на своє лексичне оточення, всередині якого існує змінна name. Через це, коли закликається myFunc, то змінна name залишається доступною для використання, і в console.log передається "ВебДоки".

Ось дещо цікавіший приклад – функція makeAdder:

function makeAdder(x) {
  return function (y) {
    return x + y;
  };
}

const add5 = makeAdder(5);
const add10 = makeAdder(10);

console.log(add5(2)); // 7
console.log(add10(2)); // 12

У цьому прикладі визначена функція makeAdder(x), що приймає єдиний аргумент – x, і повертає нову функцію. Повернена функція приймає єдиний аргумент – y, і повертає суму x та y.

По суті makeAdder є фабрикою функцій. Вона породжує функції, що можуть додати до свого аргументу певне значення. У прикладі вище фабрика функцій породжує дві нові функції: одну, що додає до свого аргументу п'ять, і одну, що додає 10.

І add5, і add10 – утворюють замикання. Вони поділяють одне оголошення тіла функції, але зберігають різні лексичні оточення. В лексичному оточенні add5 x – це 5, а в лексичному оточенні add10 x – це 10.

Практичні замикання

Замикання є корисними, тому що дають змогу пов'язувати дані (лексичне оточення) з функцією, котра обробляє ці дані. Можна провести очевидні паралелі в об'єктноорієнтованому програмуванні, де об'єкти дають змогу пов'язувати дані (властивості об'єкта) з одним чи більше методами.

Як наслідок, замикання можна використовувати всюди, де звично було б застосувати об'єкт з лишень одним методом.

Ситуації, за яких можна розглянути такий варіант, у вебтехнологіях особливо поширені. Значна частина коду, написаного клієнтським JavaScript, заснована на подіях. Описується певна логіка, котра потім прикріпляється до події, котру запускає користувач (як то клацання чи натискання клавіші). Код прикріпляється у вигляді функції зворотного виклику (однієї функції, що виконується у відповідь на подію).

Наприклад, припустімо, що треба додати до сторінки кнопки для налаштування розміру тексту. Один зі способів це зробити – вказати font-size елемента body (в пікселях), а потім задати розмір інших елементів на сторінці (як то верхнього колонтитула) за допомогою відносної одиниці em:

body {
  font-family: Helvetica, Arial, sans-serif;
  font-size: 12px;
}

h1 {
  font-size: 1.5em;
}

h2 {
  font-size: 1.2em;
}

Такі інтерактивні кнопки розміру тексту можуть змінювати властивість font-size елемента body, і такі зміни підхоплюються іншими елементами сторінки, завдяки відносним одиницям.

Ось – JavaScript:

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

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

Наразі size12, size14 і size16 – функції, що змінюють розмір тексту тіла документа на 12, 14 і 16 пікселів відповідно. Їх можна прикріпити до кнопок, як показано в прикладі коду нижче.

document.getElementById("size-12").onclick = size12;
document.getElementById("size-14").onclick = size14;
document.getElementById("size-16").onclick = size16;
<button id="size-12">12</button>
<button id="size-14">14</button>
<button id="size-16">16</button>

Запустіть код за допомогою JSFiddle.

Імітація приватних методів за допомогою замикань

Мови штибу Java дають змогу оголошувати певні методи як приватні, а отже – вони можуть бути викликані лише іншими методами того самого класу.

JavaScript, до появи класів, не мав нативного способу оголошувати приватні методи, але їх було можливо імітувати за допомогою замикань. Приватні методи не просто корисні для обмеження доступу до коду. Крім цього, вони є потужним інструментом з управління глобальним простором імен.

Код нижче демонструє, як застосувати замикання для оголошення публічних функцій, що можуть звертатися до приватних функцій та змінних. Зверніть увагу, що ці замикання відповідають патернові проєктування "Модуль".

const counter = (function () {
  let privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }

  return {
    increment() {
      changeBy(1);
    },

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

    value() {
      return privateCounter;
    },
  };
})();

console.log(counter.value()); // 0.

counter.increment();
counter.increment();
console.log(counter.value()); // 2.

counter.decrement();
console.log(counter.value()); // 1.

В попередніх прикладах кожне замикання мало власне лексичне оточення. Проте тут – одне лексичне оточення, котре поділяють три функції: counter.increment, counter.decrement і counter.value.

Спільне лексичне оточення створюється в тілі анонімної функції, котра виконується відразу після свого визначення (також це відомо як IIFE). Лексичне оточення містить два приватні значення: змінну privateCounter і функцію changeBy. До обох цих приватних значень не можна звернутися з-поза анонімної функції. Зате це можливо за допомогою трьох публічних функцій, повернених з анонімної обгортки.

Ці три публічні функції утворюють замикання, що поділяють одне лексичне оточення. Завдяки лексичному охопленню JavaScript кожна з них має доступ до змінної privateCounter і функції changeBy.

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

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

    value() {
      return privateCounter;
    },
  };
};

const counter1 = makeCounter();
const counter2 = makeCounter();

console.log(counter1.value()); // 0.

counter1.increment();
counter1.increment();
console.log(counter1.value()); // 2.

counter1.decrement();
console.log(counter1.value()); // 1.
console.log(counter2.value()); // 0.

Зверніть увагу, що два лічильники зберігають незалежність одне від одного. Кожне замикання посилається на іншу версію змінної privateCounter. Щоразу при виклику одного з лічильників його лексичне оточення змінюється шляхом зміни значення цієї змінної. Зміни до значення змінної в одному замиканні не впливає на значення в іншому замиканні.

Примітка: Використання замикань у такий спосіб надає переваги, що зазвичай пов'язуються з об'єктноорієнтованим програмуванням. А саме – приховування даних та інкапсуляція.

Ланцюжок областей видимості замикань

Кожне замикання має три області видимості:

  • Локальну область (власну)
  • Навколишню область (може бути областю видимості блоку, функції чи модуля)
  • Глобальну область

Поширена помилка – не усвідомлювати, що коли зовнішня функція сама є вкладеною функцією, то звертання до області видимості зовнішньої функції залучає навколишню область зовнішньої функції – що фактично утворює ланцюжок областей видимості функцій. Для демонстрації – наступний код прикладу.

// глобальна область
const e = 10;
function sum(a) {
  return function (b) {
    return function (c) {
      // область зовнішніх функцій
      return function (d) {
        // локальна область
        return a + b + c + d + e;
      };
    };
  };
}

console.log(sum(1)(2)(3)(4)); // 20

Також це можна записати без анонімних функцій:

// глобальна область
const e = 10;
function sum(a) {
  return function sum2(b) {
    return function sum3(c) {
      // область зовнішніх функцій
      return function sum4(d) {
        // локальна область
        return a + b + c + d + e;
      };
    };
  };
}

const sum2 = sum(1);
const sum3 = sum2(2);
const sum4 = sum3(3);
const result = sum4(4);
console.log(result); // 20

У прикладі вище – низка вкладених функцій, кожна з яких має доступ до області зовнішніх функцій. В такому контексті можна сказати, що замикання мають доступ до всіх областей видимості зовнішніх функцій.

Замикання так само можуть захоплювати змінні з областей видимості модулів та блоків. Наприклад, нижче утворюється замикання над змінною блокової області y:

function outer() {
  let getY;
  {
    const y = 6;
    getY = () => y;
  }
  console.log(typeof y); // undefined
  console.log(getY()); // 6
}

outer();

Замикання над модулями можуть бути цікавішими.

// myModule.js
let x = 5;
export const getX = () => x;
export const setX = (val) => {
  x = val;
};

Тут модуль експортує пару функцій: гетер і сетер, котра замикається над змінною модульної області x. Навіть коли до x не можна напряму звернутися з інших модулів, її значення можна отримати й змінити за допомогою функцій.

import { getX, setX } from "./myModule.js";

console.log(getX()); // 5
setX(6);
console.log(getX()); // 6

Замикання також можуть замикатися над імпортованими значеннями, котрі вважаються живими зв'язуваннями, адже коли змінюється початкове значення, відповідно змінюються й імпортовані значення.

// myModule.js
export let x = 1;
export const setX = (val) => {
  x = val;
};
// closureCreator.js
import { x } from "./myModule.js";

export const getX = () => x; // Замкнутися над імпортованим живим зв'язуванням
import { getX } from "./closureCreator.js";
import { setX } from "./myModule.js";

console.log(getX()); // 1
setX(2);
console.log(getX()); // 2

Створення замикань у циклах: поширена помилка

До запровадження ключового слова let існувала поширена проблема з замиканнями, котра траплялася при створенні їх всередині циклу. Для демонстрації – код прикладу нижче.

<p id="help">Корисні примітки з'являться тут</p>
<p>Електронна пошта: <input type="text" id="email" name="email" /></p>
<p>Ім'я: <input type="text" id="name" name="name" /></p>
<p>Вік: <input type="text" id="age" name="age" /></p>
function showHelp(help) {
  document.getElementById("help").textContent = help;
}

function setupHelp() {
  var helpText = [
    { id: "email", help: "Ваша адреса електронної пошти" },
    { id: "name", help: "Ваше повне ім'я" },
    { id: "age", help: "Ваш вік (мусить перевищувати 16)" },
  ];

  for (var i = 0; i < helpText.length; i++) {
    // Винуватець – застосування на цьому рядку `var`
    var item = helpText[i];
    document.getElementById(item.id).onfocus = function () {
      showHelp(item.help);
    };
  }
}

setupHelp();

Спробуйте запустити код у JSFiddle.

Масив helpText визначає три корисні підказки, кожна з яких пов'язана з ідентифікатором поля введення у документі. Цикл обходить ці визначення, підв'язуючи подію onfocus до кожного з них, що показує відповідний метод допомоги.

Коли спробувати скористатися цим кодом, ви побачите, що він не працює як очікувалося. Незалежно від того, яке поле отримало фокус, буде виводитися повідомлення про ваш вік.

Причина цього полягає в тому, що функції, присвоєні onfocus, утворюють замикання; вони складаються з визначення функції та захопленого з області функції setupHelp оточення. Три замикання створені циклом, але кожне з них поділяє те саме лексичне оточення, котре має змінну, котра змінює своє значення (item). Так виходить через те, що змінна item оголошена з var, а отже – у зв'язку з підніманням має функціональну область видимості. Значення item.help визначається тоді, коли виконуються функції зворотного виклику onfocus. Через те, що цикл вже виконався на ту мить, змінний об'єкт item (котрий поділяють всі три замикання) надалі вказує на останній запис у списку helpText.

Одне з можливих розв'язань проблеми – використання більшої кількості замикань, а саме – застосування фабрики функцій, як описано вище:

function showHelp(help) {
  document.getElementById("help").textContent = help;
}

function makeHelpCallback(help) {
  return function () {
    showHelp(help);
  };
}

function setupHelp() {
  var helpText = [
    { id: "email", help: "Ваша адреса електронної пошти" },
    { id: "name", help: "Ваше повне ім'я" },
    { id: "age", help: "Ваш вік (мусить перевищувати 16)" },
  ];

  for (var i = 0; i < helpText.length; i++) {
    var item = helpText[i];
    document.getElementById(item.id).onfocus = makeHelpCallback(item.help);
  }
}

setupHelp();

Запустіть цей код за допомогою цього посилання на JSFiddle.

Це працює як очікувалося. Замість того, аби всі функції зворотного виклику поділяли одне лексичне оточення, функція makeHelpCallback створює нове лексичне оточення для кожної функції зворотного виклику, в котрому help посилається на відповідний рядок з масиву helpText.

Іще один спосіб записати цей код за допомогою анонімних замикань – такий:

function showHelp(help) {
  document.getElementById("help").textContent = help;
}

function setupHelp() {
  var helpText = [
    { id: "email", help: "Ваша адреса електронної пошти" },
    { id: "name", help: "Ваше повне ім'я" },
    { id: "age", help: "Ваш вік (мусить перевищувати 16)" },
  ];

  for (var i = 0; i < helpText.length; i++) {
    (function () {
      var item = helpText[i];
      document.getElementById(item.id).onfocus = function () {
        showHelp(item.help);
      };
    })(); // Негайне прикріплення слухача події з поточним значенням елемента (що зберігається до кінця ітерації).
  }
}

setupHelp();

Якщо не хочете використовувати більше замикань – можна застосувати ключові слова let чи const:

function showHelp(help) {
  document.getElementById("help").textContent = help;
}

function setupHelp() {
  const helpText = [
    { id: "email", help: "Ваша адреса електронної пошти" },
    { id: "name", help: "Ваше повне ім'я" },
    { id: "age", help: "Ваш вік (мусить перевищувати 16)" },
  ];

  for (let i = 0; i < helpText.length; i++) {
    const item = helpText[i];
    document.getElementById(item.id).onfocus = () => {
      showHelp(item.help);
    };
  }
}

setupHelp();

Цей приклад застосовує замість varconst, тож кожне замикання зв'язане зі змінною блокової області, а отже – додаткові замикання не потрібні.

Іще один варіант – використати для ітерації масиву helpText метод forEach() і прикріпити слухач до кожного <input>, отак:

function showHelp(help) {
  document.getElementById("help").textContent = help;
}

function setupHelp() {
  var helpText = [
    { id: "email", help: "Ваша адреса електронної пошти" },
    { id: "name", help: "Ваше повне ім'я" },
    { id: "age", help: "Ваш вік (мусить перевищувати 16)" },
  ];

  helpText.forEach(function (text) {
    document.getElementById(text.id).onfocus = function () {
      showHelp(text.help);
    };
  });
}

setupHelp();

Міркування щодо швидкодії

Як згадано вище, кожен примірник функції управляє власними областю видимості й замиканням. Таким чином, недоцільно даремно створювати всередині одних функцій інші функції, непотрібні для конкретної задачі, адже це негативно вплине на швидкодію сценарію, як щодо швидкості виконання, так щодо споживання пам'яті.

Наприклад, при створенні нового об'єкта чи функції методи зазвичай пов'язані з прототипом об'єкта, а не визначені в конструкторі об'єкта. Причина цього в тому, що щоразу, коли викликається конструктор, методи повторно присвоювалися б (тобто при кожному створенні об'єкта).

Погляньте на наступний випадок:

function MyObject(name, message) {
  this.name = name.toString();
  this.message = message.toString();
  this.getName = function () {
    return this.name;
  };

  this.getMessage = function () {
    return this.message;
  };
}

Через те, що код вище не користується перевагами використання замикань у цьому конкретному випадку, натомість можна переписати його для уникання використання замикань – отак:

function MyObject(name, message) {
  this.name = name.toString();
  this.message = message.toString();
}
MyObject.prototype = {
  getName() {
    return this.name;
  },
  getMessage() {
    return this.message;
  },
};

Проте перевизначення прототипа є нерекомендованим. Наступний приклад замість цього додає функції до наявного прототипа:

function MyObject(name, message) {
  this.name = name.toString();
  this.message = message.toString();
}
MyObject.prototype.getName = function () {
  return this.name;
};
MyObject.prototype.getMessage = function () {
  return this.message;
};

У двох прикладах вище успадкований прототип може поділятися усіма об'єктами, а визначення методів не повинні відбуватися при кожному створенні об'єкта. Дивіться Успадкування та ланцюжок прототипів для отримання подробиць.