Cigüeña, Parte 4: Implementación de declaraciones y conclusión

Publicado: 2022-03-11

En nuestra búsqueda para crear un lenguaje de programación liviano usando C++, comenzamos creando nuestro tokenizador hace tres semanas y luego implementamos la evaluación de expresiones en las siguientes dos semanas.

Ahora, es el momento de terminar y entregar un lenguaje de programación completo que no será tan poderoso como un lenguaje de programación maduro pero que tendrá todas las características necesarias, incluido un tamaño muy pequeño.

Me parece divertido cómo las nuevas empresas tienen secciones de preguntas frecuentes en sus sitios web que no responden a las preguntas que se hacen con frecuencia, sino a las preguntas que les gustaría que les hicieran. Haré lo mismo aquí. Las personas que siguen mi trabajo a menudo me preguntan por qué Stork no compila en algún código de bytes o al menos en algún lenguaje intermedio.

¿Por qué Stork no compila a Bytecode?

Estoy feliz de responder a esta pregunta. Mi objetivo era desarrollar un lenguaje de secuencias de comandos de tamaño reducido que se integrara fácilmente con C++. No tengo una definición estricta de "pequeña huella", pero imagino un compilador que será lo suficientemente pequeño como para permitir la portabilidad a dispositivos menos potentes y que no consumirá demasiada memoria cuando se ejecute.

Cigüeña C++

No me enfoqué en la velocidad, ya que creo que codificarás en C++ si tienes una tarea de tiempo crítico, pero si necesitas algún tipo de extensibilidad, entonces un lenguaje como Stork podría ser útil.

No afirmo que no haya otros lenguajes mejores que puedan realizar una tarea similar (por ejemplo, Lua). Sería verdaderamente trágico si no existieran, y simplemente les estoy dando una idea del caso de uso de este lenguaje.

Dado que estará integrado en C++, me resulta útil usar algunas características existentes de C++ en lugar de escribir un ecosistema completo que tenga un propósito similar. No solo eso, sino que también encuentro este enfoque más interesante.

Como siempre, puedes encontrar el código fuente completo en mi página de GitHub. Ahora, echemos un vistazo más de cerca a nuestro progreso.

Cambios

Hasta esta parte, Stork era un producto parcialmente completo, por lo que no pude ver todos sus inconvenientes y fallas. Sin embargo, como tomó una forma más completa, cambié las siguientes cosas introducidas en partes anteriores:

  • Las funciones ya no son variables. Ahora hay una function_lookup separada en compiler_context . function_param_lookup se renombra a param_lookup para evitar confusiones.
  • Cambié la forma en que se llaman las funciones. Existe el método de call en runtime_context que toma std::vector de argumentos, almacena el índice de valor de retorno anterior, inserta argumentos en la pila, cambia el índice de valor de retorno, llama a la función, extrae argumentos de la pila, restaura el índice de valor de retorno anterior y devuelve el resultado. De esa forma, no tenemos que mantener la pila de índices de valor de retorno, como antes, porque la pila de C++ sirve para ese propósito.
  • Clases RAII agregadas en compiler_context que son devueltas por llamadas a sus funciones miembro scope y function . Cada uno de esos objetos crea nuevos local_identifier_lookup y param_identifier_lookup , respectivamente, en sus constructores y restaura el estado anterior en el destructor.
  • Una clase RAII agregada en runtime_context , devuelta por la función miembro get_scope . Esa función almacena el tamaño de la pila en su constructor y lo restaura en su destructor.
  • Eliminé la palabra clave const y los objetos constantes en general. Pueden ser útiles, pero no son absolutamente necesarios.
  • Se eliminó la palabra clave var , ya que actualmente no se necesita en absoluto.
  • Agregué la palabra clave sizeof , que verificará el tamaño de una matriz en tiempo de ejecución. Tal vez algunos programadores de C++ encuentren blasfema la elección del nombre, ya que C++ sizeof se ejecuta en tiempo de compilación, pero elegí esa palabra clave para evitar la colisión con algún nombre de variable común, por ejemplo, size .
  • Agregué la palabra clave tostring , que convierte explícitamente cualquier cosa en string . No puede ser una función, ya que no permitimos la sobrecarga de funciones.
  • Varios cambios menos interesantes.

Sintaxis

Dado que estamos usando una sintaxis muy similar a C y sus lenguajes de programación relacionados, le daré solo los detalles que pueden no ser claros.

Las declaraciones de tipos de variables son las siguientes:

  • void , usado solo para el tipo de retorno de función
  • number
  • string
  • T[] es una matriz de lo que contiene elementos de tipo T
  • R(P1,...,Pn) es una función que devuelve el tipo R y recibe argumentos de tipo P1 a Pn . Cada uno de esos tipos puede tener el prefijo & si se pasa por referencia.

