Cicogna, parte 4: Dichiarazioni di attuazione e conclusione
Pubblicato: 2022-03-11Nella nostra ricerca per creare un linguaggio di programmazione leggero utilizzando C++, abbiamo iniziato creando il nostro tokenizer tre settimane fa, quindi abbiamo implementato la valutazione dell'espressione nelle due settimane successive.
Ora è il momento di concludere e fornire un linguaggio di programmazione completo che non sarà potente come un linguaggio di programmazione maturo ma avrà tutte le funzionalità necessarie, incluso un ingombro molto ridotto.
Trovo divertente come le nuove aziende abbiano sezioni FAQ sui loro siti Web che non rispondono alle domande che vengono poste di frequente, ma alle domande che vogliono che vengano poste. Farò lo stesso qui. Le persone che seguono il mio lavoro spesso mi chiedono perché Stork non compila in alcuni bytecode o almeno in un linguaggio intermedio.
Perché Stork non viene compilato in bytecode?
Sono felice di rispondere a questa domanda. Il mio obiettivo era sviluppare un linguaggio di scripting di piccole dimensioni che si integri facilmente con C++. Non ho una definizione rigida di "ingombro ridotto", ma immagino un compilatore che sarà abbastanza piccolo da consentire la portabilità su dispositivi meno potenti e non consumerà troppa memoria durante l'esecuzione.
Non mi sono concentrato sulla velocità, poiché penso che codificherai in C++ se hai un'attività critica dal punto di vista del tempo, ma se hai bisogno di una sorta di estensibilità, un linguaggio come Stork potrebbe essere utile.
Non sostengo che non ci siano altre lingue migliori in grado di svolgere un compito simile (ad esempio Lua). Sarebbe davvero tragico se non esistessero, e ti sto semplicemente dando un'idea del caso d'uso di questo linguaggio.
Dal momento che sarà incorporato in C++, trovo utile utilizzare alcune funzionalità esistenti di C++ invece di scrivere un intero ecosistema che servirà a uno scopo simile. Non solo, ma trovo anche questo approccio più interessante.
Come sempre, puoi trovare il codice sorgente completo sulla mia pagina GitHub. Ora, diamo un'occhiata più da vicino ai nostri progressi.
I cambiamenti
Fino a questa parte, Stork era un prodotto parzialmente completo, quindi non sono stato in grado di vedere tutti i suoi inconvenienti e difetti. Tuttavia, poiché ha preso una forma più completa, ho modificato le seguenti cose introdotte nelle parti precedenti:
- Le funzioni non sono più variabili. C'è una
function_lookup
separata incompiler_context
ora.function_param_lookup
viene rinominato inparam_lookup
per evitare confusione. - Ho cambiato il modo in cui vengono chiamate le funzioni. C'è il metodo
call
inruntime_context
che accettastd::vector
di argomenti, memorizza il vecchio indice del valore di ritorno, inserisce gli argomenti nello stack, cambia l'indice del valore di ritorno, chiama la funzione, apre gli argomenti dallo stack, ripristina il vecchio indice del valore di ritorno e restituisce il risultato. In questo modo, non dobbiamo mantenere lo stack degli indici dei valori di ritorno, come prima, perché lo stack C++ serve a questo scopo. - Classi RAII aggiunte in
compiler_context
che vengono restituite dalle chiamate alle funzioni membroscope
efunction
. Ciascuno di questi oggetti crea rispettivamente un nuovolocal_identifier_lookup
eparam_identifier_lookup
, nei rispettivi costruttori e ripristina il vecchio stato nel distruttore. - Una classe RAII aggiunta in
runtime_context
, restituita dalla funzione membroget_scope
. Quella funzione memorizza la dimensione dello stack nel suo costruttore e la ripristina nel suo distruttore. - Ho rimosso la parola chiave
const
e gli oggetti costanti in generale. Potrebbero essere utili ma non sono assolutamente necessari. - parola chiave
var
rimossa, in quanto attualmente non è affatto necessaria. - Ho aggiunto la parola chiave
sizeof
, che verificherà la dimensione di un array in runtime. Forse alcuni programmatori C++ troveranno blasfema la scelta del nome, poiché C++sizeof
viene eseguito in fase di compilazione, ma ho scelto quella parola chiave per evitare collisioni con un nome di variabile comune, ad esempiosize
. - Ho aggiunto la parola chiave
tostring
, che converte esplicitamente qualsiasi cosa instring
. Non può essere una funzione, poiché non consentiamo il sovraccarico della funzione. - Varie modifiche meno interessanti.
Sintassi
Poiché stiamo usando una sintassi molto simile al C e ai relativi linguaggi di programmazione, ti fornirò solo i dettagli che potrebbero non essere chiari.
Le dichiarazioni di tipo variabile sono le seguenti:
-
void
, utilizzato solo per il tipo restituito della funzione -
number
-
string
-
T[]
è un array di ciò che contiene elementi di tipoT
-
R(P1,...,Pn)
è una funzione che restituisce il tipoR
e riceve argomenti di tipo daP1
aPn
. Ciascuno di questi tipi può essere preceduto da&
se viene passato per riferimento.
La dichiarazione della funzione è la seguente: [public] function R name(P1 p1, … Pn pn)
Quindi, deve essere preceduto da function
. Se è preceduto da public
, può essere chiamato da C++. Se la funzione non restituisce il valore, valuterà il valore predefinito del tipo restituito.
Permettiamo -loop for
una dichiarazione nella prima espressione. Consentiamo anche if
-statement e switch
-statement con un'espressione di inizializzazione, come in C++17. L'istruzione if
inizia con un if
-block, seguito da zero o più elif
-block e, facoltativamente, da un else
-block. Se la variabile fosse stata dichiarata nell'espressione di inizializzazione dell'istruzione if
, sarebbe visibile in ciascuno di quei blocchi.
Consentiamo un numero facoltativo dopo un'istruzione break
che può interrompersi da più cicli nidificati. Quindi puoi avere il seguente codice:
for (number i = 0; i < 100; ++i) { for(number j = 0; j < 100; ++j) { if (rnd(100) == 0) { break 2; } } }
Inoltre, si interromperà da entrambi i loop. Quel numero viene convalidato in fase di compilazione. Quant'è fico?
compilatore
Molte funzionalità sono state aggiunte in questa parte, ma se vado troppo nel dettaglio, probabilmente perderò anche i lettori più persistenti che continuano a sopportarmi. Pertanto, salterò intenzionalmente una parte molto importante della storia: la compilation.
Questo perché l'ho già descritto nella prima e nella seconda parte di questa serie di blog. Mi stavo concentrando sulle espressioni, ma compilare qualsiasi altra cosa non è molto diverso.
Ti faccio comunque un esempio. Questo codice compila le istruzioni while
:
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)); }
Come puoi vedere, è tutt'altro che complicato. Analizza while
, quindi (
, quindi crea un'espressione numerica (non abbiamo booleani) e quindi analizza )
.
Successivamente, compila un'istruzione di blocco che può essere all'interno di {
e }
o meno (sì, ho consentito blocchi a istruzione singola) e alla fine crea un'istruzione while
.
Hai già familiarità con i primi due argomenti di funzione. Il terzo, possible_flow
, mostra i comandi di modifica del flusso consentiti ( continue
, break
, return
) nel contesto che stiamo analizzando. Potrei mantenere queste informazioni nell'oggetto se le istruzioni di compilazione fossero funzioni membro di una classe di compiler
, ma non sono un grande fan delle classi mastodontiche e il compilatore sarebbe sicuramente una di queste classi. Passare un argomento in più, soprattutto sottile, non farà male a nessuno, e chissà, forse un giorno riusciremo a parallelizzare il codice.
C'è un altro aspetto interessante della compilation che vorrei spiegare qui.
Se vogliamo supportare uno scenario in cui due funzioni si chiamano a vicenda, possiamo farlo in C-way: consentendo la dichiarazione in avanti o avendo due fasi di compilazione.
Ho scelto il secondo approccio. Quando viene trovata la definizione della funzione, ne analizzeremo il tipo e il nome nell'oggetto denominato incomplete_function
. Quindi, salteremo il suo corpo, senza un'interpretazione, semplicemente contando il livello di nidificazione delle parentesi graffe fino a quando non chiudiamo la prima parentesi graffa. Raccoglieremo i token nel processo, li manterremo in incomplete_function
e aggiungeremo un identificatore di funzione in compiler_context
.
Una volta passato l'intero file, compileremo ciascuna delle funzioni completamente, in modo che possano essere richiamate in runtime. In questo modo, ogni funzione può chiamare qualsiasi altra funzione nel file e accedere a qualsiasi variabile globale.
Le variabili globali possono essere inizializzate mediante chiamate alle stesse funzioni, il che ci porta immediatamente al vecchio problema "pollo e uova" non appena tali funzioni accedono a variabili non inizializzate.
Se dovesse mai accadere, il problema viene risolto lanciando runtime_exception
, e questo è solo perché sono gentile. Franky, la violazione di accesso è il minimo che puoi ottenere come punizione per aver scritto un codice del genere.
La portata globale
Esistono due tipi di entità che possono apparire nell'ambito globale:
- Variabili globali
- Funzioni
Ogni variabile globale può essere inizializzata con un'espressione che restituisce il tipo corretto. L'inizializzatore viene creato per ogni variabile globale.
Ogni inizializzatore restituisce lvalue
, quindi servono come costruttori di variabili globali. Quando non viene fornita alcuna espressione per una variabile globale, viene costruito l'inizializzatore predefinito.
Questa è la funzione membro initialize
in runtime_context
:
void runtime_context::initialize() { _globals.clear(); for (const auto& initializer : _initializers) { _globals.emplace_back(initializer->evaluate(*this)); } }
Viene chiamato dal costruttore. Cancella il contenitore delle variabili globali, come può essere chiamato in modo esplicito, per reimpostare lo stato runtime_context
.
Come accennato in precedenza, dobbiamo verificare se accediamo a una variabile globale non inizializzata. Pertanto, questa è la funzione di accesso della variabile globale:
variable_ptr& runtime_context::global(int idx) { runtime_assertion( idx < _globals.size(), "Uninitialized global variable access" ); return _globals[idx]; }
Se il primo argomento restituisce false
, runtime_assertion
genera un runtime_error
con il messaggio corrispondente.
Ogni funzione viene implementata come lambda che acquisisce la singola istruzione, che viene quindi valutata con il runtime_context
dalla funzione.
Ambito della funzione
Come si può notare dalla compilazione dell'istruzione while
, il compilatore viene chiamato ricorsivamente, a partire dall'istruzione block, che rappresenta il blocco dell'intera funzione.
Ecco la classe base astratta per tutte le istruzioni:
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; };
L'unica funzione oltre a quelle predefinite è execute
, che esegue la logica dell'istruzione su runtime_context
e restituisce il flow
, che determina dove andrà la logica del programma.
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(); };
Le funzioni statiche del creatore sono autoesplicative e le ho scritte per impedire un flow
illogico con break_level
diverso da zero e il tipo diverso da flow_type::f_break
.
Ora consume_break
creerà un flusso di interruzione con un livello di interruzione in meno o, se il livello di interruzione raggiunge lo zero, il flusso normale.
Ora controlleremo tutti i tipi di istruzione:
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(); } };
Qui, simple_statement
è l'istruzione creata da un'espressione. Ogni espressione può essere compilata come un'espressione che restituisce void
, in modo che simple_statement
possa essere creata da essa. Poiché né break
né continue
o return
possono far parte di un'espressione, simple_statement
restituisce flow::normal_flow()
.
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(); } };
Il block_statement
mantiene lo std::vector
delle istruzioni. Li esegue, uno per uno. Se ciascuno di essi restituisce un flusso non normale, restituisce immediatamente quel flusso. Utilizza un oggetto di ambito RAII per consentire dichiarazioni di variabili di ambito locale.
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
valuta l'espressione che crea una variabile locale e inserisce la nuova variabile locale nello 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
ha il livello di interruzione valutato in fase di compilazione. Restituisce semplicemente il flusso che corrisponde a quel livello di interruzione.
class continue_statement: public statement { public: continue_statement() = default; flow execute(runtime_context&) override { return flow::continue_flow(); } };
continue_statement
restituisce semplicemente flow::continue_flow()
.
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
e return_void_statement
entrambi restituiscono flow::return_flow()
. L'unica differenza è che il primo ha l'espressione che restituisce il valore restituito prima di restituire.

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
, che viene creato per un if
-block, zero o più elif
-blocks e un else
-block (che potrebbe essere vuoto), valuta ciascuna delle sue espressioni finché un'espressione non restituisce 1
. Quindi esegue quel blocco e restituisce il risultato dell'esecuzione. Se nessuna espressione restituisce 1
, restituirà l'esecuzione dell'ultimo blocco ( else
).
if_declare_statement
è l'istruzione che ha dichiarazioni come prima parte di una clausola if. Inserisce tutte le variabili dichiarate nello stack e quindi esegue la sua classe base ( if_statement
).
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
esegue le sue istruzioni una per una, ma prima salta all'indice appropriato che ottiene dalla valutazione dell'espressione. Se una qualsiasi delle sue istruzioni restituisce un flusso non normale, restituirà immediatamente quel flusso. Se ha flow_type::f_break
, consumerà prima una pausa.
switch_declare_statement
consente una dichiarazione nella sua intestazione. Nessuno di questi consente una dichiarazione nel corpo.
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
e do_while_statement
eseguono entrambi la loro istruzione body mentre la loro espressione restituisce 1
. Se l'esecuzione restituisce flow_type::f_break
, lo consumano e lo restituiscono. Se restituisce flow_type::f_return
, lo restituiscono. In caso di esecuzione normale, o continua, non fanno nulla.
Potrebbe sembrare che continue
non abbia effetto. Tuttavia, l'affermazione interna ne è stata influenzata. Se era, ad esempio, block_statement
, non è stato valutato fino alla fine.
Trovo carino che while_statement
sia implementato con il C++ while
e do-statement
con il C++ do-while
.
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
e for_statement_declare
sono implementati in modo simile a while_statement
e do_statement
. Sono ereditati dalla classe for_statement_base
, che esegue la maggior parte della logica. for_statement_declare
viene creato quando la prima parte del ciclo for
è una dichiarazione di variabile.

