Использование let объявлений переменных и особенности образуемых при этом замыканий в JavaScript

от автора

Написать данную заметку меня сподвигло прочтение статьи на Хабре «Var, let или const? Проблемы областей видимости переменных и ES6» и комментариев к ней, а также соответствующей части книги Закаса Н. «Understanding of ECMAScript 6». Исходя из прочитанного я вынес, что не всё так однозначно в оценке использования var или let. Авторы и комментаторы склоняются к тому, что при отсутствии необходимости поддержки старых версий браузеров имеет смысл полностью отказаться от использования var, а также использовать некоторые упрощенные конструкции, заместо старых, по умолчанию.

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

Для начала хотелось бы рассмотреть выражения немедленно вызываемых функций (Immediately Invoked Function Expression, IIFE) в циклах.

let func1 = [];  for (var i = 0; i < 3; i++) { 	func1.push((function(i) { return function() { console.log(i); } })(i)); }  func1.forEach(function(func) { func(); });  /* В консоли получим  0 newECMA6add.js:4:59 1 newECMA6add.js:4:59 2 newECMA6add.js:4:59 */

или можно обойтись без них используя let:

let func1 = [];  for (let i = 0; i < 3; i++) { 	func1.push(function() { console.log(i); }); }  func1.forEach(function(func) { func(); });  /* В консоли также получим  0 newECMA6add.js:4:37 1 newECMA6add.js:4:37 2 newECMA6add.js:4:37 */ 

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

«This loop works exactly like the loop that used var and an IIFE but is arguably cleaner»

что, впрочем, сам же, чуть далее, косвенно опровергает.

Дело в том, что каждая итерация цикла при использовании let создает отдельную локальную переменную i и при этом привязка в функциях отправленных в массив идет также по отдельным переменным от каждой итерации.

В данном конкретном случае, результат действительно не отличается, но, что если мы немного усложним код?

let func1 = [];  for (var i = 0; i < 3; i++) { 	func1.push((function(i) { return function() { console.log(i); }})(i)); 	++i; }  func1.forEach(function(func) { func(); });  /* В консоли получим  0 newECMA6add.js:4:59 2 newECMA6add.js:4:59 */

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

Теперь сравним с let-варинтом без IIFE

let func1 = [];  for (let i = 0; i < 3; i++) { 	func1.push(function() { console.log(i); }); 	++i; }  func1.forEach(function(func) { func(); });  /* В консоли получим  1 newECMA6add.js:4:37 3 newECMA6add.js:4:37 */

Результат, как видно, изменился, и, природа этого изменения, в том, что мы не вызывали функцию со значением сразу, а функция взяла значения имеющиеся в замыканиях на конкретных итерациях цикла.

Чтобы глубже понять суть происходящего, рассмотрим примеры с двумя массивами. И для начала, возьмём var, без IIFE:

let func1 = [], 	func2 = [];  for (var i = 0; i < 3; i++) { 	func2.push(function() { console.log(++i); }); 	func1.push(function() { console.log(++i); }); 	++i; }  func1.forEach(function(func) { func(); }); func2.forEach(function(func) { func(); });  /* В консоли получим  5 newECMA6add.js:6:37 6 newECMA6add.js:6:37 7 newECMA6add.js:5:37 8 newECMA6add.js:5:37  */

Здесь всё пока очевидно — замыкания нет (хотя можно сказать, что и есть, но на глобальную область видимости, хоть это и не вполне корректно, так как доступ к i есть по сути везде) т. е., аналогично, но с локальной областью видимости, переменной i будет подобная запись:

let func1 = [], 	func2 = [];  function test() { 	for (var i = 0; i < 3; i++) { 		func2.push(function() { console.log(++i); }); 		func1.push(function() { console.log(++i); }); 		++i; 	} } test();  func1.forEach(function(func) { func(); }); func2.forEach(function(func) { func(); });  /* В консоли также получим  5 newECMA6add.js:7:41 6 newECMA6add.js:7:41 7 newECMA6add.js:6:41 8 newECMA6add.js:6:41  */

