Universelles DSL. Ist es möglich?


Die Sprache des Fachgebiets. Nicht mit allgemeinen Sprachkonstrukten überladen. Gleichzeitig können Sie sehr komplexe Logik mit nur wenigen Zeilen implementieren. Das alles ist DSL.

Für die Erstellung eines DSL muss der Entwickler jedoch qualifiziert sein. Die regelmäßige Anwendung dieses Ansatzes wird zur Routine, eine andere Sprache zu entwickeln. Die Lösung könnte darin bestehen, ein universelles Tool zu erstellen - eine Engine, die sich für völlig unterschiedliche Aufgaben eignet und einfach zu modifizieren ist. In diesem Artikel werden wir in C # die vom Standpunkt der Implementierung einfachste, aber gleichzeitig recht leistungsfähige Sprach-Engine entwickeln, mit der Sie ein ziemlich breites Spektrum von Problemen lösen können.

Einleitung


Es gibt zwei Möglichkeiten, ein Anwendungsprojekt zu entwickeln: es so einfach zu gestalten, dass es offensichtlich keine Mängel aufweist, oder es so komplex zu gestalten, dass es keine offensichtlichen Mängel aufweist. C. E. R. Hoar (CAR Hoare)
In diesem Artikel möchte ich eine der Entwicklungstechniken vorstellen, die mir und meinem Team helfen, mit der Komplexität von Projekten umzugehen. Zum anderen können Sie damit schnell Prototyp-Anwendungen entwickeln. Die Entwicklung einer Programmiersprache erscheint auf den ersten Blick zu kompliziert. So ist es, wenn wir über ein universelles Werkzeug sprechen. Wenn es darum geht, einen engen Themenbereich abzudecken, rechtfertigt sich die Entwicklung einer bestimmten Sprache häufig.

Einmal stand ich vor der Aufgabe, eine Implementierung einer industriellen Sprache (IEC 61131-3) zur Einbettung in Kundensoftware zu entwickeln. Im Laufe dieser Arbeit habe ich mich für das Thema Dolmetscherstruktur interessiert und seitdem Dolmetscher für esoterische und wenig sprachliche Themen als Hobby geschrieben. In Zukunft wurde ein Verständnis dafür entwickelt, wie selbstgeschriebene Dolmetscher den Alltag vereinfachen können.


Das Hauptziel vernünftiger Programmiersprachen besteht darin, den Programmier- und Leseprozess zu vereinfachen. Das Schreiben in asm ist einfacher als in Maschinencodes, das Schreiben in C ist einfacher als in asm, in C # ist es noch einfacher und so weiter.

Dies wird hauptsächlich durch die populärste Methode des Reduktionismus erreicht - Aufteilung einer komplexen Aufgabe in einfache und erkennbare Komponenten - Standardisierung ihrer Interaktion und einer bestimmten Syntax.

Die Programmiersprache besteht aus einer Reihe von Operatoren, die im Wesentlichen die Grundlage für die Sprache, die grundlegenden Bausteine ​​und die Syntax bilden, die die Schreibweise für Kombinationen von Operatoren sowie die Standardbibliothek definieren. Sequenzen elementarer Aktionen nach syntaktischen Regeln werden zu Funktionen gruppiert, Funktionen zu Klassen (sofern OOP vorhanden), Klassen zu Bibliotheken und diese wiederum zu Paketen zusammengefasst. So sieht eine typische Mainstream-Sprache aus. Im Prinzip reichen diese Techniken aus, um die meisten alltäglichen Aufgaben zu lösen. Dies ist jedoch nicht die Grenze, da Sie einen Schritt weiter gehen können - zu einer höheren Abstraktionsebene, und Sie müssen die Grenzen der verwendeten Sprache überschreiten, wenn sie keine Metaprogrammierung in Form von Makros unterstützt.


