Storch, Teil 4: Anweisungen implementieren und zusammenfassen
Veröffentlicht: 2022-03-11In 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.
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
incompiler_context
.function_param_lookup
wurde inparam_lookup
umbenannt, um Verwirrung zu vermeiden. - Ich habe die Art und Weise geändert, wie Funktionen aufgerufen werden. Es gibt die
call
Methode inruntime_context
, diestd::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 Memberfunktionenscope
undfunction
zurückgegeben werden. Jedes dieser Objekte erstellt jeweils neuelocal_identifier_lookup
undparam_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-Funktionget_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 Beispielsize
. - Ich habe das Schlüsselwort
tostring
hinzugefügt, das alles explizit instring
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 TypsT
enthält -
R(P1,...,Pn)
ist eine Funktion, die den TypR
zurückgibt und Argumente der TypenP1
bisPn
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.

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.