Storch, Teil 4: Anweisungen implementieren und zusammenfassen

Veröffentlicht: 2022-03-11

In unserem Bestreben, eine leichtgewichtige Programmiersprache mit C++ zu erstellen, haben wir vor drei Wochen damit begonnen, unseren Tokenizer zu erstellen, und dann haben wir die Ausdrucksauswertung in den folgenden zwei Wochen implementiert.

Jetzt ist es an der Zeit, eine vollständige Programmiersprache zusammenzufassen und bereitzustellen, die nicht so leistungsfähig wie eine ausgereifte Programmiersprache ist, aber alle erforderlichen Funktionen hat, einschließlich eines sehr geringen Platzbedarfs.

Ich finde es lustig, wie neue Unternehmen FAQ-Bereiche auf ihren Websites haben, die nicht häufig gestellte Fragen beantworten, sondern Fragen, die sie gestellt bekommen möchten. Das werde ich hier auch tun. Leute, die meine Arbeit verfolgen, fragen mich oft, warum Stork nicht zu einem Bytecode oder zumindest einer Zwischensprache kompiliert.

Warum kompiliert Stork nicht zu Bytecode?

Gerne beantworte ich diese Frage. Mein Ziel war es, eine kleine Skriptsprache zu entwickeln, die sich leicht in C++ integrieren lässt. Ich habe keine strikte Definition von „kleinem Fußabdruck“, aber ich stelle mir einen Compiler vor, der klein genug ist, um auf weniger leistungsstarke Geräte portiert werden zu können, und der bei der Ausführung nicht zu viel Speicher verbraucht.

C++ Storch

Ich habe mich nicht auf die Geschwindigkeit konzentriert, da ich denke, dass Sie in C++ codieren werden, wenn Sie eine zeitkritische Aufgabe haben, aber wenn Sie eine Art Erweiterbarkeit benötigen, dann könnte eine Sprache wie Stork nützlich sein.

Ich behaupte nicht, dass es keine anderen, besseren Sprachen gibt, die eine ähnliche Aufgabe erfüllen können (zB Lua). Es wäre wirklich tragisch, wenn es sie nicht gäbe, und ich gebe Ihnen lediglich eine Vorstellung vom Anwendungsfall dieser Sprache.

Da es in C++ eingebettet wird, finde ich es praktisch, einige vorhandene Funktionen von C++ zu verwenden, anstatt ein ganzes Ökosystem zu schreiben, das einem ähnlichen Zweck dient. Nicht nur das, ich finde diesen Ansatz auch interessanter.

Den vollständigen Quellcode finden Sie wie immer auf meiner GitHub-Seite. Schauen wir uns nun unseren Fortschritt genauer an.

Änderungen

Bis zu diesem Teil war Stork ein teilweise vollständiges Produkt, daher konnte ich nicht alle seine Nachteile und Mängel erkennen. Da es jedoch eine vollständigere Form annahm, habe ich die folgenden Dinge geändert, die in früheren Teilen eingeführt wurden:

  • Funktionen sind keine Variablen mehr. Es gibt jetzt ein separates function_lookup in compiler_context . function_param_lookup wurde in param_lookup umbenannt, um Verwirrung zu vermeiden.
  • Ich habe die Art und Weise geändert, wie Funktionen aufgerufen werden. Es gibt die call Methode in runtime_context , die std::vector von Argumenten nimmt, den alten Rückgabewertindex speichert, Argumente auf den Stack schiebt, den Rückgabewertindex ändert, die Funktion aufruft, Argumente vom Stack holt, den alten Rückgabewertindex wiederherstellt und gibt das Ergebnis zurück. Auf diese Weise müssen wir den Stack der Rückgabewertindizes nicht wie zuvor aufbewahren, da der C++-Stack diesen Zweck erfüllt.
  • In compiler_context hinzugefügte RAII-Klassen, die von Aufrufen an seine Memberfunktionen scope und function zurückgegeben werden. Jedes dieser Objekte erstellt jeweils neue local_identifier_lookup und param_identifier_lookup in ihren Konstruktoren und stellt den alten Zustand im Destruktor wieder her.
  • Eine in runtime_context hinzugefügte RAII-Klasse, die von der Member-Funktion get_scope wird. Diese Funktion speichert die Stapelgröße in ihrem Konstruktor und stellt sie in ihrem Destruktor wieder her.
  • Ich habe das Schlüsselwort const und konstante Objekte im Allgemeinen entfernt. Sie könnten nützlich sein, sind aber nicht unbedingt notwendig.
  • var -Schlüsselwort entfernt, da es derzeit überhaupt nicht benötigt wird.
  • Ich habe das Schlüsselwort sizeof hinzugefügt, das die Größe eines Arrays zur Laufzeit überprüft. Vielleicht finden einige C++-Programmierer die Namenswahl blasphemisch, da C++ sizeof in der Kompilierzeit läuft, aber ich habe dieses Schlüsselwort gewählt, um Kollisionen mit einigen gängigen Variablennamen zu vermeiden - zum Beispiel size .
  • Ich habe das Schlüsselwort tostring hinzugefügt, das alles explizit in string konvertiert. Es kann keine Funktion sein, da wir das Überladen von Funktionen nicht zulassen.
  • Diverse weniger interessante Änderungen.

