16 липня 2023 р.

Область видимості змінної, замикання

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

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

Але що станеться, якщо зовнішні змінні змінюються після створення функції? Чи отримає функція нові значення чи старі?

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

Давайте розширимо наші знання, щоб зрозуміти ці та більш складні сценарії.

Тут ми поговоримо про змінні let/const

У JavaScript існує 3 способи оголошення змінної: let, const (сучасні способи) та var (залишок минулого).

  • У цій статті ми будемо використовувати let для змінних у прикладах.
  • Змінні, оголошені через const, поводяться так само, тому ця стаття також стосується const.
  • var має деякі помітні відмінності, вони будуть розглянуті в статті Застаріле ключове слово "var".

Блоки коду

Якщо змінна оголошена всередині блоку коду {...}, вона буде доступна лише всередині цього блоку.

Наприклад:

{
  // тут виконується певна робота з локальними змінними, яку не слід бачити зовні

  let message = "Привіт"; // змінна видима тільки у цьому блоці

  alert(message); // Привіт
}

alert(message); // Помилка: змінну message не було оголошено

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

{
  // показати повідомлення
  let message = "Привіт";
  alert(message);
}

{
  // показати інше повідомлення
  let message = "Бувай";
  alert(message);
}
Без блоків буде помилка

Будь-ласка, зверніть увагу, що без окремих блоків буде помилка, якщо ми використовуємо let з однаковою назвою змінної:

// показати повідомлення
let message = "Привіт";
alert(message);

// показати інше повідомлення
let message = "Бувай"; // Помилка: змінна вже оголошена
alert(message);

Для if, for, while і так далі, змінні, оголошені в {...} також видно тільки всередині:

if (true) {
  let phrase = "Привіт!";

  alert(phrase); // Привіт!
}

alert(phrase); // Помилка, такої змінної немає!

Тут, після завершення if, alert нижче не побачить phrase, отже, помилка.

Це чудово, оскільки це дозволяє нам створювати локально-блокові змінні, специфічні для гілки if.

Те ж саме справедливо і для циклів for та while:

for (let i = 0; i < 3; i++) {
  // змінну `i` видно тільки всередині цього циклу for
  alert(i); // 0, потім 1, потім 2
}

alert(i); // Помилка, такої змінної немає

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

Вкладені функції

Функція називається “вкладеною”, коли вона створюється всередині іншої функції.

З JavaScript це зробити дуже легко.

І ми можемо використовувати це для організації нашого коду, наприклад:

function sayHiBye(firstName, lastName) {

  // допоміжна вкладена функція для використання нижче
  function getFullName() {
    return firstName + " " + lastName;
  }

  alert( "Привіт, " + getFullName() );
  alert( "Бувай, " + getFullName() );

}

Тут вкладена функція getFullName() створена для зручності. Вона має доступ до внутрішніх змінних функції і тому може повернути повне ім’я. Вкладені функції досить поширені в JavaScript.

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

Нижче, makeCounter створює функцію “counter”, яка повертає наступний номер при кожному виклику:

function makeCounter() {
  let count = 0;

  return function() {
    return count++;
  };
}

let counter = makeCounter();

alert( counter() ); // 0
alert( counter() ); // 1
alert( counter() ); // 2

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

Як це працює? Якщо ми створимо кілька лічильників, чи будуть вони незалежними? Що відбувається зі змінними тут?

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

Лексичне середовище

Тут будуть дракони!

Поглиблене технічне пояснення попереду.

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

Для наочності пояснення поділено на кілька етапів.

1 етап. Змінні

У JavaScript кожна запущена функція, блок коду {...}, і скрипт в цілому мають внутрішній (прихований) асоційований об’єкт, відомий як Лексичне середовище (Lexical Environment).

