Cigüeña, Parte 3: Implementación de expresiones y variables

Publicado: 2022-03-11

En la Parte 3 de nuestra serie, nuestro lenguaje de programación ligero finalmente se ejecutará. No será Turing-completo, no será poderoso, pero podrá evaluar expresiones e incluso llamar a funciones externas escritas en C++.

Intentaré describir el proceso con el mayor detalle posible, principalmente porque es el propósito de esta serie de blogs, pero también para mi propia documentación porque, en esta parte, las cosas se complicaron un poco.

Comencé a codificar esta parte antes de la publicación del segundo artículo, pero luego resultó que el analizador de expresiones debería ser un componente independiente que merece su propia publicación de blog.

Eso, junto con algunas técnicas de programación infames, hizo posible que esta parte no fuera monstruosamente grande y, sin embargo, es probable que algunos lectores señalen dichas técnicas de programación y se pregunten por qué tuve que usarlas.

¿Por qué usamos macros?

A medida que adquirí experiencia en programación trabajando en diferentes proyectos y con diferentes personas, aprendí que los desarrolladores tienden a ser bastante dogmáticos, probablemente porque es más fácil de esa manera.

Macros en C++

El primer dogma de la programación es que la instrucción goto es mala, perversa y horrible. Puedo entender dónde se origina ese sentimiento y estoy de acuerdo con esa noción en la gran mayoría de los casos cuando alguien usa la instrucción goto . Por lo general, se puede evitar y, en su lugar, se podría escribir un código más legible.

Sin embargo, no se puede negar que romper con el bucle interno en C++ se puede lograr fácilmente con la instrucción goto . La alternativa, que requiere una variable bool o una función dedicada, podría ser menos legible que el código que cae dogmáticamente en el cubo de las técnicas de programación prohibidas.

El segundo dogma, relevante exclusivamente para los desarrolladores de C y C++, es que las macros son malas, perversas, terribles y, básicamente, un desastre a punto de ocurrir. Esto casi siempre va acompañado de este ejemplo:

 #define max(a, b) ((a) > (b) ? (a) : (b)) ... int x = 3; int z = 2; int y = max(x++, z);

Y luego hay una pregunta: ¿Cuál es el valor de x después de este fragmento de código, y la respuesta es 5 porque x se incrementa dos veces, uno a cada lado del ? -operador.

El único problema es que nadie usa macros en este escenario. Las macros son malas si se usan en un escenario donde las funciones ordinarias funcionan bien, especialmente si pretenden ser funciones, por lo que el usuario no se da cuenta de sus efectos secundarios. Sin embargo, no las usaremos como funciones y usaremos letras mayúsculas para sus nombres para que sea obvio que no son funciones. No podremos depurarlos correctamente, y eso es malo, pero viviremos con eso, ya que la alternativa es copiar y pegar el mismo código docenas de veces, lo cual es mucho más propenso a errores que las macros. Una solución a ese problema es escribir el generador de código, pero ¿por qué deberíamos escribirlo cuando ya tenemos uno incrustado en C++?

Los dogmas en la programación son casi siempre malos. Estoy usando cautelosamente "casi" aquí solo para evitar caer recursivamente en la trampa del dogma que acabo de preparar.

Puede encontrar el código y todas las macros para esta parte aquí.

Variables

En la parte anterior, mencioné que Stork no se compilará en binario o algo similar al lenguaje ensamblador, pero también dije que será un lenguaje de tipo estático. Por lo tanto, estará compilado, pero en un objeto C++ que podrá ejecutarse. Será más claro más adelante, pero por ahora, digamos que todas las variables serán objetos por sí mismas.

Dado que queremos mantenerlos en el contenedor de variables globales o en la pila, un enfoque conveniente es definir la clase base y heredar de ella.

 class variable; using variable_ptr = std::shared_ptr<variable>; class variable: public std::enable_shared_from_this<variable> { private: variable(const variable&) = delete; void operator=(const variable&) = delete; protected: variable() = default; public: virtual ~variable() = default; virtual variable_ptr clone() const = 0; template <typename T> T static_pointer_downcast() { return std::static_pointer_cast< variable_impl<typename T::element_type::value_type> >(shared_from_this()); } };