Syntax

Da wir eine Syntax verwenden, die C und seinen verwandten Programmiersprachen sehr ähnlich ist, werde ich Ihnen nur die Details geben, die möglicherweise nicht klar sind.

Variablentypdeklarationen lauten wie folgt:

  • void , wird nur für den Rückgabetyp der Funktion verwendet
  • number
  • string
  • T[] ist ein Array dessen, was Elemente des Typs T enthält
  • R(P1,...,Pn) ist eine Funktion, die den Typ R zurückgibt und Argumente der Typen P1 bis Pn empfängt. Jedem dieser Typen kann ein & vorangestellt werden, wenn er als Referenz übergeben wird.

Die Funktionsdeklaration sieht wie folgt aus: [public] function R name(P1 p1, … Pn pn)

Es muss also function vorangestellt werden. Wenn ihr das Präfix public vorangestellt ist, kann sie von C++ aus aufgerufen werden. Wenn die Funktion den Wert nicht zurückgibt, wird sie zum Standardwert ihres Rückgabetyps ausgewertet.

Wir erlauben for -loop mit einer Deklaration im ersten Ausdruck. Wir erlauben auch if -statement und switch -statement mit einem Initialisierungsausdruck, wie in C++17. Die if -Anweisung beginnt mit einem if -Block, gefolgt von null oder mehr elif -Blöcken und optional einem else -Block. Wenn die Variable im Initialisierungsausdruck der if -Anweisung deklariert wurde, wäre sie in jedem dieser Blöcke sichtbar.

Wir erlauben eine optionale Zahl nach einer break -Anweisung, die mehrere verschachtelte Schleifen unterbrechen kann. Sie können also den folgenden Code haben:

 for (number i = 0; i < 100; ++i) { for(number j = 0; j < 100; ++j) { if (rnd(100) == 0) { break 2; } } }

Außerdem wird es von beiden Schleifen brechen. Diese Nummer wird in der Kompilierzeit validiert. Wie cool ist das?

Compiler

In diesem Teil wurden viele Funktionen hinzugefügt, aber wenn ich zu detailliert werde, werde ich wahrscheinlich selbst die hartnäckigsten Leser verlieren, die mich noch ertragen. Deshalb überspringe ich absichtlich einen sehr großen Teil der Geschichte – die Zusammenstellung.

Das liegt daran, dass ich es bereits im ersten und zweiten Teil dieser Blogserie beschrieben habe. Ich habe mich auf Ausdrücke konzentriert, aber alles andere zu kompilieren ist nicht viel anders.

Ich gebe Ihnen jedoch ein Beispiel. Dieser Code kompiliert while -Anweisungen:

 statement_ptr compile_while_statement( compiler_context& ctx, tokens_iterator& it, possible_flow pf ) { parse_token_value(ctx, it, reserved_token::kw_while); parse_token_value(ctx, it, reserved_token::open_round); expression<number>::ptr expr = build_number_expression(ctx, it); parse_token_value(ctx, it, reserved_token::close_round); block_statement_ptr block = compile_block_statement(ctx, it, pf); return create_while_statement(std::move(expr), std::move(block)); }

Wie Sie sehen können, ist es alles andere als kompliziert. Es analysiert while , then ( , dann erstellt es einen Zahlenausdruck (wir haben keine booleschen Werte) und analysiert dann ) .