Об’єкт лексичного середовища складається з двох частин:

  1. Запис середовища (Environment Record) – об’єкт, який зберігає всі локальні змінні як властивості (та деяку іншу інформацію, наприклад значення this).
  2. Посилання на зовнішнє лексичне середовище, яке пов’язане із зовнішнім кодом.

“Змінна” це лише властивість спеціального внутрішнього об’єкта, Запис середовища (Environment Record). “Отримати або змінити змінну” насправді означає “отримати або змінити властивість цього об’єкта”.

У цьому простому коді без функцій є лише одне лексичне середовище:

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

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

Коли код виконується, лексичне середовище змінюється.

Ось трохи довший код:

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

  1. Коли скрипт запускається, лексичне середовище попередньо заповнюється усіма оголошеними змінними.
    • Спочатку вони перебувають у стані “Неініціалізовано” (Uninitialized). Це особливий внутрішній стан, який означає, що рушій знає про змінну, але на неї не можна посилатися, поки вона не буде оголошена з let. Це майже те саме, ніби змінна не існує.
  2. Потім з’являється оголошення змінної let phrase. Поки що ми тільки оголосили змінну, тому її значення undefined. Але з цього моменту ми можемо використовувати її.
  3. phrase присвоюється значення.
  4. phrase змінює значення.

Поки що все виглядає просто, правда?

  • Змінна – це властивість спеціального внутрішнього об’єкта, пов’язана з блоком/функцієї/скриптом що зараз виконується.
  • Робота зі змінними – це насправді робота з властивостями цього об’єкта.
Лексичне середовище – це об’єкт специфікації

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

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

2 етап. Функції створені як Function Declarations

Функція також є значенням, як і значення у змінних.

Різниця в тому, що функція створена за допомогою Function Declaration, ініціалізується миттєво і повністю.

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

Ось чому ми можемо використовувати функцію, оголошену з Function Declaration, ще до рядка з оголошенням.

Наприклад, ось початковий стан глобального лексичного середовища, коли ми додаємо функцію:

Така поведінка стосується лише Function Declarations, а не Function Expressions, де ми призначаємо функцію змінній, наприклад ось так let say = function(name)....

3 етап. Внутрішнє та зовнішнє лексичне середовище

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

Наприклад, для say("John"), це виглядає так (виконання знаходиться у рядку, позначеному стрілкою):

Під час виклику функції у нас є два лексичні середовища: внутрішнє (для виклику функції) і зовнішнє (глобальне):

  • Внутрішнє лексичне середовище відповідає поточному виконанню функції say. Воно має єдину властивість: name – аргумент функції. Ми викликали say("John"), тож значення у name буде "John".
  • Зовнішнє лексичне середовище – це глобальне лексичне середовище. У ньому є змінна phrase та сама функція.

Внутрішнє лексичне середовище має посилання на зовнішнє.

Коли код хоче отримати доступ до змінної – спочатку шукає її у внутрішньому лексичному середовищі, потім у зовнішньому, потім у зовнішньому до попереднього і так далі поки не дійде до глобального.

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

У цьому прикладі пошук відбувається наступним чином:

  • Для змінної name, alert у функції say знаходить її негайно у внутрішньому лексичному середовищі.
  • Коли він хоче отримати доступ до phrase, він спочатку шукає її серед локальних змінних, де її немає, і врешті решт іде за посиланням на зовнішнє лексичне середовище і знаходить її там.

4 етап. Повернення функції

Повернемося до прикладу з makeCounter.

function makeCounter() {
  let count = 0;

  return function() {
    return count++;
  };
}

let counter = makeCounter();

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

Отже, у нас є два вкладених лексичних середовища, як і у прикладі вище:

Різниця полягає в тому, що під час виконання makeCounter(), крихітна вкладена функція створюється, яка складається лише з одного рядка: return count++. Ми не запускаємо її, лише створюємо.

Усі функції пам’ятають лексичне середовище, в якому вони були створені. Технічно тут немає ніякої магії: усі функції мають приховану властивість з назвою [[Environment]], що зберігає посилання на лексичне середовище, де була створена функція:

