Dies ist der zweite Teil der Geschichte über die Portierung der Jinja2-Template-Engine nach C ++. Sie können die erste hier lesen: Vorlagen dritter Ordnung oder wie ich Jinja2 nach C ++ portiert habe . Es wird sich auf den Prozess des Renderns von Vorlagen konzentrieren. Oder mit anderen Worten, einen Dolmetscher einer Python-ähnlichen Sprache von Grund auf neu zu schreiben.
Rendern als solches
Nach dem Parsen wird die Vorlage zu einem Baum, der Knoten von drei Typen enthält: Nur-Text , berechnete Ausdrücke und Kontrollstrukturen . Dementsprechend sollte während des Rendervorgangs einfacher Text ohne Änderungen im Ausgabestream platziert werden, Ausdrücke sollten berechnet, in Text konvertiert, der in den Stream eingefügt wird, und Kontrollstrukturen müssen ausgeführt werden. Auf den ersten Blick war die Implementierung des Renderprozesses nicht kompliziert: Sie müssen nur alle Knoten des Baums umgehen, alles berechnen, alles ausführen und Text generieren. Alles ist einfach. Genau solange zwei Bedingungen erfüllt sind: a) Alle Arbeiten werden mit Zeichenfolgen nur eines Typs (Zeichenfolge oder Zeichenfolge) ausgeführt. b) Es werden nur sehr einfache und grundlegende Ausdrücke verwendet. Tatsächlich werden inja und Jinja2CppLight mit solchen Einschränkungen implementiert. Bei meinem Jinja2Cpp funktionieren beide Bedingungen nicht. Zunächst habe ich für beide Saitentypen eine transparente Unterstützung festgelegt. Zweitens wurde die gesamte Entwicklung nur gestartet, um die Jinja2-Spezifikation fast vollständig zu unterstützen, und dies ist im Wesentlichen eine vollwertige Skriptsprache. Daher musste ich beim Rendern tiefer graben als beim Parsen.
Expressionsbewertung
Eine Vorlage wäre keine Vorlage, wenn sie nicht parametrisiert werden könnte. Im Prinzip erlaubt Jinja2 die Option, Vorlagen "an sich" zu erstellen - alle erforderlichen Variablen können in der Vorlage selbst festgelegt und dann gerendert werden. Das Arbeiten in einer Vorlage mit Parametern, die "außerhalb" erhalten wurden, bleibt jedoch der Hauptfall. Das Ergebnis der Auswertung eines Ausdrucks hängt also davon ab, welche Variablen (Parameter) mit welchen Werten an den Berechnungspunkten sichtbar sind. Und der Haken ist, dass es in Jinja2 nicht nur Bereiche gibt (die verschachtelt werden können), sondern auch komplizierte Regeln der "Transparenz". Hier ist zum Beispiel eine Vorlage:
{% set param1=10 %} {{ param1 }}
Als Ergebnis seiner Wiedergabe wird Text 10
empfangen
Die Option ist etwas komplizierter:
{% set param1=10 %} {{ param1 }} {% for param1 in range(10) %}-{{ param1 }}-{% endfor %} {{ param1 }}
10-0--1--2--3--4--5--6--7--8--9-10
bereits am 10-0--1--2--3--4--5--6--7--8--9-10
Der Zyklus generiert einen neuen Bereich, in dem Sie Ihre eigenen Variablenparameter definieren können. Diese Parameter sind außerhalb des Bereichs nicht sichtbar, ebenso wie sie nicht die Werte derselben Parameter im externen Bereich schleifen. Noch schwieriger mit Erweiterungs- / Blockkonstrukten, aber es ist besser, dies in der Jinja2-Dokumentation zu lesen.
Somit erscheint der Kontext der Berechnungen. Oder besser gesagt, Rendering im Allgemeinen:
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; };
Von hier aus .
Der Kontext enthält einen Zeiger auf eine Sammlung von Werten, die beim Aufrufen der Renderfunktion erhalten wurden, eine Liste (Stapel) von Bereichen, den aktuell aktiven Bereich und einen Zeiger auf eine Rückrufschnittstelle mit verschiedenen Funktionen, die zum Rendern nützlich sind. Aber etwas später über ihn. Die Parametersuchfunktion führt die Liste der Kontexte nacheinander bis zur externen durch, bis der erforderliche Parameter gefunden wird.
Nun ein wenig zu den Parametern selbst. Aus Sicht der externen Schnittstelle (und ihrer Benutzer) unterstützt Jinja2 die folgende Liste gültiger Typen:
- Zahlen (int, double)
- Saiten (schmal, breit)
- Bool
- Arrays (eher dimensionslose Tupel)
- Wörterbücher
- Reflektierte C ++ - Strukturen
All dies wird durch einen speziellen Datentyp beschrieben, der auf Basis von boost :: variante erstellt wurde:
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; };
Von hier aus .
Natürlich können Elemente von Arrays und Wörterbüchern beliebige Typen sein. Das Problem ist jedoch, dass dieser Satz von Typen für den internen Gebrauch zu eng ist. Um die Implementierung zu vereinfachen, wurden die folgenden zusätzlichen Typen unterstützt:
- Zeichenfolge im Zielformat. Es kann schmal oder breit sein, je nachdem, welche Art von Vorlage gerendert wird.
- aufrufbarer Typ
- AST-Baumassemblierung
- Schlüssel-Wert-Paar
Durch diese Erweiterung wurde es möglich, Servicedaten über den Rendering-Kontext zu übertragen, der sonst in öffentlichen Headern "glänzen" müsste, und einige Algorithmen, die mit Arrays und Wörterbüchern arbeiten, erfolgreicher zu verallgemeinern.
Boost :: Variante wurde nicht zufällig ausgewählt. Seine umfangreichen Funktionen werden verwendet, um mit Parametern bestimmter Typen zu arbeiten. Jinja2CppLight verwendet polymorphe Klassen für denselben Zweck, während inja das Bibliothekstypsystem nlohmann json verwendet. Diese beiden Alternativen passten leider nicht zu mir. Grund: die Möglichkeit des n-ary Dispatchings für boost :: variante (und jetzt - std :: variante). Für einen Variantentyp können Sie einen statischen Besucher erstellen, der zwei bestimmte gespeicherte Typen akzeptiert, und ihn gegen ein Wertepaar setzen. Und alles wird so funktionieren, wie es sollte! Im Fall von polymorphen Klassen oder einfachen Gewerkschaften funktioniert diese Bequemlichkeit nicht:
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; } };
Von hier aus .
Ein solcher Besucher heißt ganz einfach:
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
hier ein Wrapper über boost::apply_visitor
, der den Besucher des durch den Template-Parameter angegebenen Typs auf ein Paar von Variantenwerten anwendet und zuvor bei Bedarf einige Konvertierungen boost::apply_visitor
. Wenn der Designer des Besuchers Parameter benötigt, werden diese nach den Objekten übergeben, für die der Besucher gilt:
comparator = [](const KeyValuePair& left, const KeyValuePair& right) { return ConvertToBool(Apply2<visitors::BinaryMathOperation>(left.value, right.value, BinaryExpression::LogicalLt, BinaryExpression::CaseSensitive)); };
Die Logik von Operationen mit Parametern lautet daher wie folgt: Variante (n) -> Auspacken mit Besucher -> Ausführen der gewünschten Aktion für bestimmte Werte bestimmter Typen -> Zurückpacken des Ergebnisses in die Variante. Und ein Minimum an Magie Undercover. Es wäre in jedem Fall möglich, alles wie in js zu implementieren: Operationen ausführen (z. B. Ergänzungen), ein bestimmtes System zum Konvertieren von Zeichenfolgen in Zahlen, Zahlen in Zeichenfolgen, Zeichenfolgen in Listen usw. auswählen und seltsame und unerwartete Ergebnisse erzielen. Ich habe einen einfacheren und vorhersehbareren Weg gewählt: Wenn eine Operation an einem Wert (oder einem Wertepaar) unmöglich oder unlogisch ist, wird ein leeres Ergebnis zurückgegeben. Wenn Sie einer Zeichenfolge eine Zahl hinzufügen, können Sie daher nur dann eine Zeichenfolge erhalten, wenn die Verkettungsoperation ('~') verwendet wird. Andernfalls ist das Ergebnis ein leerer Wert. Die Priorität von Operationen wird durch die Grammatik bestimmt, daher sind während der AST-Verarbeitung keine zusätzlichen Überprüfungen erforderlich.
Filter und Tests
Was andere Sprachen in Jinja2 als "Standardbibliothek" bezeichnen, nennt man "Filter". Im Wesentlichen ist ein Filter eine Art komplexe Operation für einen Wert links vom Zeichen '|', dessen Ergebnis ein neuer Wert ist. Filter können durch Organisieren einer Pipeline in einer Kette angeordnet werden:
{{ menuItems | selectattr('visible') | map(attribute='title') | map('upper') | join(' -> ') }}
Hier werden nur die Elemente mit dem sichtbaren Attribut true auf das Array menuItems ausgewählt. Anschließend wird das title-Attribut aus diesen Elementen in Großbuchstaben konvertiert und die resultierende Liste der Zeilen mit dem Trennzeichen '->' in eine Zeile eingefügt. Oder sagen wir als Beispiel aus dem Leben:
{% macro MethodsDecl(class, access) %} {% for method in class.methods | rejectattr('isImplicit') | selectattr('accessType', 'in', access) %} {{ method.fullPrototype }}; {% endfor %} {% endmacro %}
Von hier aus .
Alternative Option {% macro MethodsDecl(class, access) %} {{ for method in class.methods | rejectattr('isImplicit') | selectattr('accessType', 'in', access) | map(attribute='fullPrototype') | join(';\n') }}; {% endmacro %}
Dieses Makro durchläuft alle Methoden der angegebenen Klasse, verwirft diejenigen, für die das isImplicit-Attribut auf true festgelegt ist, wählt die verbleibenden Methoden aus, für die der Wert des accessType-Attributs mit einem der angegebenen übereinstimmt, und zeigt deren Prototypen an. Relativ klar. Und es ist alles einfacher als dreistöckige Zyklen und wenn es um Zäune geht. Übrigens kann etwas Ähnliches in C ++ innerhalb der Bereich v.3- Spezifikation durchgeführt werden.
Tatsächlich war der Hauptfehler in der Zeit mit der Implementierung von ungefähr vierzig Filtern verbunden, die ich in den Basissatz aufgenommen habe. Aus irgendeinem Grund nahm ich an, dass ich in ein oder zwei Wochen damit fertig werden könnte. Es war zu optimistisch. Und obwohl die typische Implementierung des Filters recht einfach ist: Nehmen Sie einen Wert und wenden Sie einen Funktor darauf an, es gab zu viele davon, und ich musste basteln.
Eine separate interessante Aufgabe im Implementierungsprozess war die Logik der Argumentverarbeitung. In Jinja2 können wie in Python an den Aufruf übergebene Argumente entweder benannt oder positionell sein. Die Parameter in der Filterdeklaration können entweder obligatorisch oder optional sein (mit Standardwerten). Darüber hinaus können sich optionale Parameter im Gegensatz zu C ++ an einer beliebigen Stelle in der Anzeige befinden. Es war notwendig, einen Algorithmus zum Kombinieren dieser beiden Listen unter Berücksichtigung unterschiedlicher Fälle zu entwickeln. Nehmen wir an, es gibt eine Bereichsfunktion: range([start, ]stop[, step])
. Es kann auf folgende Arten aufgerufen werden:
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)
Usw. Und ich würde es sehr begrüßen, wenn im Code zur Implementierung der Filterfunktion nicht alle diese Fälle berücksichtigt werden müssten. Infolgedessen entschied er sich für die Tatsache, dass im Filtercode, Tester oder Funktionscode die Parameter ausschließlich namentlich angegeben werden. Eine separate Funktion vergleicht die tatsächliche Liste der Argumente mit der erwarteten Liste der Parameter auf dem Weg, indem überprüft wird, ob alle erforderlichen Parameter auf die eine oder andere Weise angegeben sind:
Großes Stück Code 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;
Von hier aus .
Es wird so genannt (zum Beispiel für range
):
bool isArgsParsed = true; auto args = helpers::ParseCallParams({{"start"}, {"stop", true}, {"step"}}, m_params, isArgsParsed); if (!isArgsParsed) return InternalValue();
und gibt die folgende Struktur zurück:
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; } };
das notwendige Argument, von dem es einfach durch seinen Namen genommen wird:
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();
Ein ähnlicher Mechanismus wird bei der Arbeit mit Makros und Testern verwendet. Und obwohl es anscheinend nicht kompliziert ist, die Argumente der einzelnen Filter und Tests zu beschreiben, gibt es keine (wie man sie implementiert), aber selbst die „Grundmenge“, die etwa fünfzig von diesen und anderen umfasste, erwies sich als ziemlich umfangreich für die Implementierung. Und dies vorausgesetzt, dass es nicht alle möglichen kniffligen Dinge enthielt, wie das Formatieren von Zeichenfolgen für HTML (oder C ++), das Ausgeben von Werten in Formaten wie XML oder JSON und dergleichen.
Im nächsten Teil konzentrieren wir uns auf die Implementierung der Arbeit mit mehreren Vorlagen (Export, Include, Makros) sowie auf faszinierende Abenteuer mit der Implementierung der Fehlerbehandlung und die Arbeit mit Zeichenfolgen unterschiedlicher Breite.
Traditionell Links:
Jinja2-Spezifikation
Jinja2Cpp Implementierung