Язык предметной области. Не перегружен конструкциями языка общего назначения. При этом позволяет всего несколькими строчками реализовать весьма сложную логику. Все это — DSL.
Однако создание DSL требует от разработчика соответствующей квалификации. Регулярное использование этого подхода превращается в рутину разработки очередного языка. Решением может стать создание универсального инструмента — движка, который будет применим к совершенно разным задачам и прост в модификации. В этой статье мы на C# разработаем простейший с точки зрения реализации, но при этом достаточно мощный языковой движок, при помощи которого можно решить достаточно широкий круг задач.
Введение
Есть два способа разработки проекта приложения: сделать его настолько простым, чтобы было очевидно, что в нем нет недостатков, или сделать его настолько сложным, чтобы в нем не было очевидных недостатков. Ч. Э. Р. Хоар (C. A. R. Hoare)В этой статье я хочу поделиться одним из приемов разработки, который помогает мне и моей команде, с одной стороны, бороться со сложностью проектов. А с другой — позволяет быстрее разрабатывать прототипы приложений. На первый взгляд разработка языка программирования кажется слишком сложной задачей. Так оно и есть, если речь идет об универсальном инструменте. Если же целью является покрытие узкой предметной области, то разработка специфического языка часто вполне себя оправдывает.
Когда-то передо мной стояла задача разработать реализацию промышленного языка (IEC 61131-3) для встраивания в ПО заказчика. В ходе этой работы я увлекся темой интерпретатора-строения и с тех пор в качестве хобби писал интерпретаторы эзотерических и не очень языков. В дальнейшем пришло понимание как использовать самописные интерпретаторы для упрощения повседневной жизни.
Основная цель вменяемых языков программирования — упростить процесс программирования и чтения программы. Писать на asm проще, чем в машинных кодах, писать на C проще, чем на asm, на C# — еще проще и так далее.
Достигается это в основном за счет самого популярного приема редукционизма — разбиения сложной задачи на простые и осознаваемые компоненты — стандартизации их взаимодействия и определенного синтаксиса.
Язык программирования состоит из набора операторов, что по сути является базисом языка, элементарными строительными блоками, и синтаксиса, который задает способ записи комбинирования операторов, а также стандартной библиотеки. Последовательности элементарных действий согласно синтаксическим правилам группируются в функции, функции группируются в классы (если есть ООП), классы объединяются в библиотеки, а те, в свою очередь, в пакеты. Так выглядит типичный мейнстрим язык. В принципе этих приемов вполне достаточно для решения большинства повседневных задач. Однако это еще не предел, ведь можно сделать шаг дальше — к более высокому уровню абстракции, при этом придется выйти за пределы используемого языка, если он не поддерживает метапрограммирование в виде макросов.
В наше время большая часть проектов сводится к комбинированию уже готовых компонент и незначительной низкоуровневой самописной части. Комбинирование компонент обычно делается средствами универсального языка программирования — C#, Java, Python и других. Хотя эти языки и высокоуровневые, они также являются универсальными, а потому обязательно содержат синтаксические конструкции для низкоуровневых операций, создания функций, классов, описание обобщенных типов, асинхронное программирование и многое другое. Из-за этого задача «Сделать раз, сделать два, сделать три» обрастает массой синтаксических конструкций и может раздуться до сотен строк кода и более.
Упростить переиспользование компонент можно, если повторить прием редукционизма, но уже к этим самым компонентам. Достигается это через разработку специализированного языка, который имеет упрощенный синтаксис и служит исключительно для описания взаимодействия этих компонент. Такой подход называется ЯОП (языково-ориентированное программирование), а языки именуются DSL (Domain-Specific Language — язык, специфический для предметной области).
Из-за отсутствия избыточных конструкций всего несколько строк на DSL могут реализовать довольно сложный функционал, что приводит к положительным следствиям: увеличивается скорость разработки, сокращается количество ошибок, упрощается тестирование системы.
Этот подход при удачном применении может существенно увеличить гибкость разрабатываемого продукта за счет возможности написания компактных скриптов, определяющих и расширяющих поведение системы. Приложений данному подходу может быть масса, о чем свидетельствует распространенность данного подхода, ведь DSL повсюду. Распространенный HTML является языком описания документов, SQL — язык структурированных запросов, JSON — язык описания структурированных данных, XAML, PostScript, Emacs Lisp, язык nnCron и много других.
При всех плюсах у DSL есть и существенный недостаток — высокие требования к разработчику системы.
Далеко не каждый разработчик имеет знания и опыт и в разработке даже примитивного языка. Разработать достаточно гибкий и производительный язык могут еще меньшее количество специалистов. Есть и другие проблемы. Например, в определенный момент развития изначально заложенного функционала может не хватить и понадобится создавать функции или ООП. А там, где есть функции, может потребоваться оптимизация хвостовой рекурсии, чтобы обходиться без циклов, и так далее. При этом приходится учитывать обратную совместимость, чтобы ранее написанные скрипты продолжали работать с новой версией.
Еще одна проблема заключается в том, что язык, разработанный для решения одной задачи, совершенно не подходит для других. Следовательно, приходится разрабатывать новый DSL с нуля, таким образом разработка новых языков становится рутинной задачей. Это опять усложняет сопровождение и уменьшает повторное использование кода, который сложно поделить между разными реализациями DSL и проектами, их использующими.
Выход видится в создании DSL для построения DSL. Здесь я имею в виду не РБНФ, а скорее язык, который может быть изменен встроенными средствами до языка предметной области. Основной помехой в создании гибкого и трансформируемого языка является наличие жестко заданного синтаксиса и система типов. За все время развития компьютерной индустрии было предложено несколько гибких языков без синтаксиса, но дожили до наших дней и продолжают активно развиваться языки Forth и Lisp. Главная особенность этих языков в том, что они благодаря своей структуре и гомоиконности могут за счет встроенных средств менять поведение интерпретатора и при необходимости разбирать синтаксические конструкции, которые не были заложены изначально.
Для Forth есть решения, расширяющие его синтаксис до C или до Scheme. «Форт» часто критикуют за непривычную постфиксную последовательность аргументов и операций, которая диктуется использованием стека для передачи аргументов. Однако «Форт» имеет доступ к текстовому интерпретатору, это позволяет при необходимости скрыть от пользователя обратную запись. Да и, наконец, это же дело привычки, а вырабатывается она довольно быстро.
Семейство языков Lisp опирается на макросы, которые позволяют вводить DSL при необходимости. А доступ к интерпретатору и ридеру способствует реализации метациклических интерпретаторов с заданными особенностями интерпретации. Например, реализация Scheme lisp Racket позиционируется как среда для разработки языков и имеет из коробки языки для создания веб-серверов, построения GUI-интерфейсов, язык логического вывода и другие.
Такая гибкость делает эти языки хорошими кандидатами на роль движка универсального DSL.
«Форт» и Lisp в основном развиваются как языки общего назначения, хоть и нишевые, как следствие — тянут за собой функционал, который бывает избыточным для DSL языка. Но при этом они достаточно просты для реализации, а значит, можно разработать ограниченную версию с возможностью ее расширения. Это позволит переиспользовать ядро такого языка с небольшими модификациями (в идеале — без) под конкретную задачу.
Также хочу отметить, что эти языки отлично подходят не только для написания скриптов, но и для интерактивного взаимодействия с системой через REPL. Что, с одной стороны, может быть удобно для отладки, а с другой — выступать в качестве доступного пользователю интерфейса с системой. Есть мнение, что текстовый интерфейс с системой в ряде случаев может быть эффективнее графического, поскольку он значительно проще для реализации, более гибок, позволяет пользователю обобщать типовые операции в функции и так далее. Ярким примером текстового интерфейса может быть Bash. А если язык будет гомоиконным, то его конструкции можно сравнительно легко генерировать и парсить и минимальными силами реализовать поверх интерпретатора графический язык — это может быть полезно, когда целевой пользователь далек от программирования.
В наше время в качестве DSL для конфигурирования широко используются языки описания данных XML и JSON. Безусловно, это отличная практика, однако в ряде случаев одних только данных бывает недостаточно и нужно, например, описывать операции над ними.
В этом посте я предлагаю создать простой интерпретатор языка «Форт» и покажу, как его адаптировать для решения конкретных задач.
Язык «Форт» был выбран как наиболее простой в реализации и использовании, при этом достаточно мощный для применения его в качестве DSL для ряда задач. По сути, сердцем языка является адресный интерпретатор, который даже на ассемблере занимает всего несколько строчек, а основной объем реализации приходится на примитивы, которых тем больше, чем более универсальной, быстрой и гибкой должна быть реализация. Также важной частью языка является текстовый интерпретатор, позволяющий взаимодействовать с адресным интерпретатором.
Адресный интерпретатор
Базовым элементом языка «Форт» является слово, которое отделяется от других слов и атомов (чисел) пробелами, концами строк и табуляциями.
Слово имеет такой же смысл и свойства, как и функция из других языков, например C. Слова, зашитые в реализации, то есть реализованные теми же средствами, что и интерпретатор, аналогичны операторам из других языков. Фактически программа на любом языке программирования — не более чем комбинация операторов языка и данных. Поэтому создание языка программирования можно рассматривать как определение операторов и способа их комбинирования. При этом такие языки, как С, определяют разный способ записи операторов, который определяет синтаксис языка. В большинстве языков модификация операторов обычно невозможна — например, нельзя изменить синтаксис или поведение оператора if.
На языке «Форт» все операторы и их комбинации (пользовательские слова) имеют одинаковый способ записи. Форт-слова делятся на примитивные и пользовательские. Можно определить слово, которое будет перегружать примитив, таким образом меняется поведение примитивов. Хотя в действительности переопределенное слово будет реализовано через заданные изначально примитивы. В нашей реализации примитивом будет функция на C#. Слово, определенное пользователем, состоит из списка адресов слов, которые должны быть выполнены. Поскольку есть два вида слов, интерпретатор должен их различать. Разделение примитивов и пользовательских слов осуществляется через те же примитивы, каждое пользовательское слово начинается с операции DoList и заканчивается операцией Exit.
Можно долго описывать, как происходит такое разделение, но проще понять это, изучив порядок выполнения программы интерпретатора. Для этого реализуем минимальный интерпретатор, зададим несложную программу и посмотрим, как она будет выполняться пошагово.
Наша форт-машина состоит из линейной памяти, стека данных, стека возврата, указателя инструкций, указателя слова. Также у нас будет отдельное место для хранения примитивов.
public object[] Mem; // Память программ
public Stack<int> RS; // Стек возвратов
public Stack<object> DS; // Стек данных
public int IP; // Указатель инструкций
public int WP; // Указатель слова
public delegate void CoreCall();
public List<CoreCall> Core; // Хранилище примитивов
Суть интерпретации заключается в переходе по адресу в памяти и в исполнении инструкции, которая там указана. Весь адресный интерпретатор — сердце языка — в нашем случае будет определен в одной функции Next().
public void Next() {
while (true) {
if (IP == 0)
return;
WP = (int)Mem[IP++];
Core[(int)Mem[WP]]();
}
}
Каждое пользовательское слово начинается с команды DoList, задача которой — сохранить текущий адрес интерпретации на стеке и установить адрес интерпретации следующего слова.
public void DoList() {
RS.Push(IP);
IP = WP + 1;
}
Для выхода из слова используется команда Exit, которая восстанавливает адрес со стека возвратов.
public void Exit() {
IP = RS.Pop();
}
Для наглядной демонстрации принципа работы интерпретатора введем команду, она будет имитировать полезную работу. Назовем ее Hello().
public void Hello() {
Console.WriteLine("Hello");
}
Сначала необходимо инициализировать машину и указать примитивы, чтобы интерпретатор работал правильно. Также нужно указать адреса примитивов в памяти программ.
Mem = new Object[1024];
RS = new Stack<int>();
DS = new Stack<object>();
Core = new List<CoreCall>();
Core.Add(Next);
Core.Add(DoList);
Core.Add(Exit);
Core.Add(Hello);
const int opNext = 0;
const int opDoList = 1;
const int opExit = 2;
const int opHello = 3;
// core pointers
Mem[opNext] = opNext;
Mem[opDoList] = opDoList;
Mem[opExit] = opExit;
Mem[opHello] = opHello;
Теперь можем составить несложную программу, в нашем случае пользовательский код будет начинаться с адреса 4 и состоять из двух подпрограмм. Первая подпрограмма начинается с адреса 7 и вызывает вторую, которая начинается по адресу 4 и выводит слово Hello на экран.
// program
Mem[4] = opDoList; // 3) сохраняем адрес интерпретации IP = 9 на стеке возвратов, затем устанавливаем IP = WP + 1 = 5
Mem[5] = opHello; // 4) выводим на экран сообщение
Mem[6] = opExit; // 5) выходим из слова, восстанавливаем IP = 9 со стека возвратов
Mem[7] = opDoList; // 1) точка входа в подпрограмму
Mem[8] = 4; // 2) вызов подпрограммы по адресу 4, устанавливаем WP = 4
Mem[9] = opExit; // 6) выходим из слова, восстанавливая IP = 0 со стека возвратов
Чтобы выполнить программу, нужно сначала сохранить в стеке возврата значение 0, по которому адресный интерпретатор прервет цикл интерпретации, и установить точку входа, после чего запустить интерпретатор.
var entryPoint = 7; // адрес точки входа
IP = 0; // устанавливаем IP = 0, чтобы завершить выполнение программы по ее завершении
WP = entryPoint; // устанавливаем WP = 7 в качестве адреса точки входа
DoList(); // выполняем команду начала интерпретации слова, сохраняя IP = 0 на стеке возвратов
Next(); // запускаем адресный интерпретатор
Как было описано, в этом интерпретаторе примитивы будут храниться в отдельной памяти. Конечно, можно было бы реализовать иначе: например, в памяти программ сохранять делегат на функцию оператор. С одной стороны, такой интерпретатор не получился бы проще, а с другой — был бы явно медленнее, поскольку каждый шаг интерпретации требовал бы проверку типа, кастинг и исполнение, операций получается больше.
Каждое пользовательское слово нашего интерпретатора начинается с примитива DoList, задача которого — сохранить текущий адрес интерпретации и перейти на следующий адрес. Выход из подпрограммы осуществляется операцией Exit, которая восстанавливает адрес со стека возвратов для дальнейшей интерпретации. По сути, мы описали весь адресный интерпретатор. Чтобы выполнять произвольные программы, достаточно расширить его примитивами. Но прежде нужно разобраться с текстовым интерпретатором, который и обеспечивает интерфейс к адресному интерпретатору.
Текстовый интерпретатор
Язык «Форт» не имеет синтаксиса, программы на нем записываются словами, разделенными пробелами, табуляциями или концами строк. Следовательно, задача текстового интерпретатора — разбивать входной поток на слова (токены), находить для них точку входа, выполнять или записывать в память. Но не все токены подлежат исполнению. Если интерпретатор не находит слово, он пытается его интерпретировать как числовую константу. Кроме того, у текстового интерпретатора есть два режима: режим интерпретации и режим программирования. В режиме программирования адреса слов не исполняются, а записываются в память, таким образом определяются новые слова.
Канонические реализации «Форта» обычно совмещают словарь (словарная статья) и память программ, определяя единый кодофайл в виде односвязного списка. В нашей реализации в памяти будет только исполняемый код, а точки входа слов будут храниться в отдельной структуре — словаре.
public Dictionary<string, List<WordHeader>> Entries;
В этом словаре задается соответствие слова нескольким заголовкам, таким образом можно определить произвольное количество подпрограмм с одним именем, а затем удалить это определение и начать использовать старое. Также сохраненный старый адрес позволяет найти имя слова в словаре, даже если оно было переопределено, что особенно полезно для формирования трассировки стека или при отладке для изучения памяти. WordHeader — это класс, который хранит адрес входа в подпрограмму и флаг немедленной интерпретации.
public class WordHeader {
public int Address;
public bool Immediate;
}
Флаг Immediate дает интерпретатору указание, что данное слово должно быть исполнено в режиме программирования, а не записано в память. Схематически логику интерпретатора можно изобразить следующим образом: правая рука — YES, левая — NO.
Для считывания входного потока будем использовать TextReader, для вывода — TextWriter.
public TextReader Input;
public TextWriter Output;
Реализация интерпретатора по приведенной выше схеме будет находиться в одной функции Interpreter().
void Interpreter() {
while (true) {
var word = ReadWord(Input);
if (string.IsNullOrWhiteSpace(word))
return; // EOF
var lookup = LookUp(word);
if (IsEvalMode) {
if (lookup != null) {
Execute(lookup.Address);
} else if (IsConstant(word)) {
DS.Push(ParseNumber(word));
} else {
DS.Clear();
Output.WriteLine($"The word {word} is undefined");
}
} else { // program mode
if (lookup != null) {
if (lookup.Immediate) {
Execute(lookup.Address);
} else {
AddOp(lookup.Address);
}
} else if (IsConstant(word)) {
AddOp(LookUp("doLit").Address);
AddOp(ParseNumber(word));
} else {
IsEvalMode = true;
DS.Clear();
Output.WriteLine($"The word {word} is undefined");
}
}
}
}
Интерпретация выполняется в цикле, выход из которого осуществляется по достижении конца входного потока (например, конец файла), при этом функция ReadWord вернет пустую строку. Задача ReadWord — с каждым вызовом возвращать очередное слово.
static string ReadWord(TextReader sr) {
var sb = new StringBuilder();
var code = sr.Read();
while (IsWhite((char)code) && code > 0) {
code = sr.Read();
}
while (!IsWhite((char)code) && code > 0) {
sb.Append((char)code);
code = sr.Read();
}
return sb.ToString();
}
static bool IsWhite(char c) {
return " \n\r\t".Any(ch => ch == c);
}
После того как слово было считано, происходит попытка найти его в словаре. В случае успеха возвращается заголовок слова, в противном случае — null.
public WordHeader LookUp(string word) {
if (Entries.ContainsKey(word)) {
return Entries[word].Last();
}
return null;
}
Проверить, является ли введенное значение числом, можно по первым двум символам. Если первый символ — цифра, то предполагаем, что это число. Если первый символ — знак «+» или «−», а второй — цифра, скорее всего, это тоже число.
static bool IsConstant(string word) {
return IsDigit(word[0]) || (word.Length >= 2 && (word[0] == '+' || word[0] == '-') && IsDigit(word[1]));
}
Для конвертации строки в число можно использовать стандартные методы Int32.TryParse и Double.TryParse. Но они не отличаются быстродействием по ряду причин, поэтому я использую кастомное решение.
static object ParseNumber(string str) {
var factor = 1.0;
var sign = 1;
if (str[0] == '-') {
sign = -1;
str = str.Remove(0, 1);
} else if (str[0] == '+') {
str = str.Remove(0, 1);
}
for (var i = str.Length - 1; i >= 0; i--) {
if (str[i] == '.') {
str = str.Remove(i, 1);
return IntParseFast(str) * factor * sign;
}
factor *= 0.1;
}
return IntParseFast(str) * sign;
}
static int IntParseFast(string value) {
// An optimized int parse method.
var result = 0;
foreach (var c in value) {
if (!(c >= '0' && c <= '9'))
return result; // error
result = 10 * result + (c - 48);
}
return result;
}
Метод ParseNumber умеет конвертировать как целочисленные значения, так и числа с плавающей точкой, например «1.618».
Исполнение слова происходит аналогично тому, как мы раньше запускали адресный интерпретатор. В случае возникновения исключения будет распечатана трассировка стека адресного интерпретатора.
public void Execute(int address) {
try {
if (address < Core.Count) { // eval core
Core[address](); // invoke core function
} else { // eval word
IP = 0; // set return address
WP = address; // set eval address
DoList(); // fake doList
Next(); // run evaluator
}
} catch (Exception e) {
Output.WriteLine(e.Message);
var wpEntry = Entries.FirstOrDefault(d => d.Value.Any(en => en.Address == WP));
var ipEntry = Entries.FirstOrDefault(d => d.Value.Any(en => en.Address == SearchKnowAddress(IP)));
Output.WriteLine($"WP = {WP:00000} - '{wpEntry.Key}', IP = {IP:00000} - '{ipEntry.Key}'");
if (RS.Any()) {
Output.WriteLine("Stack trace...");
foreach (var a in RS) {
var ka = SearchKnowAddress(a);
var sEntry = Entries.FirstOrDefault(d => d.Value.Any(en => en.Address == ka));
Output.WriteLine($"...{a:00000} -- {sEntry.Key}");
}
RS.Clear();
DS.Clear();
} else if (address < Core.Count) {
var entry = Entries.FirstOrDefault(d => d.Value.Any(en => en.Address == address));
Output.WriteLine($"Core word is {entry.Key}");
}
IP = WP = 0;
}
}
Когда интерпретатор находится в режиме компиляции и слово не помечено для немедленного исполнения, его адрес должен быть записан в память.
public void AddOp(object op) {
Mem[Here++] = op;
}
Переменная here хранит адрес следующей свободной ячейки. Поскольку эта переменная должна быть доступна из среды исполнения как переменная языка «Форт», значение here хранится в памяти программ по заданному смещению.
public int _hereShift;
public int Here {
get => (int)Mem[_hereShift];
set => Mem[_hereShift] = value;
}
Чтобы при интерпретации отличить числовую константу от адреса слова, при компиляции перед каждой константой компилируется вызов слова doLit, которое считывает следующее значение в памяти и размещает его на стеке данных.
public void DoLit() {
DS.Push(Mem[IP++]);
}
Мы описали адресный и текстовые интерпретаторы, дальнейшее развитие заключается в наполнении ядра атомами. Различные версии «Форт» имеют разный набор базовых слов, самой минималистичной реализацией будет, пожалуй, eForth, который содержит всего 31 примитив. Поскольку примитив выполняется быстрее составных пользовательских слов, минималистичные реализации «Форта» обычно медленнее многословных реализаций. Сравнение набора слов нескольких версий интерпретаторов можно посмотреть здесь.
В описываемом здесь интерпретаторе я также старался излишне не раздувать словарь базовых слов. Но для удобства интеграции с платформой .net решил реализовать математику, булевы операции и, конечно же, рефлекшен через набор примитивов. При этом часть слов, которые в реализациях «Форта» часто являются примитивами, здесь отсутствуют, подразумевая реализацию средствами интерпретатора.
На момент написания статьи базовый набор составляет 68 слов.
// Core
SetCoreWord("nop", Nop);
SetCoreWord("next", Next);
SetCoreWord("doList", DoList);
SetCoreWord("exit", Exit);
SetCoreWord("execute", Execute);
SetCoreWord("doLit", DoLit);
SetCoreWord(":", BeginDefWord);
SetCoreWord(";", EndDefWord, true);
SetCoreWord("branch", Branch);
SetCoreWord("0branch", ZBranch);
SetCoreWord("here", GetHereAddr);
SetCoreWord("quit", Quit);
SetCoreWord("dump", Dump);
SetCoreWord("words", Words);
SetCoreWord("'", Tick);
SetCoreWord(",", Comma);
SetCoreWord("[", Lbrac, true);
SetCoreWord("]", Rbrac);
SetCoreWord("immediate", Immediate, true);
// Mem
SetCoreWord("!", WriteMem);
SetCoreWord("@", ReadMem);
SetCoreWord("variable", Variable);
SetCoreWord("constant", Constant);
// RW
SetCoreWord(".", Dot);
SetCoreWord(".s", DotS);
SetCoreWord("cr", Cr);
SetCoreWord("bl", Bl);
SetCoreWord("word", ReadWord, true);
SetCoreWord("s\"", ReadString, true);
SetCoreWord("key", Key);
// Comment
SetCoreWord("(", Comment, true);
SetCoreWord("\\", CommentLine, true);
// .net mem
SetCoreWord("null", Null);
SetCoreWord("new", New);
SetCoreWord("type", GetType);
SetCoreWord("m!", SetMember);
SetCoreWord("m@", GetMember);
SetCoreWord("ms@", GetStaticMember);
SetCoreWord("ms!", SetStaticMember);
SetCoreWord("load-assembly", LoadAssembly);
SetCoreWord("invk", invk);
// Boolean
SetCoreWord("true", True);
SetCoreWord("false", False);
SetCoreWord("and", And);
SetCoreWord("or", Or);
SetCoreWord("xor", Xor);
SetCoreWord("not", Not);
SetCoreWord("invert", Invert);
SetCoreWord("=", Eql);
SetCoreWord("<>", NotEql);
SetCoreWord("<", Less);
SetCoreWord(">", Greater);
SetCoreWord("<=", LessEql);
SetCoreWord(">=", GreaterEql);
// Math
SetCoreWord("-", Minus);
SetCoreWord("+", Plus);
SetCoreWord("*", Multiply);
SetCoreWord("/", Devide);
SetCoreWord("mod", Mod);
SetCoreWord("1+", Inc);
SetCoreWord("1-", Dec);
// Stack
SetCoreWord("drop", Drop);
SetCoreWord("swap", Swap);
SetCoreWord("dup", Dup);
SetCoreWord("over", Over);
SetCoreWord("rot", Rot);
SetCoreWord("nrot", Nrot);
Для определения новых пользовательских слов используется два слова ядра — «:» и «;». Слово «:» считывает из входного потока имя нового слова, создает заголовок с этим ключом, в память программ добавляется адрес базового слова doList и интерпретатор переводится в режим компиляции. Все последующие слова будут скомпилированы, за исключением тех, которые помечены как немедленные (immediate).
public void BeginDefWord() {
AddHeader(ReadWord(Input));
AddOp(LookUp("doList").Address);
IsEvalMode = false;
}
Завершается компиляция словом «;», которое записывает в память программ адрес слова «exit» и переводит в режим интерпретации. Теперь можно определить пользовательские слова — например, циклы, условный оператор и другие.
Eval(": ? @ . ;");
Eval(": allot here @ + here ! ;");
Eval(": if immediate doLit [ ' 0branch , ] , here @ 0 , ;");
Eval(": then immediate dup here @ swap - swap ! ;");
Eval(": else immediate [ ' branch , ] , here @ 0 , swap dup here @ swap - swap ! ;");
Eval(": begin immediate here @ ;");
Eval(": until immediate doLit [ ' 0branch , ] , here @ - , ;");
Eval(": again immediate doLit [ ' branch , ] , here @ - , ;");
Eval(": while immediate doLit [ ' 0branch , ] , here @ 0 , ;");
Eval(": repeat immediate doLit [ ' branch , ] , swap here @ - , dup here @ swap - swap ! ;");
Eval(": // immediate [ ' \\ , ] ;"); // C like comment
Остальные стандартные слова здесь описывать не буду — в сети на соответствующих тематических ресурсах достаточно информации по ним. Для взаимодействия с платформой я определил 9 слов:
- «null» — пушит null на стек;
- «type» — пушит тип класса на стек «word TrueForth.MyClass type»;
- «new» — забирает тип со стека, создает экземпляр класса и размещает его на стеке, аргументы конструктора, если есть, тоже должны быть на стеке «word TrueForth.MyClass type new»;
- «m!» — забирает со стека экземпляр объекта, имя поля, значение и присваивает указанному полю значение;
- «m@» — забирает со стека экземпляр объекта, имя поля и возвращает значение поля на стек;
- «ms!» и «ms@» — аналогично предыдущим, но для статических полей, вместо экземпляра на стеке должен быть тип;
- «load-assembly» — забирает со стека пусть к сборке и грузит в память;
- «invk» — забирает со стека делегат, аргументы и вызывает его «1133 word SomeMethod word TrueForth.MyClass type new m@ invk».
Я описал основные моменты реализации языка «Форт», данная реализация не стремится поддержать ANSI стандарты на язык, поскольку ее задача — реализовать движок для построения DSL, а не реализовать язык общего назначения. Разработанного интерпретатора в большинстве случаев достаточно для построения несложного языка предметной области.
Использовать приведенный выше интерпретатор можно разными способами. Например, можно создать экземпляр интерпретатора, после чего подать на вход скрипт инициализации, в котором определяются необходимые слова. Последние посредством рефлексии взаимодействуют с системой.
public static bool Init4Th() {
Interpreter = new OForth();
if (File.Exists(InitFile)) {
Interpreter.Eval(File.ReadAllText(InitFile));
return true;
} else {
Console.WriteLine($"Файл инициализации {InitFile} не найден!");
return false;
}
}
Пример конфигурации системы по рассылке отчетов
( ***** БЛОК ИНИЦИАЛИЗАЦИИ ***** )
word GetFReporter word ReportProvider.FlexReports.FReporterEntry type new m@ invk constant fr // создаем ссылку на инстанс генератора отчетов
: ежедневно word ReportProvider.FlexReports.FDailyReport type new ; // слово для создания инстанса ежедневного отчета
: запустить word AddReport fr m@ invk ; // слово для добавления инстанса ежедневного отчета в генератор отчетов
: время [ ' word , ] ; // синтаксический сахар
: почта [ ' word , ] ; // синтаксический сахар
: заголовок [ ' s" , ] ; // синтаксический сахар, считывание заголовка должно завершаться кавычкой "
: и ; // сахар
: тело dup [ ' word , ] swap word MailSql swap m! ;
: вложение dup [ ' word , ] swap word XlsSql swap m! ;
( ***** БЛОК ЗАПУСКА ОТЧЕТОВ ***** )
cr s" НАЧАЛО ИНИЦИАЛИЗАЦИИ ОТЧЕТОВ" . cr cr
заголовок Оферты на изъятие" время 08:00 почта mail@tinkoff.ru ежедневно тело seizure.sql запустить
заголовок Ошибки сверки, исправленные за прошлые сутки" время 08:00 почта mail@tinkoff.ru ежедневно тело fixed-errors-top.sql и вложение fixed-errors.sql запустить
заголовок От каких дат лежат комплекты на WO" время 08:00 почта mail@tinkoff.ru ежедневно тело wo-wait-complect-dates.sql запустить
заголовок Ошибки ввода за прошлые сутки" время 07:30 почта mail@tinkoff.ru ежедневно тело top-previous-input-errors.sql и вложение previous-input-errors.sql запустить
заголовок Регистрация входящей корреспонденции за прошлые сутки" время 10:00 почта mail@tinkoff.ru ежедневно тело collection-report.sql запустить
заголовок Отчёт о работе в BPM за прошлые сутки" время 08:00 почта mail@tinkoff.ru ежедневно вложение bpm-inbox-report.sql запустить
заголовок Мониторинг новых комплектов на ScanDoc3 за последние 7 дней" время 07:50 почта mail@tinkoff.ru ежедневно тело new-sd3-complects-prevew.sql и вложение new-sd3-complects.sql запустить
( ******************************** )
cr s" ИНИЦИАЛИЗАЦИЯ ЗАВЕРШЕНА" . cr
Можно поступить иначе: передать на вход интерпретатора через стек данных уже готовые объекты и дальше взаимодействовать с ними через интерпретатор. Как, например, я делал для восстановления настроек устройства для получения сканов документов, сканер, веб-камера или виртуальное устройство (для отладки или обучения). В данном случае набор параметров, настроек, порядок инициализации разных устройств сильно отличается и решается через форт-интерпретатор тривиально.
var interpreter = new OForth();
interpreter.DS.Push(this); // Push current instance on DataStack
interpreter.Eval("constant arctium"); // Define constant with the instance
if (File.Exists(ConfigName)) {
interpreter.Eval(File.ReadAllText(ConfigName));
}
Конфигурация генерируется программно, получается что-то вроде этого:
s" @device:pnp:\\?\usb#vid_2b16&pid_6689&mi_00#6&1ef84f63&0&0000#{65e8773d-8f56-11d0-a3b9-00a0c9223196}\global" s" Doccamera" word Scanning.Devices.PhotoScanner.PhotoScannerDevice type new
dup s" 3264x2448, FPS:20, BIT:24" swap word SetSnapshotMode swap m@ invk
dup s" 1280x720, FPS:30, BIT:24" swap word SetPreviewMode swap m@ invk
word SetActiveDevice arctium m@ invk
Кстати, похожим способом генерируются скрипты *.ps и *.pdf, ведь как PostScript, так и Pdf по сути являются подмножеством «Форт», но служат исключительно для отрисовки документов на экране или принтере.
Так же просто можно реализовать интерактивный режим для консольного и не только приложения. Для этого нужно сначала инициализировать систему через заготовленный скрипт, затем запустить интерпретацию, натравив интерпретатор на стандартный ввод STDIN.
var interpreter = new OForth();
const string InitFile = "Init.4th";
if (File.Exists(InitFile)) {
interpreter.Eval(File.ReadAllText(InitFile));
} else {
Console.WriteLine($"Файл инициализации {InitFile} не найден!");
}
interpreter.Eval(Console.In); // Start interactive console
Скрипт инициализации может быть таким:
( ***** БЛОК ИНИЦИАЛИЗАЦИИ ***** )
word ComplectBuilder.Program type constant main // сохраняем ссылку на тип
: mode! [ ' word , ] word Mode main ms! ; // помогает менять режим
: init word Init main ms@ invk ; // инициализация
: load [ ' word , ] word LoadFile main ms@ invk ; // загрузка файла
: start word StartProcess main ms@ invk ; // запуск процесса
: count word Count main ms@ invk ; // прочитано строк
: all count ; // сахар
( ***** ПОДГОТОВКА ***** )
init
cr cr s" Система готова к использованию, для справки введите команду help" . cr cr
( ***** СПРАВКА ***** )
: help
s" Пример загрузки и запуска обработки файла с нулевой позиции:" . cr
s" load scandoc_test.csv 0 all start" . cr
bl bl s" load scandoc_test.csv -- загружает файл в память" . cr
bl bl s" 0 all start -- запускает обработку, пропуская 0 записей и обрабатывая не более all записей" . cr
cr
s" Можно изменить режим на DEV TEST PROD:" . cr
s" mode! DEV init" . cr
s" Текущий режим можно посмотреть так:" . cr
s" word Mode main ms@ . cr" . cr
;
В качестве ввода может быть не только консоль или текст из TextBox приложения с UI, но и сеть. В этом случае можно реализовать простое интерактивное управление, например сервисом, для отладки, запуска, остановки компонентов. Возможности такого использования ограничены фантазией разработчика и решаемой задачей. Можно реализовать систему макросов, которая записывает операции с UI в виде последовательности форт-слов для последующего воспроизведения и редактирования.
В заключение хочу поделиться еще одним примером использования движка. Чтобы особо не заморачиваться с рефлекшеном, можно на этапе инициализации передать в интерпретатор анонимные функции, чтобы через их вызов управлять поведением системы.
Чтобы вкомпилировать делегат, используем следующую функцию:
public void Callback(string word, MulticastDelegate action) {
if (string.IsNullOrWhiteSpace(word) || word.Any(c => " \n\r\t".Any(cw => cw == c))) {
throw new Exception("invalid format of word");
}
DS.Push(action);
Eval($": {word} [ ' doLit , , ] invk ;");
}
Делегат на функцию помещается на стек данных DS.Push(action), затем компилируется слово с вызовом этого делегата. Напомню, что слова, помещенные в квадратные скобки [ ], будут интерпретироваться, а не компилироваться. Слово ‘ Tick ищет следующее слово и помещает его адрес на стек, в нашем случае это слово doLit, которое нужно для того, чтобы во время компиляции делегат как константа был положен на стек. Первая Comma «,» вкомпилирует doLit, вторая вкомпилирует делегат со стэка.
Теперь попробуем написать простенький конфиг, для этого определим класс элемента конфига. Пример взят из реального проекта, где указанные имена имеют семантический смысл:
public class WoConfItem {
public string ComplectType;
public string Route;
public string Deal;
public bool IsStampQuery;
}
Интерпретатор конфига делается элементарно — определяются необходимые слова, затем интерпретируется конфиг:
Код интерпретатора
public class WoConfig {
private OForth VM;
private List<WoConfItem> _conf;
public WoConfig(string confFile) {
_conf = new List<WoConfItem>();
VM = new OForth();
// Определяем слова используемые для настройки
VM.Callback("new-conf", new Action(ClearConf));
VM.Callback("{", new Func<WoConfItem>(NewConf));
VM.Callback("}", new Action<WoConfItem>(AddConf));
VM.Callback("complect-type", new Func<WoConfItem,string,WoConfItem>(ConfComplectType));
VM.Callback("route", new Func<WoConfItem,string,WoConfItem>(ConfRoute));
VM.Callback("deal", new Func<WoConfItem,string,WoConfItem>(ConfDeal));
VM.Callback("is-stamp-query", new Func<WoConfItem,bool,WoConfItem>(ConfIsStampQuery));
// синтаксический сахар, меняем порядок аргументов, реализуем постфиксную запись для этих слов
var initScript = new StringBuilder();
initScript.AppendLine(": complect-type [ ' word , ] swap complect-type ;");
initScript.AppendLine(": route [ ' word , ] swap route ;");
initScript.AppendLine(": deal [ ' word , ] swap deal ;");
initScript.AppendLine(": is-stamp-query ' execute swap is-stamp-query ;");
VM.Eval(initScript.ToString());
// интерпретируем конфигурацию
WatchConfig(confFile);
}
private void ReadConfig(string path) {
using (var reader = new StreamReader(File.OpenRead(path), Encoding.Default)) {
VM.Eval(reader);
}
}
readonly Func<string, bool> _any = s => s == "*";
public WoConfItem GetConf(string complectType, string routeId) {
return _conf?.FirstOrDefault(cr =>
(cr.ComplectType == complectType || _any(cr.ComplectType)) &&
(cr.Route == routeId || _any(cr.Route))
);
}
public bool IsAllow(string complectType, string routeId) {
return GetConf(complectType, routeId) != null;
}
void WatchConfig(string path) {
var directory = Path.GetDirectoryName(path);
var fileName = Path.GetFileName(path);
// создание пустого конфига, если его не было
if (!File.Exists(path)) {
if (!Directory.Exists(directory)) {
Directory.CreateDirectory(directory);
}
var sb = new StringBuilder();
sb.AppendLine("\\ WO passport configuration");
sb.AppendLine("new-conf");
sb.AppendLine("");
sb.AppendLine("\\ Config rules");
sb.AppendLine("\\ { -- begin config item, } -- end config item, * -- match any values");
sb.AppendLine("\\ Example:");
sb.AppendLine("\\ { complect-type * route offer deal 100500 is-stamp-query true }");
sb.AppendLine("");
File.WriteAllText(path, sb.ToString(), Encoding.Default);
}
// интерпретация конфига
ReadConfig(path);
// подписка на изменение конфига
var fsWatcher = new FileSystemWatcher(directory, fileName);
fsWatcher.Changed += (sender, args) => {
try {
fsWatcher.EnableRaisingEvents = false;
// программы редактирования файлов создают несколько событий по изменению,
// чтобы дождаться окончания сохранения файла, вводим задержку
// значение задержки исключительно эмпирическое
Thread.Sleep(1000);
ReadConfig(path);
} catch (Exception e) {
Console.WriteLine(e);
} finally {
fsWatcher.EnableRaisingEvents = true;
}
};
fsWatcher.EnableRaisingEvents = true;
}
// реализация слов, используемых для конфигурирования
void ClearConf() {
_conf.Clear();
}
void AddConf(WoConfItem conf) {
_conf.Add(conf);
}
static WoConfItem NewConf() {
return new WoConfItem();
}
static WoConfItem ConfComplectType(WoConfItem conf, string complectType) {
conf.ComplectType = complectType;
return conf;
}
static WoConfItem ConfRoute(WoConfItem conf, string route) {
conf.Route = route;
return conf;
}
static WoConfItem ConfDeal(WoConfItem conf, string deal) {
conf.Deal = deal;
return conf;
}
static WoConfItem ConfIsStampQuery(WoConfItem conf, bool isStampQuery) {
conf.IsStampQuery = isStampQuery;
return conf;
}
}
Пример конфигурации:
\ WO passport configuration
new-conf
\ Config rules
\ { -- begin config item, } -- end config item, * -- match any values
\ Example:
\ { complect-type * route offer deal 100500 is-stamp-query true }
\ ***** offer *****
{ complect-type offer route offer is-stamp-query false deal 5c18e87bfeed2b0b883fd4df }
{ complect-type KVK route offer is-stamp-query true deal 5d03a8a1edf8af0001876df0 }
{ complect-type offer-cred route offer is-stamp-query true deal 5d03a8a1edf8af0001876df0 }
{ complect-type offer-dep route offer is-stamp-query true deal 5d03a8a1edf8af0001876df0 }
{ complect-type quick-meeting route offer is-stamp-query true deal 5d03a8a1edf8af0001876df0 }
{ complect-type exica route offer is-stamp-query true deal 5d03a894e2f5850001435492 }
{ complect-type reissue route offer is-stamp-query true deal 5d03a894e2f5850001435492 }
\ ***** offer-flow *****
{ complect-type KVK route offer-flow is-stamp-query true deal 5d03a8a1edf8af0001876df0 }
{ complect-type offer-cred route offer-flow is-stamp-query true deal 5d03a8a1edf8af0001876df0 }
{ complect-type offer-dep route offer-flow is-stamp-query true deal 5d03a8a1edf8af0001876df0 }
{ complect-type reissue route offer-flow is-stamp-query true deal 5d03a894e2f5850001435492 }
Конечно, я привел самые простые примеры, но этот подход позволяет реализовывать сложные DSL и главное — с минимальными временными затратами.
Итак, мы рассмотрели пример создания и использования простейшего скриптового движка «Форт». Благодаря особенностям языка он может быть использован как универсальный DSL.
Безусловно, разработанный интерпретатор имеет ряд ограничений, как инструментальных — например, отсутствие локальных переменных, структур, гибкое управление текстовым интерпретатором, так и функциональных — медленное взаимодействие с платформой через рефлексию. Однако он достаточно прост для понимания и модификации, благодаря чему при необходимости его можно расширить для решения специфических задач.
Но главное — это понимание и навык разработки движков интерпретаторов различного толка, благодаря чему можно существенно расширить инструментарий и возможности реализации себя как квалифицированного разработчика. Главное — практический опыт, ведь лучший способ проверки любой теории — это практика!
Посмотреть полный исходный код интерпретатора можно здесь, на гитхабе.
Также рекомендую ресурс с отличным описанием форт-машины(часть 1), часть 2
Удачи!