Тому, counter.[[Environment]] має посилання на лексичне середовище, яке має вигляд {count: 0}. Так функція запам’ятовує, де вона була створена, незалежно від того, де вона викликається. Посилання у [[Environment]] встановлюється раз і назавжди під час створення функції.

Пізніше коли counter() викликається, для виклику створюється нове лексичне середовище, а посилання на зовнішнє лексичне середовище для нього береться з counter.[[Environment]]:

Тепер, коли код всередині counter() шукає змінну count, він спочатку шукає у власному лексичному середовищі (воно порожнє, оскільки там немає локальних змінних), потім у зовнішньому лексичному середовищі виклику makeCounter(), де він її знаходить і змінює.

Змінна оновлюється в лексичному середовищі, де вона існує.

Ось стан після виконання:

Якщо ми викликаємо counter() кілька разів, змінна count буде збільшена до 2, 3 і так далі, в одному місці.

Замикання

У програмуванні існує загальний термін “замикання”, який розробники зазвичай мають знати.

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

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

Коли під час співбесіди розробник отримує запитання “що таке замикання?”, правильною відповіддю буде визначення замикання та пояснення, що всі функції в JavaScript є замиканнями, і, можливо, ще кілька слів про технічні деталі: властивість [[Environment]], і як взагалі працюють лексичні середовища.

Збирання сміття

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

Однак, якщо є вкладена функція, яка все ще доступна після завершення виклику основної функції, то вона має властивість [[Environment]], яка посилається на лексичне середовище, створене під час виклику.

У цьому випадку лексичне середовище все ще доступне навіть після завершення функції, тому воно залишається “живим”.

Наприклад:

