Bocian, część 4: Implementacja instrukcji i podsumowanie
Opublikowany: 2022-03-11W naszym dążeniu do stworzenia lekkiego języka programowania przy użyciu C++ zaczęliśmy od stworzenia naszego tokenizera trzy tygodnie temu, a następnie zaimplementowaliśmy ocenę wyrażeń w kolejnych dwóch tygodniach.
Teraz nadszedł czas, aby zakończyć i dostarczyć kompletny język programowania, który nie będzie tak potężny jak dojrzały język programowania, ale będzie miał wszystkie niezbędne funkcje, w tym bardzo mały rozmiar.
To zabawne, że nowe firmy mają na swoich stronach sekcje FAQ, które nie odpowiadają na często zadawane pytania, ale pytania, które chcą, aby były zadawane. Zrobię to samo tutaj. Osoby śledzące moją pracę często pytają mnie, dlaczego Stork nie kompiluje do jakiegoś kodu bajtowego lub przynajmniej do jakiegoś języka pośredniego.
Dlaczego bocian nie kompiluje do kodu bajtowego?
Chętnie odpowiem na to pytanie. Moim celem było stworzenie niewielkiego języka skryptowego, który z łatwością zintegruje się z C++. Nie mam ścisłej definicji „małych rozmiarów”, ale wyobrażam sobie kompilator, który będzie wystarczająco mały, aby umożliwić przenoszenie do mniej wydajnych urządzeń i nie będzie zużywał zbyt dużo pamięci po uruchomieniu.
Nie skupiałem się na szybkości, ponieważ myślę, że jeśli masz zadanie, w którym czas ma krytyczne znaczenie, będziesz kodować w C++, ale jeśli potrzebujesz jakiejś rozszerzalności, język taki jak Stork może być przydatny.
Nie twierdzę, że nie ma innych, lepszych języków, które mogą wykonać podobne zadanie (na przykład Lua). Byłoby naprawdę tragicznie, gdyby nie istniały, a ja tylko daję ci wyobrażenie o przypadku użycia tego języka.
Ponieważ będzie on osadzony w C++, uważam za przydatne wykorzystanie niektórych istniejących funkcji C++ zamiast pisania całego ekosystemu, który będzie służył podobnemu celowi. Nie tylko to, ale także uważam to podejście za bardziej interesujące.
Jak zawsze, pełny kod źródłowy można znaleźć na mojej stronie GitHub. Przyjrzyjmy się teraz bliżej naszym postępom.
Zmiany
Do tej części Stork był produktem częściowo kompletnym, więc nie byłem w stanie dostrzec wszystkich jego wad i wad. Ponieważ jednak przybrał pełniejszy kształt, zmieniłem następujące rzeczy wprowadzone w poprzednich częściach:
- Funkcje nie są już zmiennymi. Istnieje teraz osobna
function_lookup
wcompiler_context
. nazwafunction_param_lookup
zostaje zmieniona naparam_lookup
, aby uniknąć nieporozumień. - Zmieniłem sposób wywoływania funkcji. Istnieje metoda
call
wruntime_context
, która pobierastd::vector
argumentów, przechowuje stary indeks wartości zwracanych, odkłada argumenty na stos, zmienia indeks wartości zwracanej, wywołuje funkcję, usuwa argumenty ze stosu, przywraca stary indeks wartości zwracanej i zwraca wynik. W ten sposób nie musimy trzymać stosu indeksów wartości zwracanych, jak poprzednio, ponieważ stos C++ służy do tego celu. - Klasy RAII dodane w
compiler_context
, które są zwracane przez wywołaniascope
ifunction
jego funkcji członkowskich. Każdy z tych obiektów tworzy odpowiednio nowelocal_identifier_lookup
iparam_identifier_lookup
, w swoich konstruktorach i przywraca stary stan w destruktorze. - Klasa RAII dodana w
runtime_context
, zwrócona przez funkcję członkowskąget_scope
. Ta funkcja przechowuje rozmiar stosu w swoim konstruktorze i przywraca go w swoim destruktorze. - Usunąłem słowo kluczowe
const
i ogólnie obiekty stałe. Mogą być przydatne, ale nie są absolutnie konieczne. - Usunięto słowo kluczowe
var
, ponieważ obecnie nie jest ono w ogóle potrzebne. - Dodałem słowo kluczowe
sizeof
, które będzie sprawdzać rozmiar tablicy w czasie wykonywania. Być może niektórzy programiści C++ uznają wybór nazwy za bluźnierczy, ponieważ C++sizeof
działa w czasie kompilacji, ale wybrałem to słowo kluczowe, aby uniknąć kolizji z jakąś popularną nazwą zmiennej - na przykładsize
. - Dodałem słowo kluczowe
tostring
, które jawnie konwertuje wszystko nastring
. Nie może to być funkcja, ponieważ nie pozwalamy na przeciążanie funkcji. - Różne mniej interesujące zmiany.
Składnia
Ponieważ używamy składni bardzo podobnej do C i powiązanych z nią języków programowania, podam tylko szczegóły, które mogą nie być jasne.
Deklaracje typu zmiennej są następujące:
-
void
, używany tylko dla funkcji zwracanej typu -
number
-
string
-
T[]
jest tablicą zawierającą elementy typuT
-
R(P1,...,Pn)
to funkcja, która zwraca typR
i otrzymuje argumenty typów odP1
doPn
. Każdy z tych typów może być poprzedzony znakiem&
, jeśli jest przekazywany przez odwołanie.
Deklaracja funkcji jest następująca: [public] function R name(P1 p1, … Pn pn)
Tak więc musi być poprzedzony function
. Jeśli jest poprzedzony public
, można go wywołać z C++. Jeśli funkcja nie zwróci wartości, zwróci wartość domyślną zwracanego typu.
Dopuszczamy -loop for
deklaracją w pierwszym wyrażeniu. Dopuszczamy również if
-statement i switch
-statement z wyrażeniem inicjującym, tak jak w C++17. Instrukcja if
zaczyna się od bloku if
, po którym następuje zero lub więcej bloków elif
i opcjonalnie else
jeden blok. Gdyby zmienna była zadeklarowana w wyrażeniu inicjującym instrukcji if
, byłaby widoczna w każdym z tych bloków.
Dopuszczamy opcjonalną liczbę po instrukcji break
, która może przerwać wiele zagnieżdżonych pętli. Możesz więc mieć następujący kod:
for (number i = 0; i < 100; ++i) { for(number j = 0; j < 100; ++j) { if (rnd(100) == 0) { break 2; } } }
Ponadto zerwie się z obu pętli. Ten numer jest weryfikowany w czasie kompilacji. Jakie to jest świetne?
Kompilator
W tej części zostało dodanych wiele funkcji, ale jeśli będę zbyt szczegółowy, prawdopodobnie stracę nawet najbardziej wytrwałych czytelników, którzy wciąż się ze mną trzymają. Dlatego celowo pominę jedną bardzo dużą część historii - kompilację.
To dlatego, że opisałem to już w pierwszej i drugiej części tej serii blogów. Skupiłem się na wyrażeniach, ale kompilacja czegokolwiek innego nie różni się zbytnio.
Podam jednak jeden przykład. Ten kod kompiluje instrukcje 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)); }
Jak widać, nie jest to skomplikowane. Analizuje while
, then (
, następnie buduje wyrażenie liczbowe (nie mamy wartości logicznych), a następnie analizuje )
.
Następnie kompiluje instrukcję blokową, która może znajdować się wewnątrz {
i }
lub nie (tak, zezwalam na bloki jednoinstrukcyjne) i na końcu tworzy instrukcję while
.
Znasz już pierwsze dwa argumenty funkcji. Trzecia, possible_flow
, pokazuje dozwolone polecenia zmieniające przepływ ( continue
, break
, return
) w kontekście, który analizujemy. Mógłbym zachować te informacje w obiekcie, gdyby instrukcje kompilacji były funkcjami składowymi jakiejś klasy compiler
, ale nie jestem wielkim fanem klas mamuta, a kompilator z pewnością byłby jedną z takich klas. Przekazanie dodatkowego argumentu, zwłaszcza cienkiego, nikomu nie zaszkodzi, a kto wie, może kiedyś uda nam się zrównoleglić kod.
Jest jeszcze jeden interesujący aspekt kompilacji, który chciałbym tutaj wyjaśnić.
Jeśli chcemy wesprzeć scenariusz, w którym dwie funkcje wywołują się nawzajem, możemy to zrobić w sposób C: zezwalając na deklarację forward lub mając dwie fazy kompilacji.
Wybrałem drugie podejście. Po znalezieniu definicji funkcji przeanalizujemy jej typ i nazwę do obiektu o nazwie incomplete_function
. Następnie pominiemy jego ciało, bez interpretacji, po prostu licząc poziom zagnieżdżenia nawiasów klamrowych, aż zamkniemy pierwszy nawias klamrowy. W tym procesie zbierzemy tokeny, zachowamy je w incomplete_function
i dodamy identyfikator funkcji do compiler_context
.
Gdy przekażemy cały plik, skompilujemy całkowicie każdą z funkcji, aby można było je wywołać w czasie wykonywania. W ten sposób każda funkcja może wywołać dowolną inną funkcję w pliku i uzyskać dostęp do dowolnej zmiennej globalnej.
Zmienne globalne mogą być inicjowane przez wywołania tych samych funkcji, co prowadzi nas natychmiast do starego problemu „kurczak i jajko”, gdy tylko te funkcje uzyskają dostęp do niezainicjowanych zmiennych.
Jeśli tak się stanie, problem zostanie rozwiązany przez wyrzucenie runtime_exception
— i to tylko dlatego, że jestem miły. Franky, naruszenie dostępu to najmniejsza kara za napisanie takiego kodu.
Globalny zasięg
W zasięgu globalnym mogą pojawić się dwa rodzaje encji:
- Zmienne globalne
- Funkcje
Każdą zmienną globalną można zainicjować za pomocą wyrażenia zwracającego poprawny typ. Inicjator jest tworzony dla każdej zmiennej globalnej.
Każdy inicjator zwraca lvalue
, więc służą one jako konstruktory zmiennych globalnych. Gdy nie podano wyrażenia dla zmiennej globalnej, konstruowany jest domyślny inicjator.
To jest initialize
funkcja członkowska w runtime_context
:
void runtime_context::initialize() { _globals.clear(); for (const auto& initializer : _initializers) { _globals.emplace_back(initializer->evaluate(*this)); } }
Jest wywoływany od konstruktora. Czyści kontener zmiennej globalnej, ponieważ można ją jawnie wywołać, aby zresetować stan runtime_context
.
Jak wspomniałem wcześniej, musimy sprawdzić, czy mamy dostęp do niezainicjowanej zmiennej globalnej. Dlatego jest to akcesor zmiennej globalnej:
variable_ptr& runtime_context::global(int idx) { runtime_assertion( idx < _globals.size(), "Uninitialized global variable access" ); return _globals[idx]; }
Jeśli pierwszy argument ma wartość false
, runtime_assertion
zgłasza runtime_error
z odpowiednim komunikatem.
Każda funkcja jest implementowana jako lambda, która przechwytuje pojedynczą instrukcję, która jest następnie oceniana z runtime_context
, który otrzymuje funkcja.
Zakres funkcji
Jak widać z kompilacji instrukcji while
, kompilator jest wywoływany rekurencyjnie, zaczynając od instrukcji block, która reprezentuje blok całej funkcji.
Oto abstrakcyjna klasa bazowa dla wszystkich instrukcji:
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; };
Jedyną funkcją poza funkcjami domyślnymi jest execute
, która wykonuje logikę instrukcji na runtime_context
i zwraca flow
, który określa, gdzie logika programu zostanie następnie skierowana.
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(); };
Statyczne funkcje kreatora są oczywiste i napisałem je, aby zapobiec nielogicznemu flow
z niezerowym break_level
i typem innym niż flow_type::f_break
.
Teraz consume_break
utworzy przepływ przerwy z jednym poziomem mniej lub, jeśli poziom przerwy osiągnie zero, normalny przepływ.
Teraz sprawdzimy wszystkie typy wyciągów:
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(); } };
W tym simple_statement
jest instrukcją utworzoną z wyrażenia. Każde wyrażenie można skompilować jako wyrażenie zwracające void
, dzięki czemu można z niego utworzyć simple_statement
. Ponieważ ani break
, Continue, ani return
continue
mogą być częścią wyrażenia, simple_statement
zwraca 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(); } };
block_statement
przechowuje std::vector
instrukcji. Wykonuje je jeden po drugim. Jeśli każdy z nich zwróci przepływ inny niż normalny, natychmiast go zwróci. Używa obiektu zasięgu RAII, aby umożliwić deklaracje zmiennych o zasięgu lokalnym.
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
ocenia wyrażenie, które tworzy zmienną lokalną i wypycha nową zmienną lokalną na stos.
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
ma poziom przerwania oceniany w czasie kompilacji. Po prostu zwraca przepływ, który odpowiada temu poziomowi przerwy.
class continue_statement: public statement { public: continue_statement() = default; flow execute(runtime_context&) override { return flow::continue_flow(); } };
continue_statement
po prostu zwraca 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
i return_void_statement
oba zwracają flow::return_flow()
. Jedyną różnicą jest to, że pierwsza z nich ma wyrażenie, które ocenia jako wartość zwracaną, zanim zwróci.

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
, który jest tworzony dla jednego if
-block , zero lub więcej bloków elif
i jednego else
-block (który może być pusty), oblicza każde z jego wyrażeń, dopóki jedno wyrażenie nie da wartości 1
. Następnie wykonuje ten blok i zwraca wynik wykonania. Jeśli żadne wyrażenie nie da wartości 1
, zwróci wykonanie ostatniego ( else
) bloku.
if_declare_statement
to instrukcja, która zawiera deklaracje jako pierwszą część klauzuli if. Odkłada wszystkie zadeklarowane zmienne na stos, a następnie wykonuje swoją klasę bazową ( 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
wykonuje swoje instrukcje jeden po drugim, ale najpierw przeskakuje do odpowiedniego indeksu, który otrzymuje z oceny wyrażenia. Jeśli którakolwiek z jego instrukcji zwróci przepływ inny niż normalny, zwróci ten przepływ natychmiast. Jeśli ma flow_type::f_break
, najpierw zużyje jedną przerwę.
switch_declare_statement
umożliwia deklarację w swoim nagłówku. Żaden z nich nie pozwala na deklarację w ciele.
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
i do_while_statement
wykonują swoje instrukcje body, podczas gdy ich wyrażenie ma wartość 1
. Jeśli wykonanie zwraca flow_type::f_break
, zużywają go i zwracają. Jeśli zwraca flow_type::f_return
, zwracają go. W przypadku normalnego wykonania lub kontynuowania nic nie robią.
Może się wydawać, że continue
nie ma żadnego efektu. Jednak wpłynęło to na wewnętrzne stwierdzenie. Jeśli był to na przykład block_statement
, nie został oceniony do końca.
Uważam, że to schludne, że while_statement
jest zaimplementowana z while
i do-statement
z 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
i for_statement_declare
są implementowane podobnie jak while_statement
i do_statement
. Są one dziedziczone z klasy for_statement_base
, która wykonuje większość logiki. for_statement_declare
jest tworzony, gdy pierwsza część pętli for
jest deklaracją zmiennej.

To są wszystkie klasy instrukcji, które mamy. Są budulcem naszych funkcji. Po runtime_context
zachowuje te funkcje. Jeśli funkcja jest zadeklarowana za pomocą słowa kluczowego public
, można ją wywołać według nazwy.
Na tym kończy się podstawowa funkcjonalność Stork. Wszystko inne, co opiszę, to refleksje, które dodałem, aby nasz język był bardziej użyteczny.
Krotki
Tablice to jednorodne pojemniki, ponieważ mogą zawierać elementy tylko jednego typu. Jeśli zależy nam na niejednorodnych kontenerach, od razu przychodzą na myśl konstrukcje.
Istnieją jednak bardziej trywialne, heterogeniczne kontenery: krotki. Krotki mogą przechowywać elementy różnych typów, ale ich typy muszą być znane w czasie kompilacji. Oto przykład deklaracji krotki w Stork:
[number, string] t = {22321, "Siveric"};
To deklaruje parę number
i string
oraz ją inicjuje.
Listy inicjalizacji mogą być również używane do inicjowania tablic. Gdy typy wyrażeń na liście inicjującej nie są zgodne z typem zmiennej, wystąpi błąd kompilatora.
Ponieważ tablice są zaimplementowane jako kontenery variable_ptr
, implementację krotek w czasie wykonywania otrzymaliśmy za darmo. Jest to czas kompilacji, w którym zapewniamy poprawny typ zawartych zmiennych.
Moduły
Fajnie byłoby ukryć szczegóły implementacji przed użytkownikiem Stork i przedstawić język w bardziej przyjazny dla użytkownika sposób.
To jest klasa, która nam w tym pomoże. Przedstawiam go bez szczegółów realizacji:
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(); ... };
Funkcje load
i try_load
załadują i skompilują skrypt Stork z podanej ścieżki. Po pierwsze, jeden z nich może wyrzucić stork::error
, ale drugi złapie go i wypisze na wyjściu, jeśli zostanie podany.
Funkcja reset_globals
ponownie zainicjuje zmienne globalne.
Funkcje add_external_functions
i create_public_function_caller
należy wywołać przed kompilacją. Pierwsza dodaje funkcję C++, którą można wywołać z Stork. Drugi tworzy obiekt wywoływalny, który może być użyty do wywołania funkcji Stork z C++. Spowoduje to błąd w czasie kompilacji, jeśli typ funkcji publicznej nie pasuje do R(Args…)
podczas kompilacji skryptu Stork.
Dodałem kilka standardowych funkcji, które można dodać do modułu 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);
Przykład
Oto przykład skryptu Stork:
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)); }
Oto część 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; }
Funkcje standardowe są dodawane do modułu przed kompilacją, a funkcje trace
i rnd
są używane ze skryptu Stork. Funkcja greater
jest również dodawana jako prezentacja.
Skrypt jest ładowany z pliku „test.stk”, który znajduje się w tym samym folderze co „main.cpp” (przy użyciu definicji preprocesora __FILE__
), a następnie wywoływana jest funkcja main
.
W skrypcie generujemy losową tablicę, sortując rosnąco używając komparatora less
, a następnie malejąco używając komparatora greater
napisanego w C++.
Widać, że kod jest doskonale czytelny dla każdego, kto biegle posługuje się C (lub dowolnym językiem programowania wywodzącym się z C).
Co zrobic nastepnie?
Jest wiele funkcji, które chciałbym zaimplementować w Stork:
- Struktury
- Klasy i dziedziczenie
- Połączenia między modułami
- Funkcje lambda
- Obiekty z typowaniem dynamicznym
Brak czasu i miejsca to jeden z powodów, dla których jeszcze ich nie wdrażamy. Postaram się aktualizować moją stronę GitHub o nowe wersje, ponieważ w wolnym czasie wdrażam nowe funkcje.
Zawijanie
Stworzyliśmy nowy język programowania!
Zajęło to sporą część mojego wolnego czasu w ciągu ostatnich sześciu tygodni, ale teraz mogę napisać kilka skryptów i zobaczyć, jak działają. To właśnie robiłem w ciągu ostatnich kilku dni, drapiąc się w łysą głowę za każdym razem, gdy niespodziewanie się rozbijała. Czasami był to mały błąd, a czasem paskudny błąd. Czasami jednak czułem się zakłopotany, ponieważ była to zła decyzja, którą już podzieliłem się ze światem. Ale za każdym razem naprawiałem i kodowałem.
W trakcie dowiedziałem się o if constexpr
, którego nigdy wcześniej nie używałem. Zapoznałem się również z rvalue-references i doskonałym przekazywaniem, a także z innymi mniejszymi funkcjami C++17, z którymi nie spotykam się na co dzień.
Kod nie jest doskonały — nigdy bym tak nie twierdził — ale jest wystarczająco dobry i w większości opiera się na dobrych praktykach programistycznych. A co najważniejsze – działa.
Decyzja o napisaniu nowego języka od zera może wydawać się szalona dla przeciętnej osoby, a nawet dla przeciętnego programisty, ale tym bardziej jest to powód, aby to zrobić i udowodnić sobie, że możesz to zrobić. Podobnie jak rozwiązywanie trudnej łamigłówki jest dobrym ćwiczeniem dla mózgu, aby zachować sprawność umysłową.
Nudne wyzwania są powszechne w naszym codziennym programowaniu, ponieważ nie możemy wybrać tylko interesujących aspektów i musimy wykonać poważną pracę, nawet jeśli jest to czasami nudne. Jeśli jesteś profesjonalnym programistą, Twoim priorytetem jest dostarczenie pracodawcy wysokiej jakości kodu i nałożenie jedzenia na stół. To może czasami sprawić, że unikniesz programowania w wolnym czasie i może stłumić entuzjazm podczas wczesnych lat nauki programowania.
Jeśli nie musisz, nie trać entuzjazmu. Pracuj nad czymś, jeśli uznasz to za interesujące, nawet jeśli zostało to już zrobione. Nie musisz uzasadniać powodu do zabawy.
A jeśli potrafisz to – choćby częściowo – włączyć do swojej pracy zawodowej, to dobrze dla Ciebie! Niewiele osób ma taką możliwość.
Kod dla tej części zostanie zamrożony z dedykowaną gałęzią na mojej stronie GitHub.