Danach kompiliert es eine Blockanweisung, die sich innerhalb von { und } befinden kann oder nicht (ja, ich habe Einzelanweisungsblöcke zugelassen) und erstellt am Ende eine while -Anweisung.

Die ersten beiden Funktionsargumente kennen Sie bereits. Der dritte, possible_flow , zeigt die zulässigen flussverändernden Befehle ( continue , break , return ) in dem Kontext, den wir analysieren. Ich könnte diese Informationen im Objekt behalten, wenn die Kompilierungsanweisungen Mitgliedsfunktionen einer Compilerklasse wären, aber ich bin kein großer Fan von Mammutklassen, und der compiler wäre definitiv eine solche Klasse. Das Übergeben eines zusätzlichen Arguments, insbesondere eines dünnen, wird niemandem schaden, und wer weiß, vielleicht können wir eines Tages den Code parallelisieren.

Es gibt noch einen weiteren interessanten Aspekt der Zusammenstellung, den ich hier erläutern möchte.

Wenn wir ein Szenario unterstützen möchten, in dem sich zwei Funktionen gegenseitig aufrufen, können wir dies auf C-Weise tun: indem wir die Vorwärtsdeklaration zulassen oder zwei Kompilierungsphasen haben.

Ich habe mich für den zweiten Ansatz entschieden. Wenn die Funktionsdefinition gefunden wird, werden wir ihren Typ und Namen in das Objekt mit dem Namen „ incomplete_function “ parsen. Dann überspringen wir seinen Körper ohne Interpretation, indem wir einfach die Verschachtelungsebene der geschweiften Klammern zählen, bis wir die erste geschweifte Klammer schließen. Wir werden dabei Token sammeln, sie in „ incomplete_function “ aufbewahren und einen Funktionsbezeichner in „ compiler_context “ hinzufügen.

Sobald wir die gesamte Datei übergeben haben, werden wir jede der Funktionen vollständig kompilieren, damit sie zur Laufzeit aufgerufen werden können. Auf diese Weise kann jede Funktion jede andere Funktion in der Datei aufrufen und auf jede globale Variable zugreifen.

Globale Variablen können durch Aufrufe derselben Funktionen initialisiert werden, was uns sofort zum alten „Huhn und Ei“-Problem führt, sobald diese Funktionen auf nicht initialisierte Variablen zugreifen.

Sollte das jemals passieren, wird das Problem gelöst, indem eine runtime_exception – und das nur, weil ich nett bin. Franky, Zugriffsverletzung ist das Mindeste, was man als Strafe für das Schreiben eines solchen Codes bekommen kann.

Der globale Geltungsbereich

Es gibt zwei Arten von Entitäten, die im globalen Geltungsbereich erscheinen können:

  • Globale Variablen
  • Funktionen

Jede globale Variable kann mit einem Ausdruck initialisiert werden, der den korrekten Typ zurückgibt. Der Initialisierer wird für jede globale Variable erstellt.

Jeder Initialisierer gibt lvalue zurück, sodass sie als Konstruktoren für globale Variablen dienen. Wenn für eine globale Variable kein Ausdruck bereitgestellt wird, wird der Standardinitialisierer erstellt.

Dies ist die Elementfunktion initialize in runtime_context :

 void runtime_context::initialize() { _globals.clear(); for (const auto& initializer : _initializers) { _globals.emplace_back(initializer->evaluate(*this)); } }

Es wird vom Konstruktor aufgerufen. Es löscht den globalen Variablencontainer, da er explizit aufgerufen werden kann, um den Zustand runtime_context .

Wie ich bereits erwähnt habe, müssen wir prüfen, ob wir auf eine nicht initialisierte globale Variable zugreifen. Daher ist dies der globale Variablenzugriff:

 variable_ptr& runtime_context::global(int idx) { runtime_assertion( idx < _globals.size(), "Uninitialized global variable access" ); return _globals[idx]; }

Wenn das erste Argument als false ausgewertet wird, runtime_assertion einen runtime_error mit der entsprechenden Meldung aus.

Jede Funktion wird als Lambda implementiert, das die einzelne Anweisung erfasst, die dann mit dem runtime_context ausgewertet wird, den die Funktion empfängt.