Como puede ver, es bastante simple, y la función clone , que hace la copia profunda, es su única función de miembro virtual aparte del destructor.

Como siempre usaremos objetos de esta clase a través de shared_ptr , tiene sentido heredarla de std::enable_shared_from_this , para que podamos obtener fácilmente el puntero compartido de ella. La función static_pointer_downcast está aquí por conveniencia porque con frecuencia tendremos que bajar de esta clase a su implementación.

La implementación real de esta clase es variable_impl , parametrizada con el tipo que contiene. Se instanciará para los cuatro tipos que utilizaremos:

 using number = double; using string = std::shared_ptr<std::string>; using array = std::deque<variable_ptr>; using function = std::function<void(runtime_context&)>;

Usaremos double como nuestro tipo de número. Las cadenas se cuentan por referencia, ya que serán inmutables, para permitir ciertas optimizaciones al pasarlas por valor. Array será std::deque , ya que es estable, y observemos que runtime_context es la clase que contiene toda la información relevante sobre la memoria del programa durante el tiempo de ejecución. Llegaremos a eso más tarde.

Las siguientes definiciones también se utilizan con frecuencia:

 using lvalue = variable_ptr; using lnumber = std::shared_ptr<variable_impl<number>>; using lstring = std::shared_ptr<variable_impl<string>>; using larray = std::shared_ptr<variable_impl<array>>; using lfunction = std::shared_ptr<variable_impl<function>>;

La "l" utilizada aquí se abrevia como "lvalue". Siempre que tengamos un lvalue para algún tipo, usaremos el puntero compartido a variable_impl .

Contexto de tiempo de ejecución

Durante el tiempo de ejecución, el estado de la memoria se mantiene en la clase runtime_context .

 class runtime_context{ private: std::vector<variable_ptr> _globals; std::deque<variable_ptr> _stack; std::stack<size_t> _retval_idx; public: runtime_context(size_t globals); variable_ptr& global(int idx); variable_ptr& retval(); variable_ptr& local(int idx); void push(variable_ptr v); void end_scope(size_t scope_vars); void call(); variable_ptr end_function(size_t params); };

Se inicializa con el recuento de variables globales.

  • _globals mantiene todas las variables globales. Se accede a ellos con la función miembro global con el índice absoluto.
  • _stack mantiene variables locales y argumentos de funciones, y el número entero en la parte superior de _retval_idx mantiene el índice absoluto en _stack del valor de retorno actual.
  • Se accede al valor devuelto con la función retval , mientras que a las variables locales y los argumentos de función se accede con la función local pasando el índice relativo al valor devuelto actual. Los argumentos de función tienen índices negativos en este caso.
  • La función push agrega la variable a la pila, mientras que end_scope elimina el número pasado de variables de la pila.
  • La función de call cambiará el tamaño de la pila en uno y empujará el índice del último elemento en _stack a _retval_idx .
  • end_function elimina el valor de retorno y el número de argumentos pasados ​​de la pila y también devuelve el valor de retorno eliminado.

Como puede ver, no implementaremos ninguna gestión de memoria de bajo nivel y aprovecharemos la gestión de memoria nativa (C++), que podemos dar por sentado. Tampoco implementaremos ninguna asignación de montón, al menos por ahora.

Con runtime_context , finalmente tenemos todos los componentes básicos necesarios para el componente central y más difícil de esta parte.

Expresiones

Para explicar completamente la complicada solución que presentaré aquí, le presentaré brevemente un par de intentos fallidos que hice antes de decidirme por este enfoque.

