Мнения, изложенные здесь, принадлежат мне, а не моей компании.

Некоторое время назад я наткнулся на пост в блоге на FreeCodeCamp о том, как автор написал свой собственный язык программирования. В свое время я использовал самые разные языки программирования, и выбор, сделанный для разных синтаксисов, был интересным. Это заставило меня задуматься, какой выбор я сделаю.

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

Я назвал его PhillyScript, так как вырос в более крупном районе Филадельфии.

Когда я начал играть с выбором синтаксиса на выходных, я обнаружил, что тяготею к двум основным целям:

  • Сокращение стандартного кода
  • Оптимизация математического синтаксиса

В этом посте я расскажу о языке и некоторых решениях, которые я сделал, а также о том, как я начинал.

Как создать свой язык

Для создания языка программирования вам понадобятся три основных компонента:

  • Лексер
  • Парсер
  • Компилятор

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

const grammar = new Lexer((char: string) => {
  throw new Error(`Unexpected character "${char}" at row/col ${gRow}:${gCol}`)
}) as Lex
grammar
  .addRule(R.classExtension, (lexeme: string, ...ops: Op[]) => {
    return ['CLASS_EXTENSION', ...ops]
  })
  .addRule(R.classDeclaration, (lexeme: string, ...ops: Op[]) => {
    return ['CLASS', ...ops]
  })
  .addRule(R.classInstantiation, (lexeme: string, ...ops: Op[]) => {
    return ['CLASS_INSTANTIATION', ...ops]
  })
  // ...

Это дает мне массив всех токенов и операндов. В самом конце массива у меня должен быть токен «EOF», конец файла. Если нет, это означает, что весь файл не был проанализирован, вероятно, из-за синтаксической ошибки (или моей собственной ошибки регулярного выражения).

['CLASS', 'Bird', 'CLOSE_CURLY', 'NEWLINE', 'NEWLINE', 'CLASS_EXTENSION', 'Duck', 'Bird', 'CLOSE_CURLY', ..., 'EOF']

Вы можете видеть в моем примере, что у меня есть смесь типов токенов, таких как «CLASS», и имен классов, таких как «Bird». Этот массив отправляется парсеру.

Парсер преобразует этот массив в Абстрактное синтаксическое дерево (AST). Это обеспечивает более полное представление каждой команды. На основе каждого токена я определяю операцию для итерации по этому массиву и преобразования его в представление объекта.

export const parse = (tokens: Op[]) => {
  let c = 0;
  const peek = () => tokens[c];
  const consume = () => tokens[c++];
  const ast: AstLeaf[] = []
  const parsers: Parsers = {
    EOF: () => {},
    CLASS: () => {
      ast.push({
        type: 'CLASS',
        val: consume(),
      })
    },
    CLASS_EXTENSION: () => {
      ast.push({
        type: 'CLASS_EXTENSION',
        var: consume(),
        val: consume(),
      })
    },
    CLASS_INSTANTIATION: () => {
      ast.push({
        type: 'CLASS_INSTANTIATION',
        var: consume(),
        val: consume(),
      })
   },
   // ...
}
while (peek() !== 'EOF') {
    try {
      parsers[consume() as Token]()
    } catch (e) {
      throw new Error(`Cannot get parse next token "${peek()}" at index ${c} for ${tokens.join(', ')}`)
    }
  }
  return ast
};

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

[
  { type: 'CLASS', val: 'Bird' },
  { type: 'CLOSE_CURLY' },
  { type: 'NEWLINE', val: '\n' },
  { type: 'NEWLINE', val: '\n' },
  { type: 'CLASS_EXTENSION', var: 'Duck', val: 'Bird' },
  { type: 'CLOSE_CURLY' },
  { type: 'NEWLINE', val: '\n' },
  // ...
]

Пример AST, показанный выше, демонстрирует, как это выглядит, обеспечивая более точное определение каждого шага моей программы. Теперь у меня достаточно информации, чтобы скомпилировать или транспилировать ее на желаемый язык.

Транспайлер — это последний шаг, и он преобразует этот AST в код. Здесь я ориентируюсь на JavaScript, который позволит мне запустить его. В противном случае мне пришлось бы скомпилировать его либо в машинный код, либо создать собственный интерпретатор для его выполнения. Оба обременительны, поэтому транспиляция — наиболее доступный вариант.

Для каждой части AST я расширяю ее до исполняемого кода. В итоге получается большая строка, которую затем можно отправить в файл или распечатать в консоли. Поскольку мы уже прошли этапы lex и parse, существует высокая степень уверенности в том, что входные данные должны быть корректными, и выходные данные также должны быть корректными.