Funktionsumfang

Wie Sie an der Kompilierung der while -Anweisung sehen konnten, wird der Compiler rekursiv aufgerufen, beginnend mit der Blockanweisung, die den Block der gesamten Funktion darstellt.

Hier ist die abstrakte Basisklasse für alle Anweisungen:

 class statement { statement(const statement&) = delete; void operator=(const statement&) = delete; protected: statement() = default; public: virtual flow execute(runtime_context& context) = 0; virtual ~statement() = default; };

Die einzige Funktion neben den Standardfunktionen ist execute , die die Anweisungslogik für runtime_context und den flow zurückgibt, der bestimmt, wohin die Programmlogik als nächstes gehen wird.

 enum struct flow_type{ f_normal, f_break, f_continue, f_return, }; class flow { private: flow_type _type; int _break_level; flow(flow_type type, int break_level); public: flow_type type() const; int break_level() const; static flow normal_flow(); static flow break_flow(int break_level); static flow continue_flow(); static flow return_flow(); flow consume_break(); };

Statische Erstellerfunktionen sind selbsterklärend, und ich habe sie geschrieben, um einen unlogischen flow mit break_level ungleich Null und einem anderen Typ als flow_type::f_break zu verhindern.

Jetzt erstellt „ consume_break “ einen Unterbrechungsfluss mit einer Unterbrechungsebene weniger oder, wenn die Unterbrechungsebene null erreicht, den normalen Fluss.

Jetzt werden wir alle Anweisungstypen überprüfen:

 class simple_statement: public statement { private: expression<void>::ptr _expr; public: simple_statement(expression<void>::ptr expr): _expr(std::move(expr)) { } flow execute(runtime_context& context) override { _expr->evaluate(context); return flow::normal_flow(); } };

Hier ist simple_statement die Anweisung, die aus einem Ausdruck erstellt wird. Jeder Ausdruck kann als Ausdruck kompiliert werden, der void zurückgibt, sodass simple_statement erstellt werden kann. Da weder break continue Continue oder return Teil eines Ausdrucks sein können, gibt simple_statement flow::normal_flow() zurück.

 class block_statement: public statement { private: std::vector<statement_ptr> _statements; public: block_statement(std::vector<statement_ptr> statements): _statements(std::move(statements)) { } flow execute(runtime_context& context) override { auto _ = context.enter_scope(); for (const statement_ptr& statement : _statements) { if ( flow f = statement->execute(context); f.type() != flow_type::f_normal ){ return f; } } return flow::normal_flow(); } };

Die block_statement behält den std::vector der Anweisungen. Es führt sie aus, einen nach dem anderen. Wenn jeder von ihnen einen nicht normalen Fluss zurückgibt, gibt er diesen Fluss sofort zurück. Es verwendet ein RAII-Bereichsobjekt, um Deklarationen von lokalen Bereichsvariablen zu ermöglichen.

 class local_declaration_statement: public statement { private: std::vector<expression<lvalue>::ptr> _decls; public: local_declaration_statement(std::vector<expression<lvalue>::ptr> decls): _decls(std::move(decls)) { } flow execute(runtime_context& context) override { for (const expression<lvalue>::ptr& decl : _decls) { context.push(decl->evaluate(context)); } return flow::normal_flow(); } };

local_declaration_statement wertet den Ausdruck aus, der eine lokale Variable erstellt, und schiebt die neue lokale Variable auf den Stack.

 class break_statement: public statement { private: int _break_level; public: break_statement(int break_level): _break_level(break_level) { } flow execute(runtime_context&) override { return flow::break_flow(_break_level); } };

break_statement hat den Break-Level, der in der Kompilierzeit ausgewertet wird. Es gibt nur den Fluss zurück, der diesem Break-Level entspricht.

 class continue_statement: public statement { public: continue_statement() = default; flow execute(runtime_context&) override { return flow::continue_flow(); } };

Continue_Statement gibt nur flow::continue_flow() continue_statement .

 class return_statement: public statement { private: expression<lvalue>::ptr _expr; public: return_statement(expression<lvalue>::ptr expr) : _expr(std::move(expr)) { } flow execute(runtime_context& context) override { context.retval() = _expr->evaluate(context); return flow::return_flow(); } }; class return_void_statement: public statement { public: return_void_statement() = default; flow execute(runtime_context&) override { return flow::return_flow(); } };