El enfoque más sencillo es evaluar cada expresión como variable_ptr y tener esta clase base virtual:

 class expression { ... public: variable_ptr evaluate(runtime_context& context) const = 0; lnumber evaluate_lnumber(runtime_context& context) const { return evaluate(context)->static_pointer_downcast<lnumber>(); } lstring evaluate_lstring(runtime_context& context) const { return evaluate(context)->static_pointer_downcast<lstring>(); } number evaluate_number(runtime_context& context) const { return evaluate_lnumber(context)->value; } string evaluate_string(runtime_context& context) const { return evaluate_lstring(context)->value; } ... }; using expression_ptr = std::unique_ptr<expression>;

Entonces heredaríamos de esta clase para cada operación, como suma, concatenación, llamada de función, etc. Por ejemplo, esta sería la implementación de la expresión de suma:

 class add_expression: public expression { private: expression_ptr _expr1; expression_ptr _expr2; public: ... variable_ptr evaluate(runtime_context& context) const override{ return std::make_shared<variable_impl<number> >( _expr1->evaluate_number(context) + _expr2->evaluate_number(context) ); } ... };

Entonces necesitamos evaluar ambos lados ( _expr1 y _expr2 ), agregarlos y luego construir variable_impl<number> .

Podemos reducir las variables de forma segura porque verificamos su tipo durante el tiempo de compilación, por lo que ese no es el problema aquí. Sin embargo, el gran problema es la penalización de rendimiento que pagamos por la asignación del montón del objeto que regresa, que, en teoría, no es necesario. Estamos haciendo eso para satisfacer la declaración de la función virtual. En la primera versión de Stork, tendremos esa penalización cuando devolvamos números de funciones. Puedo vivir con eso, pero no con la simple expresión de preincremento que hace la asignación de montones.

Luego, probé con expresiones específicas de tipo heredadas de la base común:

 class expression { ... public: virtual void evaluate(runtime_context& context) const = 0; ... }; class lvalue_expression: public virtual expression { ... public: virtual lvalue evaluate_lvalue(runtime_context& context) const = 0; void evaluate(runtime_context& context) const override { evaluate_lvalue(context); } ... }; using lvalue_expression_ptr = std::unique_ptr<lvalue_expression>; class number_expression: public virtual expression { ... public: virtual number evaluate_number(runtime_context& context) const = 0; void evaluate(runtime_context& context) const override { evaluate_number(context); } ... }; using number_expression_ptr = std::unique_ptr<number_expression>; class lnumber_expression: public lvalue_expression, public number_expression { ... public: virtual lnumber evaluate_lnumber(runtime_context& context) const = 0; lvalue evaluate_lvalue(runtime_context& context) const override { return evaluate_lnumber(context); } number evaluate_number(runtime_context& context) const override { return evaluate_lnumber(context)->value; } void evaluate(runtime_context& context) const override { return evaluate_lnumber(context); } ... }; using lnumber_expression_ptr = std::unique_ptr<lnumber_expression>;

Esta es solo la parte de la jerarquía (solo para números), y ya nos encontramos con problemas de forma de diamante (la clase que hereda dos clases con la misma clase base).

Afortunadamente, C++ ofrece herencia virtual, lo que brinda la capacidad de heredar de la clase base, manteniendo el puntero en la clase heredada. Por lo tanto, si las clases B y C heredan virtualmente de A, y la clase D hereda de B y C, solo habría una copia de A en D.

Sin embargo, hay una serie de penalizaciones que tenemos que pagar en ese caso: el rendimiento y la incapacidad de bajar de A, por nombrar algunas, pero esto todavía parecía una oportunidad para mí de usar la herencia virtual por primera vez en mi vida.

Ahora, la implementación de la expresión de suma se verá más natural:

 class add_expression: public number_expression { private: number_expression_ptr _expr1; number_expression_ptr _expr2; public: ... number evaluate_number(runtime_context& context) const override{ return _expr1->evaluate_number(context) + _expr2->evaluate_number(context); } ... };

En cuanto a la sintaxis, no hay nada más que pedir, y esto es tan natural como parece. Sin embargo, si alguna de las expresiones internas es una expresión de número lvalue, requerirá dos llamadas a funciones virtuales para evaluarla. No perfecto, pero tampoco terrible.