La declaración de la función es la siguiente: [public] function R name(P1 p1, … Pn pn)

Por lo tanto, tiene que tener el prefijo function . Si tiene el prefijo public , entonces se puede llamar desde C++. Si la función no devuelve el valor, evaluará el valor predeterminado de su tipo de retorno.

Permitimos for -loop con una declaración en la primera expresión. También permitimos if -statement y switch -statement con una expresión de inicialización, como en C++17. La instrucción if comienza con un bloque if , seguido de cero o más bloques elif y, opcionalmente, un bloque else . Si la variable se declaró en la expresión de inicialización de la instrucción if , sería visible en cada uno de esos bloques.

Permitimos un número opcional después de una declaración de break que puede romperse de múltiples bucles anidados. Entonces puedes tener el siguiente código:

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

Además, se romperá de ambos bucles. Ese número se valida en tiempo de compilación. ¿Cuan genial es eso?

Compilador

Se agregaron muchas características en esta parte, pero si me detallo demasiado, probablemente perderé incluso a los lectores más persistentes que aún me siguen. Por lo tanto, me saltaré intencionalmente una parte muy importante de la historia: la compilación.

Eso es porque ya lo describí en la primera y segunda parte de esta serie de blogs. Me estaba enfocando en las expresiones, pero compilar cualquier otra cosa no es muy diferente.

Sin embargo, les daré un ejemplo. Este código compila declaraciones 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)); }

Como puede ver, está lejos de ser complicado. Analiza while , luego ( , luego construye una expresión numérica (no tenemos valores booleanos) y luego analiza ) .

Después de eso, compila una declaración de bloque que puede estar dentro de { y } o no (sí, permití bloques de una sola declaración) y crea una declaración while al final.

Ya estás familiarizado con los dos primeros argumentos de función. El tercero, possible_flow , muestra los comandos de cambio de flujo permitidos ( continue , break , return ) en el contexto que estamos analizando. Podría mantener esa información en el objeto si las declaraciones de compilación fueran funciones miembro de alguna clase de compiler , pero no soy un gran admirador de las clases gigantescas, y el compilador definitivamente sería una de esas clases. Pasar un argumento adicional, especialmente uno delgado, no perjudicará a nadie, y quién sabe, tal vez algún día podamos paralelizar el código.

Hay otro aspecto interesante de la compilación que me gustaría explicar aquí.

Si queremos admitir un escenario en el que dos funciones se llaman entre sí, podemos hacerlo de la manera C: permitiendo la declaración directa o teniendo dos fases de compilación.

Elegí el segundo enfoque. Cuando se encuentre la definición de la función, analizaremos su tipo y nombre en el objeto llamado incomplete_function . Luego, omitiremos su cuerpo, sin interpretación, simplemente contando el nivel de anidamiento de las llaves hasta que cerremos la primera llave. Recopilaremos tokens en el proceso, los mantendremos en incomplete_function y agregaremos un identificador de función en compiler_context .

Una vez que pasemos el archivo completo, compilaremos cada una de las funciones por completo, para que puedan ser llamadas en el tiempo de ejecución. De esa forma, cada función puede llamar a cualquier otra función en el archivo y puede acceder a cualquier variable global.

Las variables globales se pueden inicializar mediante llamadas a las mismas funciones, lo que nos lleva inmediatamente al viejo problema del "huevo y la gallina" en cuanto esas funciones acceden a variables no inicializadas.

En caso de que eso suceda, el problema se resuelve lanzando una runtime_exception de tiempo de ejecución, y eso es solo porque soy amable. Franky, la violación de acceso es lo mínimo que puedes obtener como castigo por escribir dicho código.

El alcance global

Hay dos tipos de entidades que pueden aparecer en el ámbito global:

  • Variables globales
  • Funciones

Cada variable global se puede inicializar con una expresión que devuelve el tipo correcto. El inicializador se crea para cada variable global.

Cada inicializador devuelve lvalue , por lo que sirven como constructores de variables globales. Cuando no se proporciona una expresión para una variable global, se construye el inicializador predeterminado.

Esta es la función miembro de initialize en runtime_context :

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

Se llama desde el constructor. Borra el contenedor de variables globales, ya que se puede llamar explícitamente, para restablecer el estado de runtime_context .

Como mencioné anteriormente, debemos verificar si accedemos a una variable global no inicializada. Por lo tanto, este es el descriptor de acceso de la variable global:

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

Si el primer argumento se evalúa como false , runtime_assertion lanza un runtime_error con el mensaje correspondiente.

