Stork, Partie 4 : Mise en œuvre des déclarations et conclusion
Publié: 2022-03-11Dans notre quête pour créer un langage de programmation léger à l'aide de C++, nous avons commencé par créer notre tokenizer il y a trois semaines, puis nous avons implémenté l'évaluation de l'expression dans les deux semaines suivantes.
Il est maintenant temps de conclure et de fournir un langage de programmation complet qui ne sera pas aussi puissant qu'un langage de programmation mature mais qui aura toutes les fonctionnalités nécessaires, y compris un très faible encombrement.
Je trouve amusant que les nouvelles entreprises aient des sections FAQ sur leurs sites Web qui ne répondent pas aux questions fréquemment posées, mais aux questions auxquelles elles souhaitent être posées. Je ferai la même chose ici. Les personnes qui suivent mon travail me demandent souvent pourquoi Stork ne compile pas en un bytecode ou au moins en un langage intermédiaire.
Pourquoi Stork ne compile-t-il pas en bytecode ?
Je suis heureux de répondre à cette question. Mon objectif était de développer un langage de script à faible encombrement qui s'intégrera facilement à C++. Je n'ai pas de définition stricte de "petit encombrement", mais j'imagine un compilateur qui sera suffisamment petit pour permettre la portabilité vers des appareils moins puissants et qui ne consommera pas trop de mémoire lors de son exécution.
Je ne me suis pas concentré sur la vitesse, car je pense que vous coderez en C++ si vous avez une tâche urgente, mais si vous avez besoin d'une sorte d'extensibilité, alors un langage comme Stork pourrait être utile.
Je ne prétends pas qu'il n'existe pas d'autres langages meilleurs capables d'accomplir une tâche similaire (par exemple, Lua). Ce serait vraiment tragique s'ils n'existaient pas, et je ne fais que vous donner une idée du cas d'utilisation de ce langage.
Puisqu'il sera intégré à C++, je trouve pratique d'utiliser certaines fonctionnalités existantes de C++ au lieu d'écrire un écosystème complet qui servira un objectif similaire. Non seulement cela, mais je trouve aussi cette approche plus intéressante.
Comme toujours, vous pouvez trouver le code source complet sur ma page GitHub. Maintenant, regardons de plus près nos progrès.
Changements
Jusqu'à cette partie, Stork était un produit partiellement complet, donc je n'ai pas pu voir tous ses inconvénients et ses défauts. Cependant, comme il a pris une forme plus complète, j'ai changé les éléments suivants introduits dans les parties précédentes :
- Les fonctions ne sont plus des variables. Il y a maintenant un
function_lookup
séparé danscompiler_context
.function_param_lookup
est renomméparam_lookup
pour éviter toute confusion. - J'ai changé la façon dont les fonctions sont appelées. Il y a la méthode d'
call
dansruntime_context
qui prendstd::vector
d'arguments, stocke l'ancien index de valeur de retour, pousse les arguments sur la pile, modifie l'index de valeur de retour, appelle la fonction, extrait les arguments de la pile, restaure l'ancien index de valeur de retour et renvoie le résultat. De cette façon, nous n'avons pas à conserver la pile des indices de valeur de retour, comme auparavant, car la pile C++ sert à cela. - Classes RAII ajoutées dans
compiler_context
qui sont renvoyées par des appels à ses fonctions membresscope
etfunction
. Chacun de ces objets crée de nouveauxlocal_identifier_lookup
etparam_identifier_lookup
, respectivement, dans leurs constructeurs et restaure l'ancien état dans le destructeur. - Une classe RAII ajoutée dans
runtime_context
, renvoyée par la fonction membreget_scope
. Cette fonction stocke la taille de la pile dans son constructeur et la restaure dans son destructeur. - J'ai supprimé le mot-clé
const
et les objets constants en général. Ils pourraient être utiles mais ne sont pas absolument nécessaires. - mot-clé
var
supprimé, car il n'est actuellement pas du tout nécessaire. - J'ai ajouté le mot-clé
sizeof
, qui vérifiera la taille d'un tableau lors de l'exécution. Peut-être que certains programmeurs C++ trouveront le choix du nom blasphématoire, car C++sizeof
s'exécute au moment de la compilation, mais j'ai choisi ce mot-clé pour éviter une collision avec un nom de variable commun - par exemple,size
. - J'ai ajouté le mot-clé
tostring
, qui convertit explicitement tout enstring
. Il ne peut pas s'agir d'une fonction, car nous n'autorisons pas la surcharge de fonctions. - Divers changements moins intéressants.
Syntaxe
Étant donné que nous utilisons une syntaxe très similaire à C et à ses langages de programmation associés, je ne vous donnerai que les détails qui peuvent ne pas être clairs.
Les déclarations de type de variable sont les suivantes :
-
void
, utilisé uniquement pour le type de retour de fonction -
number
-
string
-
T[]
est un tableau de ce qui contient des éléments de typeT
-
R(P1,...,Pn)
est une fonction qui renvoie le typeR
et reçoit des arguments de typesP1
àPn
. Chacun de ces types peut être préfixé par&
s'il est passé par référence.
La déclaration de la fonction est la suivante : [public] function R name(P1 p1, … Pn pn)
Il doit donc être préfixé par function
. S'il est préfixé par public
, il peut être appelé depuis C++. Si la fonction ne renvoie pas la valeur, elle sera évaluée à la valeur par défaut de son type de retour.
Nous autorisons for
avec une déclaration dans la première expression. Nous autorisons également if
-statement et switch
-statement avec une expression d'initialisation, comme en C++17. L'instruction if
commence par un bloc if
, suivi de zéro ou plusieurs blocs elif
et éventuellement d'un bloc else
. Si la variable était déclarée dans l'expression d'initialisation de l'instruction if
, elle serait visible dans chacun de ces blocs.
Nous autorisons un nombre facultatif après une instruction break
qui peut rompre à partir de plusieurs boucles imbriquées. Vous pouvez donc avoir le code suivant :
for (number i = 0; i < 100; ++i) { for(number j = 0; j < 100; ++j) { if (rnd(100) == 0) { break 2; } } }
De plus, il rompra les deux boucles. Ce nombre est validé au moment de la compilation. À quel point cela est cool?
Compilateur
De nombreuses fonctionnalités ont été ajoutées dans cette partie, mais si je deviens trop détaillé, je perdrai probablement même les lecteurs les plus persistants qui me supportent encore. Par conséquent, je sauterai intentionnellement une très grande partie de l'histoire - la compilation.
C'est parce que je l'ai déjà décrit dans les première et deuxième parties de cette série de blogs. Je me concentrais sur les expressions, mais compiler n'importe quoi d'autre n'est pas très différent.
Je vais cependant vous donner un exemple. Ce code compile les instructions 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)); }
Comme vous pouvez le voir, c'est loin d'être compliqué. Il analyse while
, puis (
, puis il construit une expression numérique (nous n'avons pas de booléens), puis il analyse )
.
Après cela, il compile une instruction de bloc qui peut être à l'intérieur de {
et }
ou non (oui, j'ai autorisé les blocs à une seule instruction) et il crée une instruction while
à la fin.
Vous connaissez déjà les deux premiers arguments de la fonction. Le troisième, possible_flow
, montre les commandes de changement de flux autorisées ( continue
, break
, return
) dans le contexte que nous analysons. Je pourrais conserver ces informations dans l'objet si les instructions de compilation étaient des fonctions membres d'une classe de compiler
, mais je ne suis pas un grand fan des classes gigantesques, et le compilateur serait certainement une de ces classes. Passer un argument supplémentaire, surtout un peu fin, ne fera de mal à personne, et qui sait, peut-être qu'un jour nous pourrons paralléliser le code.
Il y a un autre aspect intéressant de la compilation que je voudrais expliquer ici.
Si nous voulons prendre en charge un scénario où deux fonctions s'appellent, nous pouvons le faire en C : en autorisant la déclaration directe ou en ayant deux phases de compilation.
J'ai choisi la deuxième approche. Lorsque la définition de la fonction est trouvée, nous analysons son type et son nom dans l'objet nommé incomplete_function
. Ensuite, nous passerons son corps, sans interprétation, en comptant simplement le niveau d'emboîtement des accolades jusqu'à ce que nous fermions la première accolade. Nous collecterons des jetons au cours du processus, les conserverons dans incomplete_function
et ajouterons un identifiant de fonction dans le contexte du compiler_context
.
Une fois que nous aurons passé l'intégralité du fichier, nous compilerons complètement chacune des fonctions, afin qu'elles puissent être appelées dans le runtime. De cette façon, chaque fonction peut appeler n'importe quelle autre fonction du fichier et accéder à n'importe quelle variable globale.
Les variables globales peuvent être initialisées par des appels aux mêmes fonctions, ce qui nous amène immédiatement au vieux problème de « poule et œuf » dès que ces fonctions accèdent à des variables non initialisées.
Si cela devait arriver, le problème est résolu en lançant une runtime_exception
- et c'est uniquement parce que je suis gentil. Franky, la violation d'accès est le moins que vous puissiez recevoir comme punition pour avoir écrit un tel code.
La portée mondiale
Deux types d'entités peuvent apparaître dans la portée globale :
- Variables globales
- Les fonctions
Chaque variable globale peut être initialisée avec une expression qui renvoie le type correct. L'initialiseur est créé pour chaque variable globale.
Chaque initialiseur renvoie lvalue
, ils servent donc de constructeurs de variables globales. Lorsqu'aucune expression n'est fournie pour une variable globale, l'initialiseur par défaut est construit.
Il s'agit de la fonction membre initialize
dans runtime_context
:
void runtime_context::initialize() { _globals.clear(); for (const auto& initializer : _initializers) { _globals.emplace_back(initializer->evaluate(*this)); } }
Il est appelé depuis le constructeur. Il efface le conteneur de variables globales, car il peut être appelé explicitement, pour réinitialiser l'état runtime_context
.
Comme je l'ai mentionné plus tôt, nous devons vérifier si nous accédons à une variable globale non initialisée. Par conséquent, il s'agit de l'accesseur de variable globale :
variable_ptr& runtime_context::global(int idx) { runtime_assertion( idx < _globals.size(), "Uninitialized global variable access" ); return _globals[idx]; }
Si le premier argument est évalué à false
, runtime_assertion
lève un runtime_error
avec le message correspondant.
Chaque fonction est implémentée en tant que lambda qui capture l'instruction unique, qui est ensuite évaluée avec le runtime_context
que la fonction reçoit.
Portée de la fonction
Comme vous pouvez le voir à partir de la compilation de l'instruction while
, le compilateur est appelé de manière récursive, en commençant par l'instruction block, qui représente le bloc de la fonction entière.
Voici la classe de base abstraite pour toutes les déclarations :
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; };
La seule fonction en dehors de celles par défaut est execute
, qui exécute la logique de l'instruction sur runtime_context
et renvoie le flow
, qui détermine où la logique du programme ira ensuite.
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(); };
Les fonctions de création statiques sont explicites, et je les ai écrites pour éviter un flow
illogique avec break_level
différent de zéro et le type différent de flow_type::f_break
.
Maintenant, consume_break
créera un flux de rupture avec un niveau de rupture de moins ou, si le niveau de rupture atteint zéro, le flux normal.
Maintenant, nous allons vérifier tous les types d'instruction :
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(); } };
Ici, simple_statement
est l'instruction créée à partir d'une expression. Chaque expression peut être compilée comme une expression qui renvoie void
, de sorte que simple_statement
puisse être créé à partir de celle-ci. Comme ni break
ni continue
ou return
ne peuvent faire partie d'une expression, simple_statement
renvoie 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(); } };
Le block_statement
conserve le std::vector
des instructions. Il les exécute, un par un. Si chacun d'eux renvoie un flux non normal, il renvoie ce flux immédiatement. Il utilise un objet de portée RAII pour autoriser les déclarations de variable de portée 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
évalue l'expression qui crée une variable locale et pousse la nouvelle variable locale sur la pile.
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
a le niveau de rupture évalué au moment de la compilation. Il renvoie simplement le flux qui correspond à ce niveau de rupture.
class continue_statement: public statement { public: continue_statement() = default; flow execute(runtime_context&) override { return flow::continue_flow(); } };
continue_statement
renvoie simplement 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
et return_void_statement
tous deux flow::return_flow()
. La seule différence est que le premier a l'expression qu'il évalue à la valeur de retour avant son retour.

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
, qui est créé pour un if
-block, zéro ou plusieurs elif
-blocks et un else
-block (qui peut être vide), évalue chacune de ses expressions jusqu'à ce qu'une expression donne 1
. Il exécute ensuite ce bloc et renvoie le résultat de l'exécution. Si aucune expression n'est évaluée à 1
, elle renverra l'exécution du dernier bloc ( else
).
if_declare_statement
est l'instruction qui contient des déclarations comme première partie d'une clause if. Il pousse toutes les variables déclarées sur la pile, puis exécute sa classe de 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
exécute ses instructions une par une, mais il saute d'abord à l'index approprié qu'il obtient à partir de l'évaluation de l'expression. Si l'une de ses instructions renvoie un flux non normal, il renverra ce flux immédiatement. S'il a flow_type::f_break
, il consommera d'abord une pause.
switch_declare_statement
autorise une déclaration dans son en-tête. Aucun de ceux-ci ne permet une déclaration dans le corps.
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
et do_while_statement
exécutent tous deux leur instruction body alors que leur expression est évaluée à 1
. Si l'exécution renvoie flow_type::f_break
, ils le consomment et reviennent. S'il renvoie flow_type::f_return
, ils le renvoient. En cas d'exécution normale, ou continuer, ils ne font rien.
Il peut sembler que continue
n'a pas d'effet. Cependant, la déclaration intérieure en a été affectée. S'il s'agissait, par exemple, de block_statement
, il n'a pas été évalué jusqu'à la fin.
Je trouve intéressant que while_statement
soit implémenté avec le C++ while
, et do-statement
avec le 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
et for_statement_declare
sont implémentés de la même manière que while_statement
et do_statement
. Ils sont hérités de la classe for_statement_base
, qui s'occupe de la majeure partie de la logique. for_statement_declare
est créé lorsque la première partie de la boucle for
est une déclaration de variable.