Agreguemos cadenas a esta mezcla y veamos a dónde nos lleva:

 class string_expression: public virtual expression { ... public: virtual string evaluate_string(runtime_context& context) const = 0; void evaluate(runtime_context& context) const override { evaluate_string(context); } ... }; using string_expression_ptr = std::unique_ptr<string_expression>;

Como queremos que los números se conviertan en cadenas, necesitamos heredar number_expression de string_expression .

 class number_expression: public string_expression { ... public: virtual number evaluate_number(runtime_context& context) const = 0; string evaluate_string(runtime_context& context) const override { return tostring(evaluate_number(context)); } void evaluate(runtime_context& context) const override { evaluate_number(context); } ... }; using number_expression_ptr = std::unique_ptr<number_expression>;

Sobrevivimos a eso, pero tenemos que volver a anular el método virtual de evaluate , o enfrentaremos serios problemas de rendimiento debido a la conversión innecesaria de número a cadena.

Entonces, las cosas evidentemente se están poniendo feas, y nuestro diseño apenas las sobrevive porque no tenemos dos tipos de expresiones que deban convertirse una en otra (en ambos sentidos). Si ese fuera el caso, o si intentáramos tener algún tipo de conversión circular, nuestra jerarquía no podría manejarlo. Después de todo, la jerarquía debe reflejar una relación es-un, no es-convertible-en relación, que es más débil.

Todos estos intentos fallidos me llevaron a un diseño complicado pero, en mi opinión, adecuado. Primero, tener una sola clase base no es crucial para nosotros. Necesitamos la clase de expresión que evaluaría como nula, pero si podemos distinguir entre expresiones nulas y expresiones de otro tipo en tiempo de compilación, no hay necesidad de convertir entre ellas en tiempo de ejecución. Por tanto, parametrizaremos la clase base con el tipo de retorno de la expresión.

Aquí está la implementación completa de esa clase:

 template <typename R> class expression { expression(const expression&) = delete; void operator=(const expression&) = delete; protected: expression() = default; public: using ptr = std::unique_ptr<const expression>; virtual R evaluate(runtime_context& context) const = 0; virtual ~expression() = default; };

Solo tendremos una llamada de función virtual por evaluación de expresión (por supuesto, tendremos que llamarla recursivamente) y dado que no compilamos en código binario, es un resultado bastante bueno. Lo único que queda por hacer es la conversión entre tipos, cuando está permitido.

Para lograr eso, parametrizaremos cada expresión con el tipo de retorno y lo heredaremos de la clase base correspondiente. Luego, en la función de evaluate , convertiremos el resultado de la evaluación al valor de retorno de esa función.

Por ejemplo, esta es nuestra expresión de suma:

 template <typename R> class add_expression: public expression<R> { ... R evaluate(runtime_context& context) const override{ return convert<R>( _expr1->evaluate(context) + _expr2->evaluate(context) ); } ... };

Para escribir la función "convertir", necesitamos algo de infraestructura:

 template<class V, typename T> struct is_boxed { static const bool value = false; }; template<typename T> struct is_boxed<std::shared_ptr<variable_impl<T> >, T> { static const bool value = true; }; string convert_to_string(number n) { std::string str if (n == int(n)) { str = std::to_string(int(n)); } else { str = std::to_string(n); } return std::make_shared<std::string>(std::move(str)); } string convert_to_string(const lnumber& v) { return convert_to_string(v->value); }

La estructura is_boxed es un rasgo de tipo que tiene una constante interna, value , que se evalúa como verdadero si (y solo si) el primer parámetro es un puntero compartido a variable_impl parametrizado con el segundo tipo.