export const transpile = (ast: AstLeaf[]) => {
  let transpilation = ''
  const transpilers: Transpilers = {
    NEWLINE: () => {
      return '\n'
    },
    CLASS: (leaf: AstLeaf) => {
      return `class ${leaf.val} {`
    },
    CLASS_EXTENSION: (leaf: AstLeaf) => {
      return `class ${leaf.var} extends ${leaf.val} {`
    },
    CLASS_INSTANTIATION: (leaf: AstLeaf) => {
      return `const ${leaf.var} = new ${leaf.val}()`
    },
    // ...
  }
  ast.forEach(leaf => {
    transpilation += transpilers[leaf.type](leaf)
  })
  return transpilation
};

Таким образом, запуск transpile(parse(lex(input))) должен привести к нашему окончательному коду:

class Bird {}
class Duck extends Bird {}

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

PhillyScript: сокращение шаблонного кода

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

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

boul Bird {}
boul Duck <- Bird {}
jawn x := 1
jawn* y := 2
jawn z @ Bird

Объявления переменных теперь называются jawn. Добавление звездочки после этого делает его изменяемым, так как неизменность является значением по умолчанию. Я использую синтаксис := для установки переменных, чтобы отличить его от условия is-equals ==, с которым люди могут ошибиться.

Классы называются boul, а синтаксис стрелки <- указывает, что класс является расширением данного класса.

Вместо того, чтобы писать, что мы создаем новый объект класса без свойств конструктора, мы можем написать, что данная переменная относится к данному классу с синтаксисом @. Это уменьшает код const z = new Bird(), так как большая его часть лишняя.

Асинхронный/ожидание

Ключевые слова async и await — отличные дополнения к языку, но запись этих ключевых слов можно упростить с помощью синтаксиса хэшей.

fun# asyncFunction {
  jawn x := #asyncOperation()
  return x
}

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

Условные

Еще одна область шаблонов — это условные предложения. Если я дважды проверяю значение переменной, мне нужно написать два совершенно разных выражения. Если я хочу знать, находится ли число в диапазоне от 0 до 10, я пишу: x > 0 && x < 10. Я могу упростить это:

jawn x := 2
jawn y := 6
if (x == 2, 4) {
  // This is true
}
if (y > 0, < 10) {
  // This is also true
}

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

ФиллиСкрипт: Математика

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

оценки

1.8 это то же самое, что и 2.2? Нет, но они довольно близки. В доказательствах вы бы сказали, что они примерно равны, но в большинстве языков программирования на самом деле нет этого оператора. Я добавил синтаксис или тильду для определения оценки.

jawn x := 5
if (x ≈ 6) {
  // x is not close to 6
}

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

jawn y := 5
if (y ~10~ 6) {
  // y is close to 6, relative to 10
}

Сравнение величин

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

printl(101 >> 10) // Prints 'true'
printl(60 <4< 20) // Prints 'false'

101 намного больше, более чем в 10 раз больше 10. Однако 60 не в 4 раза больше 20.

Отдел-Остаток

В самом начале, изучая деление в длину, нас учили, что есть делимое и остаток как две отдельные величины. Тем не менее, в большинстве языков программирования нет синтаксиса, который фактически производит это. Оператор остатка от деления создает массив с обоими значениями.

jawn x := 14
printl(x /% 4) // Prints '[3, 2]'

Факториал

Другой оператор, которого нет в языках программирования, — факториал, произведение всех значений от 1 до (значение). Это введено на этом языке.

printl(5!) // Prints '120'

Выбор диапазона

Выбор диапазона — отличная функция Python, упрощающая выбор значений из массива или строки. Мы можем использовать это и в PhillyScript.

jawn x := 'Hello World'
printl(x[:5]) // Prints 'Hello'
printl(x[6:7]) // Prints 'W'

Петли диапазона

Аналогичный синтаксис можно использовать для определения циклов for с необязательным модификатором шага.

for (jawn i = 0:3) {
    // Prints '0', '1', '2'
    console.log(i)
}
for (jawn i = 0:5:20) {
    // Prints '0', '5', '10', '15'
    console.log(i)
}

Этот синтаксис намного проще, чем записывать более длинные for (let i = 0; i < 3; i++), так как большая часть этого излишня. Вам не нужно указывать имя переменной три раза.

Арифметика массива

Еще одна замечательная функция Matlab — это арифметика массивов: использование точки для обозначения оператора должно применяться к каждому элементу массива.

jawn c := [0, 1, 2, 3]
// Prints '[2, 3, 4, 5]`
console.log(c .+ 2)
// Prints '[0, 5, 10, 15]'
console.log(c .* 5)

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

Попробуйте язык

Транспайлер и пример кода доступны на GitHub, также я составил языковое руководство с более подробной информацией и дополнительными функциями.

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

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

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

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

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

Здесь нет библиотек, линтеров, подсветки кода и всего остального, что можно было бы ожидать. Регулярные выражения, которые я написал, довольно хрупкие, и некоторые вещи могут не работать.

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