Написать данную заметку меня сподвигло прочтение статьи на Хабре «Var, let или const? Проблемы областей видимости переменных и ES6» и комментариев к ней, а также соответствующей части книги Закаса Н. «Understanding of ECMAScript 6». Исходя из прочитанного я вынес, что не всё так однозначно в оценке использования var или let. Авторы и комментаторы склоняются к тому, что при отсутствии необходимости поддержки старых версий браузеров имеет смысл полностью отказаться от использования var, а также использовать некоторые упрощенные конструкции, заместо старых, по умолчанию.
Про области видимости этих объявлений уже сказано достаточно, в том числе и в указанных выше материалах, поэтому я хотел бы заострить внимание только на некоторых неочевидных моментах.
Для начала хотелось бы рассмотреть выражения немедленно вызываемых функций (Immediately Invoked Function Expression, IIFE) в циклах.
или можно обойтись без них используя let:
Закас Н. утверждает, что оба подобных примера выдавая один и тот же результат при этом также и работают абсолютно одинаково:
Дело в том, что каждая итерация цикла при использовании let создает отдельную локальную переменную i и при этом привязка в функциях отправленных в массив идет также по отдельным переменным (областям видимости) в каждой итерации.
В данном конкретном случае, результат действительно не отличается, но, что если мы немного усложним код?
Здесь, добавив ++i наш результат оказался вполне предсказуем, так как мы вызвали функцию со значениями i, актуальными на момент вызова ещё при проходах самого цикла, поэтому последующая операция ++i не повлияла на значение переданное функции в массиве, так как оно было передано в замыкание с помощью вызова внешней function(i) и оказалось связанно с полученным ею аргументом.
Теперь сравним с let-варинтом без IIFE
Результат, как видно, изменился, и, природа этого изменения, в том, что значение используемое в замыкании теперь связано не с областью видимости охватывающей функции, как до этого, а с областью относящейся к конкретному проходу цикла for. И на момент вызова функции через оператор Array.prototype.forEach() в этой области значение i уже было увеличено на 1 следующей за добавлением функции в массив инструкцией ++i.
Чтобы глубже понять суть происходящего, рассмотрим примеры с двумя массивами. И для начала, возьмём var, без IIFE:
Здесь всё пока очевидно — замыканий нет, т. е., аналогичной, но с замыканием, будет подобная запись:
В обоих примерах происходит следующее:
1. В начале последней итерации цикла i == 2, затем инкрементируется на 1 (++i), и в конце добавляется еще 1 от i++, В результате на конец всего цикла i == 4.
2. Поочередно вызываются функции находящиеся в массивах func1 и func2, и в каждой из них последовательно инкрементируется одна и та же переменная i, захваченная по ссылке ими обеими из внешней области видимости (глобальной в первом варианте и function test() во втором).
Добавим IIFE.
Первый вариант:
При добавлении IIFE в первом случае мы вызвали зафиксированные значения i из области видимости function(i) (0 и 2, при первом и втором проходе цикла соответственно), и инкрементировали их на 1, каждая функция отдельно от другой, так как здесь нет захвата общей переменной, ввиду того, что значение i было переданно немедленно, как аргумент функции вернувшей замыкания, при соответствующих проходах цикла. Во втором случае общее связывание также отсутствует, но тут значение передается с одновременной инкременцией, поэтому на конец первого прохода i == 4, и, цикл дальше не пошёл. Но, обращаю внимание, на то, что в замыканиях связывания переменных всё также присутствуют как в первом так и втором вариантах. Например:
Теперь же рассмотрим инструкцию let, без IIFE соответственно.
А вот здесь, у нас образовались замыкания со связанными переменными цикла, и не отдельными, а общими.
В итоге мы имеем, что в первой области видимости, до вызова, значение i == 1, а во второй i == 3. Это значения, которые получила переменная i, до i++ в конце соответствующих итераций цикла, но после всех инструкций в блоке цикла.
Далее вызываются функции находящиеся в массиве func1 и они инкрементируют соответствующие переменные в обеих областях видимости и в результате в первой i == 2, а во второй i == 4.
Последующий вызов func2 инкрементирует те же переменные дальше и получает i == 3 и 5 соответственно.
Я специально поставил func2 и func1 внутри блока в таком порядке, чтобы была наглядней видна независимость от их расположения, и, чтобы подчеркнуть внимание читателя именно на факте захвата переменных цикла.
Напоследок приведу тривиальный пример направленный на закрепление понимания замыканий и области видимости let :
1. Задействование выражений немедленно вызываемых функций не является эквивалентным использованию итерируемых let переменных в функциях в циклах, и, в ряде случаев, приводит к различному результату.
2. Из-за того, что при использовании let объявления для итератора в каждой итерации создаётся отдельная локальная переменная, встаёт вопрос об утилизации ненужных данных сборщиком мусора. На этом пункте, признаться, я и хотел изначально заострить внимание, подозревая, что создание большого количества переменных в больших, соответственно, циклах будет тормозить работу компилятора, однако, при сортировке тестового массива с использованием только let объявлений переменных показало выигрыш по времени выполнения почти в два раза для массива в 100000 ячеек (по данной теме есть отдельная статья – см. ниже ссылку в комментарии – и там поясняется, что рассмотренный тест и его результаты имеют специф��ческую природу):
При этом время выполнения практически не зависело от наличия/отсутствия инструкций:
прим.: понимаю, что информация по быстродействию не нова, но для полноты картины я думаю эти два примера стоило привести.
Из всего этого можно сделать вывод о том, что несмотря на то, что применение let объявлений вместо var, в приложениях не требующих обратной совместимости с более ранними стандартами вполне оправданно, при этом стоит помнить об особенностях поведения в ситуациях с замыканиями в циклах for и, при необходимости продолжать использовать выражения немедленно вызываемых функций.
Про области видимости этих объявлений уже сказано достаточно, в том числе и в указанных выше материалах, поэтому я хотел бы заострить внимание только на некоторых неочевидных моментах.
Для начала хотелось бы рассмотреть выражения немедленно вызываемых функций (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) и оказалось связанно с полученным ею аргументом.
Теперь сравним с 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 */
Результат, как видно, изменился, и, природа этого изменения, в том, что значение используемое в замыкании теперь связано не с областью видимости охватывающей функции, как до этого, а с областью относящейся к конкретному проходу цикла for. И на момент вызова функции через оператор Array.prototype.forEach() в этой области значение i уже было увеличено на 1 следующей за добавлением функции в массив инструкцией ++i.
Чтобы глубже понять суть происходящего, рассмотрим примеры с двумя массивами. И для начала, возьмём 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 */
Здесь всё пока очевидно — замыканий нет, т. е., аналогичной, но с замыканием, будет подобная запись:
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, захваченная по ссылке ими обеими из внешней области видимости (глобальной в первом варианте и function test() во втором).
Добавим 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 */
А вот здесь, у нас образовались замыкания со связанными переменными цикла, и не отдельными, а общими.
В итоге мы имеем, что в первой области видимости, до вызова, значение 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(); var arr = [], func1 = [], func2 = []; for (var i = 0; i < 100000; i++) { arr.push(Math.random()); } for (var i = 0; i < 99999; i++) { var min, minind = i; for (var j = i + 1; j < 100000; j++) { if (arr[minind] > arr[j]) minind = j; } min = arr[minind]; arr[minind] = arr[i]; arr[i] = min; func1.push(function(i) { return function() { return i; } }(arr[i])); } func1.push(function(i) { return function() { return i; } }(arr[99999])); for (var i = 0; i < 100000; 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, minind = i; for (let j = i + 1; j < 100000; j++) { if (arr[minind] > arr[j]) minind = j; } min = arr[minind]; arr[minind] = arr[i]; arr[i] = min; func1.push(function() { return arr[i]; }); } func1.push(function() { return arr[99999]; }); for (let i = 0; i < 100000; 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 < 100000; i++) { func2.push(func1[i]()); }
прим.: понимаю, что информация по быстродействию не нова, но для полноты картины я думаю эти два примера стоило привести.
Из всего этого можно сделать вывод о том, что несмотря на то, что применение let объявлений вместо var, в приложениях не требующих обратной совместимости с более ранними стандартами вполне оправданно, при этом стоит помнить об особенностях поведения в ситуациях с замыканиями в циклах for и, при необходимости продолжать использовать выражения немедленно вызываемых функций.