return_statement und return_void_statement beide return flow::return_flow() . Der einzige Unterschied besteht darin, dass der erstere den Ausdruck hat, den er zum Rückgabewert auswertet, bevor er zurückkehrt.

 class if_statement: public statement { private: std::vector<expression<number>::ptr> _exprs; std::vector<statement_ptr> _statements; public: if_statement( std::vector<expression<number>::ptr> exprs, std::vector<statement_ptr> statements ): _exprs(std::move(exprs)), _statements(std::move(statements)) { } flow execute(runtime_context& context) override { for (size_t i = 0; i < _exprs.size(); ++i) { if (_exprs[i]->evaluate(context)) { return _statements[i]->execute(context); } } return _statements.back()->execute(context); } }; class if_declare_statement: public if_statement { private: std::vector<expression<lvalue>::ptr> _decls; public: if_declare_statement( std::vector<expression<lvalue>::ptr> decls, std::vector<expression<number>::ptr> exprs, std::vector<statement_ptr> statements ): if_statement(std::move(exprs), std::move(statements)), _decls(std::move(decls)) { } flow execute(runtime_context& context) override { auto _ = context.enter_scope(); for (const expression<lvalue>::ptr& decl : _decls) { context.push(decl->evaluate(context)); } return if_statement::execute(context); } };

if_statement , das für einen if -Block, null oder mehr elif -Blöcke und einen else -Block (der leer sein könnte) erstellt wird, wertet jeden seiner Ausdrücke aus, bis ein Ausdruck 1 ergibt. Es führt dann diesen Block aus und gibt das Ausführungsergebnis zurück. Wenn kein Ausdruck zu 1 ausgewertet wird, wird die Ausführung des letzten ( else ) Blocks zurückgegeben.

if_declare_statement ist die Anweisung, die Deklarationen als ersten Teil einer if-Klausel enthält. Es schiebt alle deklarierten Variablen auf den Stack und führt dann seine Basisklasse ( if_statement ) aus.

 class switch_statement: public statement { private: expression<number>::ptr _expr; std::vector<statement_ptr> _statements; std::unordered_map<number, size_t> _cases; size_t _dflt; public: switch_statement( expression<number>::ptr expr, std::vector<statement_ptr> statements, std::unordered_map<number, size_t> cases, size_t dflt ): _expr(std::move(expr)), _statements(std::move(statements)), _cases(std::move(cases)), _dflt(dflt) { } flow execute(runtime_context& context) override { auto it = _cases.find(_expr->evaluate(context)); for ( size_t idx = (it == _cases.end() ? _dflt : it->second); idx < _statements.size(); ++idx ) { switch (flow f = _statements[idx]->execute(context); f.type()) { case flow_type::f_normal: break; case flow_type::f_break: return f.consume_break(); default: return f; } } return flow::normal_flow(); } }; class switch_declare_statement: public switch_statement { private: std::vector<expression<lvalue>::ptr> _decls; public: switch_declare_statement( std::vector<expression<lvalue>::ptr> decls, expression<number>::ptr expr, std::vector<statement_ptr> statements, std::unordered_map<number, size_t> cases, size_t dflt ): _decls(std::move(decls)), switch_statement(std::move(expr), std::move(statements), std::move(cases), dflt) { } flow execute(runtime_context& context) override { auto _ = context.enter_scope(); for (const expression<lvalue>::ptr& decl : _decls) { context.push(decl->evaluate(context)); } return switch_statement::execute(context); } };

switch_statement führt seine Anweisungen nacheinander aus, springt aber zuerst zum entsprechenden Index, den es aus der Ausdrucksauswertung erhält. Wenn eine seiner Anweisungen einen nicht normalen Fluss zurückgibt, wird dieser Fluss sofort zurückgegeben. Wenn es flow_type::f_break hat, wird es zuerst eine Pause verbrauchen.

