company_banner

Универсальный DSL. Возможно ли это?

  • Tutorial

Язык предметной области. Не перегружен конструкциями языка общего назначения. При этом позволяет всего несколькими строчками реализовать весьма сложную логику. Все это — 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.

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

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

Посмотреть полный исходный код интерпретатора можно здесь, на гитхабе.

Также рекомендую ресурс с отличным описанием форт-машины.

Удачи!
Tinkoff.ru
199,31
IT’s Tinkoff.ru — просто о сложном
Поделиться публикацией

Комментарии 43

    +2
    Универсальный DSL

    Таки универсальный или specific?

      0
      Сам язык конечно же заточен под задачу, но движок универсален. Дефолтный язык движка может быть использован как есть, в качестве универсального инструмента, или для удобства сконфигурирован под конкретный синтаксис с некоторыми ограничениями
        0
        Да, оксюморон получается. Но, наверное, к форту вполне это определение подходит, сам форт язык универсальный, но программирование на нём обычно сводится к определению новых слов и конструкций, специфичных для решения конкретной задачи.
        +1
        А при чем здесь тэг DSL. Это совсем из другой области.
        Неплохо было бы дать определение этого термина: Предметно-ориентированный язык — domain-specific language (DSL).
        Но всё же тэг DSL предполагает другое определение: digital subscriber line (DSL) — цифровая абонентская линия.
          0
          Поправил
            0
            кстати относительно digital subscriber line можно сказать точно так же, на хабре есть публикации с тэгом DSL и описанием domain-specific language
            0
            На (для) C# и ранее разрабатывались варианты Форт (Forth) :)
            Один из вариантов DeltaForth

            P.S. Следующий комментарий смогу написать через сутки.
            Domain-specific_language
              0
              Это да). Форт древний и вездесущий, для .net их тьма реализована. У меня это 5 версия). Суть статьи скорее в популяризации и объяснении принципов утилитарного фортостроения. Тема форта в интернете не раскрыта, много кто этим занимается, а вот с публикациями и пошаговыми мануалами как оно работает и как реализовать с нуля совсем плохо. Достаточно материалов типа: «2 dup *. — вуаля», а вот как строить форты не особо. Лично мне в свое время пришлось собирать по крупицам знание как реализовать форт.
              0
              Не знаю что за фильтр на картинках, но печатаю вслепую(ослепшим) на данный момент.
                0
                В смысле? Картинки понравились?)
                  0
                  прошло минут 15, и до сих пор все плавает перед глазами( теперь термин «вырвиглазно» не столь философский как ранее казался
                    0
                    Интересная у вас реакция. А это на какую-то конкретную картинку? Фильтры везде разные.
                      0
                      Нее, я туда не вернусь..) Почти все
                0
                <не туда>
                  0
                  Из-за отсутствия избыточных конструкций всего несколько строк на DSL могут реализовать довольно сложный функционал, что приводит к положительным следствиям: увеличивается скорость разработки, сокращается количество ошибок, упрощается тестирование системы.

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

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

                  Тут либо всё упрощать, что сразу убивает предметную область (или по другому — делает DSL бесполезным), либо долго и нудно сочинять нечто сложное и по сути решающее задачу за пользователя.

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

                  Хотя можно пообсуждать примеры реально используемых DSL. Правда всегда это использование очень узкое, никому кроме рассказывающего неизвестное.
                    0
                    Ну вот вам пример. Благодаря использованию DSL, разработка качественных настольных игр стала доступна даже школьникам. Что не замедлило сказаться на количестве разработанных игр.
                      0
                      Скорость, ошибки и прочее улучшаются только одним способом — нахождением общего решения проблемы и последующим программированием решения.

                      Не знаю что вы имеете ввиду под общим решением. Для целенаправленной разумной деятельности необходимо формирование в сознании модели явления или понимание. Для себя понимание определяю как выявление значимых связей явления и удержания их в сознании. У человека объем внимания ограничен, именно внимание отвечает за активацию смыслов, связей. Когда задача перестает помещаться единомоментно в сознании, тогда и происходит резкое увеличение количества ошибок и падает производительность. Здесь ключевым моментом является компактность представления знания. Чем больше букв, тем сложнее это удержать в сознании, консолидировать и оперировать. По вашей логике не важно на чем писать на ассемблере x86 или на Java, ведь есть общее решение, чтобы это не значило. На деле мы видим зависимость от уровня языка и производительностью разработки на нем. DSL априори более высокоуровневый по сравнению с языком общего назначения, позволяет бизнес логику описывать компактно. И как следствие то, что написано в статье.

                      Но когда вы пытаетесь понять, а потом исправить, всю ту галиматью, которую нарисовал вам юзер

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

                      Конечно можно долго рассуждать имеет смысл вводить DSL или нет, но важно помнить, что лучшая проверка любой теории это практика.
                        0
                        >> Здесь ключевым моментом является компактность представления знания

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

                        В целом дать пользователю какой-нибудь JavaScript и написать для него библиотеки будет много проще, чем возиться с DSL. Либо если по сути нужно заполнять какие-то шаблоны (выше пример с играми), то здесь вполне подходит обычное решение на GUI — просто собираем данные для шаблона и выполняем над ними необходимые действия. Сложной логики здесь никакой, а потому требования к пользователям низкие. Но если помимо шаблона нужна сложная логика игры — вот и приехали, придётся переходить на универсальный язык программирования.

                        >> На деле мы видим зависимость от уровня языка и производительностью разработки на нем

                        Уровень языка никак не относится к делению на DSL и универсальные языки. Это всё высокоуровневые языки программирования. Но в DSL обычно присутствует некий набор функций, характерный для предметной области. То есть отличие двух высокоуровневых языков, один из которых DSL, а другой — универсальный язык, состоит в том, что в DSL сразу присутствует специфический функционал, который в универсальном языке может быть добавлен в виде библиотеки, но отсутствует в дефолтной реализации.

                        >> По хорошему юзеру это понимать и исправлять.

                        Что бы юзер понял, ему нужно всё объяснить в знакомых терминах. Если ошибка будет выдаваться в виде «в строке Х около символа на позиции У присутствует синтаксическая ошибка», то юзер долго будет пытаться понять о чём вообще речь. Но даже при таком простом описании ошибок уже сложно как-то передать проблему в структуре данных, а не в синтаксисе. Например — в предметной области есть деревья, пользователь их описывает на DSL, с точки зрения синтаксиса всё корректно, но вот структура дерева кривая. Теперь вопрос — сколько видов кривизны структуры дерева может быть? Не отвечайте сразу, сначала добавьте в модель зависимости между узлами дерева, а потом попробуйте описать это всё коротко на DSL.

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

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

                        Либо вы путаете универсальную логику с заранее подготовленными решениями для конкретной предметной области. Тогда я выше уже говорил — на универсальном языке просто реализуем необходимые библиотеки и далее обеспечиваем к ним доступ пользователей. Доступ обеспечивается тремя способами — (1) обычный GUI, (2) DSL и (3) напрямую на универсальном языке. Способ №1 очень прост и давно отработан, но там сложно с добавлением логики, поэтому приходится делать решения со скриптовыми языками, имеющими доступ к введённым пользователем данным. Способ №2 отличается от №1 тем, что заставляет пользователя самостоятельно представлять в голове всё то, что в №1 ему даётся в GUI, а в остальном логика работает идентично, кроме чисто субъективных отличий DSL от скриптового языка. Ну и способ №3 отличается от №2 тем, что вообще нет необходимости делать ничего, кроме написания библиотеки. То есть способ №3 самый экономный. А раз уж мы знаем, что пользователь в любом случае будет учить язык программирования, то пусть уж учит универсальный и дёргает оттуда библиотеку.
                          +1
                          Компактность достигается выкидыванием из языка всех конструкций длиннее одной буквы, а понятность достигается структурой языка и форматированием.

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

                          Ну смотрите, вы же наверняка использует регулярные выражения, конечно можно ту же логику описать на JavaScript, но так обычно не делают. На SQL web сервер не напишешь, но это мало кого беспокоит. Применение сценарных языков тоже имеет место, от DSL отличается тем, что последние описывают логику в терминах предметной области, за счет чего и достигает компактность и понятность(RegExp, SQL, awk, TeX, lex/yacc).

                          Очевидно, что можно найти ситуации когда dsl не подходит, так ни кто и не говорит, что нужно его везде применять. Нужно сохранять адекватность и действовать в соответствии со временем, местом и обстоятельствами. Хотя помню был(или есть) проект операционной системы, написанной на DSL. Для каждой задачи создавался DSL и на нем задача решалась, в результате исходник получился компактным и легко читаемым. К сожалению не помню названия и ссылки нет, попробую поискать.
                            –2
                            Регулярные выражения это специфическая функция из предметной области «работа с шаблонами в произвольных последовательностях». То есть если стоит задача работать с последовательностями, то логично написать на универсальном языке некую библиотеку, которая и будет выполнять основные функции по работе с последовательностью. Далее эту функцию можно напрямую выставить в виде API в том же языке, на котором она разрабатывалась. Это просто, быстро, эффективно с точки зрения работы с дополнительной логикой, с которой универсальный язык легко справляется.

                            И второй вариант — сочиняем DSL. Ну вот сочинили, и прикрутили к нему всё ту же выше показанную библиотеку. Но в добавок сочиняем ещё и правила текстового взаимодействия с этой библиотекой. Ладно если почти весь требуемый функционал хорошо ложится именно на регулярки, но если где-то требуется слегка отличающаяся от шаблонов логика? Сразу получаем очень негибкое решение, нацеленное на очень узкую группу задач.

                            А теперь сравните затраты пользователей. Они учат какой-то новый язык (DSL), а внутри в любом случае учат эти самые регулярные выражения. То есть учатся два раза. И что мешает учиться сразу в сторону универсальности? То есть вместо DSL учим JavaScript. При чём не весь, а только простейшую логику — условные операторы, циклы, функции. А внутри пишем что-то вроде «var result=apply('[1-9,a-z]','Long string from our specific domain')». Можно написать это чуть короче на неком вымученном DSL, но зачем? Что это даст? Объяснить, что «сюда суёшь регулярку, а сюда последовательность» в случае того же JavaScript очень просто, так зачем тогда DSL? Что он даст пользователю?
                              0
                              И кроме того, универсальный язык уже дает готовые отладчики и прочие инструменты, включая хорошие сообщения об ошибках. Для DSL все это придется писать с нуля, и качеством оно будет явно хуже.

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

                              Например, очень хорошо зашел DSL, выглядящий как таблица, нарисованная псевдографикой, — для выбора некоего значения в зависимости от условий.
                      0
                      Альтернативный вариант — использование макропроцессора поверх более универсального языка, как правило, интерпретирующего. Собственно, по классу грамматики (регулярная) Форт и макропроцессор эквивалентны.
                      Пример для SQL
                      Несмотря на простоту, в таком подходе есть свои недостатки, поэтому если ресурсы позволяют, то я предпочту написать транслятор КС-грамматики и стековую виртуальную машину исполнения (а ля Форт, кстати).
                        +1
                        У форта неограниченная грамматика: все части форт-системы (даже такие как интерпретатор и компилятор) являются самыми обычнымм определениями на равне с другими форт-словами и могут быть изменены, переопределены и вызваны в любой момент, также исходный текст программы и состояние интерпретатора доступны прямо во время исполнения для чтения и изменения. Возможно придумать любой синтаксис на какой только хватит фантазии программиста и памяти системы.

                        Например здесь синтаксис зависит от введёного символа:
                        : PARSE&TYPE  ( c "ccc<char>" -- c-addr u )
                            ." PARSED: " PARSE TYPE CR ;
                        
                        : AAA ." AAA CALLED" CR ;
                        : BBB ." BBB CALLED" CR ;
                        
                        \ ограничитель - символ '1'
                        .( TEST 1:) CR
                        CHAR 1 PARSE&TYPE AAA 1 BBB 2 CR
                        
                        \ ограничитель заранее не известен
                        .( TEST 2:) CR
                        KEY PARSE&TYPE AAA 1 BBB 2 CR
                        .( TEST 3:) CR
                        KEY PARSE&TYPE AAA 1 BBB 2 CR
                        
                        BYE
                        

                        Try it online!


                        Форт также может быть сам себе препроцессором и так иногда делают, но часто проще сразу скомпилировать нужный код чем заниматься подстановкой текста.
                          0
                          Синтаксис Форта описывается регулярной грамматикой (отсюда простота разбора входного текста). Вы же говорите о семантике, а не о синатаксисе.
                            +1
                            В форте семантика влияет на синтаксис. Форт не регулярный язык, его нельзя разобрать конечным автоматом.

                            Пример попроще:
                            ( это комментарий, транслятор его игнорирует, а вернее это делает слово "(" )
                            : ( ;  \ переопределение слова "("
                            ( такого синтаксиса для комментария у нас больше нет, никто ничего не пропустил, и интерпретатор попытается выполнить эти слова, и произойдёт ошибка)


                            Вы сможете описать это хоть какой-нибудь грамматикой?
                              0
                              Я же говорю, вы смешиваете синтаксис и семантику. Синтаксис по-прежнему простенький и разбирается КА или регулярными выражениями, а вот терминалы могут изменяться динамически по ходу трансляции. В макропроцессоре ровно все то же самое: можно переопределять символы до морковкина заговения. Если посмотреть на тонкие манипуляции с Си-шным препроцессором или с древним, но супермощным M4, то там подобной эзотерики очень много.
                                +1
                                Синтаксис тоже может меняться в процессе трансляции Форт от потребностей и если дальше закрыть возможности доступа к Форт ядру, то мы и получаем произвольно созданный DSL поверх универсального Форт языка.

                                P.S. Разбирать синтаксис-семантику транслируемого начального кода потребуется, в этом случае, в рамках воссоздания понимания расширяемой Форт семантики и синтаксиса!
                                А, предыдущий пример, действительно достаточно прост для KA.

                                И, что в таком случае, понимать под терминалом для KA?

                                В Форт даже ввели термин XT — выполнимый токен, некая сущность (сопоставленная однозначно со словом при его поиске в словаре) которую можно выполнить например с помощью EXECUTE или использовать например сформировав их в массиве для доступа.
                                + ещё разделили семантику СЛОВА на период компиляции и выполнения
                                и ввели слово POSTPONE <СЛОВО> (добавить к определению семантику времени выполнения слова) помимо уже ранее существовавшего исторически COMPILE, (помимо и других эффективных элементов языка, зря что ли Форт язык зачастую называют Форт-системой? :)

                                Те же EBNF есть и в реализации обычного синтаксиса Форт, но это никак не характеризует Форт в его уникальном дизайне как языкового инструментария.

                                  0
                                  Изменение синтаксиса это, например, если вместо слова PARSE&TYPE после каких-то переопределений можно будет писать PARSE AND TYPE, при этом это остается одним словом, а не тремя.
                                    0
                                    Из форт программы доступна вся форт система, соответственно можно написать ридер который анализирует хоть естественный язык и в стихах описать GUI, потом опять вернуться к форту. Причем это будет цельный и монолитный листинг. Понятно, что так вряд ли кто-то будет делать, хотя это технически возможно. Кстати есть проект по реализации AI на форте, называется MindForth, по этой теме можно много интересного материала нагуглить.
                                      0
                                      Зачем расширять тему? Мы говорим о конкретных вещах: лексический/синтаксический анализатор (семантический опускаю) и генератор кода для подсистемы исполнения (runtime VM). Если вы утверждаете, что синтаксис меняется по ходу исполнения, то соответствующий анализатор тоже должен меняться. Вот собственно и все.
                                      +1
                                      Конечно можно, почему нет? Судя по всему вы спорите о том, чего не знаете.

                                      Как я уже сказал, в форте есть полный контроль над разбором текста прямо во время этого самого разбора, это слова:
                                      • SOURCE — адрес и длина текущей интерпретируемой строки;
                                      • >IN — переменная (доступна и для чтения и для изменения) хранящая смещение первого непросмотренного символа от начала строки;
                                      • REFILL — прочитать следующую строку из входного источника.


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

                                      В добавок есть слова для парсинга: WORD PARSE
                                      для создания словарей: WORDLIST VOCABULARY
                                      для поиска слов: FIND SEARCH-WORDLIST
                                      для управления контекстом поиска: GET-ORDER SET-ORDER ALSO ONLY PREVIOUS
                                        0
                                        В свою очередь я должен спросить, уверены ли вы, что знаете про синтаксис и грамматики? Если грамматики нет, то что разбирает анализатор?
                                          +1
                                          Граматики Форт «нет» (хотя есть договорённости по соглашениям взаимодействия Форт-слов в стандартах языка — что то подобие распределённого компилятора-транслятора, а то как бы в Форт возможно было бы использовать высокоуровневые управляющие конструкции), ровно до того момента, когда её захочется использовать.

                                          Как пример разбора Паскаль синтаксиса Форт-словами Let's Build a Compiler и при этом перекомпилировать ядро Форт-системы нет необходимости, чтобы загрузить (как «плагин» на языке Форт), необходимый лексикон слов для этого.

                                          P.S. Не совсем могу понять, какую мысль в своих сообщениях, относительно Форт или нет пытаетесь донести.
                                          То, что можно взять (переключится на) соответствующий специализированный транслятор-компилятор при работе с отличным от Форт синтаксисом? Да, с определёнными накладными расходами можно, а можно то же самое осуществить в рамках самого Форт языка сделав соответствующее расширение (или также отдельный транслятор-компилятор).
                                          Есть и Форт расширения по пониманию в рамках Форт языка и обычных формул и ООП и преобразований C->Forth, Forth->C…

                                          Можно даже, в рамках какого то приложения (инструментария) написанного на произвольном языке — внутрь его добавить Форт. :)

                                          COMPARE слово тоже есть.

                                          mail.ru счётчик популярности-активности посещений рускоязычного Форт форума. (понимаю, что информацию в IT «анализируют» не только рускоязычные счётчики)
                                            0
                                            Ну, понятно. Грамматики нет, а синтаксический разбор, причем базового уровня (КА без МП или регулярные выражения) есть.
                                              0
                                              Не совсем могу понять, какую мысль в своих сообщениях, относительно Форт или нет пытаетесь донести.

                                              Вы знакомы с теорией формальных языков?
                                                0
                                                А стоит ли теорию формальных языков применять к форт? В языках типа Си, phyton, js и др. грамматика задана и не может быть изменена. В форте грамматика есть, но она не постоянная, что не ложится на теорию формальных языков. По ходу интерпретации программы грамматику можно изменить, поэтому можно сказать что ее нет совсем. Конечно можно придумать автомат описывающий форт, но это и будет реализация форта, разве нет?
                                                  0
                                                  А стоит ли теорию формальных языков применять к форт?

                                                  Его транслятор же как-то работает?
                                                  Грамматика у него простая как три копейки: программа состоит из слов, которые следуют друг за другом. БНФ можно написать двумя правилами.
                                                  Интерпретация слов и всё, что Вы можете изменить — это уже более высокий слой абстракции. Не грамматика, как её понимают в теории формальных языков.
                                                  Именно это пытается донести cross_join
                                                    0
                                                    Грамматика у него простая как три копейки: программа состоит из слов, которые следуют друг за другом.

                                                    Про любой язык можно сказать, что программа на нём состоит из лексем (слов) следующих друг за другом. ;)

                                                    Например есть слова которые начинают какую-либо конструкцию и сответствующие им завершающие слова, типа : NAME ... ;, CODE name ... ENDCODE и управляющие конструкции, разве они в совокупности не образуют синтаксические конструкции?

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

                                                      Но в случае Форта — это действительно так и есть. Потому что всё более сложное в нём вынесено на уровень интерпретации.

                                                      Ну а особое управление комментариями — не новость в мировой культуре.
                                                      +1
                                                      Весь вопрос лишь только в том, на каком уровне понимания Форт-системы появляется «грамматика» языка. :)

                                                      Cогласно следующей схеме (немного другое изображение приведённой в статье диаграммы) базового цикла транслятора Форт-системы,

                                                      image

                                                      на данной схеме вообще нет никакой информации по граматике языка. Есть только поиск СЛОВ в текущем контексте словарей, анализ признаков найденных СЛОВ ( IMMEDIATE -активное слово для немедленного выполнения, остальные компиляция, если режим компиляции или интерперетации при выполнении.) не нашли СЛОВО, пробуем его понять как число и если успех кладём его на стэк, или напечатать при соответствующем состоянии системы, иначе ошибка.
                                                      Всё!
                                                      Вся остальная специфика СЛОВ языка оговаривается в стандарте или Сам разработчик определяет из своих предпочтений и.др., но обычно с определёнными следованиями уже существующими решениями дизайна Форт-систем. Даже в выборе названий СЛОВ, не достаточно употребимых, могут быть отхождения по их семантике.

                                                      Подход с КА при этом должен будет учитывать специфику заложенную в Форт-языке и не будет никак проще приведённой выше картинки работы транслятора Форт-языка.
                                                      В этой схеме даже не специфицированы детали поиска слов в Форт-системе, который отдаётся на «откуп» разработчику инструментария.

                                                      P.S. Дальнейшее «само-раскручивание» Форт-системы происходит на уровне построения вариантов внутренней структуры Форт-системы (как разновидности шитого кода или нативного построения СЛОВ Форт языка, здесь же могут быть проведены и какие то оптимизации)
                                                      Это уровень «кухни» Форт-системы, обычно скрывается внутри Форт-системы, но пишется зачастую или на ассемблере или на Форт. Наиболее проста, конечно, классика Форт-систем при использовании механики шитого кода.

                                                      т.е. подытоживая формальная теория языков программирования, в общем варианте, не сможет понять логику «право-применения» найденного слова периода исполнения и компиляции сформированную программистом.
                                                      Типа даже гипотетически Форт конструкцию CREATE… DOES>

                                                      А формальное знание факта, что в начальной основе языка лежат СЛОВА разделённые пробелом, формально же даёт возможность проверки на общность Форт-систем правилам данной граматики, но не более, т.к. должны быть и более значимые результаты сего формализма.

                                                      image :)
                                                      Здесь, среди прочих книг по Форту, есть книга за авторством С.Н.Баранова Н.Р. Ноздрунова «Язык Форт и его реализации» (может помочь в понимании, например, как построена amForth система для AVR и других Фортов для контроллеров реализующих шитый код)
                                                        0
                                                        понимания Форт-системы появляется «грамматика» языка

                                                        Что значит «появляется»? Она была использована на этапе разработки форт-системы. В этапах же работы оной — она не «появляется».

                                                        А Вы опять смешиваете всё в кучу, как философ, честное слово.
                                                          0
                                                          Примерно это подразумевается под словом «появляется».
                                                          Обрабатываем данные встроенные в исходник Форта
                                                          Скрытый текст
                                                          VARIABLE aDump
                                                          VARIABLE Length
                                                          : >DI 
                                                                  0. BL WORD COUNT >NUMBER 2DROP D>S ( DUP . BL EMIT )  
                                                          ;
                                                          : dump-read? HERE aDump ! BASE @  
                                                              CR HEX 0x401000
                                                              BEGIN
                                                          	REFILL
                                                              WHILE
                                                                       >DI OVER - ?DUP IF DUP ALLOT + ( DUP . KEY DROP ) THEN \ PLACE
                                                                       >DI C, >DI C, >DI C, >DI C, >DI C, >DI C, >DI C, >DI C, 
                                                                       >DI C, >DI C, >DI C, >DI C, >DI C, >DI C, >DI C, >DI C,
                                                                     BL WORD DROP ( COUNT TYPE CR )
                                                                  0x10 + 
                                                              REPEAT
                                                              ." length ="  0x401000 - .
                                                              HERE aDump @  - DUP .  Length !  
                                                              BASE !
                                                              aDump @ Length @ DUMP
                                                          ;
                                                          HEX
                                                          dump-read?
                                                          00401000  28 10 00 00 00 00 00 00  00 00 00 00 48 10 00 00  (...........H...
                                                          00401010  38 10 00 00 00 00 00 00  00 00 00 00 00 00 00 00  8...............
                                                          00401020  00 00 00 00 00 00 00 00  5C 10 00 00 6C 10 00 00  ........\...l...

                                                          Тыц

                                                          При запуске данный пример выведет в консоль встроенные данные в файле (перехватили управление в Форт исходнике входным потоком — обработки данных добавленных в файл) Структура данных при обработке может быть произвольно сложной.

                              0
                              Кроме Форта и Лиспа еще есть Ребол.

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

                              Самое читаемое