В обоих примерах происходит следующее:

1. В начале последней итерации цикла i == 2, затем инкрементируется на 1 (++i), и в конце добавляется еще 1 от i++, В результате на конец всего цикла i == 4.

2. Поочередно вызываются функции находящиеся в массивах func1 и func2, и в каждой из них последовательно инкрементируется одна и та же переменная i, находящаяся в замыкании относительно своей области видимости, что особенно заметно, когда мы имеем дело не с глобальной переменной, а локальной.

Добавим IIFE.
Первый вариант:

let func1 = [], 	func2 = [];  for (var i = 0; i < 3; i++) { 	func2.push((function(i) { return function() { console.log(++i); } })(i)); 	func1.push((function(i) { return function() { console.log(++i); } })(i)); 	++i; }  func1.forEach(function(func) { func(); }); func2.forEach(function(func) { func(); });  /* В консоли получим  1 newECMA6add.js:6:56 3 newECMA6add.js:6:56 1 newECMA6add.js:5:56 3 newECMA6add.js:5:56 */

Второй вариант:

let func1 = [], 	func2 = [];  for (var i = 0; i < 3; i++) { 	func2.push((function(i) { return function() { console.log(i); } })(++i)); 	func1.push((function(i) { return function() { console.log(i); } })(++i)); 	++i; }  func1.forEach(function(func) { func(); }); func2.forEach(function(func) { func(); });  /* В консоли получим  2 newECMA6add.js:6:56 1 newECMA6add.js:5:56 */

При добавлении IIFE в первом случае мы просто вызвали зафиксированные значения i в function(i) (0 и 2, при первом и втором проходе цикла соответственно), и инекрементировали их на 1, каждая функция отдельно от другой, так как здесь замыкание на общую переменную цикла отсутствует, ввиду того, что значение i было переданно немедленно при проходах цикла. Во втором случае также нет замыкания, но там значение передавалось с одновременной инкременцией, поэтому на конец первого прохода i == 4, и, цикл дальше не пошёл. Но, обращаю внимание, на то, что отдельные замыкания для каждой функции всё также присутствуют в первом варианте:

let func1 = [], 	func2 = [];  for (var i = 0; i < 3; i++) { 	func2.push((function(i) { return function() { console.log(++i); } })(i)); 	func1.push((function(i) { return function() { console.log(++i); } })(i)); 	++i; }  func1.forEach(function(func) { func(); }); func2.forEach(function(func) { func(); }); func1.forEach(function(func) { func(); }); func2.forEach(function(func) { func(); });  /* В консоли получим  1 newECMA6add.js:6:56 3 newECMA6add.js:6:56 1 newECMA6add.js:5:56 3 newECMA6add.js:5:56 2 newECMA6add.js:6:56 4 newECMA6add.js:6:56 2 newECMA6add.js:5:56 4 newECMA6add.js:5:56 */

прим.: даже если обрамить цикл функцией, общими, замыкания, естественно не станут.

Теперь же рассмотрим инструкцию let, без IIFE соответственно.

let func1 = [], 	func2 = [];  for (let i = 0; i < 3; i++) { 	func2.push(function() { console.log(++i); }); 	func1.push(function() { console.log(++i); }); 	++i; }  func1.forEach(function(func) { func(); }); func2.forEach(function(func) { func(); });  /* В консоли получим  2 newECMA6add.js:6:41 4 newECMA6add.js:6:41 3 newECMA6add.js:5:41 5 newECMA6add.js:5:41 */

А вот здесь, у нас опять образовалось замыкание, и не одно, а два, и не отдельные, а общие, что логично, учитывая известный принцип работы let в циклах.

В итоге мы имеем, что в первом замыкании, до вызова функций находящихся в массивах, значение i == 1, а во втором i == 3. Это значения, которые получила переменная i, до i++ и итерации цикла, но после всех инструкций в блоке цикла, и, они замыкаются по каждой конкретной итерации.

