Приветствуют читателей Хабра. Меня зовут Олег Иванов, последние пару лет являюсь фронтенд-разработчиком в ит-компании WMT Group. В этой статье не будет серьёзных рассуждений, глобальных задач и историй успеха. Скандалов, интриг и расследований из глобального ит-мира тоже не планируется.
Вместо этого мы напишем функцию-счётчика на JavaScript. Несколько раз, причём всякий раз по-разному с пользой.
Казалось бы, задача простейшая, даже старый-добрый to-do list куда сложнее и заковыристее. Предложение написать функцию-счётчика на собеседованиях уже классика, которая делается на автомате. Уверен, что большинство опытных воинов фронтенда либо помнят традиционное решение, либо по памяти накатают его в течение 30 секунд. Традиционно эту задачу решают замыканием. Однако, почему именно им? А если попробовать сделать это как-то иначе? Я попробовал и реализовал счётчик несколькими разными (подчас неочевидными) способами, попутно спросив совета у нейросетей, а также подключив тяжёлую артиллерию в лице Babel, TypeScript и даже WebAssembly. Давайте посмотрим, что получилось!
Формулировка задачи
Начнем с базы — сформулируем, а что мы собственно хотим.
Необходимо создать функцию counter, при первом вызове отдающую 0, а при каждом последующем значение на 1 больше предыдущего.
Дополнительное важное условие: не использовать в коде внешних библиотек, сделав всё своими силами. Так что всевозможные стейт-менеджеры и прочие хэлперы из бездн npm нам сегодня не пригодятся - справимся своими силами. Приступим!
Unit-тест
В лучших традициях Test-Driven Development (который я уважаю, но, откровенно говоря, использую далеко не всегда), напишем тест-спецификацию, который определит, что мы разработали функцию, удовлетворяющую исходным условиям. Условия у нас всего 2 - 0 при первом вызове и увеличение значения на 1 при каждом последующем:
describe('Тестирование функции счётчика', () => {it('Первый вызов выдаёт 0', () => {const firstTimeCalled = counter();expect(firstTomeCalled).toEqual(0);});it('Каждое новый вызов функции увеличивает её значение на 1', () => {const calledFirstValue = counter();const calledSecondValue = counter();expect(calledSecondValue - calledFirstValue).toEqual(1);});})
Ну вот и всё. Давайте же попробуем сделать тест зелёным!
Возможные решения
Разумеется, умудрённые опытом фронтендеры на инстинктах сразу же вспомнят о замыканиях и решат эту задачу с их использованием. Но давайте предположим, что мы начинаем решать задачу, не зная о них.
Что же нам нужно для решения? Необходимо как-то сохранять состояние вызываемой функции. И способов тут может быть много. Давайте попробуем найти столько, сколько сможем.
В лоб — глобальная переменная
let i = 0;function counter() {return i++}
Начнём с самого очевидного - глобальная переменная. Что интересно, искусственный интеллект решает эту задачу примерно так же. Вот так мне ответил You.com
Разумеется, вариант так себе. Любой более-менее разбирающийся в программировании человек знает, что глобальные переменные это чаще всего плохо и явное зло (но не всегда знает почему). Проблема конкретно этого решения в том, что мы можем изменить i извне, то есть не контролируем её значение, что чревато проблемами в дальнейшем. Кроме того, получившаяся функция не переиспользуема - мы не можем создать ещё один счётчик, который будет считать с нуля, все функции будут отдавать одно и то же значение.
С другой стороны, такого условия и не было. Как бы ни был этот способ плох - он работает и проходит тест! Тем не менее, отвечать на собеседовании этой реализацией можно разве что в качестве троллинга - очевидно, что решение слишком «джуновское» и явно от вас ожидают другого.
Оффтоп: А потом я спросил у Яндекса и получил такой ответ:
Выглядит вроде похоже на традиционную реализацию с замыканием, но зачем это странное условие if (currentValue === 0)? Из-за него значение счётчика так и останется нулём. Ну и к тому же не соблюдено требование к названию функции. Алиса тут на удивление не справилась. Надеюсь, со временем это исправят!
Замыкание
Итак, нам нужно хранить текущее значение счётчика, но глобальная переменная не подходит (и её аналоги в лице глобального/сессионного хранилища тоже - они тоже прекрасно подменяются). Где же нам его сохранять? Если мы положим значение внутрь функции, то оно каждый раз будет создаваться заново. Но выход есть! И это именно тот способ, ради которого эту задачу обычно и задают - замыкание.
Выше Яндекс.GPT попробовал решить задачу с помощью замыкания и перехитрил сам себя. Что ж, давайте же покажем, как это делается на самом деле.
const counterMaker = () => {let i = 0;return () => i++;};const counter = counterMaker();
Замыкание — вещь крутая и нужная, большинство разработчиков умеет пользоваться и успешно это делают, но есть вопрос, который может поставить неопытного человека в тупик. А что такое это ваше замыкание?
Доверимся авторитетному источнику в лице MDN
Замыкание — это комбинация функции и лексического окружения, в котором эта функция была определена. Другими словами, замыкание даёт вам доступ к Scope внешней функции из внутренней функции.
Иными словами, то, что находится внутри функции counterMaker (а именно переменная i и возвращаемая внутренняя функция, которая и будет тем самым счётчиком). Скоуп функции counterMaker будет жить даже после того, как она завершит выполнение, то есть, значение переменной i сохранится, но не будет доступно извне. То, что нужно!
Если же нас попросят обойтись без объявления вспомогательных переменных, то мы с честью справимся с этим.
const {counter} = (() => {let i = 0; return {counter: () => i++}})();
Здесь мы использовали старый добрый IIFE (Immediately-invoked function expression) - проще говоря, самовызывающуюся функцию. Это позволило не делать внешних функций, а сразу создать готовое замыкание.
Впрочем, даже это решение можно сделать компактнее, передав значение по умолчанию в параметре вместо объявления переменной. А если уже совсем упороться в компактность, можно получить что-то в духе великого и могучего Brainfuck:
const counter = ((_)=>()=>_++)(0)
Но разве это компактно? Вот компактно!
const counter = (_=>i=>_++)`0`
// параметр i абсолютно не нужен, однако позволяет сэкономить аж 1 символ!
// что касается обратного апострофа - он просто показывает, что можно вместо скобок использовать его
И это корректный и рабочий код! Мы объявляем функцию с параметром _, значение которого сохраняется в области видимости и увеличивается при каждом новом вызове. Вместо _++ можно попытаться всех запутать и написать -_-- (правда отдавать при первом вызове функция будет не 0, а -0 - однако это не мешает пройти тест)- но это уже для особых ценителей.
ООП
Но ведь JavaScript помимо всего прочего ещё и ООП-язык, в котором есть классы и инкапсуляция. Давайте же используем это!
class Counter {count=0;counter() {return this.count++;}}const cntItem = new Counter();const counter = () => cntItem.counter();
Получилось (традиционно для ООП) довольно многословно, да и для такой задачи явный оверхэд. Тем не менее, всё работает и вполне неплохо. Кроме одного большого НО… Сейчас сами всё увидите:
cntItem.count = 1000;
counter(); // 1000 - упс
counter(); // 1001 - ой
В таком виде решение с классами оказывается не лучше глобальной переменной. А причина проста - в нашем решении нет модификаторов доступа для полей и методов класса (просто потому что их достаточно долго не было в стандарте ECMAScript). К счастью, они появились в ES2022 - теперь достаточно добавить перед именем # (private) / _ (protected)
class Counter {#count=0;counter() {return this.#count++;}}const cntItem = new Counter();const counter = () => cntItem.counter();
cntItem.count = 1000;
cntItem['#count'] = 1000; // и даже так
counter(); // 0 - ничего не изменилось, всё с 0 - как и должно быть
counter(); // 1
Как видим, всё в порядке - данные внутри экземпляра класса надёжно сокрыты. Только нужен браузер с поддержкой private модификатора - к счастью, судя по caniuse.com подойдут любые актуальные версии. В node.js поддерживается с достаточно древней 12 версии, так что можно пользоваться без страха несовместимости и там.
Просим помощи транспайлера - получаем прототипы
Классы появились в ES6 - вроде бы достаточно давно, но до сих пор есть мастодонты вроде IE11, которые о них не знают. Мне стало интересно, что получится при транспайлинге кода с классами. Babel выдал многословную портянку со своей реализацией класса - имеет право на жизнь, но не так интересно. TypeScript оказался куда лаконичнее:
var Counter = /** @class */ (function () {function Counter() {this.count = 0;}Counter.prototype.counter = function () {return this.count++;};return Counter;}());var cntItem = new Counter();var counter = function () { return cntItem.counter(); };
Старое доброе ООП на прототипах…Увы, это решение подвержено тому же недостатку, что первоначальное с классами - counter подменяется достаточно легко, достаточно присвоить cntItem.counter какое-то другое значение. Я хотел решить проблему так же, как и в прошлый раз - но код с модификаторами доступа TypeScript преобразовывать в ES5 уже не хочет, требуя как минимум ES2015. А в ES2015 получается примерно как с Babel - появляется большой блок с вспомогательными функциями - скучно! Однако, мы пойдём другим путём и чуть изменим получившийся выше код:
var Counter = /** @class */ (function () {let count;function Counter() {count = 0;}Counter.prototype.counter = function () {return count++;};return Counter;}());var cntItem = new Counter();var counter = function () { return cntItem.counter(); };
Получаем подобие private полей в нашем псевдоклассе. Правда, по сути это замыкание, вид сбоку - никуда от них не денешься!
Примечание: неожиданно, но именно так решал задачу GigaChat от Сбера:
Контекст
Или всё-таки можно без замыкания? Давайте сдуем пыль с наших знаний по ключевому слову this и попытаемся его использовать
const counter = function () { this.i = (this.i || 0) + 1; return this.i};
console.log(counter()); // Выводит 0
console.log(counter()); // Выводит 1
console.log(counter()); // Выводит 2
Кратко о том, что тут происходит: мы используем контекст выполнения функции из this, храня текущее значение в нём. Круто! То, что нужно! Только вот….
i = 1000;
console.log(counter()); // Выводит 1000
console.log(counter()); // Выводит 1001
Ой… Функция использует глобальный контекст. То есть, по сути мы никуда не ушли от глобальной переменной, просто используем её иначе. А ещё эта функция по понятным причинам не будет работать в strict режиме.
Получается, контекст мимо?
Как бы не так! Мы же можем задать его явно с помощью bind:
const counter = (function () { return this.i++;}).bind({i:0})
console.log(counter()); // Выводит 0
console.log(counter()); // Выводит 1
console.log(counter()); // Выводит 2
i = 1000;
console.log(counter()); // Выводит 3
console.log(counter()); // Выводит 4
Проблема решена. Однако, как известно, когда в руках есть молоток, всё вокруг кажется гвоздями. Таким молотком для многих (для меня в том числе) являются стрелочные функции, которыми любят подменять традиционные - как минимум потому что выходит короче. Что ж, давайте сделаем нашу функцию стрелочной
const counter = (() => { return this.i++;}).bind({i:0})
console.log(counter()); // Упс, NaN
console.log(counter()); // Опять NaN
i = 1000;
console.log(counter()); // 1000
console.log(counter()); // 1001
Стрелочной функции контекст уже не переопределить… Как создавалась с глобальным, так и будет его упрямо использовать. Что ж, function так function
Генератор
Не знаю, один ли я такой, но в моей работе фронтенд-разработчика я применял генераторы явным образом около нуля раз. Я знаю, что штука хорошая и понимаю, как они работают, знаю, что на основе генераторов можно сделать множество крутых вещей типа реализации async/await, однако, к своему стыду, всю карьеру как-то обходил их стороной.
Тем не менее, основные сведения о генераторах я знаю. По сути это функция, которую можно поставить на паузу до следующего вызова, после которого она продолжит выполнение с последнего возвращаемого результата.
Если подумать, функция-генератор хранит своё предыдущее состояние до следующего запуска. А ведь нам как раз и нужно возвращать новый результат на основе предыдущего! Что ж, давайте попробуем:
const counterGen =function* () {let i =0;while (true) yield i++;//
генератор не завершится никогда - он в бесконечном цикле. Однако, при каждом вызове будет возвращать значение счётчика, увеличенное на 1 и становиться на паузу - то, что нужно}
const counterGenItem =counterGen();const counter = () => counterGenItem.next().value
Все необходимые критерии соблюдены - изначальное значение 0, каждый раз получаем значение на 1 больше, внутрь залезть и изменить содержание нашего черного ящика никак. По сути, получилась вариация глобальной переменной, доведённая до ума.
Но вот оставить только функцию-счётчик и не иметь больше ничего в текущей области видимости у меня не вышло… Точнее, вышло, но с лишь использованием замыкания, оставив в нём тот самый генератор. А это уже неспортивно. Но, с другой стороны, решение с классом нас же устроило? Так что и генератор вполне подходит.
Проксируем объект
Если генераторы худо-бедно знакомы большинству программистов на JavaScript, то о Proxy знает гораздо меньше даже среди знатоков языка. Хотя на их использовании основан такой популярный стейт-менеджер как MobX - значит, штука наверняка крутая и полезная. Попробуем же применить Proxy к нашей задаче.
В чём же суть прокси? По-простому (совсем по-простому) - в возможности произвести какие угодно действия при запросе или изменении свойства объекта. При проксировании создаётся новый объект, на который можно навесить функцию-обработчик событий get и set для любого свойства. И если немного поколдовать, мы получим следующее:
Только вот вне контекста count, увы, это уже не будет работать. Тем не менее, вполне рабочий способ, позволяющий гибко и масштабировано… увеличить count на 1, да.
WebAssembly
А зачем нам писать на этом скучном JavaScript? Давайте выжмем все соки из нашего рантайма и используем WASM. Сразу скажу, что писать прямо на WASM мне было (сначала) лень, поэтому я использовал свои базовые знания языка C времён второго курса.
Получился вот такой исходный код на C (до безобразия похожий на аналогичный на JavaScript):
int i = 0;
int counter () {
return i++
}
Скомпилировалось это без особых проблем, дав нам 2 варианта. Первый - код на WebAssembly в формате WAT (WebAssembly Tree):
(module
(table 0 anyfunc)
(memory $0 1)
(data (i32.const 12) "0000")
(export "memory" (memory $0))
(export "counter" (func $0))
(func $counter (; 0 ;) (result i32)
(local $0 i32)
(i32.store offset=12
(i32.const 0)
(i32.add
(tee_local $0
(i32.load offset=12
(i32.const 0)
)
)
(i32.const 1)
)
)
(get_local $0)
)
)
Довольно очевидно, что в этом коде много лишнего, не нужного для изначальной задачи. Однако то, что требуется по условиям задачи, этот код делает - экспортирует функцию counter, каждый раз выдающую значение на 1 больше. Но как нам использовать получившийся результат?
А для этого нам нужен второй вариант - бинарный код WebAssembly. Преобразуем его в байтовый массив с помощью The WebAssembly Binary Toolkit и получаем вот такой набор цифр:
const wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,148,128,128,128,0,2,6,109,101,109,111,114,121,2,0,7,99,111,117,110,116,101,114,0,0,10,155,128,128,128,0,1,149,128,128,128,0,1,1,127,65,0,65,0,40,2,12,34,0,65,1,106,54,2,12,32,0,11,11,138,128,128,128,0,1,0,65,12,11,4,0,0,0,0]); //
просто поверьте, что тут то же самое, что в WAT выше
const wasmModule = new WebAssembly.Module(wasmCode);const wasmInstance = new WebAssembly.Instance(wasmModule);
const {counter} = wasmInstance.exportscounter(); // 0counter(); // 1counter(); // 2
Если нам нужен однострочник - без проблем!
const {counter} = new WebAssembly.Instance(new WebAssembly.Module(new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,148,128,128,128,0,2,6,109,101,109,111,114,121,2,0,7,99,111,117,110,116,101,114,0,0,10,155,128,128,128,0,1,149,128,128,128,0,1,1,127,65,0,65,0,40,2,12,34,0,65,1,106,54,2,12,32,0,11,11,138,128,128,128,0,1,0,65,12,11,4,0,0,0,0]))).exports;counter(); // 0counter(); // 1counter(); // 2
Очевидно, что использовать для задачи уровня счётчика WebAssembly - стрельба даже не из пушки по воробьям, а использование Звезды Смерти по тем же самым несчастным птицам. Однако, получился прекрасный пример «чёрного ящика», когда мы вообще не представляем, что творится внутри метода, но точно уверены, что он отдаёт ровно то, что нам нужно. Если выучите всю эту последовательность байт, сможете шокировать людей на собеседованиях (не факт, правда, что шокировать в хорошем смысле).
Но я решил не останавливаться на достигнутом и написал код непосредственно на WebAssembly, дабы сделать итоговое решение ещё компактнее. Получилось вот так:
WebAssembly, дабы сделать итоговое решение ещё компактнее. Получилось вот так:
(module
(global $c (mut i32) (i32.const -1))
(func (export "c") (result i32)
i32.const 1
global.get 0
i32.add
global.set 0
global.get 0)
)
Если вкратце: мы создаём мутабельную переменную типа i32 $c, ну и дальше собственно экспортируемая функция-счётчик под коротким именем c. Переводим в JavaScript:
const counter = new WebAssembly.Instance(new WebAssembly.Module(Uint8Array.from([0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 127, 3, 2, 1, 0, 6, 6, 1, 127, 1, 65, 127, 11, 7, 5, 1, 1, 99, 0, 0, 10, 13, 1, 11, 0, 65, 1, 35, 0, 106, 36, 0, 35, 0, 11]))).exports.c;
Это решение вполне компактно и наверняка увеличивает значение переменной со скоростью света. Но главное, что этот счётчик делает что нужно и не позволяет подменить значение внутри себя. PROFIT!
Заключение
В процессе реализации новых решений для:
-
разобрался с генераторами
-
познакомился и погрузился в контекст WebAssembly
-
освежил в памяти базовые аспекты: контекст, прототипы, замыкания
-
поделился опытом с читателями Хабра
А значит, всё было не зря! Надеюсь, было интересно и познавательно. А ещё больше надеюсь, что я упустил ещё пару-тройку интересных вариантов написания счётчика, о которых мне расскажут в комментариях. Спасибо за внимание!
Автор: WMT