Как сделать интерпретатор c
Пишем простейший скриптовый язык программирования на C# (Часть 1)
Добрый день, %username%. Сегодня мы будем писать скриптовый язык программирования на C#, а точнее его интерпретатор.
Пролог
Я изучаю C# уже год, но никак не могу его нормально выучить. Подумав чуть-чуть я понял что нужно писать что-то более сложное, и в процессе этого получать опыт. Я решил написать интерпретатор Brainfuck. Написал его я минут за 5, но он так и остался без циклов. Затем я начал придумывать свой язык, писал кучу концептов синтаксиса и т.д.
Скорее под кат!
Версия v1.0
Первая версия была не то что не удачной, а просто провалом. Не очень удобный синтаксис и BASIC-подобие было не очень хорошим тоном программирования (По моему мнению). Синтаксис был таким:
Версия v2.0 — Настоящее время
Речь сегодня пойдет именно об этой версии языка. На данный момент это самый приемлимый вариант. Функциональность данной версии уступает версии 1.0, но она гораздо удобней.
Пишем? Пишем!
Ну, наконец кодинг. Сначало осмотрим синтаксис языка, и его функциональную модель.
Вид функций:
Наверное вы заметили странные F и NULL, сейчас о них подробнее.
F и NULL — типы функций, F — работа с файловой системой, NULL — не имеют родителя.
т.е. Синтаксис таков:
Теперь понятно? Думаю да.
Что такое %c? Это равноценно таким штукам как \n, \a etc. (Уж извините, склероз, забыл как называются).
%c — ставим запятую (см. далее)
%n — новая строка
Кодинг скоро? Да, прямо сейчас!
И так кодинг которого все долго ждали.
Сейчас напишем главную функцию, она читает файл по строкам и передает строки парсеру.
Думаю тут все предельно ясно, поехали дальше.
Парсер
Итак, у нас есть Main.cs, funct.cs и Parse.cs. Это все наши классы.
Main.cs — это то что мы писали выше
Parse.cs — наш парсер строк
funct.cs — методы
Модель обрезания строки на функцию и аргументы:
string func = code.Substring(0,a); — начинаем обрезание с 0 символа до первой ‘(‘, в итоге получаем function
string args = code.Substring(a+1,b-a-1); — получаем аргументы начитая с первого символа идущего после ‘(‘ до «);» (+1)
string[] arg = args.Split(‘,’); — создаем массив с аргументами разделенные ‘,’
func = func.Replace(» «, «»); — делаем это во избежание ошибки с пробелом.
Вот Вам 3 базовые функции, write, writeline и readline.
Функции
У нас в языке есть 3 переменные,
с ними можно работать, это и есть новшество языка по сравненю с прошлой версей.
На этом первая часть заканчивается, вторая часть будет про файловую систему. До встречи!
Пишем простой интерпретатор на C++ с помощью TDD, часть 1
Введение
Многие C++ программисты слышали про разработку через тестирование. Но почти все материалы по данной теме касаются более высокоуровневых языков и сосредоточены больше на общей теории, чем на практике. Итак, в данной статье я попробую привести пример пошаговой разработки через тестирование небольшого проекта на C++. А именно, как можно предположить из названия, простого интерпретатора математических выражений. Такой проект также является неплохой code kata, так как на его выполнение затрачивается не более часа (если не писать параллельно статью об этом).
Архитектура
Несмотря на то, что при использовании TDD архитектура приложения постепенно проявляется сама собой, начальная её проработка всё же необходима. Благодаря этому может значительно снизиться общее время, затраченное на реализацию. Это особенно эффективно в тех случаях, когда существуют готовые примеры подобных систем, которые можно взять за образец. В данном случае, существует вполне устоявшееся мнение о том, как должны быть устроены компиляторы и интерпретаторы, чем и можно воспользоваться.
Существует множество библиотек и инструментов, которые могут облегчить разработку интерпретаторов и компиляторов. Начиная от Boost.Spirit и заканчивая ANTLR и Bison. Можно даже запустить канал интерпретатора командной строки через popen и вычислить выражение через него. Целью данной статье является пошаговая разработка достаточно сложной системы с помощью TDD, поэтому будет использоваться только стандартная библиотека C++ и встроенный в IDE тестовый фреймворк.
Для начала, составим список того, что должен уметь наш простой интерпретатор, в порядке убывания приоритета:
Инструментарий
Программа будет писаться в Visual Studio 2013 с установленным Visual C++ Compiler Nov 2013 CTP. Тесты будут на основе встроенного в студию тестового фреймворка для C++ проектов CppUnitTestFramework. Он предоставляет минимальную поддержку для написания модульных тестов (по сравнению с Boost.Test, или CppUTest), но, с другой стороны, хорошо интегрирован в среду разработки. Альтернативой может служить Eclipse с установленным плагином C/C++ Unit и настроенным Boost.Test, GTest, или QtTest. В такой конфигурации рекомендую использовать clang, так как он предоставляет несколько мощнейших compile- и runtime анализаторов, в результате чего, в связке с TDD, код становится совершенно неуязвимым для ошибок.
Итак, создадим новый проект типа «Native Unit Test Project» и удостоверимся, что всё компилируется.
Лексер
Начнём с разработки лексера. Будем следовать привычному для TDD циклу Red-Green-Refactor:
Сейчас проект компилируется, но тест, что было ожидаемо, падает. Для определения того, что же писать дальше, можно воспользоваться техникой The Transformation Priority Premise (TPP) авторства Роберта Мартина. Трансформации являются аналогами рефакторингов, но, в отличии от них, используются для изменения поведения кода, тогда как рефакторинг к изменению поведения не приводит. Каждая трансформация ведёт к изменению кода от более конкретного к более общему. Главная их особенность в том, что они имеют разные приоритеты, в зависимости от которых выбирается то, какой код писать для прохождения теста и какой будет следующий тест. А именно, те трансформации, которые проще (располагаются выше в списке) должны быть более предпочтительны, чем те, которые снизу. При намерении создать новый тест, выбирается такой, что для его прохождения нужно применить более простую трансформацию. Это не является строгим правилом, но следование TPP может вести к более простому коду за меньшее количество шагов.
Сам список трансформаций:
Посмотрим, что должен уметь лексер для выполнения первого пункта требований. Занесём это в список тестов.
Также пришлось изменить определение токена и добавить перечисление Operator с названиями арифметических операторов. Можно было бы использовать для этой цели просто тип wchar_t с символом оператора, но тогда в будущем придётся иметь дело с тем, как различать бинарные и унарные операции.
После этого тест компилируется, но не проходит, так как мы продолжаем возвращать пустую последовательность токенов. Исправим это, применив трансформацию (unconditional → if).
Добавив метод ToString для перечисления TokenType и подправив аналогичный метод для самого токена, заставим всё компилироваться, а тесты проходить. Напишем следующий тест из списка.
Он не проходит. Примени трансформацию (constant → scalar) для класса токена.
Для удобства преобразования токена к нужному типу будем использовать оператор неявного приведения.
Аналогично напишем тест для числового токена.
Все тесты, относящиеся к токену проходят, можно восстановить предыдущие тесты и убедиться, что ничего не сломалось. Последний тест всё так же не проходит. Приступим к его исправлению.
Выгладит пока что не очень симпатично, но тест проходит.
После этого можно приступить и к более сложным тестам. Попробуем обработать полюс и число одновременно.
Теперь заставить пройти тест довольно просто: применим трансформацию (if → while). Можно было бы использовать рекурсию, но я решил целенаправленно делать не рекурсивный алгоритм.
Применим (unconditional → if) добавив проверку на то, что символ является оператором.
На данном этапе проведём рефакторинг данной функции. Выделим логику в отдельный класс и разобьём на отдельный методы. Поместим этот класс в пространство имён Detail чтобы не засорять публичный интерфейс лексера. Теперь функция Tokenize просто будет служить фасадом для модуля лексера.
Как видно, извлечение класса сделало код гораздо понятнее. Без тестов такой рефакторинг был бы, как минимум, рискованным. Теперь добавим поддержку скобок и остальных операторов.
Все тесты проходят и можно приступить к написанию парсера. Ниже приводится весь исходный код на данный момент.
Код к статье на GitHub. Названия коммитов и их порядок соответствуют тестам и рефакторингам, описанным выше. Окончанию первой части соответствует метка «Конец_первой_части».
Разработка парсера будет рассмотрена во второй части. Разработка вычислителя и фасада для парсера, а также рефакторинг всего кода будут рассмотрены в третьей части.
Интерпретатор скрипта на С++
Написал простой интерпретатор, конечно не конкурент lua, но тоже может пригодиться.
Кому интересно прошу.
Сразу пример, что получилось:
Что хотелось
Идея была в том, чтобы полностью отделить функционал (пользовательские операторы и функции) от самого языка скрипта, ограничиться минимумом ключевых слов (всем известных), ну и сделать интерпретатор и язык компактным и удобным для использования.
Что получилось
Скриптовый язык вышел простой и ограниченный конечно.
Состоит из трех компонентов — переменные, выражения и функции, и нескольких базовых ключевых слов. Тип значений для всех компонентов — строка.
Нет жестко забитых функций и операторов, все добавляет программист перед запуском скрипта.
Как это работает
Первый этап — парсинг скрипта. На этом этапе идет синтаксический разбор текста скрипта: выделяются ключевые слова, операторы, функции.
Явно АСД не создается, но косвенно появляется (можно сказать в плоскости массива) в виде очереди операций, которые должны быть выполнены последовательно, то есть другими словами, все встречающиеся сущности попадают в массив операций сразу в нужном порядке выполнения.
Все ошибки написания скрипта находятся на этом этапе.
Второй этап — выполнение скрипта. Здесь идет проход по массиву операций, с последовательным выполнением каждой.
Внутри все построено на рекурсивном вызове функций и проверках условий вызова.
Основные компоненты скрипта:
Ко всем переменным в скрипте можно обращаться (и изменять их при необходимости) из основного кода, например, в функции:
В скрипте функция вызывается по имени, параметры передаются в скобочках, как обычно:
Функция может принимать другие функции и выражения:
При создании оператора помимо определения нужно задать приоритет.
Приоритет работает так же как в С++: нулевой наивысший, далее чем больше значение приоритета, тем позже будет выполнен оператор. Порядок выполнения операторов с одинаковым приоритетом — слева направо.
Операторы используются в выражениях.
Теперь остался вопрос, как передать структуру в функцию. Здесь можно непосредственно передавать имя структуры, а в функции уже брать конкретные поля:
Но лучше не сразу передавать имя структуры в функцию, а воспользоваться макросом (о макросе ниже). Макрос можно определить предварительно в основном коде:
В этом случае будет проведена проверка существования макроса на этапе парсинга скрипта:
Теперь опишу остальные ключевые слова языка скрипта, в основном это управляющие конструкции.
Во всех управляющих конструкциях, можно обойтись без фигурных скобок, если тело состоит из одного выражения:
Макросы можно ставить в параметры функций:
На метку можно перемещаться из основного кода, например, в функции скрипта вызвать специальную функцию ‘gotoOnLabel’ (это конечно грязный хак, специально для месье, которые знают..):
Как использовать и где может быть полезен
Предлагается использовать как код, то есть добавлять в свой проект файл исходного кода интерпретатора, он всего один. Заголовочный файл тоже единственный.
Может использоваться в простых случаях, когда не хочется подключать что-то внешнее, но нужно дать пользователю возможность интерактивно влиять на ход выполнения ПО.
Либо в случаях, когда не хочется давать пользователю в руки весь арсенал скриптовых языков, а ограничиться простым набором команд.
Еще можно попробовать построить RPC на его основе.
Что дальше, что планируется нового
Если коротко, то ничего.
Проект в ширину расти не будет, никаких новых ключевых слов, структур не планируется добавлять. Не хочу, чтобы он разбух и превратился в еще один птичий язык, в котором надо разбираться, что там наворочено, а здесь все пока прозрачно более-менее.
Только поддержка, правка багов, возможно, стоит добавить несколько заранее определенных пользовательских функций, отдельно.
Пишем интерпретатор скрипта и стековую машину
Краткая предыстория
Господа, я раскрою вам страшную тайну. Я знаю, что признаться в таком грехе — очень стыдно, но тем не менее, признаюсь. Я — один-эсник. Да-да-да, я пишу большие и малые решения с помощью желтого фреймворка и совесть меня не мучает))).
Помимо 1С я знаю и другие языки, но я молодой, могу себе позволить. Мой знакомый, которого я упомянул во введении — наоборот — военный отставник, хороший инженер-электронщик, переучился в программисты, когда вышел на гражданку. Для грамотного технаря такая переквалификация — несложное дело, платят в сфере 1С неплохо, а хорошим спецам — так и вовсе хорошо. Однако, с возрастом все труднее становится гнаться за новыми технологиями, все сложнее следить за стремительно меняющимся миром IT. Когда состоялся упомянутый разговор, он сказал что-то вроде: «Да это долго, новый язык учить, вот если бы такой скрипт на 1С можно было написать, но чтоб в консоли выполнялся…» Тогда-то я и вспомнил свою старую идею с написанием интерпретатора, которая теперь из чисто исследовательской получала вполне прикладную направленность — выполнение скриптов, работающих с инфраструктурой WSH, но на языке 1С.
Немного о языке программирования
Про язык 1С говорят, что это Visual Basic, переведенный промтом. Это действительно так. По духу язык очень близок к VB — в нем нестрогая типизация, нет ООП, лямбда-выражений и замыканий. В нем «словесный», а не «сишный» синтаксис. Кроме того, поскольку все ключевые слова имеют английские аналоги, то код 1С, написанный в английских терминах отличить с первого взгляда от VB почти невозможно. Разве что символ комментария — две косые черты, а не апостроф, как в бейсике.
Язык различает процедуры и функции, имеет классические циклы «For» и «While», а также итераторный цикл «For Each … In». Обращение к свойствам и методам объектов выполняется «через точку», обращение к массивам — в квадратных скобках. Каждый оператор завершается точкой с запятой.
Что такое «стековая машина» и как она работает
Каждая инструкция представляет собой однобайтовый код операции от 0 до 255, за которым следуют такие параметры, как регистры или адреса памяти (википедия).
В нашей машине команда будет иметь фиксированную длину и состоять из двух чисел — кода операции и аргумента операции. Каждая команда подразумевает набор действий над данными в стеке.
Например, псевдокод операции сложения «1+2» может выглядеть следующим образом:
Первые две команды кладут в стек слагаемые, третья команда выполняет сложение и кладет результат в стек. В таком случае, операция присваивания «A = 1 + 2» может выглядеть так:
Видно, что команды Push и LoadVar имеют аргумент, команда Add в аргументе не нуждается.
Преимущество стековых вычислений в том, что операции выполняются в порядке их следования, не нужно обращать внимание на приоритеты операций. Что написано, то и выполняется. Перед выполнением операции ее операнды заталкиваются в стек. Такой способ описания выражений получил название «обратной польской записи»
Задача написания стековой машины сводится к «изобретению» необходимого набора команд, которые эта машина будет понимать.
Устройство компилятора
Синтаксический анализатор проверяет, что набор лексем, которые дал ему лексический анализатор является осмысленным, что перед нами программа, а не бессмысленный набор букв.
Анализатор строит абстрактное синтаксическое дерево (AST), которое затем подается на вход генератора кода.
Генератор кода создает байт-код, обходя узлы синтаксического дерева.
Я начал писать компилятор по такой же трехзвенной схеме, но потом мне это показалось излишне правильным и в рамках данной задачи — ненужным. Поэтому, я отказался от AST и объединил синтаксический анализ с генерацией кода.
Компилятор в цикле запрашивает у парсера очередную лексему и, глядя на нее, генерирует код. Попутно проверяет, что лексема является разрешенной в данном контексте. AST в этой схеме оказывается лишним.
Конечный автомат
Устройство виртуальной машины
Контексты видимости
Все переменные и методы всегда принадлежат к какому-либо контексту. Существует глобальный контекст, контекст модуля и локальный контекст внутри метода. Получается стек доступных (видимых) имен, к которому можно обращаться при выполнении. С этим стеком идет активная работа в момент компиляции и выполнения.
Более того, если развить идею и предположить, что можно создавать экземпляры контекстов, то естественным образом возникает модульность. Если у нас есть некий модуль, как набор методов и переменных, то мы имеем контекст, в котором весь код этого модуля работает. Теперь, если мы создадим два экземпляра одного и того же модуля, то фактически мы создадим два экземпляра одного класса. Каждый из них будет видеть свой собственный контекст и иметь собственное состояние.
Документация 1С этого прямо не говорит, но наблюдаемое поведение позволяет сделать вывод, что выполнение модулей в исполняющей среде 1С работает именно таким образом. Исполняющая среда работает не с экземплярами объектов, а с контекстами, которые «присоединяются» к виртуальной машине по мере необходимости.
Память
Условная «память» машины представляет собой список подключенных к ней контекстов. Каждый контекст имеет свой номер и состояние (конкретные значения переменных). Команды чтения/записи переменных имеют аргумент в виде номера ячейки в таблице контекстов. Каждая запись в таблице описывает номер контекста и номер переменной в этом контексте, над которой выполняется действие.
Индекс | Номер контекста | Номер переменной |
---|---|---|
0 | 0 | 0 |
1 | 0 | 1 |
2 | 1 | 3 |
Например, если поступила команда PushVar 1, то в таблице по индексу 1 выбирается запись, в которой сказано — взять из контекста 0 переменную номер 1.
Переменные — это простой массив, который каждый подключенный контекст сообщает машине. Машина пишет и читает данные из этого массива, изменяя таким образом состояние подключенных контекстов. При этом неважно, что за контекст подключен — экземпляр какого-то класса или глобальный контекст. Машина умеет менять переменные, а что за переменные — не имеет значения.
Стек вызовов
Выполнение кода организовано с помощью так называемых «кадров», каждый из которых представляет собой набор локальных переменных и указатель на текущую инструкцию. При вызове какого-либо метода текущий кадр помещается в стек. Таким образом сохраняется состояние машины на момент вызова.
Затем формируется новый кадр с пустым массивом локальных переменных и номером инструкции, равным началу вызываемого метода. Этот новый кадр становится текущим и цикл выполнения команд продолжается уже с него.
По достижении команды Return текущий кадр уничтожается (вместе со значениями локальных переменных, которые вышли из области видимости), из стека вызовов извлекается сохраненный кадр с предыдущим состоянием и цикл исполнения команд продолжается с точки вызова.
Фрагмент псевдокода с вызовом метода.
Предположим, что текущая инструкция имеет номер 6. Это вызов по адресу 0. Локальные переменные и номер текущей инструкции сохраняются в стеке вызовов. Далее, управление передается по адресу 0, а затем возвращается на адрес 6, где происходит переход далее, на очередную инструкцию.
Реализация команд
Устройство исполняемого модуля
Константы
Переменные
Методы
Методы разделяются на процедуры и функции. Последние могут возвращать значения. По умолчанию параметры в методы передаются по ссылке. Для передачи по значению параметр метода должен быть обозначен ключевым словом «Знач» (аналог ByVal в Бейсике).
В скомпилированном модуле содержится информация о параметрах метода, их обязательности, наличии возвращаемых значений и т.п. Каждый метод имеет свой номер. Кроме того, для каждого метода указывается количество локальных переменных этого метода.
Методы, как и переменные могут быть внешними, по отношению к модулю (т.е. объявленными где-то глобально). Обращение к методам организовано так же, как и в переменных — через таблицу соответствия, определяющую контекст, в котором находится вызываемый метод.
Конечная структура модуля
Работа со значениями
Интерфейс позволяет выяснить действительный тип значения, а также выполнить приведение к базовым типам. Такое приведение необходимо, например, для выполнения арифметических операций. Класс, реализующий конкретный тип значения сам пробует выполнить приведение своего значения к каждому из базовых типов.
При вычислении выражения тип конечного результата определяется по типу первого операнда. Так, выражение «12345» + 10 должно выдать строковый результат. При выполнении сложения второй аргумент будет приведен к строке и выполнена конкатенация.
Напротив, операция 10 + «12345» выполнит попытку приведения строки «12345» к числу. Если это приведение будет невозможным — возникает исключение «ошибка приведения к типу Число».
В приведенных примерах у класса, реализующего тип «Число» вызывается метод AsString(), а у класса, реализующего тип «Строка» — метод AsNumber().
Доступ к свойствам и методам объектов
Каждый объект может иметь свойства и методы, к которым можно обратиться «через точку». Обращение выполняется по имени. Механика обращения по имени представлена специальным интерфейсом, который позволяет выяснять наличие у объекта нужных членов и обращение к ним.
При обращении к свойству или методу у объекта запрашивается номер этого свойства/метода. Далее по этому номеру выполняется запрос о доступности чтения и записи свойства, количеству параметров метода, наличию возвращаемого значения и т.д. После выяснения этой информации выполняется вызов.
Для свойств это установка или чтение значения. Для методов — вызов как процедуры или вызов как функции. В последнем случае возвращенное значение кладется в стек виртуальной машины.
Работа с объектами как с контекстами и наоборот
Выше я упомянул о модульности и о создании объектов, как экземпляров контекстов. Давайте рассмотрим подробнее, что имелось в виду и как организовано обращение к объектам «через точку».
Представьте, что у нас есть некая библиотека функций. Мы можем вызывать функции из этой библиотеки и наслаждаться результатом. А теперь представьте, что библиотека — это экземпляр класса, с набором публичных методов и свойств. Этот экземпляр незаметно «встроен» в нашу область видимости так, что мы вызываем методы экземпляра напрямую, как если бы это были глобальные функции.
Если экземпляр класса » MathLibrary » подключить в стек областей видимости, то функции Sin, Cos и Sqrt можно вызывать прямым обращением. Они будут видны, как обычные методы, объявленные где-то на уровне библиотек.
А теперь сделаем обратную операцию. Если мы написали какой-то скрипт, то он сам по себе является контекстом. Он предоставляет область видимости в которой работает код. А что, если мы создадим экземпляр этого скрипта и запишем его в переменную? Получится, что наш скрипт является классом, со своими свойствами и методами и с ним можно работать, как с объектом. На этом принципе построена возможность подключать (импортировать) внешние файлы скриптов, и работать с ними, как с объектами — создавать экземпляры, вызывать методы и т.п.
При этом когда скрипт А вызывает скрипт Б, то скрипт Б подключается в память машины, как контекст и исполнение кода Б идет, как если бы Б был единственным скриптом. При возврате из модуля Б он отсоединяется и выполнение снова переходит к скрипту А.
Примеры байт-кода
Ниже приведены примеры того, как организовано исполнение некоторых операций в байт-коде.
Что там было про WSH?
Инфраструктура скриптов WSH представлена рядом COM объектов. К этим объектам можно обращаться из скриптовых языков, обращаясь к их членам по имени, с помощью IDispatch. Ничего не напоминает?
Достаточно сделать небольшую обертку над IDispatch, которая позволит машине работать с этими COM-объектами через упомянутый интерфейс IRuntimeContextInstance.
На рисунке представлено окно тестового приложения, которое я использовал для отладки скриптов. В нем выполнен скрипт, перечисляющий метки дисков с помощью объекта Scripting.FileSystemObject
Конечно, на данный момент полностью все возможности WSH не реализованы и абсолютно полноценной заменой этот движок быть не может. Например, отсутствует возможность создания объектов WSH на удаленных машинах, а если покопать глубже, то и еще найдется много всего. Тем не менее, для большинства локальных задач, как мне кажется, этот движок вполне подойдет.
Исходники и прочее
Весь проект состоит из двух приложений и dll с движком машины.
Приложение TestApp — это вспомогательный GUI-based инструмент, в котором можно пробовать работу движка, писать тестовые скрипты и запускать их выполнение.
Консольное приложение oscript это основной инструмент работы в командной строке.
Поддерживаемые команды oscript показывает сам, если его запустить без параметров.
Есть также мысль сделать объединение скриптов и исполняющего приложения в один модуль, чтобы получался самостоятельный exe-файл. Но до этого пока руки не дошли.
Исходные коды доступны на bitbucket. Там же, коротенькое wiki о доступных языковых средствах.
Кому интересно посмотреть, как работает, но не хочется собирать из исходников — setup.exe
Короткое заключение
В рамках проекта разработан интерпретатор сценариев на языке 1С, включающий в себя стековую виртуальную машину, исполняющую сценарий и транслятор языка 1С в байт-код виртуальной машины.
Производительность получилась примерно сравнимой с оригиналом. Если учесть, что это любительский проект, сделанный «на коленке», то результат, полагаю, можно считать неплохим. Каких-то серьезных исследований по скорости я не проводил. Ради интереса можно посравнивать скорость математики и больших чисел, но это уже немного отдельная тема.
Надеюсь, данный опус был вам интересен. Форкайте проект, критикуйте, мне будет приятно. Удачи.