La implementación de la función convert sería posible incluso en versiones anteriores de C++, pero hay una declaración muy útil en C++17 llamada if constexpr , que evalúa la condición en el tiempo de compilación. Si se evalúa como false , eliminará el bloque por completo, incluso si provoca el error de tiempo de compilación. De lo contrario, dejará caer el bloque else .

 template<typename To, typename From> auto convert(From&& from) { if constexpr(std::is_convertible<From, To>::value) { return std::forward<From>(from); } else if constexpr(is_boxed<From, To>::value) { return unbox(std::forward<From>(from)); } else if constexpr(std::is_same<To, string>::value) { return convert_to_string(from); } else { static_assert(std::is_void<To>::value); } }

Intenta leer esta función:

  • Convertir si es convertible en C++ (esto es para variable_impl puntero upcast).
  • Desempaquetar si está en caja.
  • Convertir a cadena si el tipo de destino es cadena.
  • No haga nada y verifique si el objetivo está vacío.

En mi opinión, esto es mucho más legible que la sintaxis anterior basada en SFINAE.

Proporcionaré una breve descripción general de los tipos de expresión y omitiré algunos detalles técnicos para que sea razonablemente breve.

Hay tres tipos de expresiones hoja en un árbol de expresión:

  • expresión variable global
  • Expresión de variable local
  • expresión constante
 template<typename R, typename T> class global_variable_expression: public expression<R> { private: int _idx; public: global_variable_expression(int idx) : _idx(idx) { } R evaluate(runtime_context& context) const override { return convert<R>( context.global(_idx) ->template static_pointer_downcast<T>() ); } };

Aparte del tipo de retorno, también se parametriza con el tipo de variable. Las variables locales se tratan de manera similar, y esta es la clase para las constantes:

 template<typename R, typename T> class constant_expression: public expression<R> { private: T _c; public: constant_expression(T c) : _c(std::move(c)) { } R evaluate(runtime_context& context) const override { return convert<R>(_c); } };

En este caso, convertimos la constante inmediatamente en el constructor.

Esto se usa como la clase base para la mayoría de nuestras expresiones:

 template<class O, typename R, typename... Ts> class generic_expression: public expression<R> { private: std::tuple<typename expression<Ts>::ptr...> _exprs; template<typename... Exprs> R evaluate_tuple( runtime_context& context, const Exprs&... exprs ) const { return convert<R>(O()( std::move(exprs->evaluate(context))...) ); } public: generic_expression(typename expression<Ts>::ptr... exprs) : _exprs(std::move(exprs)...) { } R evaluate(runtime_context& context) const override { return std::apply( [&](const auto&... exprs){ return this->evaluate_tuple(context, exprs...); }, _exprs ); } };

El primer argumento es el tipo de functor que será instanciado y llamado para la evaluación. El resto de los tipos son tipos de devolución de expresiones secundarias.

Para reducir el código repetitivo, definimos tres macros:

 #define UNARY_EXPRESSION(name, code)\ struct name##_op {\ template <typename T1> \ auto operator()(T1 t1) {\ code;\ }\ };\ template<typename R, typename T1>\ using name##_expression = generic_expression<name##_op, R, T1>; #define BINARY_EXPRESSION(name, code)\ struct name##_op {\ template <typename T1, typename T2>\ auto operator()(T1 t1, T2 t2) {\ code;\ }\ };\ template<typename R, typename T1, typename T2>\ using name##_expression = generic_expression<name##_op, R, T1, T2>; #define TERNARY_EXPRESSION(name, code)\ struct name##_op {\ template <typename T1, typename T2, typename T3>\ auto operator()(T1 t1, T2 t2, T3 t3) {\ code;\ }\ };\ template<typename R, typename T1, typename T2, typename T3>\ using name##_expression = generic_expression<name##_op, R, T1, T2, T3>;

Tenga en cuenta que operator() se define como una plantilla, aunque normalmente no tiene por qué ser así. Es más fácil definir todas las expresiones de la misma manera en lugar de proporcionar tipos de argumentos como macroargumentos.

Ahora, podemos definir la mayoría de las expresiones. Por ejemplo, esta es la definición de /= :

 BINARY_EXPRESSION(div_assign, t1->value /= t2; return t1; );

