Esta é a segunda parte da história sobre como portar o mecanismo de modelo Jinja2 para C ++. Você pode ler o primeiro aqui: Modelos de terceira ordem ou como eu portava o Jinja2 para C ++ . Ele se concentrará no processo de renderização de modelos. Ou, em outras palavras, sobre escrever do zero um intérprete de uma linguagem semelhante a python.
Renderização como tal
Após a análise, o modelo se transforma em uma árvore contendo nós de três tipos: texto sem formatação , expressões calculadas e estruturas de controle . Assim, durante o processo de renderização, o texto simples deve ser colocado sem nenhuma alteração no fluxo de saída, as expressões devem ser calculadas, convertidas em texto, que será colocado no fluxo e as estruturas de controle devem ser executadas. À primeira vista, não havia nada difícil na implementação do processo de renderização: basta percorrer todos os nós da árvore, calcular tudo, executar tudo e gerar texto. Tudo é simples. Exatamente desde que duas condições sejam atendidas: a) todo o trabalho seja realizado com strings de apenas um tipo (string ou wstring); b) apenas expressões muito simples e básicas são usadas. Na verdade, é com essas restrições que inja e Jinja2CppLight são implementados. No caso do meu Jinja2Cpp, ambas as condições não funcionam. Em primeiro lugar, inicialmente estabeleci um suporte transparente para os dois tipos de strings. Em segundo lugar, todo o desenvolvimento foi iniciado apenas para apoiar a especificação Jinja2 quase na íntegra, e essa, em essência, é uma linguagem de script completa. Portanto, tive que me aprofundar mais na renderização do que na análise.
Avaliação de Expressão
Um modelo não seria um modelo se não pudesse ser parametrizado. Em princípio, o Jinja2 permite a opção de modelos "em si" - todas as variáveis necessárias podem ser definidas dentro do próprio modelo e depois renderizadas. Mas trabalhar em um modelo com parâmetros obtidos "fora" continua sendo o caso principal. Assim, o resultado da avaliação de uma expressão depende de quais variáveis (parâmetros) com quais valores são visíveis nos pontos de cálculo. E o problema é que no Jinja2 não há apenas escopo (que pode ser aninhado), mas também com regras complicadas de "transparência". Por exemplo, aqui está um modelo:
{% set param1=10 %} {{ param1 }}
Como resultado de sua renderização, o texto 10
será recebido
A opção é um pouco mais complicada:
{% set param1=10 %} {{ param1 }} {% for param1 in range(10) %}-{{ param1 }}-{% endfor %} {{ param1 }}
É processado tão cedo quanto 10-0--1--2--3--4--5--6--7--8--9-10
O ciclo gera um novo escopo no qual você pode definir seus próprios parâmetros variáveis, e esses parâmetros não serão visíveis fora do escopo, assim como não triturarão os valores dos mesmos parâmetros no externo. Ainda mais complicado com construções de extensão / bloqueio, mas é melhor ler sobre isso na documentação do Jinja2.
Assim, o contexto dos cálculos aparece. Ou melhor, renderizando em geral:
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; };
Daqui
O contexto contém um ponteiro para uma coleção de valores obtidos quando a função de renderização foi chamada, uma lista (pilha) de escopos, o escopo ativo atual e um ponteiro para uma interface de retorno de chamada, com várias funções úteis para renderização. Mas sobre ele um pouco mais tarde. A função de pesquisa de parâmetros sobe seqüencialmente a lista de contextos até a externa, até encontrar o parâmetro necessário.
Agora um pouco sobre os próprios parâmetros. Do ponto de vista da interface externa (e de seus usuários), o Jinja2 suporta a seguinte lista de tipos válidos:
- Números (int, duplo)
- Cordas (estreitas, largas)
- bool
- Matrizes (mais como tuplas sem dimensão)
- Dicionários
- Estruturas C ++ refletidas
Tudo isso é descrito por um tipo de dados especial criado com base no boost :: variant:
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; };
Daqui
Obviamente, elementos de matrizes e dicionários podem ser qualquer um dos tipos listados. Mas o problema é que, para uso interno, esse conjunto de tipos é muito estreito. Para simplificar a implementação, era necessário suporte para os seguintes tipos adicionais:
- String no formato de destino. Pode ser estreito ou largo, dependendo do tipo de modelo que está sendo renderizado.
- tipo de chamada
- Montagem de árvore AST
- Par de valor-chave
Com essa expansão, tornou-se possível transferir dados de serviço através do contexto de renderização, que, caso contrário, precisariam ser "brilhados" em cabeçalhos públicos, além de generalizar com mais êxito alguns algoritmos que funcionam com matrizes e dicionários.
Boost :: variant não foi escolhido por acaso. Seus recursos avançados são usados para trabalhar com parâmetros de tipos específicos. O Jinja2CppLight usa classes polimórficas para a mesma finalidade, enquanto o inja usa o sistema de tipo de biblioteca nlohmann json. Ambas as alternativas, infelizmente, não me agradaram. Razão: a possibilidade de envio n-ário para boost :: variant (e agora - std :: variant). Para um tipo de variante, você pode criar um visitante estático que aceite dois tipos armazenados específicos e configurá-lo em um par de valores. E tudo funcionará como deveria! No caso de classes polimórficas ou uniões simples, essa conveniência não 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; } };
Daqui
Esse visitante é chamado de maneira muito simples:
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
aqui é um wrapper over boost::apply_visitor
, que aplica o visitante do tipo especificado pelo parâmetro template a um par de valores variantes, fazendo algumas conversões anteriormente, se necessário. Se o designer do visitante precisar de parâmetros, eles serão passados após os objetos aos quais o visitante se aplica:
comparator = [](const KeyValuePair& left, const KeyValuePair& right) { return ConvertToBool(Apply2<visitors::BinaryMathOperation>(left.value, right.value, BinaryExpression::LogicalLt, BinaryExpression::CaseSensitive)); };
Assim, a lógica das operações com parâmetros é a seguinte: variante (s) -> descompactando usando o visitante -> executando a ação desejada em valores específicos de tipos específicos -> empacotando o resultado novamente na variante. E um mínimo de magia disfarçada. Seria possível implementar tudo como em js: executar operações (por exemplo, adições) em qualquer caso, escolhendo um determinado sistema de conversão de strings para números, números para strings, strings para listas, etc. E obter resultados estranhos e inesperados. Eu escolhi uma maneira mais simples e previsível: se uma operação em um valor (ou um par de valores) é impossível ou ilógica, um resultado vazio é retornado. Portanto, ao adicionar um número a uma sequência, você pode obter uma sequência apenas se a operação de concatenação ('~') for usada. Caso contrário, o resultado será um valor vazio. A prioridade das operações é determinada pela gramática; portanto, nenhuma verificação adicional é necessária durante o processamento do AST.
Filtros e testes
O que os outros idiomas chamam de "biblioteca padrão" no Jinja2 é chamado de "filtros". Em essência, um filtro é um tipo de operação complexa em um valor à esquerda do sinal '|', cujo resultado será um novo valor. Os filtros podem ser organizados em uma cadeia organizando um pipeline:
{{ menuItems | selectattr('visible') | map(attribute='title') | map('upper') | join(' -> ') }}
Aqui, apenas os elementos com o atributo visível definido como true serão selecionados na matriz menuItems, o atributo title será obtido desses elementos, convertidos em maiúsculas, e a lista de linhas resultante será colada com o separador '->' em uma linha. Ou, digamos, como um exemplo da vida:
{% macro MethodsDecl(class, access) %} {% for method in class.methods | rejectattr('isImplicit') | selectattr('accessType', 'in', access) %} {{ method.fullPrototype }}; {% endfor %} {% endmacro %}
Daqui
Opção alternativa {% macro MethodsDecl(class, access) %} {{ for method in class.methods | rejectattr('isImplicit') | selectattr('accessType', 'in', access) | map(attribute='fullPrototype') | join(';\n') }}; {% endmacro %}
Essa macro repete todos os métodos da classe especificada, descarta aqueles para os quais o atributo isImplicit está definido como true, seleciona os demais para os quais o valor do atributo accessType corresponde a um dos especificados e exibe seus protótipos. Relativamente claro. E é tudo mais fácil do que ciclos de três andares e se é necessário cercar. A propósito, algo semelhante em C ++ pode ser feito dentro da especificação da faixa v.3 .
Na verdade, a principal falha no tempo foi associada à implementação de cerca de quarenta filtros, que incluí no conjunto básico. Por alguma razão, deduzi que poderia lidar com isso em uma semana ou duas. Foi muito otimista. E embora a implementação típica do filtro seja bastante simples: pegue um valor e aplique algum functor a ele, havia muitos deles, e eu tive que mexer.
Uma tarefa interessante separada no processo de implementação foi a lógica do processamento de argumentos. No Jinja2, como no python, os argumentos passados para a chamada podem ser nomeados ou posicionais. E os parâmetros na declaração do filtro podem ser obrigatórios ou opcionais (com valores padrão). Além disso, ao contrário do C ++, parâmetros opcionais podem ser localizados em qualquer lugar do anúncio. Foi necessário criar um algoritmo para combinar essas duas listas, levando em consideração casos diferentes. Aqui, digamos, existe uma função range([start, ]stop[, step])
: range([start, ]stop[, step])
. Pode ser chamado das seguintes maneiras:
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)
E assim por diante E eu gostaria muito que no código para implementar a função de filtro não fosse necessário levar todos esses casos em consideração. Como resultado, ele decidiu que, no código do filtro, testador ou código de função, os parâmetros são obtidos estritamente pelo nome. E uma função separada compara a lista real de argumentos com a lista esperada de parâmetros ao longo do caminho, verificando se todos os parâmetros necessários são fornecidos de uma maneira ou de outra:
Grande pedaço 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;
Daqui
É assim chamado (por exemplo, range
):
bool isArgsParsed = true; auto args = helpers::ParseCallParams({{"start"}, {"stop", true}, {"step"}}, m_params, isArgsParsed); if (!isArgsParsed) return InternalValue();
e retorna a seguinte estrutura:
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; } };
o argumento necessário do qual é retirado simplesmente pelo nome:
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();
Um mecanismo semelhante é usado ao trabalhar com macros e testadores. E, embora pareça não haver nada complicado na descrição dos argumentos de cada filtro e teste, não existe (como implementá-lo), mas mesmo o conjunto "básico", que incluía cerca de cinquenta desses e de outros, mostrou-se bastante volumoso para implementação. E isso desde que não incluísse todos os tipos de coisas complicadas, como formatar seqüências de caracteres para HTML (ou C ++), gerar valores em formatos como xml ou json, etc.
Na próxima parte, focaremos na implementação do trabalho com vários modelos (exportar, incluir, macros), bem como em aventuras fascinantes com a implementação do tratamento de erros e trabalharemos com strings de diferentes larguras.
Tradicionalmente, os links:
Especificação Jinja2
Implementação Jinja2Cpp