switch_declare_statement erlaubt eine Deklaration in seinem Header. Keines davon erlaubt eine Deklaration im Körper.

 class while_statement: public statement { private: expression<number>::ptr _expr; statement_ptr _statement; public: while_statement(expression<number>::ptr expr, statement_ptr statement): _expr(std::move(expr)), _statement(std::move(statement)) { } flow execute(runtime_context& context) override { while (_expr->evaluate(context)) { switch (flow f = _statement->execute(context); f.type()) { case flow_type::f_normal: case flow_type::f_continue: break; case flow_type::f_break: return f.consume_break(); case flow_type::f_return: return f; } } return flow::normal_flow(); } };
 class do_statement: public statement { private: expression<number>::ptr _expr; statement_ptr _statement; public: do_statement(expression<number>::ptr expr, statement_ptr statement): _expr(std::move(expr)), _statement(std::move(statement)) { } flow execute(runtime_context& context) override { do { switch (flow f = _statement->execute(context); f.type()) { case flow_type::f_normal: case flow_type::f_continue: break; case flow_type::f_break: return f.consume_break(); case flow_type::f_return: return f; } } while (_expr->evaluate(context)); return flow::normal_flow(); } };

while_statement und do_while_statement führen beide ihre Body-Anweisung aus, während ihr Ausdruck zu 1 ausgewertet wird. Wenn die Ausführung flow_type::f_break , verbrauchen sie es und kehren zurück. Wenn es flow_type::f_return , geben sie es zurück. Bei normaler Ausführung oder Fortsetzung tun sie nichts.

Es kann so aussehen, als ob continue keine Wirkung hat. Allerdings war die innere Aussage davon betroffen. Wenn es beispielsweise block_statement war, wurde es nicht bis zum Ende ausgewertet.

Ich finde es schön, dass while_statement mit dem C++- while und do-statement mit dem C++- do-while implementiert wird.

 class for_statement_base: public statement { private: expression<number>::ptr _expr2; expression<void>::ptr _expr3; statement_ptr _statement; public: for_statement_base( expression<number>::ptr expr2, expression<void>::ptr expr3, statement_ptr statement ): _expr2(std::move(expr2)), _expr3(std::move(expr3)), _statement(std::move(statement)) { } flow execute(runtime_context& context) override { for (; _expr2->evaluate(context); _expr3->evaluate(context)) { switch (flow f = _statement->execute(context); f.type()) { case flow_type::f_normal: case flow_type::f_continue: break; case flow_type::f_break: return f.consume_break(); case flow_type::f_return: return f; } } return flow::normal_flow(); } }; class for_statement: public for_statement_base { private: expression<void>::ptr _expr1; public: for_statement( expression<void>::ptr expr1, expression<number>::ptr expr2, expression<void>::ptr expr3, statement_ptr statement ): for_statement_base( std::move(expr2), std::move(expr3), std::move(statement) ), _expr1(std::move(expr1)) { } flow execute(runtime_context& context) override { _expr1->evaluate(context); return for_statement_base::execute(context); } }; class for_declare_statement: public for_statement_base { private: std::vector<expression<lvalue>::ptr> _decls; expression<number>::ptr _expr2; expression<void>::ptr _expr3; statement_ptr _statement; public: for_declare_statement( std::vector<expression<lvalue>::ptr> decls, expression<number>::ptr expr2, expression<void>::ptr expr3, statement_ptr statement ): for_statement_base( std::move(expr2), std::move(expr3), std::move(statement) ), _decls(std::move(decls)) { } flow execute(runtime_context& context) override { auto _ = context.enter_scope(); for (const expression<lvalue>::ptr& decl : _decls) { context.push(decl->evaluate(context)); } return for_statement_base::execute(context); } };

for_statement und for_statement_declare werden ähnlich implementiert wie while_statement und do_statement . Sie werden von der Klasse for_statement_base geerbt, die den größten Teil der Logik übernimmt. for_statement_declare wird erstellt, wenn der erste Teil der for -Schleife eine Variablendeklaration ist.

C++ Stork: Implementieren von Anweisungen

Dies sind alles Anweisungsklassen, die wir haben. Sie sind Bausteine ​​unserer Funktionen. Wenn runtime_context erstellt wird, werden diese Funktionen beibehalten. Wird die Funktion mit dem Schlüsselwort public deklariert, kann sie über den Namen aufgerufen werden.

Damit ist die Kernfunktionalität von Stork abgeschlossen. Alles andere, was ich beschreiben werde, sind nachträgliche Einfälle, die ich hinzugefügt habe, um unsere Sprache nützlicher zu machen.

