Замыкания на практике: счётчики и приватность
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Замыкание перестаёт быть головоломкой, когда видишь, зачем оно нужно: спрятать данные, собрать функцию по частям, не пересчитывать одно и то же дважды. Здесь — пять приёмов, которые встречаются в живом коде каждый день, и одна ловушка с памятью.
Обычная переменная внутри функции живёт ровно до конца вызова. Но если функция вернёт другую функцию, которая эту переменную читает, — переменная останется жить, а снаружи к ней не будет ни одного пути. Это и есть приватность: не соглашение «не трогайте», а физическая невозможность.
Обратите внимание на c.count: свойства с таким именем у объекта просто нет. Мы не прятали его подчёркиванием и не писали #private — переменная осталась в области видимости, которую объект не показывает.
Каждый вызов фабрики создаёт новое замыкание со своей переменной. Счётчики не мешают друг другу — это ключевое свойство, из-за которого приём вообще работает.
Фабрика — та же идея, только запомненная переменная не меняется, а задаёт поведение. Вы пишете общий алгоритм один раз, а наружу отдаёте набор специализированных функций.
double и triple — один и тот же код с разной памятью. Так удобно готовить форматтеры, валидаторы, логгеры: настройку передали один раз, дальше вызываете короткой функцией без лишних аргументов.
Замыкание — не магия. Функция просто помнит, где она родилась, и держит эту комнату открытой.
У var нет блочной области видимости: на весь цикл существует одна переменная, и все созданные функции запомнили именно её. У let на каждой итерации своя переменная — значит, и своё замыкание. Подробный разбор — в статье «Замыкания: как функция помнит своё окружение»; здесь ловушка рядом, чтобы её было видно на месте.
Частичное применение — это фабрика, доведённая до общего вида: мы запоминаем несколько первых аргументов, а остальные примем позже. Запомненные значения живут в замыкании возвращённой функции.
Каррирование — родственный приём: функция от нескольких аргументов превращается в цепочку функций от одного. Каждое звено цепочки помнит свой аргумент, и результат появляется только в конце.
Промежуточные звенья вроде add10 — обычные значения: их можно передавать, складывать в массив, вызывать позже. Именно поэтому такой стиль удобен там, где функции передают друг другу: map, обработчики событий, конвейеры.
Если функция для одного и того же аргумента всегда даёт один и тот же ответ, ответ можно запомнить. Кэш держим в замыкании — он не мешает никому снаружи и не может быть случайно затёрт.
Счётчик внутри фабрики — доказательство: настоящее вычисление произошло один раз, остальные два вызова вернули готовое. Ни до кэша, ни до счётчика снаружи не дотянуться: они остались в замыкании, наружу торчат только две функции. Важное условие — функция должна быть чистой: мемоизировать что-то, зависящее от времени или от сети, нельзя, вы законсервируете старый ответ.
Чтобы кэшировать любую функцию, ключом берём все аргументы. store в параметрах по умолчанию создаётся один раз — при вызове memoize — и дальше живёт в замыкании обёртки.
Ключ через JSON.stringify прост, но не всесилен: объекты с разным порядком полей дадут разные ключи, а функции и undefined в аргументах потеряются. Для двух-трёх примитивов этого хватает.
Инициализация, отправка формы, показ подсказки — есть действия, которые обязаны случиться ровно один раз, сколько бы раз их ни попросили. Обёртка хранит в замыкании флаг и готовый результат.
Вместо отдельного флага мы используем саму переменную fn: после первого вызова в ней null, проверка if (fn) больше не проходит. Заодно исходная функция освобождается — ссылки на неё в замыкании не остаётся.
Второй вызов вернул «старт 1», а не «старт 2», и это не ошибка: аргументы второго вызова просто игнорируются. Если такое поведение вас не устраивает — верните undefined или бросьте ошибку, решение за вами.
Пока жива функция, живо и всё, до чего она может дотянуться. Обычно это ровно то, что нужно, — но иногда в памяти застревает мегабайт, который вам уже не нужен.
Лечится это скучно и надёжно: доставайте из большого объекта то немногое, что нужно, и запоминайте только его. Тогда после выхода из фабрики массив никем не удерживается и сборщик мусора его заберёт.
Тот же счёт применим к мемоизации: кэш внутри замыкания растёт бесконечно, если ключи всё время новые. Для долгоживущих функций ограничивайте размер кэша или чистите его — иначе оптимизация превратится в утечку.
- Замыкание делает переменную приватной физически: снаружи к ней нет пути, только через возвращённые функции.
- Каждый вызов фабрики создаёт новое замыкание — счётчики и настроенные функции не мешают друг другу.
- Частичное применение и каррирование хранят зафиксированные аргументы в замыкании и отдают готовую к вызову функцию.
- Мемоизация прячет кэш внутри функции; работает только для чистых функций и требует присмотра за размером кэша.
- Пока жива функция, живо всё, до чего она дотягивается: запоминайте нужное значение, а не весь объект, из которого его достали.
1/3Что выведет код? function makeCounter() { let n = 0; return () => ++n; } const a = makeCounter(); const b = makeCounter(); a(); a(); console.log(a(), b());
2/3Что выведет цикл с var? const fns = []; for (var i = 0; i < 3; i++) fns.push(() => i); console.log(fns[0](), fns[1](), fns[2]());
3/3Сколько раз выполнится тело исходной функции? let n = 0; const init = once(() => { n += 1; return "ok"; }); init(); init(); init(); console.log(n);
Счётчик с приватным состоянием
Напишите фабрику makeCounter(start), которая возвращает объект с методами up(), down(), reset() и value(). Значение должно храниться так, чтобы снаружи его нельзя было прочитать или изменить напрямую — только через методы. Два разных счётчика не должны влиять друг на друга.
Переменные n и start остаются в области видимости фабрики: методы их видят, внешний код — нет. reset возвращает не ноль, а исходное значение, поэтому start тоже нужно запомнить.
Ключевая проверка — c.n даёт undefined: у объекта такого свойства нет, значение живёт только в замыкании. Каждый вызов makeCounter создаёт свою переменную n, поэтому счётчики независимы.
Мемоизация с доказательством
Напишите обёртку memoize(fn) для функции одного аргумента: повторный вызов с тем же аргументом должен вернуть сохранённый результат, не выполняя тело fn. Докажите это счётчиком вызовов — как в разделе про мемоизацию.
Кэш создаётся один раз внутри memoize и остаётся жить в замыкании возвращённой функции. Проверять наличие ключа нужно через has, а не через истинность значения: иначе закэшированные 0, "" или undefined будут считаться промахом и пересчитываться каждый раз.
calls равен двум — по одному настоящему вычислению на каждый новый аргумент. Помните про обратную сторону: этот Map будет расти, пока жива функция fast.