Сила шаблона проектирования Интерпретатора (Interpreter) в JavaScript

Spread the love

В этом посте мы рассмотрим шаблон проектирования Интерпретатор (Interpreter) в JavaScript. Мы реализуем Интерпретатор, а также основные грамматические представления анализируемого кода. Так мы создадим интерфейс для использования в таких приложениях как например парсеры.

Шаблоны проектирования делятся на три категории: поведенческие, порождающие и структурные (Behavioral, Creational, и Structural). Интерпретатор принадлежит к поведенческой группе. Из всех паттернов интерпретатор кажется наиболее запутанным, но, по моему опыту, мышление в перспективе на более высоком уровне (взгляд со стороны) лучшим образом позволяет понять сложные вещи.

Когда нужно применять шаблон интерпретатора?

Иногда мы можем столкнуться с ситуациями, когда нам нужен какой-то способ сообщить программе, как интерпретировать данные на основе определенного формата. Этот шаблон также широко используется в синтаксическом анализе SQL кода, механизмах обработки символов и т. д.

Думайте об этом как о создании своего скриптового «языка».

Например, массивы могут содержать вложенные массивы в одном из своих атрибутов, которые так же могут содержать еще больше массивов и т. д.:

const items = [
  {
    profile: {
      username: 'bob',
      members: [
        {
          username: 'mike',
        },
        {
          username: 'sally123',
        },
        {
          username: 'panera',
          members: [
            {
              username: 'sonOfPanera',
            },
          ],
        },
      ],
    },
  },
]

Цель состоит в том, чтобы создать своего рода «грамматическое» представление, которое в большинстве случаев способно к рекурсии.

Когда мы смотрим на его структуру, каждая часть в представлении грамматики является либо составным элементом, либо листом дерева. В перспективе более высокого уровня обратите внимание, как эти грамматические представления формируют структуру составного шаблона проектирования:

interpreter-design-pattern-composite-structure.png

Обратите внимание, что некоторые дочерние элементы содержат больше дочерних элементов, и глубина увеличивается настолько, насколько это необходимо. Именно здесь рекурсия имеет решающее значение и становится необходимой в алгоритмах обхода.

Главным участником этого паттерна является сам интерпретатор.

Реализация интерпретатора и грамматических представлений

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

Допустим нам нужно интерпретировать такую строку (мы начнем с пустого массива, чтобы упростить объяснение):

const items = []

Если мы сможем прочитать эту строку кода, тогда мы сможем манипулировать и элементами массива?

Мы можем прочитать этот код построчно, примерно так:

let srcCode = `const items = [`

let prevChar
let nextChar

for (let index = 0; index < srcCode.length; index++) {
  nextChar = srcCode[index + 1]

  const char = srcCode[index]

  if (
    char === 'i' &&
    nextChar === 't' &&
    srcCode[index + 2] === 'e' &&
    srcCode[index + 3] === 'm' &&
    srcCode[index + 4] === 's'
  ) {
    srcCode = srcCode.split('')
    srcCode = [
      ...srcCode.slice(0, index),
      'collection',
      ...srcCode.slice(index + 'items'.length),
    ].join('')
  }

  prevChar = char
}

Однако это не очень эффективно, потому что нет надежного способа просмотра предыдущих/следующих элементов, а также возможности определить, какая строка или столбец начинается с определенного выражения, объявления и т. д. У нас также нет способа изменять логику поведения, например изменять тип объявления (var, let, const).

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

Итак, возвращаясь к этой строке:

const items = []

Вместо этого мы можем создать структуру в виде класса Interpreter состоящий в свою очередь из несколько классов VariableDeclaration, VariableDeclarator, ArrayExpression, Identifier . Имейте в виду, что эта структура — самая мощная часть этого шаблона, поскольку мы можем настроить каждый класс так, чтобы он делал все, что захотим, например, переопределить метод toString по умолчанию:

class VariableDeclaration {
  constructor() {
    this.kind = null
    this.declarations = []
  }
  toString() {
    let output = ''

    output += `${this.kind} `

    this.declarations.forEach((declaration) => {
      output += declaration.toString()
    })

    return output
  }
}

class VariableDeclarator {
  constructor() {
    this.id = null
    this.init = null
  }
  interpret() {
    return this.init.interpret()
  }
  toString() {
    let output = ''
    output += this.id.toString()
    output += ' = '
    output += this.init.toString()
    return output
  }
}

class ArrayExpression {
  constructor() {
    this.elements = []
  }
  toString() {
    let output = ''

    output += '['

    this.elements.forEach((elem) => {
      output += elem.toString()
    })

    output += ']'

    return output
  }
}

class Identifier {
  constructor(name) {
    this.name = name
  }
  toString() {
    return this.name
  }
}

Идеально! Шаблон проектирования Интерпретатор в действии! Теперь у нас есть некоторое представление для нашей грамматики.

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

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

class Interpreter {
  interpret(srcCode = '') {
    let nodes = []
    let words = srcCode.split(/(\s|\r|\n|\t\|=|\.|\]|\[)/)

    for (let index = 0; index < words.length; index++) {
      const word = words[index]
      if (/var|let|const/.test(word)) {
        const kind = ['var', 'let', 'const'].find((char) => word.includes(char))
        const variableName = words[index + 2]

        words.shift()
        words.shift()
        words.shift()
        words.shift()
        words.shift()
        words.shift()
        words.shift()

        const declaration = new VariableDeclaration()
        const declarator = new VariableDeclarator()
        const variable = new Identifier(variableName)

        if (words[0] === '[') {
          declarator.init = new ArrayExpression()
        }

        declaration.kind = kind
        declaration.declarations = [declarator]
        declarator.id = variable

        nodes.push(declaration)
      }
    }

    return nodes
  }

  toString(nodes) {
    let output = ''

    for (const node of nodes) {
      output += node.toString()
    }

    return output
  }
}

После этого наш интерпретатор может использовать клиентский код и манипулировать этой строкой кода:

const interpreter = new Interpreter()
const interpreted = interpreter.interpret(srcCode)
const newCode = interpreter.toString(interpreted) // `const items = []`
const interpreter = new Interpreter()
const interpreted = interpreter.interpret(srcCode)

if (interpreted[0] instanceof VariableDeclaration) {
  interpreted[0].declarations[0].id.name = 'collection'
}

const newCode = interpreter.toString(interpreted) // `const collection = []`

Контекст

Обычно в сочетании с интерпретаторами используются объекты контекста. Если мы сможем переопределить toString во всех наших объектах представления грамматики, мы определенно сможем повысить мощность нашего интерпретатора, вводя полезную информацию о состоянии в объект, называемый контекстом.

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

array-expression-manipulation-interpreter-design-pattern.png
interpreter-design-pattern-with-context.png

Дополнительные советы

  • Шаблон интерпретатора наиболее эффективен при работе с шаблоном Компоновщик. Другими словами, объединение шаблона
    Компоновщик с шаблоном Интерпретатора является обязательным при работе с составными структурами.
  • Шаблон Builder (Строитель) можно использоваться для создания иерархических структур таких как грамматическое представления языка.
  • Абстрактную фабрику можно использовать для создания сложных объектов (например, в javascript это может быть очень длинный оператор возврата функции с большим количеством двоичных выражений).
  • Подобно шаблону Компоновщик, шаблон Посетителя (visitor) и Итератора (iterator) также работает с Интерпретатором. Visitor часто реализуются с помощью алгоритма рекурсивного обхода, который сам по себе реализует шаблон Итератора. Интерпретаторы могут использовать шаблон Visitor для обхода составных деревьев.
interpreter-design-pattern-relations-in-javascript.png

Заключение

Спасибо за прочтение.

Перевод: The Power of Interpreter Design Pattern in JavaScript

Была ли вам полезна эта статья?
[0 / 0]

Spread the love
Подписаться
Уведомление о
guest
0 Комментарий
Inline Feedbacks
View all comments