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


Заключение


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

Комментарии (29)


  1. Aingis
    13.11.2018 12:37
    +1

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

    Можно конечно, но немного сжульничав:

    const Point = (
      prototype => (
        prototype.toString = Function`return '(' + this.x + ', ' + this.y + ')'`,
        (x, y) => (p => (p.x = x, p.y = y, p))(Object.create(prototype))
      )
    )(new Object);
    

    Спрашивается, а кому это полезно и зачем оно надо? Это совершенно вредно для начинающих, так как формирует ложное представление об излишней сложности и девиантности языка. Но может быть полезно практикующим, так как позволяет взглянуть на особенности языка с другой стороны…
    Есть такая игра: «return true to win», так там много задачек в таком духе. Позволяет взглянуть на язык со многих сторон. Чтобы пройти все уровни, чуть ли не весь MDN перечитаешь.


    1. cerberus_ab Автор
      13.11.2018 13:03

      О, да я не один такой! =)
      Спасибо большое за решение и за ссылку! MDN я возможно уже перечитал: все чаще нахожу вдохновение в самой спецификации. Там еще столько интересного…


      1. BuranLcme
        14.11.2018 19:08
        +1

        Я с той же целью упражнялся с wtfjs. И предпочитал смотреть не в MDN, а напрямую в спецификацию


        1. cerberus_ab Автор
          14.11.2018 19:13

          О, я кажется когда-то давно проверял свою интуицию по Вашей статье (аж 2014 год!)… и был не очень доволен своим результатом )


    1. b360124
      13.11.2018 14:14

      Вот еще один интересный способ, тут мы избавились от вложенных функций, от comma operator и как-то короче вышло )))

      const point = (x, y, p) => ([p, p.x, p.y, p.__proto__.toString] = [new Object, x, y, Function`return \`(\${this.x}, \${this.y})\``])[0];
      


      1. Aingis
        13.11.2018 14:41

        Ну, так вы перебьёте Object.prototype.toString.


        1. b360124
          13.11.2018 14:56

          Пардонс, вот уже рабочее решение

          const point = (x, y, p) => ([p, p.x, p.y, p.__proto__.toString] = [Object.create(new Object), x, y, Function`return \`(\${this.x}, \${this.y})\``])[0];
          
          const testMy = point(10,5);
          testMy.x = 15;
          
          console.log(testMy.toString())
          
          const test = {};
          console.log(test.toString())
          


          1. cerberus_ab Автор
            13.11.2018 15:13

            Опять таки здесь, нет нормального наследования: для каждого нового объекта создается свой объект-прототип. В примере выше через верхний IIFE прототип создан один раз и доступ к нему создаваемые объекты имеют через замыкание.


            1. b360124
              13.11.2018 17:07
              +1

              Вот уже есть )))

              const point = ((o = Object(), _ = o.toString = Function`return \`(\${this.x}, \${this.y})\``) => (x, y) => ([p, p.x, p.y] = [Object.create(o), x, y])[0])();
              
              const testMy = point(10,5);
              const testMy2 = point(20,5);
              testMy.x = 15;
              
              console.assert(testMy.__proto__ === testMy2.__proto__);
              
              console.log(testMy.toString())
              
              const test = {};
              console.log(test.toString())
              
              


              1. cerberus_ab Автор
                13.11.2018 17:21

                Отлично!
                У вас явная предрасположенность к деструктуризации ) выглядит правда интереснее comma operator'а.


              1. Aingis
                13.11.2018 17:27

                Что-то без дефолтного параметра с `toString` и деструктуризации выходит ещё короче на несколько символов, особенно если пробелы убрать (в ответе ширина блока кода меньше):

                const point = ((o = Object()) => (o.toString = Function('return `(${this.x}, ${this.y})`'), (x, y) => (p = Object.create(o), p.x = x, p.y = y, p)))();
                

                И да, подстановки в template literals (`(${this.x}, ${this.y})`) — это тоже фигурные скобки.


                1. cerberus_ab Автор
                  13.11.2018 17:28

                  подстановки в template literals — это тоже фигурные скобки

                  Угу, я поэтому в своем примере использовал join строк вместо темплейта.


            1. b360124
              13.11.2018 20:15
              +1

              В принципе можно вообще без IIFE, использую свойства функции, многословней, но как бы тоже вариант.

              const point = (x, y, o = point.obj = point.obj || Object(), _ = o.toString = o.hasOwnProperty('toString') ? o.toString : Function('return `(${this.x}, ${this.y})`'), p) =>
                ([p, p.x, p.y] = [Object.create(o), x, y])[0];
              const testMy = point(10,5);
              const testMy2 = point(20,5);
              testMy.x = 15;
              
              
              console.assert(testMy2.__proto__ === testMy.__proto__)
              console.assert(point.obj.isPrototypeOf(testMy2))
              console.log(testMy2.toString())
              
              const test = {};
              console.log(test.toString())
              


              Вот еще более многословный вариант, зато не нужно привязываться к имени функции, просто в use strict не работает arguments.callee

              
              const point = Function('x', 'y', `
                const o = arguments.callee.obj = arguments.callee.obj || Object();
                if (!o.hasOwnProperty('toString')) o.toString = Function('return \`(\${this.x}, \${this.y})\`');
                const p = Object.create(o);
                [p.x, p.y] = [x, y];
                return p;
              `)
              
              



              1. cerberus_ab Автор
                13.11.2018 20:22

                Вы уже сделали мой день )) можете предложить свой вариант обфускации, затронув какие-нибудь другие конструкции и особенности языка. Было бы классно )


                1. b360124
                  14.11.2018 17:30
                  +1

                  Да, как бы особо не знаю, на медиуме почитываю старые постыАлександра Майорова, он тоже один из нас, любителей извращенного ненормального программирование.
                  И Вам спасибо, с Вашего поста про мемоизацию у меня как бы и пошел интерес в эту сторону ;)


  1. b360124
    13.11.2018 13:10
    +1

    Вот еще решение на основе параметров, тем самым мы избавились от comma operator в теле функции

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


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


    Можно сделать через eval:

    const a = { count: 5, getCount(b) { return this.count + b} };
    const b = { count: 10 };
    
    const bind = (f, ctx, ...a) => (...args) =>
      typeof f === 'function'
        ? f.apply(ctx, a.concat(args))
        : eval(`throw new Error("This isn't function")`);
    
    const getCountB = bind(a.count, b);
    
    console.log(a.getCount(5));
    console.log(getCountB(5));
    


    1. cerberus_ab Автор
      13.11.2018 13:13

      Вот еще решение на основе параметров

      Я даже не подумал об этом! Круто )


      Можно сделать через eval

      Вот оно уже настоящее зло )


      1. b360124
        13.11.2018 13:33

        Может так зла будет меньше заменяя eval на Function

        const bind = (f, ctx, ...a) => (...args) =>
          typeof f === 'function'
            ? f.apply(ctx, a.concat(args))
            : new Function`throw new Error("This isn't function")`;
        


        1. cerberus_ab Автор
          13.11.2018 13:57

          Ну, главное чтобы это Крокфорд не увидел )


          1. b360124
            13.11.2018 14:17

            Главное не использовать это в продакшине, а то от Крокфорда последствия неизвестные, но тим-лид может и настучать по голове за такой код )))


            1. Frimko
              13.11.2018 19:23

              не, обычно тест на такой код записать просят. После пары тройки раз, чувак смекает в чем причина. И вроде как ты добрый и правильную дорогу указал, без насилия. гибче быть нужно.


  1. freestlr
    13.11.2018 21:22

    Если гнаться за минимизацией, и опустить условие о приватном счетчике (которого в постановке задачи все равно не было), можно сделать вот так:


    m=(f,t)=>t=(...a)=>t[t.c=t.c+1||1]=f(...a)

    В результате получается круглое число символов и бонусом запоминаем результаты всех вызовов.
    Проверить можно так:


    var log=m((...a) => (console.log(...a), a[0]))
    log(1,2,3,4)
    log(5,6,7)
    log(8,9)
    console.log('got', log.c, 'calls')


    1. cerberus_ab Автор
      13.11.2018 21:24

      Ну это уже какая-то смесь одной задачи с другой… в этой все таки хотелось сделать почище. Спасибо за интерес )


  1. another-one
    15.11.2018 16:35

    Издеваетесь, вот, думать людей заставляете…

    const func = (f) => ( o = (...a) => (o.count++, f(...a)), o.count = 0 , o )


    1. cerberus_ab Автор
      15.11.2018 16:36

      Нее, так нельзя ) счетчик должен остаться приватным.


      1. another-one
        15.11.2018 17:08

        Както очень долго над первым решением думал. Нужно больше практитки.
        В условии не написано про приватность.
        Можно к передаваемой функции прицепить атрибут. Некрасиво, но работает.

        const func = (f) => ( f.count = 0 , o = (...a) => (f.count++, console.log(f.count), f(...a)), o.getCount = () => f.count, o )


        1. cerberus_ab Автор
          15.11.2018 17:23

          А в чем собственно разница? Мы все равно можем поменять значение f.count напрямую, а потом дергать func.getCount неправильно. Надумано? но если декорировать одну и ту же функцию дважды, то аукнется факт использования исходной функции в качестве хранилища счетчика: он обнулится. Модифицировать переданную функцию считаю не лучшей практикой:


          const sum = (x, y) => x + y;
          let f1 = func(sum);
          f1(1, 2); // 3
          f1.getCount(); // 1
          let f2 = func(sum);
          f1.getCount(); // 0 ?

          Для обеспечения приватности нам необходимо использовать для замыкания локальную переменную в теле декорирующей функции.


          1. another-one
            15.11.2018 17:31

            Упс… да, при повторном декорировании сломается. И count будет «приватным» только для анонимной функции. Я же говорю — издеваетесь)


            1. cerberus_ab Автор
              15.11.2018 17:42

              Не то, чтобы специально! но даже в ненормальном программирование можно следовать нормальным практикам ) по возможности!