Podemos definir casi todas las expresiones usando estas macros. Las excepciones son los operadores que tienen un orden definido de evaluación de argumentos ( && lógico y || , operador ternario ( ? ) y coma ( , ) ), índice de matriz, llamada a función y param_expression , que clona el parámetro para pasarlo a la función por valor.

No hay nada complicado en la implementación de estos. La implementación de llamadas a funciones es la más compleja, así que lo explicaré aquí:

 template<typename R, typename T> class call_expression: public expression<R>{ private: expression<function>::ptr _fexpr; std::vector<expression<lvalue>::ptr> _exprs; public: call_expression( expression<function>::ptr fexpr, std::vector<expression<lvalue>::ptr> exprs ): _fexpr(std::move(fexpr)), _exprs(std::move(exprs)) { } R evaluate(runtime_context& context) const override { std::vector<variable_ptr> params; params.reserve(_exprs.size()); for (size_t i = 0; i < _exprs.size(); ++i) { params.push_back(_exprs[i]->evaluate(context)); } function f = _fexpr->evaluate(context); for (size_t i = params.size(); i > 0; --i) { context.push(std::move(params[i-1])); } context.call(); f(context); if constexpr (std::is_same<R, void>::value) { context.end_function(_exprs.size()); } else { return convert<R>( context.end_function( _exprs.size() )->template static_pointer_downcast<T>() ); } } };

Prepara el runtime_context empujando todos los argumentos evaluados en su pila y llamando a la función de call . Luego llama al primer argumento evaluado (que es la función misma) y devuelve el valor de retorno del método end_function . Podemos ver el uso de la sintaxis if constexpr aquí también. Nos evita escribir la especialización para toda la clase para funciones que devuelven void .

Ahora, tenemos todo lo relacionado con las expresiones disponible durante el tiempo de ejecución. Lo único que queda es la conversión del árbol de expresiones analizadas (descrito en la publicación anterior del blog) al árbol de expresiones.

Generador de expresiones

Para evitar confusiones, nombremos diferentes fases de nuestro ciclo de desarrollo del lenguaje:

Diferentes fases del ciclo de desarrollo de un lenguaje de programación
  • Meta-compile-time: la fase en la que se ejecuta el compilador de C++
  • Tiempo de compilación: la fase en la que se ejecuta el compilador Stork
  • Tiempo de ejecución: la fase en la que se ejecuta el script de Stork