Cada función se implementa como lambda que captura la declaración única, que luego se evalúa con el runtime_context que recibe la función.

Alcance de la función

Como puede ver en la compilación de sentencias while , el compilador se llama recursivamente, comenzando con la sentencia de bloque, que representa el bloque de toda la función.

Aquí está la clase base abstracta para todas las declaraciones:

 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 única función, además de las predeterminadas, es execute , que ejecuta la lógica de la instrucción en el runtime_context y devuelve el flow , que determina a dónde irá la lógica del programa a continuación.

 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(); };

Las funciones de creador estático se explican por sí mismas, y las escribí para evitar flow ilógico con break_level distinto de cero y el tipo diferente de flow_type::f_break .

Ahora, consume_break creará un flujo de ruptura con un nivel de ruptura menos o, si el nivel de ruptura llega a cero, el flujo normal.

Ahora, verificaremos todos los tipos de declaraciones:

 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(); } };

Aquí, simple_statement es la declaración que se crea a partir de una expresión. Cada expresión se puede compilar como una expresión que devuelve void , de modo que se puede crear simple_statement a partir de ella. Como ni break ni continue o return pueden ser parte de una expresión, simple_statement devuelve 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 mantiene el std::vector de declaraciones. Los ejecuta, uno por uno. Si cada uno de ellos devuelve un flujo no normal, devuelve ese flujo de inmediato. Utiliza un objeto de ámbito RAII para permitir declaraciones de variables de ámbito local.

 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 evalúa la expresión que crea una variable local y coloca la nueva variable local en la pila.

 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 tiene el nivel de ruptura evaluado en el tiempo de compilación. Simplemente devuelve el flujo que corresponde a ese nivel de ruptura.

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

continue_statement simplemente devuelve 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 y return_void_statement ambos devuelven flow::return_flow() . La única diferencia es que el primero tiene la expresión que evalúa el valor de retorno antes de regresar.

 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 , que se crea para un bloque if , cero o más bloques elif y else bloque (que podría estar vacío), evalúa cada una de sus expresiones hasta que una expresión se evalúa como 1 . Luego ejecuta ese bloque y devuelve el resultado de la ejecución. Si ninguna expresión se evalúa como 1 , devolverá la ejecución del último bloque ( else ).

if_declare_statement es la declaración que tiene declaraciones como la primera parte de una cláusula if. Empuja todas las variables declaradas a la pila y luego ejecuta su clase 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 ejecuta sus declaraciones una por una, pero primero salta al índice apropiado que obtiene de la evaluación de la expresión. Si alguna de sus declaraciones devuelve un flujo no normal, devolverá ese flujo de inmediato. Si tiene flow_type::f_break , primero consumirá un descanso.

switch_declare_statement permite una declaración en su encabezado. Ninguno de ellos permite una declaración en el cuerpo.

 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 y do_while_statement ejecutan su declaración de cuerpo mientras que su expresión se evalúa como 1 . Si la ejecución devuelve flow_type::f_break , lo consumen y lo devuelven. Si devuelve flow_type::f_return , lo devuelven. En caso de ejecución normal, o continuar, no hacen nada.

Puede parecer que continue no tiene ningún efecto. Sin embargo, la declaración interna se vio afectada por ello. Si era, por ejemplo, block_statement , no se evaluó hasta el final.

Me parece genial que while_statement se implemente con C++ while y do-statement con 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 y for_statement_declare se implementan de manera similar a while_statement y do_statement . Se heredan de la clase for_statement_base , que hace la mayor parte de la lógica. for_statement_declare se crea cuando la primera parte del bucle for es una declaración de variable.

C++ Stork: Implementación de declaraciones

Estas son todas las clases de declaración que tenemos. Son componentes básicos de nuestras funciones. Cuando se crea runtime_context , mantiene esas funciones. Si la función se declara con la palabra clave public , se puede llamar por su nombre.

Eso concluye la funcionalidad central de Stork. Todo lo demás que describiré son ideas posteriores que agregué para hacer que nuestro lenguaje sea más útil.

tuplas

Los arreglos son contenedores homogéneos, ya que pueden contener elementos de un solo tipo solamente. Si queremos contenedores heterogéneos, las estructuras vienen inmediatamente a la mente.

Sin embargo, existen contenedores heterogéneos más triviales: las tuplas. Las tuplas pueden mantener los elementos de diferentes tipos, pero sus tipos deben conocerse en tiempo de compilación. Este es un ejemplo de una declaración de tupla en Stork:

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

Esto declara el par de number y string y lo inicializa.