Heutzutage beschränken sich die meisten Projekte auf eine Kombination aus vorgefertigten Komponenten und einem unbedeutenden, selbstgeschriebenen Teil auf niedriger Ebene. Die Kombination der Komponenten erfolgt normalerweise mit Hilfe einer universellen Programmiersprache - C #, Java, Python und anderen. Obwohl diese Sprachen auf hoher Ebene sind, sind sie auch universell und enthalten daher notwendigerweise syntaktische Konstruktionen für Operationen auf niedriger Ebene, die Erstellung von Funktionen, Klassen, die Beschreibung verallgemeinerter Typen, die asynchrone Programmierung und vieles mehr. Aus diesem Grund wächst die Aufgabe "Einmal ausführen, zweimal ausführen, dreimal ausführen" mit einer Vielzahl syntaktischer Konstruktionen und kann Hunderte von Codezeilen und mehr umfassen.

Sie können die Wiederverwendung von Komponenten vereinfachen, wenn Sie die Technik des Reduktionismus wiederholen, jedoch bereits für genau diese Komponenten. Dies wird durch die Entwicklung einer speziellen Sprache erreicht, die eine vereinfachte Syntax aufweist und ausschließlich zur Beschreibung des Zusammenwirkens dieser Komponenten dient. Dieser Ansatz wird als YaOP (sprachenorientierte Programmierung) bezeichnet, und Sprachen werden als DSL (domänenspezifische Sprache - eine domänenspezifische Sprache) bezeichnet.

Aufgrund des Fehlens redundanter Konstruktionen können nur wenige Leitungen auf dem DSL eine recht komplexe Funktionalität implementieren, was positive Konsequenzen hat: Die Entwicklungsgeschwindigkeit nimmt zu, die Anzahl der Fehler nimmt ab und die Systemtests werden vereinfacht.

Bei erfolgreicher Anwendung kann dieser Ansatz die Flexibilität des zu entwickelnden Produkts erheblich erhöhen, da kompakte Skripte geschrieben werden können, die das Verhalten des Systems definieren und erweitern. Wie die Verbreitung dieses Ansatzes zeigt, kann es viele Anwendungen für diesen Ansatz geben, da DSL überall verfügbar ist. Allgemeines HTML ist eine Dokumentbeschreibungssprache, SQL ist eine strukturierte Abfragesprache, JSON ist eine strukturierte Datenbeschreibungssprache, XAML, PostScript, Emacs Lisp, nnCron und viele andere.


Bei allen Vorteilen hat DSL einen erheblichen Nachteil - hohe Anforderungen an den Systementwickler.

Nicht jeder Entwickler hat das Wissen und die Erfahrung, auch eine primitive Sprache zu entwickeln. Selbst eine kleinere Anzahl von Spezialisten kann eine ausreichend flexible und produktive Sprache entwickeln. Es gibt noch andere Probleme. Zu einem bestimmten Zeitpunkt in der Entwicklung der ursprünglich festgelegten Funktionalität reicht dies möglicherweise nicht aus, und es ist erforderlich, Funktionen oder OOP zu erstellen. Und wo es Funktionen gibt, kann eine Optimierung der Schwanzrekursion erforderlich sein, um auf Schleifen usw. zu verzichten. Gleichzeitig muss die Abwärtskompatibilität berücksichtigt werden, damit zuvor geschriebene Skripte weiterhin mit der neuen Version funktionieren.

Ein weiteres Problem ist, dass eine Sprache, die zur Lösung eines Problems entwickelt wurde, für andere völlig ungeeignet ist. Daher muss ein neues DSL von Grund auf neu entwickelt werden, damit die Entwicklung neuer Sprachen zur Routine wird. Dies verkompliziert wiederum die Wartung und reduziert die Wiederverwendung von Code, der für verschiedene DSL-Implementierungen und Projekte, die sie verwenden, schwierig zu teilen ist.