Далее вызываются функции находящиеся в массиве func1 и они инкрементируют соответствующие переменные в обоих замыканиях и в результате в первом i == 2, а во втором i == 4.

Последующий вызов func2 инкрементирует дальше и получает i == 3 и 5 соответственно.

Я специально поставил func2 и func1 внутри блока в таком порядке, чтобы была наглядней видна независимость от их расположения, и, чтобы подчеркнуть внимание читателя именно на факте замыкания.

Напоследок приведу тривиальный пример направленный на закрепление понимания замыканий и области видимости let :

let func1 = [];  { 	let i = 0; 	func1.push(function() { console.log(i); }); 	++i; }  func1.forEach(function(func) { func(); });  console.log(i);  /* 1 newECMA6add.js:5:34 ReferenceError: i is not definednewECMA6add.js:10:1 */

Что мы имеем в итоге

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

2. Из-за того, что при использовании let объявление для итератора в каждой итерации создаётся отдельная локальная переменная, и, встаёт вопрос об утилизации ненужных данных сборщиком мусора. На этом пункте, признаться я и хотел изначально заострить внимание, подозревая, что создание большого количества переменных в больших, соответственно, циклах будет тормозить работу компилятора, однако, при сортировке тестового массива с использованием только let объявлений переменных показало выигрыш по времени выполнения почти в два раза для массива в 100000 ячеек:

Вариант с var:

const start = Date.now(); let arr = [], 	func1 = [], 	func2 = [];  for (var i = 0; i < 100000; i++) { 	arr.push(Math.random()); }  for (var i = 0; i < 99999; i++) { 	var min = arr[i]; 	var minind = i; 	for (var j = i + 1; j < 100000; j++) { 		if (min > arr[j]) { 			min = arr[j]; 			minind = j; 		} 	} 	if (min != arr[i]) { 		var temp = arr[i]; 		arr[i] = arr[minind]; 		arr[minind] = temp; 		func1.push((function(i) { return function() { return i; } })(arr[i])); 	} }  for (var i = 0; i < 10000; i++) { 	func2.push(func1[i]()); }  const end = Date.now();  console.log((end - start)/1000); // 9.847

И вариант с let:

const start = Date.now(); let arr = [], 	func1 = [], 	func2 = [];  for (let i = 0; i < 100000; i++) { 	arr.push(Math.random()); }  for (let i = 0; i < 99999; i++) { 	let min = arr[i]; 	let minind = i; 	for (let j = i + 1; j < 100000; j++) { 		if (min > arr[j]) { 			min = arr[j]; 			minind = j; 		} 	} 	if (min != arr[i]) { 		let temp = arr[i]; 		arr[i] = arr[minind]; 		arr[minind] = temp; 		func1.push(function() { return arr[i]; }); 	} }  for (var i = 0; i < 10000; i++) { 	func2.push(func1[i]()); }  const end = Date.now();  console.log((end - start)/1000); // 5.3

При этом время выполнения практически не зависело от наличия/отсутствия инструкций:

с IIFE

func1.push((function(i) { return function() { return i; } })(arr[i]));

либо

без IIFE

func1.push(function() { return arr[i]; });

и

вызова функций

for (var i = 0; i < 10000; i++) { 	func2.push(func1[i]()); }

прим.: понимаю, что информация по быстродействию не нова, но для полноты картины я думаю эти два примера стоило привести.

Из всего этого можно сделать вывод о том, что применение let объявлений вместо var, в приложениях не требующих обратной совместимости с более ранними стандартами более чем оправдано, особенно в случаях с циклами. Но, при этом стоит помнить об особенностях поведения в ситуациях с замыканиями и, при необходимости продолжать использовать выражения немедленно вызываемых функций.


ссылка на оригинал статьи https://habr.com/ru/post/462971/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *