Натискай та забирай від 1 000 до 6 000 ₴ + iPhone

JavaScript: замикання (Closures) та їх роль у сучасному програмуванні

JavaScript: замикання (Closures) та їх роль у сучасному програмуванні

  • 25 листопада
  • читати 15 хв
Володимир Шайтан
Володимир Шайтан Senior Full Stack Developer у UKEESS Software House, Викладач Комп'ютерної школи Hillel.
Артур Яворський

Програмування завжди було для мене чимось більшим, ніж механічне написанням коду. Так, інколи це переписування одного і того ж, бо іноді так треба. Але якщо ви спитаєте мене на більш глибокому рівні (тут потрібно вставити відповідну мелодію:), то це процес пошуку рішень, експериментів і часом боротьби зі помилками в консолі, які здаються невиправними.

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

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

І сьогодні я хочу поговорити про одну з тих особливостей JavaScript, яка часто викликає абсолютне нерозуміння на початку, але з часом стає абсолютно незамінною частиною інструментарію кожного розробника.

Замикання. Це слово звучить дещо технічно, але насправді за ним стоїть ідея, яка може кардинально змінити підхід до організації коду.

Чому це важливо? Уявіть, що вам потрібно зробити свій код більш чистим, організованим, а ще — потрібна приватність деяких даних, щоб уникнути змін деяких змінних. І для цього не потрібні десятки строк коду чи сторонні бібліотеки.

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

CLOSURES

Розпочнімо з визначення. Що таке замикання?

Замикання — це функція, яка «запам’ятовує» своє лексичне оточення, навіть якщо ця функція виконується поза областю, де була створена.

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

Складно? Далі буде ставати зрозуміліше:)

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

function createUserManager() {
   const users = []; // Приватний масив, недоступний ззовні

   return {
       addUser(name, age) {
           users.push({ name, age });
           console.log(`Користувач ${name} доданий.`);
       },
       removeUser(name) {
           const index = users.findIndex(user => user.name === name);
           if (index !== -1) {
               users.splice(index, 1);
               console.log(`Користувач ${name} видалений.`);
           } else {
               console.log(`Користувача ${name} не знайдено.`);
           }
       },
       getUsers() {
           return users.map(user => `${user.name} (${user.age} років)`);
       },
       findUser(name) {
           const user = users.find(user => user.name === name);
           return user ? `${user.name} (${user.age} років)` : `Користувача ${name} не знайдено.`;
       }
   };
}

А працювати це буде приблизно так:

  • Створення менеджера користувачів
const userManager = createUserManager();
  • Додавання користувачів
userManager.addUser('Джон', 25);
userManager.addUser('Анна', 30);
  • Отримання списку користувачів
console.log(userManager.getUsers()); // ['Джон (25 років)', 'Анна (30 років)']
  • Пошук користувача
console.log(userManager.findUser('Джон')); // Джон (25 років)
console.log(userManager.findUser('Макс')); // Користувача Макс не знайдено.
  • Видалення користувача
userManager.removeUser('Анна');
console.log(userManager.getUsers()); // ['Джон (25 років)']

А тепер розберімося на словах як же це працює:

  1. Функція createUserManager створює замикання навколо приватного масиву users.
  2. Методи addUser, removeUser, getUsers, і findUser мають доступ до цього масиву через замикання.
  3. Масив users є прихованим від зовнішнього коду, що забезпечує інкапсуляцію даних і запобігає їхньому прямому зміненню.
  4. Ви взаємодієте зі списком користувачів тільки через методи, надані функцією createUserManager.

Цей приклад демонструє, як замикання можна використовувати для створення приватних даних й управління ними через публічний інтерфейс.

ЯК ПРАЦЮЄ ЗАМИКАННЯ?

Глобально ми можемо сказати, що замикання працює наступним чином:

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

Як це можливо? Справа в тому, що JavaScript «пам’ятає» середовище, у якому функція була створена, і саме це дозволяє їй отримувати доступ до потрібних змінних навіть після їхнього звичайного «життєвого циклу».

function outer() {
   let count = 0; // Змінна, яка доступна лише всередині функції outer

   function inner() {
       count++; // Змінюємо значення змінної count
       console.log(count); // Виводимо значення count
   }

   return inner; // Повертаємо внутрішню функцію
}
const closureFunction = outer(); // Викликаємо outer, отримуємо inner
closureFunction(); // Виведе 1
closureFunction(); // Виведе 2
closureFunction(); // Виведе 3

У цьому прикладі функція inner має доступ до змінної count, яка була оголошена у функції outer. Навіть після того, як функція outer завершила виконання, змінна count залишається доступною завдяки замиканню.

ЩО ТАКЕ ЛЕКСИЧНЕ ОТОЧЕННЯ?

Це надважлива тема в програмуванні загалом, не тільки в JS, яку складно, але необхідно почати розуміти в якийсь момент цього життя. І я хочу сподіватися, що зараз буде той самий момент для вас.

Лексичне оточення (або лексичне середовище, англ. lexical environment) — це концепція в програмуванні,  яка описує середовище, у якому були оголошені змінні, функції та їхні значення.

Лексичне оточення також визначає, як змінні та функції взаємодіють із контекстом виконання (Execution Context), який створюється під час виконання коду.

Контекст виконання, своєю чергою, при оголошенні змінних і функцій містить інформацію про:

  • лексичне оточення,
  • посилання на об'єкт This,
  • механізм стека викликів.

Рекомендуємо публікацію по темі

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

ПРИКЛАД:

function outer() {
   let outerVariable = "Я зовнішня змінна";
   function inner() {
       console.log(outerVariable); // Доступ до змінної з зовнішнього оточення
   }
   return inner;
}
const closureFunction = outer();
closureFunction(); // Виведе: "Я зовнішня змінна"

