Хабрахабр

Занимательный JavaScript: Без фигурных скобок

image

И если про адекватные best-практики и шаблоны прочитано почти все, то удивительный мир того, как не надо писать код но можно, остается лишь слегка приоткрытым. Меня всегда удивлял JavaScript прежде всего тем, что он наверно как ни один другой широко распространенный язык поддерживает одновременно обе парадигмы: нормальные и ненормальное программирование.

В этой статье мы разберем еще одну надуманную задачу, требующую непростительных надругательств над нормальным решением.

Предыдущая задача:

Формулировка

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

Суть в том, что наша функция аккумулирует некоторые данные при вызове обернутой функции и предоставляет некий интерфейс для доступа к ним. Счетчик вызовов — это лишь повод, ведь есть console.count(). Просто счетчик — примитивен и понятен всем. Это может быть и сохранение всех результатов вызова, и сбор логов, и некая мемоизация.

Нельзя использовать фигурные скобки, а значит придется пересмотреть бытовые практики и ординарный синтаксис. Вся сложность — в ненормальном ограничении.

Привычное решение

Обычно, если язык или его расширение не предоставляют необходимой функции декорации, мы реализуем некоторый контейнер самостоятельно: оборачиваемая функция, аккумулируемые данные и интерфейс доступа к ним. Сначала надо выбрать отправную точку. Часто это класс:

class CountFunction invoke() { this.calls += 1; return this.f(...arguments); }
} const csum = new CountFunction((x, y) => x + y);
csum.invoke(3, 7); // 10
csum.invoke(9, 6); // 15
csum.calls; // 2

Это нам сразу не годится, так как:

  1. В JavaScript таким образом нельзя реализовать приватное свойство: мы можем как читать calls экземпляра (что нам и нужно), так и записывать в него значение извне (что нам НЕ нужно). Конечно, мы можем использовать замыкание в конструкторе, но тогда в чем смысл класса? А свежие приватные поля я бы пока опасался использовать без babel 7.
  2. Язык поддерживает функциональную парадигму, и создание экземпляра через new кажется тут не лучшим решением. Приятнее написать функцию, возвращающую другую функцию. Да!
  3. Наконец, синтаксис ClassDeclaration и MethodDefinition не позволит нам при всем желании избавиться от всех фигурных скобок.

Но у нас есть замечательный паттерн Модуль, который реализует приватность с помощью замыкания:

function count(f) { let calls = 0; return { invoke: function() { calls += 1; return f(...arguments); }, getCalls: function() { return calls; } };
} const csum = count((x, y) => x + y);
csum.invoke(3, 7); // 10
csum.invoke(9, 6); // 15
csum.getCalls(); // 2

С этим уже можно работать.

Занимательное решение

Это 4 разных случая: Для чего вообще здесь используются фигурные скобки?

  1. Определение тела функции count (FunctionDeclaration)
  2. Инициализация возвращаемого объекта
  3. Определение тела функции invoke (FunctionExpression) с двумя выражениями
  4. Определение тела функции getCalls (FunctionExpression) с одним выражением

На самом деле нам незачем возвращать новый объект, при этом усложняя вызов конечной функции через invoke. Начнем со второго пункта. Создадим нашу возвращаемую функцию df и добавим ей метод getCalls, который через замыкание будет иметь доступ к calls как и раньше: Мы можем воспользоваться тем фактом, что функция в JavaScript является объектом, а значит может содержать свои собственные поля и методы.

function count(f) { let calls = 0; function df() { calls += 1; return f(...arguments); } df.getCalls = function() { return calls; } return df;
}

С этим и работать приятнее:

const csum = count((x, y) => x + y);
csum(3, 7); // 10
csum(9, 6); // 15
csum.getCalls(); // 2

Отсутствие фигурных скобок нам обеспечит короткая запись стрелочной функции в случае единственного выражения в ее теле: C четвертым пунктом все ясно: мы просто заменим FunctionExpression на ArrowFunction.

function count(f) { let calls = 0; function df() { calls += 1; return f(...arguments); } df.getCalls = () => calls; return df;
}

Помним, что первым делом мы заменили FunctionExpression функции invoke на FunctionDeclaration df. С третьим — все посложнее. Чтобы переписать это на ArrowFunction придется решить две проблемы: не потерять доступ к аргументам (сейчас это псевдо-массив arguments) и избежать тела функции из двух выражений.

