Jinja2 en el mundo C ++, segunda parte. Renderizado

Logotipo de Jinja2 Esta es la segunda parte de la historia sobre portar el motor de plantillas Jinja2 a C ++. Puedes leer el primero aquí: plantillas de tercer orden, o cómo porté Jinja2 a C ++ . Se centrará en el proceso de renderización de plantillas. O, en otras palabras, acerca de escribir desde cero un intérprete de un lenguaje similar a python.


Renderizando como tal


Después del análisis, la plantilla se convierte en un árbol que contiene nodos de tres tipos: texto sin formato , expresiones calculadas y estructuras de control . En consecuencia, durante el proceso de representación, el texto sin formato debe colocarse sin ningún cambio en la secuencia de salida, las expresiones deben calcularse, convertirse en texto, que se colocará en la secuencia, y las estructuras de control deben ejecutarse. A primera vista, no había nada complicado en la implementación del proceso de renderizado: solo necesita recorrer todos los nodos del árbol, calcular todo, ejecutar todo y generar texto. Todo es simple Exactamente siempre que se cumplan dos condiciones: a) todo el trabajo se realiza con cadenas de un solo tipo (cadena o wstring); b) solo se utilizan expresiones muy simples y básicas. En realidad, es con tales restricciones que se implementan inja y Jinja2CppLight. En el caso de mi Jinja2Cpp, ambas condiciones no funcionan. En primer lugar, inicialmente establecí soporte transparente para ambos tipos de cadenas. En segundo lugar, todo el desarrollo se inició por el simple hecho de admitir la especificación Jinja2 casi en su totalidad, y esto, en esencia, es un lenguaje de secuencias de comandos completo. Por lo tanto, tuve que profundizar más con el renderizado que con el análisis.


Evaluación de expresiones


Una plantilla no sería una plantilla si no se pudiera parametrizar. En principio, Jinja2 permite la opción de plantillas "en sí mismas": todas las variables necesarias se pueden establecer dentro de la plantilla y luego representarlas. Pero trabajar en una plantilla con parámetros obtenidos "fuera" sigue siendo el caso principal. Por lo tanto, el resultado de evaluar una expresión depende de qué variables (parámetros) con qué valores son visibles en los puntos de cálculo. Y el problema es que en Jinja2 no solo hay alcance (que puede anidarse), sino también reglas complicadas de "transparencia". Por ejemplo, aquí hay una plantilla:


{% set param1=10 %} {{ param1 }} 

Como resultado de su representación, se recibirá el texto 10
La opción es un poco más complicada:


 {% set param1=10 %} {{ param1 }} {% for param1 in range(10) %}-{{ param1 }}-{% endfor %} {{ param1 }} 

Renders desde 10-0--1--2--3--4--5--6--7--8--9-10
El ciclo genera un nuevo ámbito en el que puede definir sus propios parámetros variables, y estos parámetros no serán visibles fuera del ámbito, al igual que no triturarán los valores de los mismos parámetros en el externo. Aún más complicado con las construcciones de extensión / bloque, pero es mejor leer sobre esto en la documentación de Jinja2.


Así, aparece el contexto de los cálculos. O más bien, renderizando en general:


 class RenderContext { public: RenderContext(const InternalValueMap& extValues, IRendererCallback* rendererCallback); InternalValueMap& EnterScope(); void ExitScope(); auto FindValue(const std::string& val, bool& found) const { for (auto p = m_scopes.rbegin(); p != m_scopes.rend(); ++ p) { auto valP = p->find(val); if (valP != p->end()) { found = true; return valP; } } auto valP = m_externalScope->find(val); if (valP != m_externalScope->end()) { found = true; return valP; } found = false; return m_externalScope->end(); } auto& GetCurrentScope() const; auto& GetCurrentScope(); auto& GetGlobalScope(); auto GetRendererCallback(); RenderContext Clone(bool includeCurrentContext) const; private: InternalValueMap* m_currentScope; const InternalValueMap* m_externalScope; std::list<InternalValueMap> m_scopes; IRendererCallback* m_rendererCallback; }; 

Desde aquí


El contexto contiene un puntero a una colección de valores obtenidos cuando se llamó a la función de representación, una lista (pila) de ámbitos, el alcance activo actual y un puntero a una interfaz de devolución de llamada, con varias funciones útiles para la representación. Pero sobre él un poco más tarde. La función de búsqueda de parámetros sube secuencialmente la lista de contextos hasta la externa hasta que encuentra el parámetro necesario.


