Как стать автором
Обновить

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

Время на прочтение6 мин
Количество просмотров24K

image


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


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


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



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


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

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


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


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


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


class CountFunction {
    constructor(f) {
        this.calls = 0;
        this.f = f;
    }
    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. Мы можем воспользоваться тем фактом, что функция в JavaScript является объектом, а значит может содержать свои собственные поля и методы. Создадим нашу возвращаемую функцию df и добавим ей метод getCalls, который через замыкание будет иметь доступ к calls как и раньше:


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) и избежать тела функции из двух выражений.


С первой проблемой нам поможет справиться явно указанный для функции параметр args со spread operator. А чтобы объединить два выражения в одно, можно воспользоваться logical AND. В отличии от классического логического оператора конъюнкции, возвращающего булево, он вычисляет операнды слева направо до первого "ложного" и возвращает его, а если все "истинные" – то последнее значение. Первое же приращение счетчика даст нам 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;
};

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


Сначала разберемся со счетчиком. Мы можем создать локальную переменную, определив ее в списке параметров функции, а начальное значение передать вызовом с помощью 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));

Однако, если аргумент f не является функцией, по-хорошему мы должны выбросить исключение. А исключение throw не может быть выброшено в контексте выражения. Можно подождать 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'еров в свободное от полезной работы время.


Заключение


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

Теги:
Хабы:
Всего голосов 32: ↑30 и ↓2+28
Комментарии29

Публикации

Истории

Работа

Ближайшие события

27 августа – 7 октября
Премия digital-кейсов «Проксима»
МоскваОнлайн
28 сентября – 5 октября
О! Хакатон
Онлайн
3 – 18 октября
Kokoc Hackathon 2024
Онлайн
10 – 11 октября
HR IT & Team Lead конференция «Битва за IT-таланты»
МоскваОнлайн
25 октября
Конференция по росту продуктов EGC’24
МоскваОнлайн
7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн