Статьи

Реализация командной строки с Eval в JavaScript


В этом блоге рассматривается функция eval в JavaScript, основанная на интерактивной командной строке.
В качестве бонуса вы получите работу с генераторами ECMAScript.next (которые уже могут быть опробованы в текущих версиях Firefox).

Написание оценщика

Допустим, вы хотите реализовать интерактивную командную строку для JavaScript (например,
[1] ). С одной стороны, вам нужно получить правильный графический интерфейс пользователя: пользователь вводит код JavaScript, командная строка оценивает код и отображает результат. С другой стороны, вам придется провести оценку. Это то, что мы возьмем здесь. Это сложнее, чем кажется на первый взгляд и многому нас учит eval. Для начала давайте напишем конструктор Evaluator:

    function Evaluator() {
    }
    Evaluator.prototype.evaluate = function (str) {
        return JSON.stringify(eval(str));
    };

Чтобы использовать оценщик, мы создаем экземпляр и отправляем ему код JavaScript:

    > var e = new Evaluator();

    > e.evaluate("Math.pow(2, 53)")
    '9007199254740992'

    > e.evaluate("3 * 7")
    '21'

    > e.evaluate("'foo'+'bar'")
    '"foobar"'

JSON.stringify используется для того, чтобы результаты оценки могли быть показаны пользователю и выглядеть как входные данные. Без stringify все выглядит следующим образом:

    > console.log(123)  // OK
    123

    > console.log("abc")  // not OK
    abc

С stringify все выглядит нормально:

    > console.log(JSON.stringify(123))
    123

    > console.log(JSON.stringify("abc"))
    "abc"

Обратите внимание, что undefined не является допустимым JSON, но stringify преобразует его в неопределенное (значение, а не строка), что хорошо для наших целей. То, что мы реализовали, работает для базовых вещей, но все еще имеет несколько проблем. Давайте решать их по одному.

Проблема: объявления

Вы можете оценить объявления переменных и функций, но они сразу же забываются:

    > e.evaluate("var x = 12;")
    undefined
    > e.evaluate("x")
    ReferenceError: x is not defined

Как мы это исправим? Следующий код является решением:

    function Evaluator() {
        this.env = {};
    }
    Evaluator.prototype.evaluate = function (str) {
        str = rewriteDeclarations(str);
        var __environment__ = this.env;  // (1)
        with (__environment__) {  // (2)
            return JSON.stringify(eval(str));
        }
    };
    function rewriteDeclarations(str) {
        // Prefix a newline so that search and replace is simpler
        str = "\n" + str;
    
        str = str.replace(/\nvar\s+(\w+)\s*=/g,
                          "\n__environment__.$1 =");  // (3)
        str = str.replace(/\nfunction\s+(\w+)/g,
                          "\n__environment__.$1 = function");
    
        return str.slice(1); // remove prefixed newline
    }

this.env содержит все объявления переменных и функций в своих свойствах. Мы делаем его доступным для ввода в два этапа.

Шаг 1 — объявляем: мы присваиваем this.env __environment__ (1) и переписываем входные данные, чтобы, среди прочего, каждое объявление var присваивалось __environment__ (3). Это демонстрирует один важный аспект eval: он видит все переменные в окружающих областях. То есть, если вы вызываете eval внутри своей функции, вы выставляете все ее внутренние компоненты. Единственный способ сохранить эти внутренние секреты — поместить вызов eval в отдельную функцию и вызвать эту функцию.

Шаг 2 — доступ: используйте оператор with, чтобы свойства __environment__ выглядели как переменные для eval-ed кода. Это не идеальное решение, скорее компромисс: его следует избегать [2] и нельзя использовать в выгодном строгом режиме [3] . Но это быстрое решение для нас сейчас. Обходной путь довольно сложный [4] .

    > var e = new Evaluator();

    > e.evaluate("var x = 123;")
    '123'
    > e.evaluate("x")
    '123'

Незначительный недостаток: нормальные объявления var имеют неопределенный результат; благодаря нашему переписыванию мы теперь получаем значение, назначенное переменной.

Проблема: исключения

Прямо сейчас, исключение во входных данных оценки означает, что метод сгенерирует:

    > e.evaluate("* 3")
    SyntaxError: Unexpected token *

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

    Evaluator.prototype.evaluate = function (str) {
        try {
            str = rewriteDeclarations(str);
            var __environment__ = this.env;
            with (__environment__) {
                return JSON.stringify(eval(str));
            }
        } catch (e) {
            return e.toString();
        }
    };

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

    > var e = new Evaluator();

    > e.evaluate("* 3")
    'SyntaxError: Unexpected token *'

Проблема: console.log

Как мы обрабатываем вызовы console.log на входе? Зарегистрированные сообщения должны показываться пользователю, а не отправляться на консоль браузера. Решение на удивление легко:

    function Evaluator(cons) {
        this.env = {};
        this.cons = cons;
    }
    Evaluator.prototype.evaluate = function (str) {
        try {
            str = rewriteDeclarations(str);
            var __environment__ = this.env;
            var console = this.cons;
            with (__environment__) {
                return JSON.stringify(eval(str));
            }
        } catch (e) {
            return e.toString();
        }
    };

