Область видимости и замыкания в JavaScript

Тема довольно объемная и я не претендую на полное eё раскрытие в этой статье. Если вы хотите разобраться подробней, то искренне рекомендую вам книгу: Кайл Симпсон «Область видимости и замыкания». Я был и научен и вдохновлен этой книгой. Все ссылки на ресурсы и книгу смотрите в конце.

Область видимости

Область видимости в JS — это любая область в коде, которая содержит именованные сущности (переменные, классы, функции) и определяет их доступность из разных частей кода.

На следующем примере есть две области видимости — глобальная и область внутри функции:

// Глобальная область видимости

let number = 213;

function printNumber() {
  // Область видимости функции printNumber
  let color = "#f5f5f5"
  console.log(number + number)
}

console.log(number)
console.log(printNumber())

// Переменна color не доступна в глобальной области видимости
// Она объявлена локально внутри функции printNumber
console.log(color)

Переменная number объявлена в глобальной области видимости и поэтому доступна и внутри функции printNumber. Сама функция printNumber объявлена так же в глобальной области видимости и мы свободно ее вызываем.

Переменная color объявлена внутри функции printNumber и поэтому она не доступна в глобальной области видимости. Попытка обратиться к переменной color вне функции printNumber приведет к ошибке.

В итоге при запуске скрипта наблюдаем такую картину:

Ошибка при запуске скрипта

Ошибка при запуске скрипта

Пример выше демонстрирует, что мы получили: значение переменной number, результат сложения внутри функции и undefined, так как функция ничего не возвращает. При попытке обратиться к переменной color консоль нам сообщает, что такая переменная не найдена. Такая ошибка универсальна. То есть если в нашем коде вообще не будет переменной color, содержание ошибки не изменится. В нашем случая можно сказать, что переменная не найдена в глобальной области видимости, но вообще в коде она присутствует.

Но почему тогда мы можем обратиться к переменной number находясь в другой области видимости, но не можем таким же образом обратиться к переменной color? Для начала изменим немного имеющийся код и добавим в него цвета.

Графическое разделение на области видимости

Графическое разделение на области видимости

Код с картинки

// Глобальная область видимости

let number = 213;

function printNumber(operand) {
  // Область видимости функции printNumber
  let color = "#f5f5f5"
  console.log(number + operand)

  function printColor() {
    // Область видимости функции printColor
    console.log(color)
  }

  printColor()
}

console.log(number)
console.log(printNumber(312))

// Переменна color не доступна в глобальной области видимости
console.log(color)

// Функция printColor не доступна в глобальной области видимости
console.log(printColor())

Здесь я добавил параметр для функции printNumber, использовал значение параметра при сложении и добавил функцию printColor внутри функции printNumber. Каждый цвет на картинке обозначает свою область видимости. Отсюда можно донести простую мысль: каждая вложенная область видимости имеет доступ ко всем внешним областям видимости. В свою очередь каждая внешняя область видимости не имеет доступа к вложенным областям. Получается что:

  • Оранжевая имеет доступ к розовой и зеленой

  • Розовая имеет доступ к зеленой

  • Зеленая (глобальная) не имеет внешней области видимости

Ели вы обратили внимание, область видимости функции начинается с момента объявления параметра. То есть когда мы передаём аргумент в функцию, в нашем случае printNumber(312), то этот аргумент присваивается параметру operand и становится доступен только внутри функции.

Замыкание

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

Для начала скромный пример:

function main() {
  let say = "I'am secondary function"

  function secondary() {
    console.log(say)
  }

  return secondary
}

const hi = main()
hi()

Ничего особенного не происходит. Мы возвращаем функцию secondary из функции main. Функция secondary использует переменную say, которая была объявлена в области видимости функции main. В конце мы сохраняем результат вызова функции main в переменную hi. Так как результат вызова функции main — это функция, то переменная hi тоже становится функцией. Вызываем функцию hi и видим в консоли значение переменной say.

Функция hi является всего лишь ссылкой на функцию secondary. Вся красота в том, что до того как произойдет вызов hi(), функция main уже завершит выполнение. Если не вдаваться в подробности, а читать просто что написано, то следуя примитивной логике можно сказать, что вызов функции hi должен привести к ошибке, так как функция main уже завершила свою работу и переменной say больше не существует. Вот тут мы и попадаем в замыкание. Учитывая пример можно сформулировать определение.