Tupel

Arrays sind homogene Container, da sie nur Elemente eines einzigen Typs enthalten können. Will man heterogene Container, fallen einem sofort Strukturen ein.

Es gibt jedoch trivialere heterogene Container: Tupel. Tupel können die Elemente verschiedener Typen enthalten, aber ihre Typen müssen zur Kompilierzeit bekannt sein. Dies ist ein Beispiel für eine Tupeldeklaration in Stork:

 [number, string] t = {22321, "Siveric"};

Dies deklariert das Paar aus number und string und initialisiert es.

Initialisierungslisten können auch zum Initialisieren von Arrays verwendet werden. Wenn die Typen von Ausdrücken in der Initialisierungsliste nicht mit dem Variablentyp übereinstimmen, tritt ein Compilerfehler auf.

Da Arrays als Container von variable_ptr implementiert werden, haben wir die Laufzeitimplementierung von Tupeln kostenlos erhalten. Es ist Kompilierzeit, wenn wir den korrekten Typ der enthaltenen Variablen sicherstellen.

Module

Es wäre schön, die Implementierungsdetails vor einem Stork-Benutzer zu verbergen und die Sprache benutzerfreundlicher darzustellen.

Dies ist die Klasse, die uns dabei helfen wird, dies zu erreichen. Ich präsentiere es ohne die Implementierungsdetails:

 class module { ... public: template<typename R, typename... Args> void add_external_function(const char* name, std::function<R(Args...)> f); template<typename R, typename... Args> auto create_public_function_caller(std::string name); void load(const char* path); bool try_load(const char* path, std::ostream* err = nullptr) noexcept; void reset_globals(); ... };

Die Funktionen load und try_load laden und kompilieren das Stork-Skript aus dem angegebenen Pfad. Erstens kann einer von ihnen einen stork::error werfen, aber der zweite fängt ihn ab und gibt ihn auf der Ausgabe aus, falls vorhanden.

Die Funktion reset_globals initialisiert globale Variablen neu.

Die Funktionen add_external_functions und create_public_function_caller sollten vor dem Kompilieren aufgerufen werden. Die erste fügt eine C++-Funktion hinzu, die von Stork aufgerufen werden kann. Die zweite erzeugt das aufrufbare Objekt, das zum Aufrufen der Stork-Funktion von C++ aus verwendet werden kann. Es wird einen Kompilierzeitfehler verursachen, wenn der öffentliche Funktionstyp während der Stork-Skriptkompilierung nicht mit R(Args…) .

Ich habe mehrere Standardfunktionen hinzugefügt, die dem Stork-Modul hinzugefügt werden können.

 void add_math_functions(module& m); void add_string_functions(module& m); void add_trace_functions(module& m); void add_standard_functions(module& m);

Beispiel

Hier ist ein Beispiel für ein Storch-Skript:

 function void swap(number& x, number& y) { number tmp = x; x = y; y = tmp; } function void quicksort( number[]& arr, number begin, number end, number(number, number) comp ) { if (end - begin < 2) return; number pivot = arr[end-1]; number i = begin; for (number j = begin; j < end-1; ++j) if (comp(arr[j], pivot)) swap(&arr[i++], &arr[j]); swap (&arr[i], &arr[end-1]); quicksort(&arr, begin, i, comp); quicksort(&arr, i+1, end, comp); } function void sort(number[]& arr, number(number, number) comp) { quicksort(&arr, 0, sizeof(arr), comp); } function number less(number x, number y) { return x < y; } public function void main() { number[] arr; for (number i = 0; i < 100; ++i) { arr[sizeof(arr)] = rnd(100); } trace(tostring(arr)); sort(&arr, less); trace(tostring(arr)); sort(&arr, greater); trace(tostring(arr)); }

Hier ist der C++-Teil:

 #include <iostream> #include "module.hpp" #include "standard_functions.hpp" int main() { std::string path = __FILE__; path = path.substr(0, path.find_last_of("/\\") + 1) + "test.stk"; using namespace stork; module m; add_standard_functions(m); m.add_external_function( "greater", std::function<number(number, number)>([](number x, number y){ return x > y; } )); auto s_main = m.create_public_function_caller<void>("main"); if (m.try_load(path.c_str(), &std::cerr)) { s_main(); } return 0; }