Ahora un poco sobre los parámetros en sí. Desde el punto de vista de la interfaz externa (y sus usuarios), Jinja2 admite la siguiente lista de tipos válidos:


  • Números (int, doble)
  • Cuerdas (estrechas, anchas)
  • bool
  • Matrices (más como tuplas adimensionales)
  • Diccionarios
  • Estructuras reflejadas de C ++

Todo esto se describe mediante un tipo de datos especial creado en base a la variante boost :::


 using ValueData = boost::variant<EmptyValue, bool, std::string, std::wstring, int64_t, double, boost::recursive_wrapper<ValuesList>, boost::recursive_wrapper<ValuesMap>, GenericList, GenericMap>; class Value { public: Value() = default; template<typename T> Value(T&& val, typename std::enable_if<!std::is_same<std::decay_t<T>, Value>::value>::type* = nullptr) : m_data(std::forward<T>(val)) { } Value(const char* val) : m_data(std::string(val)) { } template<size_t N> Value(char (&val)[N]) : m_data(std::string(val)) { } Value(int val) : m_data(static_cast<int64_t>(val)) { } const ValueData& data() const {return m_data;} ValueData& data() {return m_data;} private: ValueData m_data; }; 

Desde aquí


Por supuesto, los elementos de matrices y diccionarios pueden ser cualquiera de los tipos enumerados. Pero el problema es que, para uso interno, este conjunto de tipos es demasiado limitado. Para simplificar la implementación, se necesitaba soporte para los siguientes tipos adicionales:


  • Cadena en formato de destino. Puede ser angosto o ancho según el tipo de plantilla que se esté representando.
  • tipo invocable
  • Ensamblaje de árbol AST
  • Par clave-valor

A través de esta expansión, se hizo posible transferir datos de servicio a través del contexto de representación, que de otro modo tendría que "brillar" en los encabezados públicos, así como generalizar con mayor éxito algunos algoritmos que funcionan con matrices y diccionarios.


Boost :: variante no fue elegida por casualidad. Sus ricas capacidades se utilizan para trabajar con parámetros de tipos específicos. Jinja2CppLight usa clases polimórficas para el mismo propósito, mientras que inja usa el sistema de tipo de biblioteca nlohmann json. Ambas alternativas, por desgracia, no me convenían. Motivo: la posibilidad de despachar n-ary para boost :: variant (y ahora - std :: variant). Para un tipo de variante, puede hacer un visitante estático que acepte dos tipos almacenados específicos y establecerlo en un par de valores. ¡Y todo funcionará como debería! En el caso de clases polimórficas o uniones simples, esta conveniencia no funcionará:


 struct StringJoiner : BaseVisitor<> { using BaseVisitor::operator (); InternalValue operator() (EmptyValue, const std::string& str) const { return str; } InternalValue operator() (const std::string& left, const std::string& right) const { return left + right; } }; 

Desde aquí


Tal visitante se llama muy simple:


 InternalValue delimiter = m_args["d"]->Evaluate(context); for (const InternalValue& val : values) { if (isFirst) isFirst = false; else result = Apply2<visitors::StringJoiner>(result, delimiter); result = Apply2<visitors::StringJoiner>(result, val); } 

Apply2 aquí es una envoltura sobre boost::apply_visitor , que aplica el visitante del tipo especificado por el parámetro de plantilla a un par de valores variantes, previamente haciendo algunas conversiones si es necesario. Si el diseñador del visitante necesita parámetros, se pasan después de los objetos a los que se aplica el visitante:


 comparator = [](const KeyValuePair& left, const KeyValuePair& right) { return ConvertToBool(Apply2<visitors::BinaryMathOperation>(left.value, right.value, BinaryExpression::LogicalLt, BinaryExpression::CaseSensitive)); }; 

Por lo tanto, la lógica de las operaciones con parámetros se presenta de la siguiente manera: variante (s) -> desempaquetar usando el visitante -> realizar la acción deseada en valores específicos de tipos específicos -> empaquetar el resultado nuevamente en la variante. Y un mínimo de magia encubierta. Sería posible implementar todo como en js: realizar operaciones (por ejemplo, adiciones) en cualquier caso, elegir un cierto sistema de conversión de cadenas a números, números a cadenas, cadenas a listas, etc. Y obtener resultados extraños e inesperados. Elegí una forma más simple y más predecible: si una operación en un valor (o un par de valores) es imposible o ilógico, entonces se devuelve un resultado vacío. Por lo tanto, al agregar un número a una cadena, puede obtener una cadena como resultado solo si se utiliza la operación de concatenación ('~'). De lo contrario, el resultado será un valor vacío. La gramática determina la prioridad de las operaciones, por lo tanto, no se requieren verificaciones adicionales durante el procesamiento de AST.


