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

в 7:20, , рубрики: challenge, expressions, functions, javascript, obfuscation, Занимательные задачки, ненормальное программирование

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'еров в свободное от полезной работы время.

Заключение

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

Автор: Antony Belov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js