Конструктор теперь получает пользовательскую реализацию консоли и присваивает ее this.cons. Присваивая этот объект локальной переменной с именем console (1), мы временно скрываем глобальную консоль для eval, ее заменять не нужно. Помните, что это затенение влияет на все функции, вы не сможете использовать консоль браузера нигде при оценке. Новый оценщик в действии:

    > var cons = { log: function (m) { console.log("### "+m) } };
    > var e = new Evaluator(cons);

    > e.evaluate("console.log('hello')")
    ### hello
    undefined

Проблема: eval создает привязки внутри функции

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

    > (function () { eval("var x=3"); return x }())
    3

К счастью, это легко исправить: используйте строгий режим.

    > (function () { "use strict"; eval("var x=3"); return x }())
    ReferenceError: x is not defined

Вы не можете использовать с в строгом режиме, поэтому вам придется заменить его на обходной
[4] .

Ведение деклараций в окружающей среде

В среде, где JavaScript хранит параметры и переменные функции. Он отображает имена переменных в значения и, таким образом, похож на объект. Мы могли бы избежать переписывания ввода и управлять объявлениями через окружение. Идея заключается в следующем. eval помещает объявления в
некоторую среду:

  • Нестрогий режим: среда окружающей функции.
  • Строгий режим: недавно созданная среда.

Что если бы мы могли повторно использовать эту среду для следующего вызова eval вместо того, чтобы выбросить ее? Тогда eval правильно запомнит предыдущие объявления. Строгий режим не дает нам доступа к временной среде, которую он создает для каждого вызова. Тем не менее, в нестрогом режиме мы могли бы поддерживать окружение окружающей функции. В следующих подразделах рассматриваются два способа сделать это.

Объявления через вложенные области видимости

Если вы создадите функцию g внутри другой функции f, то g навсегда сохранит ссылку на текущую среду f env
f . Всякий раз, когда вызывается g, создается новая g-специфическая среда env
g . Но env
g указывает на его
родительскую среду env
f . Переменные, которые не могут быть найдены в области видимости g (как управляется через env
g ), ищутся в области видимости f (через env
f ). Таким образом, env
f не теряется, пока существует g.

Это дает нам стратегию для сохранения среды функции, которая вызывает eval. В следующем коде эта функция называется evalHelper и создает новую функцию, которая должна использоваться для следующего вызова eval. Следовательно, объявления, сделанные в первой функции, доступны в более поздней функции.

    function Evaluator() {
        var that = this;
        that.evalHelper = function (str) {
            that.evalHelper = function (str) {
                return eval(str);
            };
            return eval(str);
        };
    }
    Evaluator.prototype.evaluate = function (str) {
        return this.evalHelper(str);
    };

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

    > var e = new Evaluator();

    > e.evaluate("var x = 7;");
    undefined
    > e.evaluate("x * 3")
    21

Объявления через генератор

Было бы замечательно, если бы мы могли «перезапустить» функцию, которая вызывает eval, и повторно войти в нее с прежним окружением, которое все еще на месте. Генераторы ECMAScript.next
[5] позволяют вам это делать.

Текущие версии Firefox уже поддерживают генераторы. Вот демонстрация того, как они работают в этих версиях (в ECMAScript.next вам нужно написать функцию *, но, кроме этого, код такой же):

    function mygen() {
        console.log((yield 0) + " @ 0");
        console.log((yield 1) + " @ 1");
        console.log((yield 2) + " @ 2");
    }

Выше приведена функция генератора. Вызовите его, и он создаст объект генератора. Для этого объекта сначала нужно вызвать метод next (), чтобы начать выполнение. Выход x внутри кода приостанавливает выполнение и возвращает x ранее вызванному методу объекта-генератора. После первого next () вы можете либо вызвать next (), либо отправить (y). Последнее означает, что текущая приостановленная доходность будет продолжена и приведет к значению y. Первый эквивалентен отправке (не определено). Следующее взаимодействие показывает mygen в использовании:

    > var g = mygen();

    > g.next()  // can’t use send() the first time
    0

    > g.send("a")  // continue after yield 0, pause again
    a @ 0
    1

    > g.send("b")
    b @ 1
    2

Ниже приведена реализация Evaluator, которая вызывает eval через генератор evalGenerator. Из-за этого eval всегда видит одну и ту же среду и запоминает объявления.

    function evalGenerator(console) {
        var str = yield;
        while(true) {
            try {
                var result = JSON.stringify(eval(str));
                str = yield result;
            } catch (e) {
                str = yield e.toString();
            }
        }
    }

    function Evaluator(cons) {
        this.evalGen = evalGenerator(cons);
        this.evalGen.next(); // start
    }
    Evaluator.prototype.evaluate = function (str) {
        return this.evalGen.send(str);
    };

Новый оценщик работает как положено.

    > var e = new Evaluator();

    > e.evaluate("var x = 7;")
    undefined

    > e.evaluate("x * 2")
    "14"

    > e.evaluate("* syntax_error")
    "SyntaxError: missing ; before statement"

Самая большая проблема с этим решением состоит в том, что оно использует устаревшие функции, не строгие eval, вместе с новыми генераторами функций. Вероятно, в ECMAScript.next найдется способ заставить эту комбинацию работать, но это будет взлом, и поэтому его следует избегать.

Вывод

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

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

Рекомендации

  1. Объединение редактирования кода с командной строкой
  2. JavaScript с утверждением и почему он устарел
  3. Строгий режим JavaScript: резюме
  4. Передача переменных в eval
  5. Асинхронное программирование и стиль прохождения продолжения в JavaScript