Der Ausweg besteht darin, ein DSL zu erstellen, um ein DSL aufzubauen. Damit meine ich nicht RBNF, sondern eine Sprache, die mit eingebauten Mitteln auf die Sprache des Fachgebiets umgestellt werden kann. Das Haupthindernis beim Erstellen einer flexiblen und umwandelbaren Sprache ist das Vorhandensein eines fest definierten Syntax- und Typensystems. Während des gesamten Entwicklungszeitraums der Computerindustrie wurden mehrere flexible Sprachen ohne Syntax vorgeschlagen, die sich jedoch bis heute erhalten haben, und die Sprachen Forth und Lisp entwickeln sich weiterhin aktiv. Das Hauptmerkmal dieser Sprachen ist, dass sie aufgrund ihrer Struktur und Homo-Ikonizität das Verhalten des Interpreters aufgrund der eingebauten Mittel ändern und, falls erforderlich, syntaktische Konstruktionen analysieren können, die ursprünglich nicht festgelegt wurden.

Es gibt Lösungen für die Erweiterung der Syntax von Forth auf C oder Scheme. "Fort" wird oft für die ungewöhnliche Postfix-Folge von Argumenten und Operationen kritisiert, die durch die Verwendung des Stacks zur Übergabe von Argumenten vorgegeben wird. „Fort“ hat jedoch Zugriff auf einen Textinterpreter. Auf diese Weise können Sie den umgekehrten Datensatz bei Bedarf vor dem Benutzer verbergen. Und schließlich ist dies Gewohnheitssache und entwickelt sich recht schnell.

Die Lisp-Sprachfamilie basiert auf Makros, mit denen Sie bei Bedarf DSL eingeben können. Der Zugriff auf den Interpreter und den Leser trägt zur Implementierung metazyklischer Interpreter mit bestimmten Interpretationsmerkmalen bei. Beispielsweise ist die Implementierung von Scheme lisp Racket als Entwicklungsumgebung für Sprachen positioniert und verfügt über Standardsprachen zum Erstellen von Webservern, Erstellen von GUI-Schnittstellen, Inferenzsprachen und anderen.

Diese Flexibilität macht diese Sprachen zu guten Kandidaten für die Rolle der universellen DSL-Engine.

"Fort" und "Lisp" entwickeln sich hauptsächlich als Mehrzwecksprachen, wenn auch als Nischensprachen. Sie stützen sich auf Funktionen, die für eine DSL-Sprache überflüssig sein können. Gleichzeitig sind sie einfach zu implementieren, was bedeutet, dass Sie eine eingeschränkte Version mit der Möglichkeit ihrer Erweiterung entwickeln können. Auf diese Weise können Sie den Kern einer solchen Sprache mit kleinen Änderungen (im Idealfall - ohne) für eine bestimmte Aufgabe wiederverwenden.

Ich möchte auch erwähnen, dass diese Sprachen nicht nur hervorragend zum Schreiben von Skripten geeignet sind, sondern auch für die interaktive Interaktion mit dem System über REPL. Dies kann zum einen zum Debuggen nützlich sein und zum anderen als benutzerzugängliche Schnittstelle zum System dienen. Es wird angenommen, dass die Textschnittstelle mit dem System in einigen Fällen effektiver sein kann als die grafische, da sie viel einfacher zu implementieren und flexibler ist und dem Benutzer die Verallgemeinerung typischer Operationen in Funktionen usw. ermöglicht. Ein auffälliges Beispiel für eine Textschnittstelle könnte Bash sein. Und wenn die Sprache homo-ikonisch ist, kann ihre Konstruktion relativ einfach generiert und analysiert werden, und mit minimalem Aufwand kann eine Grafiksprache über dem Interpreter implementiert werden - dies kann nützlich sein, wenn der Zielbenutzer weit vom Programmieren entfernt ist.

Heutzutage werden XML- und JSON-Datenbeschreibungssprachen häufig als DSL für die Konfiguration verwendet. Dies ist natürlich eine großartige Praxis, aber in einigen Fällen reichen die Daten allein nicht aus, und Sie müssen beispielsweise die Vorgänge an ihnen beschreiben.