А чтобы объединить два выражения в одно, можно воспользоваться logical AND. С первой проблемой нам поможет справиться явно указанный для функции параметр args со spread operator. Первое же приращение счетчика даст нам 1, а значит это под-выражение всегда будет приводится к true. В отличии от классического логического оператора конъюнкции, возвращающего булево, он вычисляет операнды слева направо до первого "ложного" и возвращает его, а если все "истинные" – то последнее значение. Теперь мы можем использовать ArrowFunction: Приводимость к "истине" результата вызова функции во втором под-выражении нас не интересует: вычислитель в любом случае остановится на нем.

function count(f) { let calls = 0; let df = (...args) => (calls += 1) && f(...args); df.getCalls = () => calls; return df;
}

Можно немного украсить запись, используя префиксный инкремент:

function count(f) { let calls = 0; let df = (...args) => ++calls && f(...args); df.getCalls = () => calls; return df;
}

Но у нас пока останется тело в фигурных скобках: Решение первого и самого сложного пункта начнем с замены FunctionDeclaration на ArrowFunction.

const count = f => { let calls = 0; let df = (...args) => ++calls && f(...args); df.getCalls = () => calls; return df;
};

А переменных у нас целых две: calls и df. Если мы хотим избавиться от обрамляющих тело функции фигурных скобок, нам придется избежать объявления и инициализации переменных через let.

Мы можем создать локальную переменную, определив ее в списке параметров функции, а начальное значение передать вызовом с помощью IIFE (Immediately Invoked Function Expression): Сначала разберемся со счетчиком.

const count = f => (calls => { let df = (...args) => ++calls && f(...args); df.getCalls = () => calls; return df;
})(0);

Так как у нас все три выражения представляют собой функции, приводимые всегда к true, то мы можем также использовать logical AND: Осталось конкатенировать три выражения в одно.

const count = f => (calls => (df = (...args) => ++calls && f(...args)) && (df.getCalls = () => calls) && df)(0);

Он предпочтительнее, так как не занимается лишними логическими преобразованиями и требует меньшего количества скобок. Но есть еще один вариант конкатенации выражений: с помощью comma operator. Операнды вычисляются слева-направо, а результатом является значение последнего:

const count = f => (calls => (df = (...args) => ++calls && f(...args), df.getCalls = () => calls, df))(0);

Мы смело избавились от объявления переменной df и оставили только присвоение нашей стрелочной функции. Наверно мне удалось вас обмануть? Повторим для df инициализацию локальной переменной в параметрах нашей IIFE функции, только не будем передавать никакого начального значения: В этом случае эта переменная будет объявлена глобально, что недопустимо!

const count = f => ((calls, df) => (df = (...args) => ++calls && f(...args), df.getCalls = () => calls, df))(0);

Таким образом цель достигнута.

Вариации на тему

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

Например, полифилл для функции bind в этом плане довольно прост: В целом можно взять любую реализацию и попробовать провернуть подобное.

const bind = (f, ctx, ...a) => (...args) => f.apply(ctx, a.concat(args));

А исключение throw не может быть выброшено в контексте выражения. Однако, если аргумент f не является функцией, по-хорошему мы должны выбросить исключение. Или у кого-то уже сейчас есть мысли? Можно подождать throw expressions (stage 2) и попробовать еще раз.

Или рассмотрим класс, описывающий координаты некоторой точки:

class Point { constructor(x, y) { this.x = x; this.y = y; } toString() { return `(${this.x}, ${this.y})`; }
}

Который может быть представлен функцией:

const point = (x, y) => (p => (p.x = x, p.y = y, p.toString = () => ['(', x, ', ', y, ')'].join(''), p))(new Object);

Можно ли этого избежать, если изрядно постараться? Только мы здесь потеряли прототипное наследование: toString является свойством объекта-прототипа Point, а не отдельно созданного объекта.

Если подумать, то из этого может получиться интересный (но не практичный) обфускатор исходного кода. В результатах преобразований мы получаем нездоровую смесь функционального программирования с императивными хаками и некоторыми особенностями самого языка. Вы можете придумать свой вариант задачи "скобочного обфускатора" и развлекать коллег и друзей JavaScript'еров в свободное от полезной работы время.

Заключение

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

Теги
Показать больше

Похожие статьи

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

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

Кнопка «Наверх»
Закрыть