Las listas de inicialización también se pueden utilizar para inicializar matrices. Cuando los tipos de expresiones en la lista de inicialización no coinciden con el tipo de variable, se producirá un error de compilación.

Dado que las matrices se implementan como contenedores de variable_ptr , obtuvimos la implementación de tuplas en tiempo de ejecución de forma gratuita. Es tiempo de compilación cuando aseguramos el tipo correcto de variables contenidas.

Módulos

Sería bueno ocultar los detalles de implementación de un usuario de Stork y presentar el idioma de una manera más fácil de usar.

Esta es la clase que nos ayudará a lograrlo. Lo presento sin los detalles de implementación:

 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(); ... };

Las funciones load y try_load cargarán y compilarán el script de Stork desde la ruta dada. Primero, uno de ellos puede lanzar un stork::error , pero el segundo lo atrapará y lo imprimirá en la salida, si se proporciona.

La función reset_globals reinicializará las variables globales.

Las funciones add_external_functions y create_public_function_caller deben llamarse antes de la compilación. El primero agrega una función de C++ que se puede llamar desde Stork. El segundo crea el objeto invocable que se puede usar para llamar a la función Stork desde C++. Provocará un error en tiempo de compilación si el tipo de función pública no coincide con R(Args…) durante la compilación del script de Stork.

Agregué varias funciones estándar que se pueden agregar al módulo 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);

Ejemplo

Este es un ejemplo de un script de 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)); }

Aquí está la parte de 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; }

Las funciones estándar se agregan al módulo antes de la compilación, y las funciones trace y rnd se usan desde el script de Stork. También se añade la función greater a modo de escaparate.

El script se carga desde el archivo "test.stk", que se encuentra en la misma carpeta que "main.cpp" (mediante el uso de una definición de preprocesador __FILE__ ), y luego se llama a la función main .

En el script, generamos una matriz aleatoria, ordenando en forma ascendente usando el comparador less y luego en forma descendente usando el comparador greater , escrito en C++.

Puede ver que el código es perfectamente legible para cualquiera que domine C (o cualquier lenguaje de programación derivado de C).

¿Qué hacer a continuación?

Hay muchas características que me gustaría implementar en Stork:

  • Estructuras
  • Clases y herencia
  • Llamadas entre módulos
  • funciones lambda
  • Objetos tipados dinámicamente

La falta de tiempo y espacio es una de las razones por las que aún no los tenemos implementados. Intentaré actualizar mi página de GitHub con nuevas versiones a medida que implemente nuevas funciones en mi tiempo libre.

Terminando

¡Hemos creado un nuevo lenguaje de programación!

Eso tomó una buena parte de mi tiempo libre en las últimas seis semanas, pero ahora puedo escribir algunos guiones y verlos ejecutarse. Es lo que estaba haciendo en los últimos días, rascándome la cabeza calva cada vez que se estrellaba inesperadamente. A veces, era un error pequeño y, a veces, un error desagradable. En otras ocasiones, sin embargo, sentí vergüenza porque se trataba de una mala decisión que ya había compartido con el mundo. Pero cada vez, lo arreglaba y seguía codificando.

En el proceso, aprendí sobre if constexpr , que nunca antes había usado. También me familiaricé más con las referencias rvalue y el reenvío perfecto, así como con otras funciones más pequeñas de C++17 que no encuentro a diario.

El código no es perfecto, nunca haría tal afirmación, pero es lo suficientemente bueno y en su mayoría sigue buenas prácticas de programación. Y lo más importante - funciona.

Decidir desarrollar un nuevo lenguaje desde cero puede parecer una locura para una persona promedio, o incluso para un programador promedio, pero es una razón más para hacerlo y demostrarte a ti mismo que puedes hacerlo. Al igual que resolver un rompecabezas difícil, es un buen ejercicio mental para mantenerse mentalmente en forma.

Los desafíos aburridos son comunes en nuestra programación diaria, ya que no podemos elegir solo los aspectos interesantes y tenemos que hacer un trabajo serio, incluso si a veces es aburrido. Si es un desarrollador profesional, su primera prioridad es entregar código de alta calidad a su empleador y poner comida en la mesa. Esto a veces puede hacer que evite programar en su tiempo libre y puede apagar el entusiasmo de sus primeros días de escuela de programación.

Si no es necesario, no pierda ese entusiasmo. Trabaja en algo si lo encuentras interesante, incluso si ya está hecho. No tienes que justificar la razón para divertirte.

Y si puedes incorporarlo, aunque sea parcialmente, en tu trabajo profesional, ¡bien por ti! No mucha gente tiene esa oportunidad.

El código de esta parte se congelará con una rama dedicada en mi página de GitHub.