In diesem Beitrag schlage ich vor, einen einfachen Interpreter für die Fort-Sprache zu erstellen und zu zeigen, wie diese angepasst werden kann, um bestimmte Probleme zu lösen.

Die Fort-Sprache wurde als am einfachsten zu implementierende und zu verwendende Sprache ausgewählt, während sie leistungsstark genug war, um sie als DSL für eine Reihe von Aufgaben zu verwenden. Tatsächlich ist das Herzstück der Sprache der Adressinterpreter, der selbst im Assembler nur wenige Zeilen benötigt, und der Hauptteil der Implementierung liegt auf den Grundelementen, die umso universeller, schneller und flexibler sind, je universeller die Implementierung sein sollte. Ein weiterer wichtiger Teil der Sprache ist der Textinterpreter, mit dem Sie mit dem Adressinterpreter interagieren können.


Adressdolmetscher


Das Grundelement der Fort-Sprache ist ein Wort, das durch Leerzeichen, Zeilenenden und Tabulatoren von anderen Wörtern und Atomen (Zahlen) getrennt ist.

Ein Wort hat dieselbe Bedeutung und dieselben Eigenschaften wie eine Funktion aus anderen Sprachen, z. B. C. Wörter, die in der Implementierung verkabelt sind, dh mit denselben Mitteln wie der Interpreter implementiert sind, ähneln Operatoren aus anderen Sprachen. Tatsächlich ist ein Programm in einer beliebigen Programmiersprache nichts anderes als eine Kombination aus Sprach- und Datenoperatoren. Daher kann die Erstellung einer Programmiersprache als Definition von Operatoren und deren Kombination angesehen werden. Darüber hinaus bestimmen Sprachen wie C eine andere Schreibweise von Operatoren, die die Syntax der Sprache bestimmt. In den meisten Sprachen ist das Ändern von Anweisungen normalerweise nicht möglich. Beispielsweise können Sie die Syntax oder das Verhalten einer if-Anweisung nicht ändern.

In der Sprache Fort haben alle Operatoren und ihre Kombinationen (Benutzerwörter) die gleiche Schreibweise. Fortwörter sind in primitive und benutzerdefinierte Wörter unterteilt. Sie können ein Wort definieren, das das Grundelement überlastet und so das Verhalten der Grundelemente ändert. Obwohl in Wirklichkeit das neu definierte Wort durch die anfangs definierten Grundelemente implementiert wird. In unserer Implementierung ist die Funktion in C # das Grundelement. Ein benutzerdefiniertes Wort besteht aus einer Liste von Adressen von Wörtern, die ausgeführt werden sollen. Da es zwei Arten von Wörtern gibt, muss der Dolmetscher zwischen ihnen unterscheiden. Die Trennung von Grundelementen und Benutzerwörtern erfolgt über dieselben Grundelemente. Jedes Benutzerwort beginnt mit einer DoList-Operation und endet mit einer Exit-Operation.

Es ist möglich, lange Zeit zu beschreiben, wie eine solche Trennung auftritt, dies ist jedoch leichter zu verstehen, wenn man die Ausführungsreihenfolge des Interpreterprogramms studiert. Dazu implementieren wir einen Minimalinterpreter, definieren ein einfaches Programm und sehen, wie es Schritt für Schritt ausgeführt wird.


Unsere Fort-Maschine besteht aus linearem Speicher, Datenstapel, Rücksprungstapel, Befehlszeiger, Wortzeiger. Wir werden auch einen separaten Ort zur Aufbewahrung von Primitiven haben.

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; //   

Das Wesentliche der Interpretation besteht darin, zur Adresse im Speicher zu navigieren und den dort angezeigten Befehl auszuführen. Der gesamte Adressinterpreter - in unserem Fall das Herz der Sprache - wird in einer Funktion Next () definiert.

 public void Next() { while (true) { if (IP == 0) return; WP = (int)Mem[IP++]; Core[(int)Mem[WP]](); } } 