Filtros y pruebas


Lo que otros idiomas llaman la "biblioteca estándar" en Jinja2 se llama "filtros". En esencia, un filtro es un tipo de operación compleja en un valor a la izquierda del signo '|', cuyo resultado será un nuevo valor. Los filtros se pueden organizar en una cadena organizando una tubería:
{{ menuItems | selectattr('visible') | map(attribute='title') | map('upper') | join(' -> ') }}
Aquí, solo aquellos elementos con el atributo visible establecido en verdadero se seleccionarán de la matriz menuItems, luego el atributo de título se tomará de estos elementos, se convertirá a mayúsculas y la lista resultante de líneas se pegará con el separador '->' en una línea. O, digamos, como un ejemplo de la vida:


 {% macro MethodsDecl(class, access) %} {% for method in class.methods | rejectattr('isImplicit') | selectattr('accessType', 'in', access) %} {{ method.fullPrototype }}; {% endfor %} {% endmacro %} 

Desde aquí


Opción alternativa
 {% macro MethodsDecl(class, access) %} {{ for method in class.methods | rejectattr('isImplicit') | selectattr('accessType', 'in', access) | map(attribute='fullPrototype') | join(';\n') }}; {% endmacro %} 

Esta macro itera sobre todos los métodos de la clase dada, descarta aquellos para los cuales el atributo isImplicit se establece en verdadero, selecciona los restantes para los cuales el valor del atributo accessType coincide con uno de los dados y muestra sus prototipos. Relativamente claro. Y todo es más fácil que los ciclos de tres pisos y es vallado. Por cierto, se puede hacer algo similar en C ++ dentro de la especificación del rango v.3 .


En realidad, la principal falla en el tiempo se asoció con la implementación de unos cuarenta filtros, que incluí en el conjunto básico. Por alguna razón supuse que podía manejarlo en una o dos semanas. Fue demasiado optimista. Y aunque la implementación típica del filtro es bastante simple: tomar un valor y aplicarle un functor, había demasiados, y tuve que jugar.
Otra tarea interesante en el proceso de implementación fue la lógica del procesamiento de argumentos. En Jinja2, como en Python, los argumentos pasados ​​a la llamada pueden ser nombrados o posicionales. Y los parámetros en la declaración del filtro pueden ser obligatorios u opcionales (con valores predeterminados). Además, a diferencia de C ++, los parámetros opcionales se pueden ubicar en cualquier parte del anuncio. Era necesario idear un algoritmo para combinar estas dos listas, teniendo en cuenta diferentes casos. Aquí, digamos, hay una función de rango: range([start, ]stop[, step]) . Se puede llamar de las siguientes maneras:


 range(10) // -> range(start = 0, stop = 10, step = 1) range(1, 10) // -> range(start = 1, stop = 10, step = 1) range(1, 10, 3) // -> range(start = 1, stop = 10, step = 3) range(step=2, 10) // -> range(start = 0, stop = 10, step = 2) range(2, step=2, 10) // -> range(start = 2, stop = 10, step = 2) 

Y así sucesivamente. Y me gustaría mucho que en el código para implementar la función de filtro no fuera necesario tener en cuenta todos estos casos. Como resultado, se decidió por el hecho de que en el código de filtro, el probador o el código de función, los parámetros se obtienen estrictamente por nombre. Y una función separada compara la lista real de argumentos con la lista esperada de parámetros en el camino al verificar que todos los parámetros requeridos se dan de una forma u otra:


Gran pieza de código
 ParsedArguments ParseCallParams(const std::initializer_list<ArgumentInfo>& args, const CallParams& params, bool& isSucceeded) { struct ArgInfo { ArgState state = NotFound; int prevNotFound = -1; int nextNotFound = -1; const ArgumentInfo* info = nullptr; }; boost::container::small_vector<ArgInfo, 8> argsInfo(args.size()); boost::container::small_vector<ParamState, 8> posParamsInfo(params.posParams.size()); isSucceeded = true; ParsedArguments result; int argIdx = 0; int firstMandatoryIdx = -1; int prevNotFound = -1; int foundKwArgs = 0; // Find all provided keyword args for (auto& argInfo : args) { argsInfo[argIdx].info = &argInfo; auto p = params.kwParams.find(argInfo.name); if (p != params.kwParams.end()) { result.args[argInfo.name] = p->second; argsInfo[argIdx].state = Keyword; ++ foundKwArgs; } else { if (argInfo.mandatory) { argsInfo[argIdx].state = NotFoundMandatory; if (firstMandatoryIdx == -1) firstMandatoryIdx = argIdx; } else { argsInfo[argIdx].state = NotFound; } if (prevNotFound != -1) argsInfo[prevNotFound].nextNotFound = argIdx; argsInfo[argIdx].prevNotFound = prevNotFound; prevNotFound = argIdx; } ++ argIdx; } int startPosArg = firstMandatoryIdx == -1 ? 0 : firstMandatoryIdx; int curPosArg = startPosArg; int eatenPosArgs = 0; // Determine the range for positional arguments scanning bool isFirstTime = true; for (; eatenPosArgs < posParamsInfo.size(); ++ eatenPosArgs) { if (isFirstTime) { for (; startPosArg < args.size() && (argsInfo[startPosArg].state == Keyword || argsInfo[startPosArg].state == Positional); ++ startPosArg) ; isFirstTime = false; continue; } int prevNotFound = argsInfo[startPosArg].prevNotFound; if (prevNotFound != -1) { startPosArg = prevNotFound; } else if (curPosArg == args.size()) { break; } else { int nextPosArg = argsInfo[curPosArg].nextNotFound; if (nextPosArg == -1) break; curPosArg = nextPosArg; } } // Map positional params to the desired arguments int curArg = startPosArg; for (int idx = 0; idx < eatenPosArgs && curArg != -1; ++ idx, curArg = argsInfo[curArg].nextNotFound) { result.args[argsInfo[curArg].info->name] = params.posParams[idx]; argsInfo[curArg].state = Positional; } // Fill default arguments (if missing) and check for mandatory for (int idx = 0; idx < argsInfo.size(); ++ idx) { auto& argInfo = argsInfo[idx]; switch (argInfo.state) { case Positional: case Keyword: continue; case NotFound: { if (!IsEmpty(argInfo.info->defaultVal)) result.args[argInfo.info->name] = std::make_shared<ConstantExpression>(argInfo.info->defaultVal); break; } case NotFoundMandatory: isSucceeded = false; break; } } // Fill the extra positional and kw-args for (auto& kw : params.kwParams) { if (result.args.find(kw.first) != result.args.end()) continue; result.extraKwArgs[kw.first] = kw.second; } for (auto idx = eatenPosArgs; idx < params.posParams.size(); ++ idx) result.extraPosArgs.push_back(params.posParams[idx]); return result; } 

Desde aquí


Se llama de esta manera (por ejemplo, range ):


 bool isArgsParsed = true; auto args = helpers::ParseCallParams({{"start"}, {"stop", true}, {"step"}}, m_params, isArgsParsed); if (!isArgsParsed) return InternalValue(); 

y devuelve la siguiente estructura:


 struct ParsedArguments { std::unordered_map<std::string, ExpressionEvaluatorPtr<>> args; std::unordered_map<std::string, ExpressionEvaluatorPtr<>> extraKwArgs; std::vector<ExpressionEvaluatorPtr<>> extraPosArgs; ExpressionEvaluatorPtr<> operator[](std::string name) const { auto p = args.find(name); if (p == args.end()) return ExpressionEvaluatorPtr<>(); return p->second; } }; 

El argumento necesario del cual se toma simplemente por su nombre:


 auto startExpr = args["start"]; auto stopExpr = args["stop"]; auto stepExpr = args["step"]; InternalValue startVal = startExpr ? startExpr->Evaluate(values) : InternalValue(); InternalValue stopVal = stopExpr ? stopExpr->Evaluate(values) : InternalValue(); InternalValue stepVal = stepExpr ? stepExpr->Evaluate(values) : InternalValue(); 

Se utiliza un mecanismo similar cuando se trabaja con macros y probadores. Y aunque parece que no hay nada complicado en la descripción de los argumentos de cada filtro y prueba, no existe (cómo implementarlo), pero incluso el conjunto "básico", que incluye alrededor de cincuenta de esos y otros, resultó ser bastante voluminoso para la implementación. Y esto siempre que no incluyera todo tipo de cosas difíciles, como formatear cadenas para HTML (o C ++), generar valores en formatos como xml o json, y similares.


En la siguiente parte, nos centraremos en la implementación del trabajo con varias plantillas (exportar, incluir, macros), así como en aventuras fascinantes con la implementación del manejo de errores y el trabajo con cadenas de diferentes anchos.


Tradicionalmente, los enlaces:


Especificación Jinja2
Implementación Jinja2Cpp

Source: https://habr.com/ru/post/es419011/


All Articles