Ce sont toutes des classes d'instructions que nous avons. Ce sont les éléments constitutifs de nos fonctions. Lorsque runtime_context
est créé, il conserve ces fonctions. Si la fonction est déclarée avec le mot clé public
, elle peut être appelée par son nom.
Cela conclut la fonctionnalité de base de Stork. Tout le reste que je vais décrire sont des réflexions après coup que j'ai ajoutées pour rendre notre langage plus utile.
Tuples
Les tableaux sont des conteneurs homogènes, car ils ne peuvent contenir que des éléments d'un seul type. Si nous voulons des conteneurs hétérogènes, les structures viennent immédiatement à l'esprit.
Cependant, il existe des conteneurs hétérogènes plus triviaux : les tuples. Les tuples peuvent conserver les éléments de différents types, mais leurs types doivent être connus au moment de la compilation. Voici un exemple de déclaration de tuple dans Stork :
[number, string] t = {22321, "Siveric"};
Cela déclare la paire de number
et de string
et l'initialise.
Les listes d'initialisation peuvent également être utilisées pour initialiser des tableaux. Lorsque les types d'expressions dans la liste d'initialisation ne correspondent pas au type de variable, une erreur de compilation se produit.
Étant donné que les tableaux sont implémentés en tant que conteneurs de variable_ptr
, nous avons obtenu gratuitement l'implémentation d'exécution des tuples. C'est au moment de la compilation que nous assurons le type correct des variables contenues.
Modules
Ce serait bien de cacher les détails d'implémentation à un utilisateur de Stork et de présenter le langage d'une manière plus conviviale.
C'est la classe qui nous aidera à accomplir cela. Je le présente sans les détails d'implémentation :
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(); ... };
Les fonctions load
et try_load
chargeront et compileront le script Stork à partir du chemin donné. Premièrement, l'un d'eux peut lancer un stork::error
, mais le second l'attrapera et l'imprimera sur la sortie, si elle est fournie.
La fonction reset_globals
réinitialisera les variables globales.
Les fonctions add_external_functions
et create_public_function_caller
doivent être appelées avant la compilation. Le premier ajoute une fonction C++ qui peut être appelée depuis Stork. Le second crée l'objet appelable qui peut être utilisé pour appeler la fonction Stork à partir de C++. Cela provoquera une erreur de compilation si le type de fonction publique ne correspond pas à R(Args…)
lors de la compilation du script Stork.
J'ai ajouté plusieurs fonctions standards qui peuvent être ajoutées au module 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);
Exemple
Voici un exemple de script 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)); }
Voici la partie 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; }
Des fonctions standard sont ajoutées au module avant la compilation, et les fonctions trace
et rnd
sont utilisées depuis le script Stork. La fonction greater
est également ajoutée en tant que vitrine.
Le script est chargé à partir du fichier "test.stk", qui se trouve dans le même dossier que "main.cpp" (en utilisant une définition de préprocesseur __FILE__
), puis la fonction main
est appelée.
Dans le script, on génère un tableau aléatoire, en triant par ordre croissant en utilisant le comparateur less
, puis par ordre décroissant en utilisant le comparator greater
, écrit en C++.
Vous pouvez voir que le code est parfaitement lisible pour toute personne maîtrisant C (ou tout langage de programmation dérivé de C).
Que faire ensuite?
Il y a de nombreuses fonctionnalités que j'aimerais implémenter dans Stork :
- Ouvrages
- Classes et héritage
- Appels inter-modules
- Fonctions lambda
- Objets typés dynamiquement
Le manque de temps et d'espace est l'une des raisons pour lesquelles nous ne les avons pas encore mis en œuvre. J'essaierai de mettre à jour ma page GitHub avec de nouvelles versions au fur et à mesure que j'implémenterai de nouvelles fonctionnalités pendant mon temps libre.
Emballer
Nous avons créé un nouveau langage de programmation !
Cela a pris une bonne partie de mon temps libre au cours des six dernières semaines, mais je peux maintenant écrire des scripts et les voir fonctionner. C'est ce que je faisais ces derniers jours, me grattant le crâne chauve à chaque fois qu'il tombait en panne de façon inattendue. Parfois, c'était un petit bug, et parfois un méchant bug. À d'autres moments, cependant, je me sentais gêné parce qu'il s'agissait d'une mauvaise décision que j'avais déjà partagée avec le monde. Mais à chaque fois, je réparais et continuais à coder.
Dans le processus, j'ai appris if constexpr
, que je n'avais jamais utilisé auparavant. Je me suis également familiarisé avec les références rvalue et la transmission parfaite, ainsi qu'avec d'autres fonctionnalités plus petites de C++17 que je ne rencontre pas quotidiennement.
Le code n'est pas parfait - je ne ferais jamais une telle affirmation - mais il est assez bon, et il suit principalement les bonnes pratiques de programmation. Et le plus important - cela fonctionne.
Décider de développer un nouveau langage à partir de zéro peut sembler fou à une personne moyenne, ou même à un programmeur moyen, mais c'est une raison de plus pour le faire et vous prouver que vous pouvez le faire. Tout comme résoudre un casse-tête difficile est un bon exercice cérébral pour rester en forme mentalement.
Les défis ennuyeux sont courants dans notre programmation quotidienne, car nous ne pouvons pas sélectionner uniquement les aspects intéressants et devons faire un travail sérieux même si c'est parfois ennuyeux. Si vous êtes un développeur professionnel, votre première priorité est de fournir un code de haute qualité à votre employeur et de mettre de la nourriture sur la table. Cela peut parfois vous faire éviter de programmer pendant votre temps libre et cela peut freiner l'enthousiasme de vos premiers jours d'école de programmation.
Si vous n'êtes pas obligé, ne perdez pas cet enthousiasme. Travaillez sur quelque chose si vous le trouvez intéressant, même si c'est déjà fait. Vous n'avez pas à justifier la raison de vous amuser.
Et si vous pouvez l'intégrer, même partiellement, dans votre travail professionnel, tant mieux pour vous ! Peu de gens ont cette opportunité.
Le code de cette partie sera gelé avec une branche dédiée sur ma page GitHub.