Jedes Benutzerwort beginnt mit einem DoList-Befehl, dessen Aufgabe es ist, die aktuelle Interpretationsadresse auf dem Stapel zu speichern und die Interpretationsadresse des nächsten Wortes festzulegen.

 public void DoList() { RS.Push(IP); IP = WP + 1; } 

Verwenden Sie zum Verlassen des Wortes den Befehl Exit, mit dem die Adresse aus dem Rückgabestapel wiederhergestellt wird.

 public void Exit() { IP = RS.Pop(); } 

Zur visuellen Demonstration des Interpreterprinzips führen wir einen Befehl ein, der nützliche Arbeit simuliert. Nennen wir es Hallo ().

 public void Hello() { Console.WriteLine("Hello"); } 

Zunächst müssen Sie den Computer initialisieren und die Grundelemente angeben, damit der Interpreter ordnungsgemäß funktioniert. Sie müssen auch die Adressen der Grundelemente im Programmspeicher angeben.

 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; 

Jetzt können wir ein einfaches Programm erstellen. In unserem Fall beginnt der Benutzercode bei Adresse 4 und besteht aus zwei Unterprogrammen. Die erste Routine beginnt bei Adresse 7 und ruft die zweite Routine auf, die bei Adresse 4 beginnt und das Wort Hallo anzeigt.

 // 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    

Um das Programm auszuführen, müssen Sie zuerst den Wert 0 im Rückgabestapel speichern, wodurch der Adressinterpreter den Interpretationszyklus unterbricht, den Einstiegspunkt festlegt und dann den Interpreter startet.

 var entryPoint = 7; //    IP = 0; //  IP = 0,        WP = entryPoint; //  WP = 7      DoList(); //     ,  IP = 0    Next(); //    

Wie beschrieben, werden in diesem Interpreter Grundelemente in einem separaten Speicher gespeichert. Natürlich könnte es auch anders implementiert sein: Beispielsweise wurde im Programmspeicher ein Vertreter der Bedienerfunktion abgelegt. Einerseits wäre ein derartiger Dolmetscher nicht einfacher geworden, andererseits wäre er deutlich langsamer gewesen, da jeder Schritt der Interpretation eine Typprüfung, ein Casting und eine Ausführung erfordern würde, wodurch mehr Operationen erhalten würden.

Jedes Benutzerwort unseres Interpreters beginnt mit dem DoList-Grundelement, dessen Aufgabe es ist, die aktuelle Adresse der Interpretation zu speichern und zur nächsten Adresse zu wechseln. Das Verlassen der Unterroutine wird durch die Exit-Operation ausgeführt, die die Adresse aus dem Rückgabestapel zur weiteren Interpretation wiederherstellt. Tatsächlich haben wir den gesamten Adressinterpreter beschrieben. Um beliebige Programme auszuführen, reicht es aus, sie mit Primitiven zu erweitern. Zunächst müssen Sie sich jedoch mit einem Textinterpreter befassen, der eine Schnittstelle zum Adressinterpreter bietet.


Textinterpreter


Die Fort-Sprache hat keine Syntax: In ihr geschriebene Programme sind Wörter, die durch Leerzeichen, Tabulatoren oder Zeilenenden getrennt sind. Daher besteht die Aufgabe des Textinterpreters darin, den Eingabestream in Wörter (Token) zu unterteilen, einen Einstiegspunkt für sie zu finden, auszuführen oder in den Speicher zu schreiben. Aber nicht alle Token unterliegen der Ausführung. Wenn der Interpreter das Wort nicht findet, versucht er es als numerische Konstante zu interpretieren. Darüber hinaus verfügt der Textinterpreter über zwei Modi: Interpretationsmodus und Programmiermodus. Im Programmiermodus werden die Wortadressen nicht ausgeführt, sondern in den Speicher geschrieben, wodurch neue Wörter ermittelt werden.