Замыкание — это способность функции запоминать своё окружение и взаимодействовать с ним в процессе выполнения кода, даже если эта функция вызывается вне того окружения, в котором была объявлена.

В нашем случае функция secondary замыкается на окружении функции main, так как использует переменную say, которая объявлена во внешнем окружении (функции main) относительно функции secondary.

Даже если мы не будем возвращать функцию secondary, а сразу вызовем ее внутри main, а потом вызовем и саму main, то это всё равно будет замыканием. Нам не обязательно возвращать замыкающуюся функцию.

Пример без возврата функции

function main() {
  let say = "I'am secondary function"

  function secondary() {
    console.log(say)
  }

  secondary()
}

main()

Примеры использования замыканий

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

Приведу несколько примеров, которые я реши привязать к простым игровым механикам.

Инкапсуляция

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

function inventory() {
    let items = []
    return {
        pishItem(item) {
            if (items.length === 10) {
                console.log('The number of items in the inventory has reached its maximum')
                return
            }
            items.push(item)
        },
        removeItem(item) {
            if (items.length === 0 || !items.includes(item)) {
                console.log('This item is not in the inventory')
                return
            }
            items.splice(items.indexOf(item), 1);
        },
        getItems() {
            console.log(items)
        }
    }
}

const actionsInventory = inventory()

actionsInventory.pishItem('book')
actionsInventory.pishItem('sword')
actionsInventory.pishItem('apple')
actionsInventory.pishItem('armor')

actionsInventory.removeItem('sword')

actionsInventory.getItems()

При первом чтении может выглядеть трудно, но по идее я модифицировал популярный пример счетчика, который инкапсулирует переменную count:

Простой счетчик

function counter() {
    let count = 0
    return function choice(action) {
        if (action === '+') {
            count++
        } else {
            count--
        }
        console.log(count)
    }
}

const changeCounter = counter()

changeCounter('+') // 1
changeCounter('-') // 0
changeCounter('+') // 1

В основном примере мы возвращаем объект с набором методов вместо одной функции. Каждая функция замыкается на массиве items и совершает по одному действию. В итоге мы помещаем в инвентарь 4 предмета, затем один предмет удаляем и выводим весь список оставшихся предметов. Таким образом мы инкапсулировали массив items, при этом создали ограниченный набор действий для пользователя.

Callback

Функция callback — это функция, переданная в другую функцию в качестве аргумента, которая затем вызывается внутри внешней функции для выполнения какого-то действия.

Представим что в нашей неизвестной игре есть три вида сундуков: золотой, серебряный, бронзовый. В зависимости от типа сундука у игрока либо повышается, либо уменьшается шанс получить ресурсы.

function openChest(chestType, callback) {
    console.log(`Открытие ${chestType} сундука...`);
    setTimeout(() => {
        let treasure;
        if (chestType === "золотой") {
            treasure = Math.random() > 0.3 ? "Ресурсы" : "ничего"; // 70% шанс найти ресурсы
        } else if (chestType === "серебряный") {
            treasure = Math.random() > 0.5 ? "Ресурсы" : "ничего"; // 50% шанс найти ресурсы
        } else {
            treasure = Math.random() > 0.7 ? "Ресурсы" : "ничего"; // 30% шанс найти ресурсы
        }

        if (treasure !== "ничего") {
            callback(null, `Вы нашли ${treasure}!`);
        } else {
            callback("Сундук пустой");
        }
    }, 1000);
}

function startGame() {
    openChest("золотой", (empty, result) => {
        if (empty) {
            console.log(empty);
        } else {
            console.log(result);
        }
    });
}

startGame();

Давайте начнем с момента вызова функции startGame. Внутри функции startGame вызывается функции openChest, аргументами которой становятся тип сундука и анонимная функция. Эта анонимная функция представляет callback функцию, которая замыкается на переменной treasure. Внутри функции openChest мы имитируем асинхронный вызов. Вызов происходит в момент условной активации сундука игроком. В нашем коде setTimeot отвечает за расчет награды и вот когда расчет заканчивается, срабатывает наш callback, который сообщает о результате расчета.

Для понимая этого кода может понадобится чуть больше времени, чем для предыдущего примера и это нормально.

Мемоизация

Для начала давайте выясним что это такое в контексте разработки и рассмотрим базовый пример.

Мемоизация — это процесс проверки и кэширования вычисленных ранее значений, для предотвращения повторных вычислений