Aquí está el pseudocódigo para el generador de expresiones:

 function build_expression(nodeptr n, compiler_context context) { if (n is constant) { return constant_expression(n.value); } else if (n is identifier) { id_info info = context.find(n.value); if (context.is_global(info)) { return global_variable_expression(info.index); } else { return local_variable_expression(info.index); } } else { //operation switch (n->value) { case preinc: return preinc_expression( build_expression(n->child[0]) ); ... case add: return add_expression( build_expression(n->child[0]), build_expression(n->child[1]) ); ... case call: return call_expression( n->child[0], //function n->child[1], //arg0 ... n->child[k+1], //argk ); } } }

Además de tener que manejar todas las operaciones, parece un algoritmo sencillo.

Si funcionara, sería genial, pero no funciona. Para empezar, necesitamos especificar el tipo de retorno de la función, y obviamente no está arreglado aquí, porque el tipo de retorno depende del tipo de nodo que estemos visitando. Los tipos de nodos se conocen en tiempo de compilación, pero los tipos de retorno deben conocerse en tiempo de metacompilación.

En la publicación anterior, mencioné que no veo la ventaja de los lenguajes que realizan la verificación dinámica de tipos. En dichos lenguajes, el pseudocódigo que se muestra arriba podría implementarse casi literalmente. Ahora, soy bastante consciente de las ventajas de los lenguajes de tipo dinámico. Karma instantáneo en su máxima expresión.

Afortunadamente, conocemos el tipo de expresión de nivel superior; depende del contexto de la compilación, pero conocemos su tipo sin analizar el árbol de expresiones. Por ejemplo, si tenemos el bucle for:

 for (expression1; expression2; expression3) ...

La primera y la tercera expresión tienen un tipo de retorno void porque no hacemos nada con el resultado de su evaluación. La segunda expresión, sin embargo, tiene un number de tipo porque la estamos comparando con cero, para decidir si detener o no el bucle.

Si conocemos el tipo de expresión que está relacionada con la operación del nodo, generalmente determinará el tipo de su expresión secundaria.

Por ejemplo, si la expresión (expression1) += (expression2) tiene el tipo lnumber , eso significa que expression1 también tiene ese tipo y expression2 tiene el tipo number .

Sin embargo, la expresión (expression1) < (expression2) siempre tiene el tipo number , pero sus expresiones secundarias pueden tener tipo number o tipo string . En el caso de esta expresión, comprobaremos si ambos nodos son números. Si es así, construiremos expression1 y expression2 como expression<number> . En caso contrario, serán del tipo expression<string> .

Hay otro problema que tenemos que tener en cuenta y tratar.

Imagínese si necesitamos construir una expresión del tipo number . Entonces, no podemos devolver nada válido si nos encontramos con un operador de concatenación. Sabemos que no puede suceder, ya que revisamos los tipos cuando construimos el árbol de expresión (en la parte anterior), pero eso significa que no podemos escribir la función de plantilla, parametrizada con el tipo de retorno, porque tendrá ramas no válidas dependiendo en ese tipo de devolución.

Un enfoque dividiría la función por tipo de retorno, usando if constexpr , pero es ineficiente porque si la misma operación existe en varias ramas, tendremos que repetir su código. Podríamos escribir funciones separadas en ese caso.

La solución implementada divide la función según el tipo de nodo. En cada una de las ramas, comprobaremos si ese tipo de rama es convertible al tipo de retorno de función. Si no es así, arrojaremos el error del compilador, porque nunca debería suceder, pero el código es demasiado complicado para una afirmación tan fuerte. Puede que haya cometido un error.

Estamos utilizando la siguiente estructura de rasgo de tipo que se explica por sí misma para verificar la convertibilidad:

 template<typename From, typename To> struct is_convertible { static const bool value = std::is_convertible<From, To>::value || is_boxed<From, To>::value || ( std::is_same<To, string>::value && ( std::is_same<From, number>::value || std::is_same<From, lnumber>::value ) ); };

Después de esa división, el código es casi sencillo. Podemos convertir semánticamente del tipo de expresión original al que queremos construir, y no hay errores en el tiempo de metacompilación.

Sin embargo, hay mucho código repetitivo, así que confié mucho en las macros para reducirlo.

 template<typename R> class expression_builder{ private: using expression_ptr = typename expression<R>::ptr; static expression_ptr build_void_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_number_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_lnumber_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_string_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_lstring_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_array_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_larray_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_function_expression( const node_ptr& np, compiler_context& context ); static expression_ptr build_lfunction_expression( const node_ptr& np, compiler_context& context ); public: static expression_ptr build_expression( const node_ptr& np, compiler_context& context ) { return std::visit(overloaded{ [&](simple_type st){ switch (st) { case simple_type::number: if (np->is_lvalue()) { RETURN_EXPRESSION_OF_TYPE(lnumber); } else { RETURN_EXPRESSION_OF_TYPE(number); } case simple_type::string: if (np->is_lvalue()) { RETURN_EXPRESSION_OF_TYPE(lstring); } else { RETURN_EXPRESSION_OF_TYPE(string); } case simple_type::nothing: RETURN_EXPRESSION_OF_TYPE(void); } }, [&](const function_type& ft) { if (np->is_lvalue()) { RETURN_EXPRESSION_OF_TYPE(lfunction); } else { RETURN_EXPRESSION_OF_TYPE(function); } }, [&](const array_type& at) { if (np->is_lvalue()) { RETURN_EXPRESSION_OF_TYPE(larray); } else { RETURN_EXPRESSION_OF_TYPE(array); } } }, *np->get_type_id()); } };

La función build_expression es la única función pública aquí. Invoca la función std::visit en el tipo de nodo. Esa función aplica el functor pasado en la variant , desacoplandolo en el proceso. Puede leer más sobre esto y sobre el funtor overloaded aquí.

La macro RETURN_EXPRESSION_OF_TYPE llama a funciones privadas para la creación de expresiones y lanza una excepción si la conversión no es posible:

 #define RETURN_EXPRESSION_OF_TYPE(T)\ if constexpr(is_convertible<T, R>::value) {\ return build_##T##_expression(np, context);\ } else {\ throw expression_builder_error();\ return expression_ptr();\ }

Tenemos que devolver el puntero vacío en la rama else, ya que el compilador no puede saber el tipo de devolución de la función en caso de conversión imposible; de lo contrario, std::visit requiere que todas las funciones sobrecargadas tengan el mismo tipo de devolución.

Existe, por ejemplo, la función que genera expresiones con una string como tipo de retorno:

 static expression_ptr build_string_expression( const node_ptr& np, compiler_context& context ) { if (std::holds_alternative<std::string>(np->get_value())) { return std::make_unique<constant_expression<R, string>>( std::make_shared<std::string>( std::get<std::string>(np->get_value()) ) ); } CHECK_IDENTIFIER(lstring); switch (std::get<node_operation>(np->get_value())) { CHECK_BINARY_OPERATION(concat, string, string); CHECK_BINARY_OPERATION(comma, void, string); CHECK_TERNARY_OPERATION(ternary, number, string, string); CHECK_INDEX_OPERATION(lstring); CHECK_CALL_OPERATION(lstring); default: throw expression_builder_error(); } }

Comprueba si el nodo mantiene la cadena constant_expression y genera expresión_constante si ese es el caso.

Luego, verifica si el nodo tiene un identificador y devuelve una expresión de variable global o local de tipo lstring en ese caso. Puede contener un identificador si implementamos variables constantes. De lo contrario, asume que el nodo contiene la operación de nodo e intenta todas las operaciones que pueden devolver string .

Aquí están las implementaciones de las macros CHECK_IDENTIFIER y CHECK_BINARY_OPERATION :

 #define CHECK_IDENTIFIER(T1)\ if (std::holds_alternative<identifier>(np->get_value())) {\ const identifier& id = std::get<identifier>(np->get_value());\ const identifier_info* info = context.find(id.name);\ if (info->is_global()) {\ return std::make_unique<\ global_variable_expression<R, T1>\ >(info->index());\ } else {\ return std::make_unique<\ local_variable_expression<R, T1>\ >(info->index());\ }\ }
 #define CHECK_BINARY_OPERATION(name, T1, T2)\ case node_operation::name:\ return expression_ptr(\ std::make_unique<name##_expression<R, T1, T2> > (\ expression_builder<T1>::build_expression(\ np->get_children()[0], context\ ),\ expression_builder<T2>::build_expression(\ np->get_children()[1], context\ )\ )\ );

La macro CHECK_IDENTIFIER tiene que consultar compiler_context para construir una expresión de variable global o local con el índice adecuado. Ese es el único uso de compiler_context en esta estructura.

Puede ver que CHECK_BINARY_OPERATION llama recursivamente a build_expression para los nodos secundarios.

Terminando

En mi página de GitHub, puede obtener el código fuente completo, compilarlo y luego escribir expresiones y ver el resultado de las variables evaluadas.

Imagino que, en todas las ramas de la creatividad humana, hay un momento en que el autor se da cuenta de que su producto está vivo, en algún sentido. En la construcción de un lenguaje de programación, es el momento en que se puede ver que el lenguaje “respira”.

En la siguiente y última parte de esta serie, implementaremos el resto del conjunto mínimo de características del lenguaje para verlo en vivo.