Kanonische Implementierungen des "Fort" kombinieren normalerweise ein Wörterbuch (Wörterbucheintrag) und einen Programmspeicher und definieren eine einzelne Codedatei in Form einer einfach verbundenen Liste. In unserer Implementierung befindet sich nur ausführbarer Code im Speicher, und die Einstiegspunkte von Wörtern werden in einer separaten Struktur gespeichert - einem Wörterbuch.

 public Dictionary<string, List<WordHeader>> Entries; 

In diesem Wörterbuch wird die Entsprechung eines Wortes auf mehrere Überschriften festgelegt. Sie können also eine beliebige Anzahl von Routinen mit demselben Namen definieren, diese Definition löschen und die alte Definition verwenden. Die gespeicherte alte Adresse ermöglicht es Ihnen auch, den Namen eines Wortes im Wörterbuch zu finden, selbst wenn es neu definiert wurde. Dies ist besonders nützlich, um einen Stack-Trace zu generieren oder um Fehler im Arbeitsspeicher zu beheben. WordHeader ist eine Klasse, die eine Subroutineneintragsadresse und ein sofortiges Interpretationsflag speichert.

 public class WordHeader { public int Address; public bool Immediate; } 

Das Direkt-Flag weist den Interpreter an, dass dieses Wort im Programmiermodus ausgeführt und nicht in den Speicher geschrieben werden soll. Schematisch kann die Logik des Interpreters wie folgt dargestellt werden: Die rechte Hand ist JA, die linke ist NEIN.


Wir werden TextReader verwenden, um den Eingabestream zu lesen, und TextWriter, um ihn auszugeben.

 public TextReader Input; public TextWriter Output; 

Die Implementierung des Interpreters gemäß dem obigen Schema erfolgt in einem Interpreter () mit einer Funktion.

 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"); } } } } 

Die Interpretation erfolgt in einer Schleife, deren Ausgabe beim Erreichen des Endes des Eingabestreams (z. B. des Endes der Datei) ausgeführt wird, während die ReadWord-Funktion eine leere Zeichenfolge zurückgibt. Die Aufgabe von ReadWord ist es, bei jedem Aufruf das nächste Wort zurückzugeben.

 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); } 

Nachdem das Wort gelesen wurde, wird versucht, es im Wörterbuch zu finden. Bei Erfolg wird der Titel des Wortes zurückgegeben, andernfalls null.

 public WordHeader LookUp(string word) { if (Entries.ContainsKey(word)) { return Entries[word].Last(); } return null; } 

Sie können anhand der ersten beiden Zeichen überprüfen, ob der eingegebene Wert eine Zahl ist. Wenn das erste Zeichen eine Zahl ist, nehmen wir an, dass es eine Zahl ist. Wenn das erste Zeichen ein "+" oder "-" ist und das zweite eine Ziffer, ist dies höchstwahrscheinlich auch eine Zahl.

 static bool IsConstant(string word) { return IsDigit(word[0]) || (word.Length >= 2 && (word[0] == '+' || word[0] == '-') && IsDigit(word[1])); } 

Um eine Zeichenfolge in eine Zahl umzuwandeln, können Sie die Standardmethoden Int32.TryParse und Double.TryParse verwenden. Da sich die Geschwindigkeit aus verschiedenen Gründen nicht unterscheidet, verwende ich eine benutzerdefinierte Lösung.

 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; } 

Die ParseNumber-Methode kann sowohl Ganzzahlwerte als auch Gleitkommazahlen konvertieren, z. B. "1.618".

Die Ausführung des Wortes erfolgt auf die gleiche Weise wie bei der Ausführung des Adressinterpreters. Im Ausnahmefall wird ein Stack-Trace des Adressinterpreters gedruckt.

 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; } } 