Queste sono tutte classi di istruzioni che abbiamo. Sono elementi costitutivi delle nostre funzioni. Quando viene creato runtime_context
, mantiene quelle funzioni. Se la funzione è dichiarata con la parola chiave public
, può essere chiamata per nome.
Ciò conclude la funzionalità principale di Stork. Tutto il resto che descriverò sono ripensamenti che ho aggiunto per rendere il nostro linguaggio più utile.
Tuple
Gli array sono contenitori omogenei, in quanto possono contenere elementi di un solo tipo. Se vogliamo contenitori eterogenei, vengono subito in mente le strutture.
Tuttavia, esistono contenitori eterogenei più banali: le tuple. Le tuple possono mantenere elementi di tipi diversi, ma i loro tipi devono essere conosciuti in fase di compilazione. Questo è un esempio di una dichiarazione di tupla in Stork:
[number, string] t = {22321, "Siveric"};
Questo dichiara la coppia di number
e string
e la inizializza.
Gli elenchi di inizializzazione possono essere utilizzati anche per inizializzare gli array. Quando i tipi di espressioni nell'elenco di inizializzazione non corrispondono al tipo di variabile, si verificherà un errore del compilatore.
Poiché gli array sono implementati come contenitori di variable_ptr
, abbiamo ottenuto l'implementazione di runtime delle tuple gratuitamente. È il momento della compilazione quando assicuriamo il tipo corretto di variabili contenute.
Moduli
Sarebbe bello nascondere i dettagli di implementazione a un utente Stork e presentare la lingua in un modo più intuitivo.
Questa è la classe che ci aiuterà a raggiungere questo obiettivo. Lo presento senza i dettagli di implementazione:
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(); ... };
Le funzioni load
e try_load
caricheranno e compileranno lo script Stork dal percorso specificato. Innanzitutto, uno di loro può lanciare una stork::error
, ma il secondo lo catturerà e lo stamperà sull'output, se fornito.
La funzione reset_globals
reinizializzerà le variabili globali.
Le funzioni add_external_functions
e create_public_function_caller
dovrebbero essere chiamate prima della compilazione. Il primo aggiunge una funzione C++ che può essere chiamata da Stork. Il secondo crea l'oggetto richiamabile che può essere utilizzato per chiamare la funzione Stork da C++. Causerà un errore in fase di compilazione se il tipo di funzione pubblica non corrisponde a R(Args…)
durante la compilazione dello script Stork.
Ho aggiunto diverse funzioni standard che possono essere aggiunte al modulo Stork.
void add_math_functions(module& m); void add_string_functions(module& m); void add_trace_functions(module& m); void add_standard_functions(module& m);
Esempio
Ecco un esempio di uno script Cicogna:
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)); }
Ecco la parte C++:
#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; }
Le funzioni standard vengono aggiunte al modulo prima della compilazione e le funzioni trace
e rnd
vengono utilizzate dallo script Stork. La funzione greater
viene aggiunta anche come vetrina.
Lo script viene caricato dal file "test.stk", che si trova nella stessa cartella di "main.cpp" (utilizzando una definizione del preprocessore __FILE__
), quindi viene chiamata la funzione main
.
Nello script, generiamo una matrice casuale, ordinando in ordine crescente utilizzando il comparatore less
, e quindi in decrescente utilizzando il comparatore greater
, scritto in C++.
Puoi vedere che il codice è perfettamente leggibile per chiunque parli fluentemente C (o qualsiasi linguaggio di programmazione derivato da C).
Cosa fare dopo?
Ci sono molte funzionalità che vorrei implementare in Stork:
- Strutture
- Classi ed eredità
- Chiamate tra moduli
- Funzioni Lambda
- Oggetti tipizzati dinamicamente
La mancanza di tempo e spazio è uno dei motivi per cui non li abbiamo già implementati. Cercherò di aggiornare la mia pagina GitHub con nuove versioni mentre implemento nuove funzionalità nel mio tempo libero.
Avvolgendo
Abbiamo creato un nuovo linguaggio di programmazione!
Ciò ha richiesto buona parte del mio tempo libero nelle ultime sei settimane, ma ora posso scrivere alcuni script e vederli in esecuzione. È quello che stavo facendo negli ultimi giorni, grattandomi la testa calva ogni volta che si schiantava inaspettatamente. A volte, era un piccolo bug, ea volte un brutto bug. Altre volte, invece, mi sono sentito in imbarazzo perché si trattava di una decisione sbagliata che avevo già condiviso con il mondo. Ma ogni volta, correggevo e continuavo a programmare.
Nel processo, ho appreso if constexpr
, che non avevo mai usato prima. Ho anche acquisito maggiore familiarità con i riferimenti rvalue e l'inoltro perfetto, nonché con altre funzionalità minori di C++ 17 che non incontro quotidianamente.
Il codice non è perfetto, non farei mai un'affermazione del genere, ma è abbastanza buono e segue principalmente buone pratiche di programmazione. E, soprattutto, funziona.
Decidere di sviluppare un nuovo linguaggio da zero può sembrare pazzesco per una persona media, o anche per un programmatore medio, ma è una ragione in più per farlo e dimostrare a te stesso che puoi farlo. Proprio come risolvere un enigma difficile è un buon esercizio per mantenersi mentalmente in forma.
Sfide noiose sono comuni nella nostra programmazione quotidiana, poiché non possiamo scegliere solo gli aspetti interessanti e dobbiamo fare un lavoro serio anche se a volte è noioso. Se sei uno sviluppatore professionista, la tua prima priorità è fornire codice di alta qualità al tuo datore di lavoro e mettere il cibo in tavola. Questo a volte può farti evitare di programmare nel tuo tempo libero e può smorzare l'entusiasmo dei tuoi primi giorni di scuola di programmazione.
Se non è necessario, non perdere quell'entusiasmo. Lavora su qualcosa se lo trovi interessante, anche se è già stato fatto. Non devi giustificare il motivo per divertirti.
E se riesci a incorporarlo, anche parzialmente, nel tuo lavoro professionale, buon per te! Non molte persone hanno questa opportunità.
Il codice per questa parte verrà bloccato con un ramo dedicato sulla mia pagina GitHub.