Привет, первая статья на Хабре. Поправляйте если что-то не так.
Предисловие
Идея написать свой ЯП появилась достаточно давно, но не хватало мотивации приступить к изучению. Отталкивало то, что крупные компании годами разрабатывают свои языки, чтобы они были работоспособны, а тут я хочу обойти их всех разом и создать удобный для себя ЯП. Но с чего--то всё таки нужно начинать. Хотя бы калькулятор уже бы был победой. Далее я буду называть свой язык программирования - Lize(читай "Лайз").
Внимание: Я ничего не знаю про то как делать ЯП, я буду изучать всё и одновременно писать статью
Что делаем?
Это будет транспилятор из Lize в Typescript, написанный на ... Typescript. Что касается будущего синтаксиса, то пока это опустим, вернёмся к этому когда будет достаточно знаний. В дополнение я буду публиковать весь код на github.
Первое же гугление дало мне знание о таких вещах как Лексер, Парсер, AST (Абстрактное синтаксическое дерево). Ничего не знаю про два последних, забудем пока про них и начнём с первого.
Что такое лексер?
Лексер - он же сканер, он же токенизатор. Первый этап преобразования простого текста который мы считаем за программу на нашем языке (Lize) в программу которую поймёт компьютер (но в нашем случае мы обойдёмся транспиляцией/переводом из нашего ЯП в TypeScript)
В моей голове это просто такая вещь которая превращает последовательность символов в последовательность подстрок имеющих некоторый id и значение.
Мы будем представлять его так:[имя: значение]
или так:(строка:столбец;ширина) [имя: значение]
.
Стоит уточнить, что символ звёздочки или слэш ни в коем случае не должен считаться знаком умножения или деления. Этап токенизации должен просто разбить исходник на токены, а потом на этапе парсинга определять, это умножение или разыменование.
// ❌ Не правильно
tokenize(`4 * 3 / 2`) // [number: 4], [multiply], [number: 3], [divide], [number: 2]
// ✅ правильно
tokenize(`4 * 3 / 2`) // [number: 4], [asterisk], [number: 3], [slash], [number: 2]
Я люблю писать утилиты и абстракции, чтобы всё усложнить упростить и сделать код элегантнее. Так что осторожно, некоторый код может показаться бессмысленным или страшным, но мне от него на душе легче.
Обработка ошибок
Мы же любим когда наш любимый ЯП тыкает нам носом туда где мы облажались. Давайте забудем про проблему ошибок раз и навсегда. Функция error
это магия JS и TS, позволяет создавать ошибки в одну строчку. Я опустил реализацию, дабы не нагружать статью, весь код вы сможете посмотреть на github.
// Примеры ошибок
export namespace LexerErrors {
export const SampleError =
error('SampleError', 'Sample error message')
export const ErrorWithArgument =
error<[string]>('ErrorWithArgument', 'Argument: {}')
export const UnexpectedCharacter =
error<[line: number, column: number]>('UnexpectedCharacter', 'Unexpected character at {}:{}')
}
// Пример
throw new SampleError
throw new ErrorWithArgument('hey')
throw new UnexpectedCharacter(1, 2)
Реализация лексера
Для начала разберёмся со структурой токена как объекта. У него будет имя/идентификатор по которому мы сможем его опознать, значение токена, если это например число или строка и его положение в исходном коде.
Создадим класс Span
, У него будет три свойства, это исходная программа, начало токена и его конец, метод width
, чтобы быстро узнать длину токена. Не будем тянуть и реализуем возможность получить номер строки и столбца
export class Span {
constructor(
private readonly src: string,
readonly start: number,
readonly end: number,
) {}
get width() {
return this.end - this.start
}
get line() {
return (this.src.slice(0, this.start).match(/\n/g) || '').length
}
get column() {
const lines = this.src.slice(0, this.start).split('\n')
if(lines.length === 1) return this.start
return this.start - lines.slice(0, lines.length - 1).join('\n').length - 1
}
}
Ну а теперь сам Token
. В качестве идентификатора токена будет выступать сам класс, а точнее его имя. Например, чтобы проверить что некоторый токен это символ звёздочки, просто используйте token instanceof Asterisk
export class Token {
constructor(readonly value: string, readonly span: Span) {}
}
// Тип конструктора пригодится позже
export type TokenKind = typeof Token
Добавим статический метод tokenize
, который мы будем переопределять для каждого токена. Он будет искать токены, принимая строку исходного кода и возвращая длину токена. Если возвращённое число -1, будем считать, что токен найти не удалось. Пока не думайте про that
, это просто ссылка на лексер. Мы не предполагаем, что Token
будет инстансирован напрямую без наследования, поэтому кинем ошибку
static tokenize(that: any, src: string): number {
throw new AbstractToken // Abstract token can't be used
}
Искать токены мы будем при помощи регулярных выражений, это невероятно просто и красиво. А чтобы проще и быстрей создавать виды токенов я решил написать билдер. Это может немного сбить с толку, но по сути это функция которая за нас реализует каждый класс токена.
// Пачка типов. T - это тип ссылки на лексер.
type GroupString = string | RegExp
type GroupValue<T> = GroupString | ((that: T) => GroupString)
type ConditionFn<T> = (that: T) => boolean
type Subscriber<T> = (that: T, result: number) => void
interface Condition<T> {
not?: boolean
fn: ConditionFn<T>
}
interface Group<T> {
value: GroupValue<T>
main?: boolean
escape?: boolean
}
// "Макрос" для реализации видов токенов
export const kind = <T>(name: string) => {
const Class = namedClass(class extends Token {
// Группы токенизации, по простому просто части одного
// регулярного выражения
private static readonly _groups: Group<T>[] = []
// Условия при соблюдении которых токен будет засчитываться
private static readonly _conditions: Condition<T>[] = []
// Подписчики. Они будут вызваны если токен будет найден
private static readonly _subscribers: Subscriber<T>[] = []
// Regex группа может быть вычислена
// resolveGroup вернёт вычисленную группу,
// если её можно вычислить
private static resolveGroup(that: T, group: GroupValue<T>) {
return typeof group === 'function' ? group(that) : group
}
// Добавляем группу(ы)
static group(...parts: GroupValue<T>[]): typeof Class {
Class._groups.push(...parts.map(value => ({ value })))
return Class
}
// Устанавливаем главную группу,
// ту, длину которой засчитает лексер
static main(part: GroupValue<T>): typeof Class {
Class._groups.push({ value: part, main: true })
return Class
}
// Добавляем разрешающее условие(я)
static allow(...conditions: ConditionFn<T>[]): typeof Class {
Class._conditions.push(...conditions.map(fn => ({ fn })))
return Class
}
// Добавляем запрещающее условие(я)
static disallow(...conditions: ConditionFn<T>[]): typeof Class {
Class._conditions.push(...conditions.map(fn => ({ fn, not: true })))
return Class
}
// Устанавливаем группу для последовательности символов
static plain(text: GroupValue<T>): typeof Class {
Class._groups.push({ value: text, escape: true, main: true })
return Class
}
// Добавляем подписчика
static on(...subscriber: Subscriber<T>[]): typeof Class {
Class._subscribers.push(...subscriber)
return Class
}
// Оповещаем подписчиков об успешной токенизации
private static emit(that: T, value: number) {
Class._subscribers.forEach(s => s(that, value))
}
// Компилируем все условия и получаем полное регулярное выражение
// при соблюдении всех условий
private static compile(that: T, doMatch = true): RegExp | undefined {
// Собираем результаты условий
for (const condition of this._conditions) {
// Вычисляем результат
let result = condition.fn(that)
// Меняем на противоположный, если добавляли через
// disallow
if (condition.not) result = !result
if (!result) {
doMatch = false
return
}
}
// Формируем регулярное выражение
return RegExp(`^${this._groups.map(g => {
// Вычисляем группу
const group = Class.resolveGroup(that, g.value)
// Проверяем разные кейсы и в зависимости от этого
// устанавливаем конечное значение группы
const value = typeof group === 'object' ? group.source : g.escape ? escapeRegex(group) : group
// оборачиваем группу
return `(${value})`
}).join('')}`)
}
// Прибавляем 1, поскольку метод match возвращает
// всё совпадение, а потом группы.
// И смотрите как удобно, если главной группы нет,
// то берём всё, тк -1 + 1 = 0, а 0 это всё совпадение
private static getMainIndex() {
return Class._groups.findIndex(g => g.main === true) + 1
}
// Реализуем главный метод токенизации
static tokenize(that: T, src: string) {
// Компилируем выражение, если его нет, то пропускаем токен
const regex = Class.compile(that)
if (!regex) return -1
// матчим строку
const matches = src.match(regex)
if (!matches) return -1
// Берём главную группу
const result: number = matches[Class.getMainIndex()].length
// Оповещаем подписчиков об успешной находке
if (result >= 0) Class.emit(that, result)
// Возвращаем длину токена
return result
}
}, name)
return Class
}
Если вы всё таки любитель читать код, что не скажешь по комментариям, то могу рассказать, что такое that
. С помощью that
я ссылкаюсь на текущий лексер, таким образом все условия и подписки будут работать независимо от лексера, если вы читали статью раньше, то that
не было и из-за этого каждый новый экземпляр лексера ломал токены добавляя повторяющиеся правила.
Теперь можем приступить к созданию видов токенов. Начнём с чего-то простого. Токенизируем простое числовое выражение.
Вот какие виды токенов мы может тут найти: Число, Открывающая Круглая Скобка, Закрывающая Круглая Скобка, Звёздочка, Слэш и Пробел(да, мы его тоже считаем)
// scripts/1.ts
const src = `(2 * 3) / 6`
const Number = kind('Number').main(/\d+/)
const Asterisk = kind('Asterisk').plain('*')
const Slash = kind('Slash').plain('/')
const OpenParen = kind('OpenParen').plain('(')
const CloseParen = kind('CloseParen').plain(')')
const Whitespace = kind('Whitespace').main(/[^\S\r\n]+/)
Приступим к классу лексера. Я также буду использовать подход с использованием итератора, чтобы мы могли контролировать процесс.
export class Lexer {
// Длина проанализированного текста
private index = 0
constructor(
// Берём на вход исходник
private src: string,
// Виды(kinds) токенов
private readonly kinds: TokenKind[],
) {}
next() { /* ... */}
// Реализуем итератор
[Symbol.iterator]() {
return {
next: () => {
return { done: this.done, value: this.next() }
},
}
}
// Вернуть true если всё токенизировали
get done() {
return this.index === this.src.length
}
}
Реализуем метод next
. Он будет перебирать виды токенов и подставлять в них строку исходника, если метод tokenize
каждого вида токена не вернёт число больше -1, то кидаем ошибку: "Неожиданный токен" иначе возвращаем экземпляр токена.
next() {
// Выполняем проход по всем видам токенов
for (const kind of this.kinds) {
// Токенизируем исходную строку начиная с this.index
// ...Отдаёт тот самый that
const result = kind.tokenize?.(this, this.src.slice(this.index)) ?? -1\
// Если не нашли, то идём дальше
if (result === -1) continue
// возвращаем экземпляр токена и обновляем this.index
return new kind(this.src.slice(this.index, this.index + result), new Span(this.src, this.index, this.index += result))
}
// Если ни один из токенов не дал о себе знать,
// то мы наткнулись на неизвестную или неверную часть программы
throw new UnexpectedToken
}
Добавим функцию для сбора всех токенов.
export const tokenize = (lexer: Lexer) => {
const tokens: Token[] = []
while (!lexer.done) tokens.push(lexer.next())
return tokens
}
Теперь можем упаковать токены в нужном порядке и посмотреть на результат
const kinds = [Number, Asterisk, Slash, OpenParen, CloseParen, Whitespace]
const result = tokenize(new Lexer(src, kinds))
// (0:0;1) [OpenParen: (]
// (0:1;1) [Number: 2]
// (0:2;1) [Whitespace: •]
// (0:3;1) [Asterisk: *]
// (0:4;1) [Whitespace: •]
// (0:5;1) [Number: 3]
// (0:6;1) [CloseParen: )]
// (0:7;1) [Whitespace: •]
// (0:8;1) [Slash: /]
// (0:9;1) [Whitespace: •]
// (0:10;1) [Number: 6]
Производительность
console.time
в среднем демонстрирует результат 0.3ms для выражения выше, однако первый старт занимает около 2.75ms. Не знаю хорошо это или не очень; в будущем попробуем тоже самое на rust и сравним производительность.
Заключение
Теперь мы научились преобразовывать символы в токены. В следующей части как только я разберусь мы приступим к созданию парсера.
Напоминаю также, что вы можете поиграться и самостоятельно изучить код клонировав репозиторий из github со всем кодом который был тут предоставлен. Я понимаю, что лексер можно было бы сделать в пару строк, но это вы и так найдёте в гугле, я же стремлюсь сделать проект максимально расширяемым, я понимаю, что не весь код может быть понятен тут, поэтому оставляю ссылку на репозиторий, вы также можете задать вопрос в комментариях.
Бонус
Токенизируем шаблонную строку
const src = "`I'm ${8 * 2}!`"
// Ещё не забыли про that. Так вот зачем он нужен
// Обычный текст, ищем только если в состоянии шаблонной строки
const Plain = kind<CustomLexer>('Plain').main(/.+?/).group(/\${|}|`/).allow(that => that.template)
const Asterisk = kind<CustomLexer>('Asterisk').plain('*')
const Number = kind<CustomLexer>('Number').main(/\d+/)
const Whitespace = kind<CustomLexer>('Whitespace').main(/[^\S\r\n]+/)
// Символ шаблонной строки (`) если находим, то переключаем состояние
const Backtick = kind<CustomLexer>('Backtick').plain('`').on(that => that.template = !that.template)
// Начало шаблонного выражения, ищем если в состоянии шаблонной строки,
// если находим, ВЫключаем режим шаблонной строки
const OpenDollarBrace = kind<CustomLexer>('OpenDollarBrace').main(/\${/).allow(that => that.template).on(that => that.template = false)
// Конец шаблонного выражения, ищем если НЕ в состоянии шаблонной строки
// если находим, Включаем режим шаблонной строки
const CloseBrace = kind<CustomLexer>('CloseBrace').main(/}/).disallow(that => that.template).on(that => that.template = true)
// Расшиярем лексер, чтобы хранить собственное состояние
class CustomLexer extends Lexer {
template = false
constructor(src: string) {
// Тот случай когда порядок важен
// Plain берёт любые символы и если он будет в начале,
// то просто "съест" начало шаблонного выражения
super(src, [Backtick, OpenDollarBrace, CloseBrace, Asterisk, Number, Whitespace, Plain])
}
}
Проверяем результат
const result = tokenize(new CustomLexer(src))
// (0:0;1) [Backtick: `]
// (0:1;4) [Plain: I'm•]
// (0:5;2) [OpenDollarBrace: ${]
// (0:7;1) [Number: 8]
// (0:8;1) [Whitespace: •]
// (0:9;1) [Asterisk: *]
// (0:10;1) [Whitespace: •]
// (0:11;1) [Number: 2]
// (0:12;1) [CloseBrace: }]
// (0:13;1) [Plain: !]
// (0:14;1) [Backtick: `]
А чё там с рекурсией?
const recursive = "`Hey ${`I'm recursive ${2 * 3}`}`"
Я прям потел когда смотрел на такую строку, но лексер сделал своё дело
(0:0;1) [Backtick: `]
(0:1;4) [Plain: Hey•]
(0:5;2) [OpenDollarBrace: ${]
(0:7;1) [Backtick: `]
(0:8;14) [Plain: I'm•recursive•]
(0:22;2) [OpenDollarBrace: ${]
(0:24;1) [Number: 2]
(0:25;1) [Whitespace: •]
(0:26;1) [Asterisk: *]
(0:27;1) [Whitespace: •]
(0:28;1) [Number: 3]
(0:29;1) [CloseBrace: }]
(0:30;1) [Backtick: `]
(0:31;1) [CloseBrace: }]
(0:32;1) [Backtick: `]
Спасибо что прочитали ВСЁ, ПРЯМ ВСЁ. Огромное спасибо. Буду очень рад оценке и комментарию.
Комментарии (32)
sunnybear
12.11.2022 23:39+2Лучше все же использовать конечные автоматы, а не регулярные выражения. Так надёжнее
pfffffffffffff
13.11.2022 09:05+1Регулярки это и есть автоматы под капотом
sunnybear
14.11.2022 15:13да, но есть задачи, которые решаются автоматами, но не решаются регулярками
AnthonyMikh
12.11.2022 23:55+5Боже, пожалуйста, нет.
Разработку нового языка программирования нужно начинать с разработки семантики. Для того, чтобы понять, какая должна быть семантика — неплохо бы иметь представление о семантике других ЯП. Только после того, как с семантикой будет ясность — стоит начинать реализацию реально нового ЯП. Синтаксис вторичен.
Если же начать реализацию с синтаксиса, почти наверняка выйдет очередная вариация на тему Алгола, и повезёт, если не слишком кривая.
pewpew
13.11.2022 00:55+2У каждого начинания есть своя цель. Автор пишет, что идея была давно и что пиешт он ЯП для себя. По пути разбираясь что к чему и изучая тему. О том и статья, как изучать написание ЯП практическим путём. Да, это велосипед. Автор прямо и открыто это заявляет. Без претензии на гениальность очередного ЯП — убийцы всех остальных ЯП.
Так что автору успехов в изучении темы. Очень рад, что у людей бывает время, желание и интерес. И пусть у него получится хоть Алгол. Но свой.kpmy
13.11.2022 01:23Если не секрет, у вас сколько своих языков на счету?
P.S. Пусть это будет вопрос ко всем прочитавшим этот комментарий.SnakeSolid
13.11.2022 08:20Сейчас на работе пишу третий language server для разных языков. Это не совсем свой язык, но все базовые вещи, присущие компиляторам там реализованы (нет только компиляции и оптимизации кода).
pewpew
13.11.2022 08:33+4Странный вопрос. Мне кажется большинство ответит как и я — 0.
У всех свои способы изучения увлекательного мира программирования.
Кто-то пишет свой движок для сайтов (я вот писал и не одну итерацию, начиная с нуля). Кто-то считает своим долгом написать свою реализацию игры «сапёр». И ведь пишут!
Кто-то вообще замахивается на собственную ОС. И тоже пишут.
Свой вклад в велосипедостроение делает почти каждый горячо сильно увлечённый чем-то программист.
За себя скажу, что в 2000-х годах меня интересовало куча всякой дичи и как из рога изобилия лилось всякое нужное и не очень (в основном не особо нужное никому кроме меня и это нормально). Это такой процесс обучения. Хочешь разобраться — берёшь и делаешь своё. Создать свой ЯП тогда меня не интересовало. А вот свою реализацию алгоритма LZW или даже упаковщик EXE-файлов я писать пытался. Или например свой анализатор EXE (чем скомпилирован, чем упакован). Это всё потому что меня интересовала эта область, а не потому что в этом у кого-то кроме меня была потребность. А потом случился World Wide Web. И я с головой увлёкся написанием собственных костылей в этой области, хотя можно было пользоваться готовыми. Но набивать свои шишки всегда интереснее.
pharo
13.11.2022 01:27+1Разработку нового языка программирования нужно начинать с разработки семантики.
И/Или с прочтения умных/полезных/интересных книг. :)
Джек Креншоу. Давайте создадим компилятор.pdf (В переложении на iForth)
Оригинальный материал здесьperfect_genius
13.11.2022 13:02Или хотя бы заглянуть сначала на compiler.su
Программисты делятся на тех, кто уже сделал компилятор своего языка программирования, и на тех, кто это сделать только собирается. Этот сайт сделан как раз для тех, кто только собирается.
0xd34df00d
14.11.2022 08:48Начинать, конечно, надо с системы типов. А так как системы типов без strong normalization неполноценны и не нужны, то семантика совершенно вторична и имеет хоть какую-то важность только как порождающая отношение β-эквивалентности.
rsashka
14.11.2022 10:21Любой язык, это в первую очередь семантика, так как типы, это всего лишь небольшая часть терминов, которыми язык оперирует. И только семантика языка определяет выразительность мыслей (исходного текста).
pharo
14.11.2022 14:01А, есть ли грань между «простотой» языка и необходимой и достаточной его выразительности семантики в решении тех или иных задач?
P.S. Не переизбыточны ли в чём семантически языки в целом?rsashka
14.11.2022 14:32Про увеличение сложности языков я даже как-то писал (Простое сложное программирование), но как измерить "выразительность семантики", мне бы самому хотелось понять. Ведь если это одна из самых главных вещей языка, то очень хотелось бы иметь инструменты для её измерения.
0xd34df00d
14.11.2022 16:26+1Выразительность мыслей — это именно система типов хотя бы просто из Карри-Говарда.
rsashka
14.11.2022 16:40Выразительность мыслей — это именно система типов хотя бы просто из Карри-Говарда.
Из вики:
Соответствие Карри — Ховарда в современном представлении не ограничивается какой-то одной логикой или системой типов.
0xd34df00d
14.11.2022 18:08+1Вы это парсите как «не ограничивается системой типов»?
Это невереный парсинг. Верный парсинг — «не ограничивается какой-то одной системой типов», что разумно, потому что это соответствие можно построить хоть для STLC (правда, оно будет скучным), хоть для завтипов, хоть для чего-то между, хоть для линейных типов.
kpmy
13.11.2022 01:26+1Автору можно пожелать успехов и посоветовать две вещи: поменьше листингов в статьях и познакомиться с EBNF (см. Н.Вирт Compiler's Construction), например.
rat1
13.11.2022 04:35+2Не, Книга дракона)
Guul
13.11.2022 09:33+1Она не очень особо рекомендовалась уже в 2009 году(разработчик gcc отмечал некоторые сильные моменты, но "But personally I am not going to buy this book" было заключительным предложением обзора). Очень большой напор на парсинг(она была написана во времена царствования lex/yacc, сейчас компиляторы массово перешли на лёгкий к пониманию и расширению рекурсивный спуск. Конечные автоматы - who dis?). В те времена для новичков рекомендовалась книга engineering a compiler. Сейчас прошло 10+ лет и хз что сейчас актуально, но это точно не дракон.
redf1sh
13.11.2022 11:52Мне в МЦСТ советовали Купер, Торцон Engineering a compiler и ещё N узкоспециализированных книг по реализации конкретных вещей
rat1
14.11.2022 08:39Возможно разработчики компилятора и не купят эту книгу, но как раз новичку очень даже пойдет. Из-за объема книги даже если выкинуть часть про yacc – полное представление о разработки компилятора можно получить.
Cdracm
14.11.2022 00:57+1спасибо автору за старание и за педагогическую пользу. должен заметить только, что в поле (в продакшене, в реальном проекте) так не делают. на каждом символе перебирать все типы лексем нельзя, потому что их десятки тысяч. вызывать class.compile, что бы это ни значило, нельзя, потому что дорого. и самое главное, использовать регексы нельзя, потому что это смерть на неигрушечных строках. вместо этого обычно генерируют конечный автомат по входной грамматике. спасибо.
LIMPIX31 Автор
14.11.2022 14:51Можете привести несколько примеров, где бы регулярные выражения не сработали или вы имеете ввиду, что большая строка будет очень долго ими обрабатываться?
Cdracm
14.11.2022 15:01да, я имею в виду что регексы тормозят и в продакшене их стараются не использовать. исплементация Pattern в java может работать экспоненциально долго.
yarkov
У каждого программиста есть свой велосипед. Кто-то калькулятор пишет, кто-то мессенджер, а кто-то ЯП придумывает. Подписался, буду следить как идут дела ))
forester22
Напоржать? ))
А если не у каждого по велосипед, тут рядом человек тоже пишет ЯП NEWLANGUAGE кажется, сорри за мой Х Французский!
Не проще вам объединиться, а не городить очередной трехполуколесный полусамокат?
Очевидно же что получится с нулевым опытом всего что можно (судя по инфе в профиле)
yarkov
Предлагаете мне тоже ЯП начать писать? Но я не хочу ))
kpmy
Начать ${newLanguage} должен любой программист, а вот закончить ${newLanguage} могут только Mozilla, Google, Microsoft.
perfect_genius
Или Гвидо.
Мне кажется, сделать свой язык успешным — это ещё сложнее, чем сделать игру и заработать на ней. Но чуть вероятнее, чем выиграть в лотерею.
Поэтому мы будем продолжать свою недостижимую цель, т.к. возможный успех принесёт слишком уж большие ништяки — работа мечты, более приятное программирование.
(мой язык хотя бы не текстовый, кстати. Жаль, пока не могу рассказать)