Wenn sich der Interpreter im Kompilierungsmodus befindet und das Wort nicht für die sofortige Ausführung markiert ist, muss seine Adresse in den Speicher geschrieben werden.

 public void AddOp(object op) { Mem[Here++] = op; } 

Die here-Variable speichert die Adresse der nächsten freien Zelle. Da diese Variable als Variable der Fort-Sprache aus der Laufzeitumgebung heraus zugänglich sein muss, wird der Wert hier mit einem bestimmten Offset im Programmspeicher abgelegt.

 public int _hereShift; public int Here { get => (int)Mem[_hereShift]; set => Mem[_hereShift] = value; } 

Um bei der Interpretation zwischen einer numerischen Konstante und einer Wortadresse zu unterscheiden, wird bei der Kompilierung vor jeder Konstante eine Kompilierung des doLit-Wortes erstellt, die den nächsten Wert im Speicher liest und auf den Datenstapel legt.

 public void DoLit() { DS.Push(Mem[IP++]); } 

Wir haben Adress- und Textinterpreter beschrieben, die Weiterentwicklung besteht darin, den Kern mit Atomen zu füllen. Verschiedene Versionen von "Fort" haben unterschiedliche Grundbegriffe. Die minimalistischste Implementierung ist vielleicht eForth, das nur 31 Grundelemente enthält. Da das Grundelement schneller ausgeführt wird als zusammengesetzte Benutzerwörter, sind minimale Fort-Implementierungen normalerweise langsamer als ausführliche Implementierungen. Einen Vergleich der Wortmenge mehrerer Versionen von Interpreten finden Sie hier .

In dem hier beschriebenen Interpreter habe ich auch versucht, das Wörterbuch der Grundwörter nicht unnötig aufzublasen. Um die Integration mit der .net-Plattform zu vereinfachen, habe ich mich entschlossen, Mathematik, Boolesche Operationen und natürlich Reflexion durch eine Reihe von Grundelementen zu implementieren. Gleichzeitig fehlen hier einige der Wörter, die in Fort-Implementierungen häufig primitiv sind, was eine Implementierung mit Hilfe des Interpreters impliziert.

Zum Zeitpunkt des Schreibens umfasst der Basissatz 68 Wörter.
 // 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); 


Um neue Benutzerwörter zu definieren, werden zwei Kernelwörter verwendet: ":" und ";". Das Wort ":" liest den Namen eines neuen Wortes aus dem Eingabestream, erstellt mit diesem Schlüssel einen Header, die Adresse des Basisworts doList wird zum Programmspeicher hinzugefügt und der Interpreter wird in den Kompilierungsmodus versetzt. Alle nachfolgenden Wörter werden mit Ausnahme der als unmittelbar gekennzeichneten Wörter zusammengestellt.

 public void BeginDefWord() { AddHeader(ReadWord(Input)); AddOp(LookUp("doList").Address); IsEvalMode = false; } 

Die Übersetzung endet mit dem Wort „;“, das die Adresse des Wortes „exit“ in den Programmspeicher schreibt und in den Interpretationsmodus versetzt. Jetzt können Sie benutzerdefinierte Wörter definieren, z. B. Schleifen, bedingte Anweisungen und andere.

 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 

Ich werde den Rest der Standardwörter hier nicht beschreiben - es gibt genügend Informationen dazu im Netzwerk über die entsprechenden thematischen Ressourcen. Um mit der Plattform zu interagieren, habe ich 9 Wörter definiert:

  • "Null" - drückt Null auf den Stapel;
  • “Type” - verschiebt den Klassentyp auf den Stapel von “word TrueForth.MyClass type”;
  • „Neu“ - Nimmt den Typ vom Stapel, erstellt eine Instanz der Klasse und platziert ihn auf dem Stapel. Eventuelle Konstruktorargumente sollten sich auch auf dem Stapel „Wort TrueForth.MyClass Typ neu“ befinden.
  • "M!" - Nimmt eine Instanz eines Objekts, Feldnamens und Werts aus dem Stapel und weist dem angegebenen Feld einen Wert zu.
  • "M @" - holt eine Instanz eines Objekts aus dem Stapel, den Feldnamen, und gibt den Wert des Felds an den Stapel zurück;
  • "Ms!" Und "ms @" - ähnlich wie die vorherigen, aber für statische Felder muss anstelle einer Instanz ein Typ auf dem Stapel vorhanden sein.
  • "Load-Assembly" - Nimmt vom Stapel, lässt ihn zur Assembly und lädt in den Speicher;
  • "Invk" - Nimmt den Delegaten und die Argumente aus dem Stapel und nennt ihn "1133 word SomeMethod word TrueForth.MyClass type new m @ invk".