Standardfunktionen werden dem Modul vor der Kompilierung hinzugefügt, und die Funktionen trace und rnd werden aus dem Stork-Skript verwendet. Als greater kommt noch die Funktionsgröße hinzu.

Das Skript wird aus der Datei „test.stk“ geladen, die sich im selben Ordner wie „main.cpp“ befindet (unter Verwendung einer __FILE__ Präprozessordefinition), und dann wird die Funktion main aufgerufen.

Im Skript generieren wir ein zufälliges Array, sortieren in aufsteigender Reihenfolge mit dem Vergleicher less und dann in absteigender Reihenfolge mit dem in C++ geschriebenen Vergleicher greater .

Sie können sehen, dass der Code für jeden, der C (oder jede von C abgeleitete Programmiersprache) fließend beherrscht, perfekt lesbar ist.

Was macht man als nächstes?

Es gibt viele Funktionen, die ich gerne in Stork implementieren würde:

  • Strukturen
  • Klassen und Vererbung
  • Aufrufe zwischen Modulen
  • Lambda-Funktionen
  • Dynamisch typisierte Objekte

Zeit- und Platzmangel ist einer der Gründe, warum wir sie noch nicht implementiert haben. Ich werde versuchen, meine GitHub-Seite mit neuen Versionen zu aktualisieren, wenn ich in meiner Freizeit neue Funktionen implementiere.

Einpacken

Wir haben eine neue Programmiersprache geschaffen!

Das hat in den letzten sechs Wochen einen guten Teil meiner Freizeit gekostet, aber jetzt kann ich einige Skripte schreiben und sehen, wie sie laufen. So habe ich es in den letzten Tagen gemacht, mich jedes Mal an der Glatze gekratzt, wenn es unerwartet abgestürzt ist. Manchmal war es ein kleiner Fehler und manchmal ein böser Fehler. Manchmal war es mir jedoch peinlich, weil es um eine schlechte Entscheidung ging, die ich bereits mit der Welt geteilt hatte. Aber jedes Mal würde ich reparieren und weiter programmieren.

Dabei erfuhr ich von if constexpr , das ich noch nie zuvor verwendet hatte. Ich habe mich auch mit rvalue-Referenzen und Perfect Forwarding vertraut gemacht, sowie mit anderen kleineren Funktionen von C++17, die mir nicht täglich begegnen.

Der Code ist nicht perfekt – ich würde das niemals behaupten – aber er ist gut genug und folgt größtenteils guten Programmierpraktiken. Und das Wichtigste - es funktioniert.

Die Entscheidung, eine neue Sprache von Grund auf neu zu entwickeln, mag für einen Durchschnittsmenschen oder sogar einen Durchschnittsprogrammierer verrückt klingen, aber es ist umso mehr ein Grund, es zu tun und sich selbst zu beweisen, dass man es kann. Genauso wie das Lösen eines schwierigen Rätsels eine gute Gehirnübung ist, um geistig fit zu bleiben.

Langweilige Herausforderungen sind in unserer täglichen Programmierung üblich, da wir uns nicht nur die interessanten Aspekte herauspicken können und ernsthafte Arbeit leisten müssen, auch wenn es manchmal langweilig ist. Wenn Sie ein professioneller Entwickler sind, besteht Ihre erste Priorität darin, Ihrem Arbeitgeber qualitativ hochwertigen Code zu liefern und Essen auf den Tisch zu bringen. Dies kann dazu führen, dass Sie das Programmieren in Ihrer Freizeit manchmal vermeiden, und es kann den Enthusiasmus Ihrer frühen Programmierschulzeit dämpfen.

Wenn Sie es nicht müssen, verlieren Sie diesen Enthusiasmus nicht. Arbeiten Sie an etwas, wenn Sie es interessant finden, auch wenn es bereits fertig ist. Sie müssen den Grund nicht rechtfertigen, um Spaß zu haben.

Und wenn Sie es – auch teilweise – in Ihre berufliche Arbeit einfließen lassen können, gut für Sie! Diese Möglichkeit haben nicht viele Menschen.

Der Code für diesen Teil wird mit einem dedizierten Zweig auf meiner GitHub-Seite eingefroren.