Классический пример:

function memoize(fn) {
    const cache = new Map();
    
    return function(...args) {
        const key = JSON.stringify(args);
        if (cache.has(key)) {
            console.log('Кэшированный результат:', cache.get(key))
            return cache.get(key);
        }
        
        const result = fn(...args);
        cache.set(key, result);
        return result;
    };
}

Давайте разберем пример построчно, прежде чем рассмотреть версию, применимую на практике.

2 >> Создаём коллекцию cash

5 >> Сохраняем массив переданных аргументов в переменной key

6 >> Проверяем, вызывалась ли ранее функция с такими же аргументами

7, 8 >> Если вызывалась, то возвращаем кэшированный результат

9 >> Если расчет с такими аргументами еще не производился, то прокидываем аргументы дальше в функцию, выполняющую расчеты.

10 >> Кэшируем массив аргументов и результат расчетов

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

// Функция поиска пути
function findPath(start, goal, grid) {
    // Этот код будет включать логику для нахождения пути от start до goal.
    // Для упрощения примера оставим функциию пустой
    
    // ...
    
    // Возвращаем путь (список координат от start до goal)
    return [start, goal];
}

// Функция мемоизации
function memoize(fn) {
    const cache = new Map();
    
    return function(...args) {
        const key = JSON.stringify(args);
        if (cache.has(key)) {
            console.log('Кэшированный результат:', cache.get(key))
            return cache.get(key);
        }
        
        const result = fn(...args);
        cache.set(key, result);
        return result;
    };
}

const memoizedFindPath = memoize(findPath);

const start = [0, 0];
const goal = [5, 5];
const grid = [
    // Представление игровой сетки (например, двумерный массив)
];

const path1 = memoizedFindPath(start, goal, grid);
console.log(path1); // "Путь от [0, 0] до [5, 5]"

// Повторный вызов findPath с теми же параметрами - результат будет взят из кэша
const path2 = memoizedFindPath(start, goal, grid);
console.log(path2); // "Путь от [0, 0] до [5, 5]"

// Вызов findPath с другими параметрами - результат будет вычислен и кэширован
const newStart = [1, 1];
const newGoal = [6, 6];
const path3 = memoizedFindPath(newStart, newGoal, grid);
console.log(path3); // "Путь от [1, 1] до [6, 6]"

Здесь может случиться недопонимание со строки 29. Происходит следующее: в переменную memoizedFindPath мы сохраняем результат выполнения функции memoize. Результат функции memoize можно продемонстрировать следующим образом:

// ...

const memoizedFindPath = function(...args) {
  const key = JSON.stringify(args)
  if (cache.has(key)) {
      console.log('Кэшированный результат:', cache.get(key))
      return cache.get(key);
  }
        
  const result = fn(...args);
  cache.set(key, result);
  return result;
};

Разница лишь в том, что в изначальном примере, функция, которую возращает memoize и которая сохраняется в memoizedFindPath замыкается на переменной cache и на переданной в качестве аргумента функции fn. Функция, которую принимает memoize (findPath), является вычислительной функцией и результат ее вычислений сохраняется в коллекцию cache. Сама функция fn замыкается на аргументах, которые будут переданы в созданную нами функцию memoizedFindPath.

То есть:

  1. В memoize мы передаём аргумент в виде функции для вычислений и возвращаем функцию, которая проверяет входящие значения в виде аргументов значения и предотвращает повторные вычисления. Грубо говоря, memoize является неким сервером, для постоянного доступа к cache.

  2. Теперь у нас есть функция memoizedFindPath, которая проверяет проверяет входящие аргументы и исходя из результатов проверки следует одному из вариантов:
    a. Если на основе переданных аргументов уже был получен результат, то memoizedFindPath вернет этот результат без повторных вычеслений.
    b. Если на основе переданных аргументов вычисления не производились, то memoizedFindPath вызовет вычислительную функцию, сохранит результат вычислений в переменную result, сохранит result в коллекцию cache в качестве значения и вернет переменную result.

Итоги

Оставлю ссылку на книгу Кайл Симпсон «Область видимости и замыкания»

Также мне нравится статья на доке

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

Специалистов, которые умеют писать базовый (поверхностный) код, очень много. А людей, которые умеют решать сложные задачи — значительно меньше.

Благодарю за прочтение, призываю к конструктивной критике и обсуждению в комментах.

Всем мир!

© Habrahabr.ru