Ich habe die Hauptpunkte der Implementierung der Fort-Sprache beschrieben. Diese Implementierung versucht nicht, ANSI-Standards für die Sprache zu unterstützen, da ihre Aufgabe darin besteht, eine Engine zum Erstellen von DSL und keine Allzwecksprache zu implementieren. In den meisten Fällen reicht der entwickelte Interpreter aus, um eine einfache Sprache für das Fachgebiet zu erstellen.

Es gibt verschiedene Möglichkeiten, den obigen Interpreter zu verwenden. Beispielsweise können Sie eine Instanz des Interpreters erstellen und dann ein Initialisierungsskript an die Eingabe senden, in dem die erforderlichen Wörter ermittelt werden. Letztere interagieren durch Reflektion mit dem System.

 public static bool Init4Th() { Interpreter = new OForth(); if (File.Exists(InitFile)) { Interpreter.Eval(File.ReadAllText(InitFile)); return true; } else { Console.WriteLine($"  {InitFile}  !"); return false; } } 

Beispiel für die Konfiguration des Berichtsverteilungssystems

 ( *****   ***** ) 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 

Sie können auch Folgendes tun: Übergeben Sie vorgefertigte Objekte über den Datenstapel an den Eingang des Interpreters und interagieren Sie dann über den Interpreter mit ihnen. Ich habe zum Beispiel die Geräteeinstellungen für den Empfang von Scans von Dokumenten, einen Scanner, eine Webcam oder ein virtuelles Gerät wiederhergestellt (zum Debuggen oder Trainieren). In diesem Fall ist der Parametersatz, die Einstellungen, die Initialisierungsreihenfolge der verschiedenen Geräte sehr unterschiedlich und wird durch den Fort-Interpreter trivial gelöst.

 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)); } 

Die Konfiguration wird programmgesteuert generiert und sieht ungefähr so ​​aus:

 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 

Übrigens werden die Skripte * .ps und * .pdf auf ähnliche Weise generiert, da sowohl PostScript als auch Pdf im Wesentlichen eine Teilmenge des Fort sind, aber ausschließlich zum Rendern von Dokumenten auf einem Bildschirm oder einem Drucker verwendet werden.

Es ist genauso einfach, den interaktiven Modus für Konsolen und nicht nur für Anwendungen zu implementieren. Dazu müssen Sie zuerst das System über das vorbereitete Skript initialisieren und dann die Interpretation starten, indem Sie den Interpreter auf die Standardeingabe STDIN setzen.

 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 

Das Initialisierungsskript kann folgendermaßen aussehen:

 ( *****   ***** ) 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 ; 

Als Eingabe kann es nicht nur eine Konsole oder einen Text aus einer TextBox-Anwendung mit einer Benutzeroberfläche geben, sondern auch ein Netzwerk. In diesem Fall können Sie eine einfache interaktive Steuerung, z. B. einen Dienst, zum Debuggen, Starten und Stoppen von Komponenten implementieren. Die Einsatzmöglichkeiten sind durch die Vorstellungskraft des Entwicklers und die zu lösende Aufgabe begrenzt. , 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.

, , — , , , , — . , .

— , . — , — !

, .

- .

Viel glück

Source: https://habr.com/ru/post/de477902/


All Articles