Лексичне оточення функції inner включає її власні змінні та змінну outerVariable з оточення функції outer.

Замикання дозволяє inner отримати доступ до outerVariable, попри те, що outer вже завершила своє виконання.

РОЛЬ ЗАМИКАННЯ У СУЧАСНОМУ ПРОГРАМУВАННІ

Хоч ця технологія для мови не нова, але тримає свою планку важливості на стабільно високому рівні. Зараз поясню чому. Уся справа в інкапсуляції (так-так, це знову ті самі принципи ООП).

Інкапсуляція даних — це принцип об'єктноорієнтованого програмування, який полягає у захисті внутрішньої реалізації  об'єкта від зовнішнього світу.

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

ПРИКЛАД:

function createSecret(secret) {
   return function () {
       return secret;
   };
}

const getSecret = createSecret("Це секрет!");
console.log(getSecret()); // "Це секрет!"

Цей код демонструє замикання в JavaScript. Функція createSecret приймає значення secret і повертає функцію, яка зберігає доступ до цього значення, навіть після завершення виконання createSecret.

Інкапсуляція тут проявляється в тому, що значення secret є доступним лише для внутрішньої функції, яка повертається з createSecret, і недоступним безпосередньо ззовні.

Це означає, що secret приховане від зовнішнього коду і може бути отримане тільки через виклик повернутої функції.

Ось як це виглядає з погляду інкапсуляції:

  1. Приватність: змінна secret знаходиться у лексичному середовищі функції createSecret і ніяк не може бути змінена чи прочитана без використання поверненої функції.
  2. Контроль доступу: повернена функція (getSecret) виступає єдиним «інтерфейсом» (тим, з чим може взаємодіяти користувач) для доступу до значення secret. Зовнішній код не може прямо змінити чи отримати доступ до цієї змінної.

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

Але що робити, якщо в модулі є частини, які потрібно приховати від зовнішнього світу, наприклад, внутрішні змінні чи логіка, яка не повинна бути доступною користувачеві?

ПРИКЛАД:

const counterModule = (function () {
   let count = 0;
   return {
       increment: function () {
           count++;
           return count;
       },
       decrement: function () {
           count--;
           return count;
       },
   };
})();

console.log(counterModule.increment()); // 1
console.log(counterModule.decrement()); // 0

А тепер розберемо що до чого у цьому прикладі.

  • Модульність
  1. Розділення коду на частини: уся логіка лічильника (збільшення, зменшення і зберігання стану) зібрана в окремий модуль counterModule. Цей модуль містить чітко визначений інтерфейс (increment і decrement), через який зовнішній код може взаємодіяти з ним.
  2. Інкапсуляція логіки: вся робота з внутрішнім станом (count) прихована від зовнішнього коду. Зовнішній код має доступ лише до функцій increment і decrement, але не може напряму змінити значення count.
  • Замикання
  1. Збереження доступу до змінноїcount: змінна count визначена у лексичному середовищі анонімної функції, яка викликається негайно ((function () { ... })()). Ця змінна доступна лише внутрішнім функціям increment і decrement.
  2. Прихованість змінної: змінна count недоступна зовні, навіть якщо модуль використовується глобально. Вона «захищена» замиканням, оскільки зберігається у середовищі анонімної функції.

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

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

Функціональне програмування дозволяє працювати з функціями як із даними: передавати їх у змінні, повертати з інших функцій і передавати як аргументи.

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

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

Функціональне програмування — це підхід у програмуванні, який наголошує на обчисленнях через застосування функцій.

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

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

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

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

Це особливо корисно у сценаріях, де потрібно часто виконувати одну й ту саму логіку з невеликими варіаціями параметрів.

ПРИКЛАД:

function multiply(a) {
   return function (b) {
       return a * b;
   };
}
const double = multiply(2);
console.log(double(5)); // 10

Тепер ми можемо перейти до останнього нового терміну в цій статті — асинхронного програмування.

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

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

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

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

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

У результаті, коли функція виконується асинхронно, вона все ще може використовувати «занотовані» змінні зі свого лексичного оточення. Наприклад, у колбеках це дозволяє мати доступ до даних, які були актуальними під час створення функції, навіть якщо ця функція викликається пізніше.

ПРИКЛАД:

function delayedMessage(message, delay) {
   setTimeout(function () {
       console.log(message);
   }, delay);
}

delayedMessage("Привіт!", 1000); // Через 1 секунду: "Привіт!"

І ми нарешті підібралися до підбивання підсумків щодо замикань. Чи це потрібно, корисно і зручно? Вирішувати вам, але обʼєктивно — так.

Тож переваги замикань:

  • захист даних: замикання дозволяють ізолювати змінні від зовнішнього доступу;
  • гнучкість: вони спрощують роботу зфункціями вищого порядку й асинхронним кодом;
  • зручність: замикання використовуються у багатьох популярних фреймворках, таких як React, Vue тощо;
  • ефективність: дозволяють уникати дублювання коду, сприяючи чистішій архітектурі програм.

Але, як і всюди, недоліки теж присутні:

  • проблеми з пам’яттю: некоректне використання замикань може призводити довитоків пам’яті;
  • складність для новачків: концепція замикань може бути важкою для розуміння, особливо у великих кодових базах;
  • дебагінг: відстеження помилок у коді з замиканнями може бути складним, оскільки змінні доступні лише у певних контекстах.

ВИСНОВОК

Як ви могли зрозуміти, замикання відіграють важливу роль у написанні JavaScript-коду.

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

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

Тож вчіться, пробуйте і не бійтеся помилятися, адже все можна оптимізувати та вдосконалити. І зустрінемось у цьому ж місці та в той самий час!

Рекомендуємо публікації по темі