function f() {
  let value = 123;

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

let g = f(); // g.[[Environment]] зберігає посилання на лексичне середовище
// відповідного виклику f()

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

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

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

// три функції в масиві, кожна з яких пов’язана з лексичним середовищем
// відповідного виклику f()
let arr = [f(), f(), f()];

Об’єкт лексичного середовища “вмирає”, коли стає недосяжним (як і будь-який інший об’єкт). Іншими словами, він існує лише тоді, коли на нього посилається принаймні одна вкладена функція.

У наведеному нижче коді після видалення вкладеної функції, лексичне середовище, до якого вона мала доступ, також стирається з пам’яті:

function f() {
  let value = 123;

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

let g = f(); // поки функція g існує, значення залишається в пам’яті

g = null; // ...і тепер пам’ять очищена

Оптимізації в реальному житті

Як ми бачили, теоретично, поки функція “жива”, всі зовнішні змінні також зберігаються.

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

Важливим побічним ефектом у рушію V8 (Chrome, Edge, Opera) є те, що така змінна стане недоступною під час налагодження.

Спробуйте запустити наведений нижче приклад у Chrome із відкритими інструментами розробника.

Коли він призупиняється на debugger, в консолі введіть alert(value).

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

  function g() {
    debugger; // в консолі введіть: alert(value); і ви побачите, що такої змінної немає!
  }

  return g;
}

let g = f();
g();

Як бачите, такої змінної немає! Теоретично вона повинна бути доступною, але рушій це оптимізував.

Це може призвести до смішних (якщо не таких трудомістких) проблем з налагодженням. Одна з них – ми можемо побачити зовнішню змінну з такою ж назвою замість очікуваної:

let value = "Сюрприз!";

function f() {
  let value = "найближче значення";

  function g() {
    debugger; // в консолі введіть: alert(value); Сюрприз!
  }

  return g;
}

let g = f();
g();

Цю особливість V8 корисно знати. Якщо ви налагоджуєте свій код у Chrome/Edge/Opera, рано чи пізно ви її зустрінете.

Це не помилка, а скоріше особливість V8. Можливо, колись це буде змінено. Ви завжди можете перевірити це, запустивши приклади на цій сторінці.

Завдання

важливість: 5

Функція sayHi використовує зовнішню змінну. Яке значення буде використано під час виконання функції?

let name = "Іван";

function sayHi() {
  alert("Привіт, " + name);
}

name = "Петро";

sayHi(); // що вона покаже "Іван" чи "Петро"?

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

Отже, виникає питання: чи побачить функція останні зміни?

Відповідь: Петро.

Функція отримує зовнішні змінні такими, якими вони є зараз, тобто вона використовує останні значення.

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

важливість: 5

Функція makeWorker створює іншу функцію і повертає її. Цю нову функцію можна викликати ще звідкись.

Чи матиме вона доступ до зовнішніх змінних з місця створення, з місця виклику, чи з обох?

function makeWorker() {
  let name = "Петро";

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

let name = "Іван";

// створити функцію
let work = makeWorker();

// викликати її
work(); // Що вона покаже?

Яке значення вона покаже? “Петро” чи “Іван”?

Відповідь: Петро.

Функція work() в коді нижче отримує name від місця його походження через посилання на зовнішнє лексичне середовище:

Отже, відповіддю буде "Петро".

Але якби не було let name у makeWorker(), тоді пошук вийшов би за межі лексичного середовища та взяв би глобальну змінну, як ми бачимо з ланцюжка вище. В такому випадку відповідь була б "Іван".

важливість: 5

Тут ми створюємо два лічильника: counter та counter2 використовуючи однакову функцію makeCounter.

Вони незалежні? Що покаже другий лічильник? 0,1 чи 2,3 чи щось інше?

function makeCounter() {
  let count = 0;

  return function() {
    return count++;
  };
}

let counter = makeCounter();
let counter2 = makeCounter();

alert( counter() ); // 0
alert( counter() ); // 1

alert( counter2() ); // ?
alert( counter2() ); // ?

Відповідь: 0,1.

Функції counter і counter2 створюються різними викликами makeCounter.

Отже, вони мають незалежні зовнішні лексичні середовища, кожне з яких має свою власну змінну count.

важливість: 5

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

Чи буде він працювати? Що він покаже?

function Counter() {
  let count = 0;

  this.up = function() {
    return ++count;
  };
  this.down = function() {
    return --count;
  };
}

let counter = new Counter();

alert( counter.up() ); // ?
alert( counter.up() ); // ?
alert( counter.down() ); // ?

Безумовно, він буде чудово працювати.

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

function Counter() {
  let count = 0;

  this.up = function() {
    return ++count;
  };

  this.down = function() {
    return --count;
  };
}

let counter = new Counter();

alert( counter.up() ); // 1
alert( counter.up() ); // 2
alert( counter.down() ); // 1
важливість: 5

Подивіться на код. Яким буде результат виклику на останньому рядку?

let phrase = "Привіт";

if (true) {
  let user = "Іван";

  function sayHi() {
    alert(`${phrase}, ${user}`);
  }
}

sayHi();

Результатом буде помилка.

Функція sayHi оголошується всередині if, тому вона доступна тільки всередині нього. Зовні функції sayHi не існує.

важливість: 4

Напишіть функцію sum яка працює ось так: sum(a)(b) = a+b.

Саме так, використовуючи подвійні дужки (це не друкарська помилка).

Наприклад:

sum(1)(2) = 3
sum(5)(-1) = 4

Щоб другі дужки працювали, функція повинна повертати іншу функцію.

Ось так:

function sum(a) {

  return function(b) {
    return a + b; // Бере "a" із зовнішнього лексичного середовища
  };

}

alert( sum(1)(2) ); // 3
alert( sum(5)(-1) ); // 4
важливість: 4

Який буде результат цього коду?

let x = 1;

function func() {
  console.log(x); // ?

  let x = 2;
}

func();

P.S. У цьому завданні є підводний камінь. Рішення не є очевидним.

Результатом буде помилка.

Спробуйте запустити це:

let x = 1;

function func() {
  console.log(x); // ReferenceError: Cannot access 'x' before initialization
  let x = 2;
}

func();

У цьому прикладі ми можемо спостерігати особливу різницю між “неіснуючою” та “неініціалізованою” змінною.

Як ви могли прочитати в статті Область видимості змінної, замикання, змінна перебуває у “неініціалізованому” стані з моменту, коли виконання входить в кодовий блок (чи у функцію). І вона залишається неініціалізованою до відповідного let.

Інакше кажучи, змінна технічно існує, але не може бути використана раніше let.

Наведений нижче код це демонструє.

function func() {
  // локальна змінна `x` відома рушію з початку функції,
  // але вона "неініціалізова" (непридатна) до let ("мертва зона")
  // звідси помилка

  console.log(x); // ReferenceError: Cannot access 'x' before initialization

  let x = 2;
}

Цю зону тимчасової непридатності змінної (від початку блоку коду до let) іноді називають “мертвою зоною”.

важливість: 5

У нас є вбудований для масивів метод arr.filter(f). Він фільтрує всі елементи через функцію f. Якщо вона повертає true, цей елемент повертається в отриманому масиві.

Зробіть набір “готових до використання” фільтрів:

  • inBetween(a, b) – фільтрує елементи які більше a та менше b. Також має включати елементи, які дорівнюють їм.
  • inArray([...]) – фільтрує елементи, які включено у заданий масив.

Використання має бути таким:

  • arr.filter(inBetween(3,6)) – вибирає лише значення від 3 до 6.
  • arr.filter(inArray([1,2,3])) – вибирає лише елементи, які включені у масив [1,2,3].

Наприклад:

/* .. ваш код для inBetween та inArray */
let arr = [1, 2, 3, 4, 5, 6, 7];

alert( arr.filter(inBetween(3, 6)) ); // 3,4,5,6

alert( arr.filter(inArray([1, 2, 10])) ); // 1,2

Відкрити пісочницю з тестами.

Фільтр inBetween

function inBetween(a, b) {
  return function(x) {
    return x >= a && x <= b;
  };
}

let arr = [1, 2, 3, 4, 5, 6, 7];
alert( arr.filter(inBetween(3, 6)) ); // 3,4,5,6

Фільтр inArray

function inArray(arr) {
  return function(x) {
    return arr.includes(x);
  };
}

let arr = [1, 2, 3, 4, 5, 6, 7];
alert( arr.filter(inArray([1, 2, 10])) ); // 1,2

Відкрити рішення із тестами в пісочниці.

важливість: 5

У нас є масив об’єктів для сортування:

let users = [
  { name: "Іван", age: 20, surname: "Іванов" },
  { name: "Петро", age: 18, surname: "Петров" },
  { name: "Енн", age: 19, surname: "Гетевей" }
];

Звичайний спосіб зробити це:

// За ім’ям (Енн, Іван, Петро)
users.sort((a, b) => a.name > b.name ? 1 : -1);

// За віком (Петро, Енн, Іван)
users.sort((a, b) => a.age > b.age ? 1 : -1);

Чи можемо ми зробити це ще менш багатослівним?

users.sort(byField('name'));
users.sort(byField('age'));

Отже, замість того, щоб кожен раз писати функцію, ми будемо викликати функцію byField(fieldName).

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

Відкрити пісочницю з тестами.

function byField(fieldName){
  return (a, b) => a[fieldName] > b[fieldName] ? 1 : -1;
}

Відкрити рішення із тестами в пісочниці.

важливість: 5

Наступний код створює масив shooters.

Кожна функція має вивести свій номер. Але щось не так…

function makeArmy() {
  let shooters = [];

  let i = 0;
  while (i < 10) {
    let shooter = function() { // створюємо функцію стрільця,
      alert( i ); // що має показувати свій номер
    };
    shooters.push(shooter); // додаємо її до масиву
    i++;
  }

  // ...і повертаємо масив стрільців
  return shooters;
}

let army = makeArmy();

// всі стрільці показують 10 замість своїх номерів 0, 1, 2, 3...
army[0](); // 10 від стрільця за номером 0
army[1](); // 10 від стрільця за номером 1
army[2](); // 10 ...і так далі.

Чому всі функції показують однакове значення?

Виправте код так, щоб він працював як передбачалося.

Відкрити пісочницю з тестами.

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

  1. Функція створює порожній масив shooters:

    let shooters = [];
  2. Наповнює його функціями у циклі через shooters.push(function).

    Кожен елемент є функцією, тому отриманий масив виглядає так:

    shooters = [
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); }
    ];
  3. Функція повертає масив.

    Потім, виклик будь-якого елемента масиву, наприклад army[5]() отримає елемент army[5] з масиву (який є функцією) і викликає її.

    Чому всі функції показують однакове значення, 10?

    Зверніть увагу, що всередині функцій shooter немає локальної змінної i. Коли така функція викликається, вона приймає i зі свого зовнішнього лексичного середовища.

    Тоді яке буде значення i?

    Якщо ми подивимося на код:

    function makeArmy() {
      ...
      let i = 0;
      while (i < 10) {
        let shooter = function() { // функція shooter
          alert( i ); // має показати свій номер
        };
        shooters.push(shooter); // додати функцію до масиву
        i++;
      }
      ...
    }

    Ми бачимо що усі функції shooter створені в лексичному середовищі функції makeArmy(). Але коли ми викликаємо army[5](), функція makeArmy вже закінчила свою роботу, і остаточне значення i це 10 (цикл while зупиняється на i=10).

    В результаті всі функції shooter отримують однакове значення із зовнішнього лексичного середовища, тобто останнє значення, i=10.

    Як ви можете бачити вище, на кожній ітерації циклу while {...}, створюється нове лексичне середовище. Отже, щоб виправити це, ми можемо скопіювати значення i у змінну всередині блоку while {...}, ось так:

    function makeArmy() {
      let shooters = [];
    
      let i = 0;
      while (i < 10) {
          let j = i;
          let shooter = function() { // функція shooter
            alert( j ); // має показати свій номер
          };
        shooters.push(shooter);
        i++;
      }
    
      return shooters;
    }
    
    let army = makeArmy();
    
    // Тепер код працює правильно
    army[0](); // 0
    army[5](); // 5

    Тут let j = i оголошує локальну змінну j та копіює до неї номер ітерації зі змінної i. Примітиви копіюються “за значенням”, тому ми фактично отримуємо незалежну копію i, що належить до поточної ітерації циклу.

    Функції тепер працюють правильно, тому що змінна i “живе” трохи ближче. Не в лексичному середовищі виклику makeArmy(), але в лексичному середовищі, яке відповідає поточній ітерації циклу:

    Такої проблеми також можна було б уникнути, якби ми використали цикл for з самого початку, ось так:

    function makeArmy() {
    
      let shooters = [];
    
      for(let i = 0; i < 10; i++) {
        let shooter = function() { // функція shooter
          alert( i ); // має показати свій номер
        };
        shooters.push(shooter);
      }
    
      return shooters;
    }
    
    let army = makeArmy();
    
    army[0](); // 0
    army[5](); // 5

    Це, по суті, те саме, тому що for на кожній ітерації створює нове лексичне середовище зі своєю змінною i. Тому shooter згенерований на кожній ітерації бере посилання на змінну i, з тієї самої ітерації.

Тепер, коли ви доклали так багато зусиль, щоб прочитати це, остаточний рецепт такий простий – використовуйте цикл for, ви можете задатися питанням – чи було воно того варте?

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

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

Відкрити рішення із тестами в пісочниці.

Навчальна карта