Это вторая часть истории о портировании шаблонного движка Jinja2 на C++. Первую можно почитать здесь: Шаблоны третьего порядка, или как я портировал Jinja2 на C++. В ней речь пойдёт о процессе рендеринга шаблонов. Или, иначе говоря, о написании "с нуля" интерпретатора питоноподобного языка.
Рендеринг как таковой
После парсинга шаблон превращается в дерево, содержащее узлы трёх типов: простой текст, вычисляемые выражения и управляющие конструкции. Соответственно, в процессе рендеринга простой текст должен без каких-либо изменений помещаться в выходной поток, выражения — вычисляться, преобразовываться в текст, который и будет помещён в поток, а управляющие конструкции — исполняться. С первого взгляда ничего сложного в том, чтобы реализовать процесс рендернга, не было: надо просто обойти все узлы дерева, всё вычислить, всё исполнить и сгенерировать текст. Всё просто. Ровно до тех пор, пока соблюдаются два условия: а) вся работа ведётся со строками только одного типа (string или wstring); б) используются только очень простые выражения и базовые. Собственно, именно с такими ограничениями и реализованы inja и Jinja2CppLight. В случае же моей Jinja2Cpp оба условия не срабатывают. Во-первых, мною изначально закладывалась прозрачная поддержка обоих типов строк. Во-вторых, вся разработка затевалась как раз ради поддержки спецификации Jinja2 почти в полном объёме, а это, в сущности, полноценный скриптовый язык. Поэтому с рендерингом пришлось поковыряться больше, чем с парсингом.
Вычисление выражений
Шаблон не был бы шаблоном, если бы его нельзя было параметризовать. В принципе, Jinja2 допускает вариант шаблонов "в себе" — все нужные переменные можно задать внутри самого шаблона, а потом его отрендерить. Но работа в шаблоне с параметрами, полученными "снаружи", остаётся основным кейсом. Таким образом, результат вычисления выражения зависит от того, какие переменные (параметры) с каким значениями видны в точки вычисления. И загвоздка в том, что в Jinja2 есть не просто области видимости (которые могут быть вложенными), но ещё и с непростыми правилами "прозрачности". Например, вот есть шаблон:
{% set param1=10 %}
{{ param1 }}
В результате его рендеринга будет получен текст 10
Вариант чуть сложнее:
{% set param1=10 %}
{{ param1 }}
{% for param1 in range(10) %}-{{ param1 }}-{% endfor %}
{{ param1 }}
Отрендерится уже в 10-0--1--2--3--4--5--6--7--8--9-10
Цикл порождает новый скоуп, в котором можно определить свои параметры-переменные, и эти параметры не будут видны за пределами скоупа, как и не будут перетирать значения одноименных параметров во внешнем. Ещё хитрее с конструкциями extends/block, но об этом уже лучше почитать в документации на Jinja2.
Таким образом, появляется контекст вычислений. А точнее, рендеринга вообще:
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;
};
Контекст содержит в себе указатель на коллекцию значений, полученных при вызове функции рендеринга, список (стек) скоупов, текущий активный скоуп и указатель на callback-интерфейс, с различными полезными для рендеринга функциями. Но о нём чуть позже. Функция поиска параметра последовательно поднимается по списку контекстов вплоть до внешнего, пока не найдёт нужный параметр.
Теперь немного о самих параметрах. С точки зрения внешнего интерфейса (и его пользователей) Jinja2 поддерживает следующий список допустимых типов:
- Числа (int, double)
- Строки (narrow, wide)
- bool
- Массивы (больше похожие на безразмеиные кортежи)
- Словари
- Отрефлексированные C++-структуры
Всё это описывается специальным типом данных, созданным на базе 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;
};
Разумеется, элементы массивов и словарей могут быть любых из перечисленных типов. Но проблема в том, что для внутреннего использования этот набор типов слишком узкий. Для упрощения реализации нужна была поддержка следующих дополнительных типов:
- Строка в целевом формате. Может быть narrow или wide в зависимости от того, какой тип шаблона рендерится.
- callable-тип
- Узел AST-дерева
- Пара "ключ-значение"
Путем такого расширения появилась возможность передавать через контекст рендеринга служебные данные, которые иначе бы пришлось "светить" в публичных заголовках, а также более удачно обощать некоторые алгоритмы, работающие с массивами и словарями.
Boost::variant был выбран не случайно. Его богатые возможности используются для работы с параметрами конкретных типов. В Jinja2CppLight для этих же целей используются полиморфные классы, а в inja — система типов библиотеки nlohmann json. Обе эти альтернативы, увы, мне не подошли. Причина: возможность n-арной диспетчеризации для boost::variant (а теперь — и std::variant). Для вариантного типа можно сделать статический визитор, который принимает два конкретных хранимых типа, и натравить его на пару значений. И все сработает как надо! В случае же с полиморфными классами или простыми union'ами такого удобства не выйдет:
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;
}
};
Вызывается такой визитор предельно просто:
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
здесь — это обёртка над boost::apply_visitor
, которая применяет визитор заданного шаблонном параметром типа к паре вариантных значений, делая предварительно некоторые преобразования, если это нужно. Если конструктору визитора требуются параметры — они передаются после объектов, к которым применяется визитор:
comparator = [](const KeyValuePair& left, const KeyValuePair& right)
{
return ConvertToBool(Apply2<visitors::BinaryMathOperation>(left.value, right.value, BinaryExpression::LogicalLt, BinaryExpression::CaseSensitive));
};
Таким образом, логика выполнения операций с параметрами выходит следующей: variant(ы) -> распаковка с помощью visitor'а -> выполнение нужного действия над конкретными значениями конкретных типов — > упаковка результата обратно в variant. И минимум подковерной магии. Можно было бы реализовать всё как в js: выполнять операции (например, сложения) в любом случае, выбрав некую систему преобразований строк в числа, чисел в строки, строки в списки и т. п. И получать при этом странные и неожиданные результаты. Я выбрал более простой и предсказуемый путь: если операция над значением (или парой значений) невозможна или алогична, то возвращается пустой результат. Поэтому при сложении числа со строкой можно в результате получить строку только в том случае, если используется операция конкатенации ('~'). В противном случае результатом будет пустое значение. Приоритет операций определяется грамматикой, поэтому никаких дополнительных проверок при обработке AST делать уже не нужно.
Фильтры и тесты
То, что в других языках называется "стандартной библиотекой" в Jinja2 называется "фильтрами". В сущности, фильтр — это некая сложная операция над значением, стоящим слева от знака '|', результатом работы которой будет новое значение. Фильтры можно выстраивать в цепочку, организуя пайплайн:
{{ menuItems | selectattr('visible') | map(attribute='title') | map('upper') | join(' -> ') }}
Здесь из массива menuItems будут выбраны только те элементы, у которых атрибут visible выставлен в true, потом у этих элементов будет взят атрибут title, преобразован в верхний регистр, и получившийся список строк будет склеен с разделителем ' -> ' в одну строку. Или, скажем, во пример "из жизни":
{% macro MethodsDecl(class, access) %}
{% for method in class.methods | rejectattr('isImplicit') | selectattr('accessType', 'in', access) %}
{{ method.fullPrototype }};
{% endfor %}
{% endmacro %}
{% macro MethodsDecl(class, access) %}
{{ for method in class.methods | rejectattr('isImplicit') | selectattr('accessType', 'in', access) | map(attribute='fullPrototype') | join(';\n') }};
{% endmacro %}
Этот макрос перебирает все методы заданного класса, отбрасывает те, у которых атрибут isImplicit выставлен в true, из оставшихся выбирает те, у которых значение атрибута accessType совпадает с одним из заданных, и выводит их прототипы. Относительно наглядно. И уж всяко проще, чем трехэтажные циклы и if'ы городить. К слову, что-то похожее в C++ можно делать в рамках спецификации range v.3.
Собственно, основной промах по времени был связан с реализацией около сорока фильтров, которые я включил в базовый набор. С чего-то я взял, что справлюсь с этим за неделю-другую. Это было слишком оптимистично. И хотя типовая реализация фильтра довольно проста: взять значение и применить к нему некоторый функтор, их оказалось слишком много, и пришлось повозиться.
Отдельной интересной задачкой в процессе реализации стала логика обработки аргументов. В Jinja2, как и в питоне, передаваемые в вызов аргументы могут быть как именованные, так и позиционные. А параметры в объявлении фильтра могут быть как обязательные, так и опциональные (со значениями по-умолчанию). Причём, в отличие от C++, опциональные параметры могут находиться в любом месте объявления. Надо было придумать алгоритм совмещения этих двух списков с учётом разных кейсов. Вот, скажем, есть функция range: range([start, ]stop[, step])
. Она может быть вызвана следующими способами:
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)
И так далее. И очень бы хотелось, чтобы в коде реализации функции фильтра не нужно было все эти кейсы учитывать. В итоге остановился на том, что в коде фильтра, тестера или функции параметры получаются строго по имени. И отдельная функция сопоставляет фактический список аргументов с ожидаемым списком параметров попутно проверяя, что все обязательные параметры тем или иным образом заданы:
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;
}
Вызывается он таким вот образом (для, скажем, range
):
bool isArgsParsed = true;
auto args = helpers::ParseCallParams({{"start"}, {"stop", true}, {"step"}}, m_params, isArgsParsed);
if (!isArgsParsed)
return InternalValue();
и возвращает следующую структуру:
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;
}
};
нужный аргумент из которой забирается просто по своему имени:
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();
Аналогичный механизм применяется при работе с макросами и тестерами. И хотя, вроде бы, ничего сложного в том, чтобы описать аргументы каждого фильтра и теста, нет (как и реализовать его), но даже "базовый" набор, в который вошло примерно пятьдесят тех и других, оказался достаточно объёмным для реализации. И это при том условии, что в него не вошли всякие хитрые штуки, как форматирование строк под HTML (или C++), вывод значений в форматах типа xml или json, и тому подобные вещи.
В следующей части речь пойдёт о реализации работы с несколькими шаблонами (export, include, макросы), а также про увлекательные приключения с реализацией обработки ошибок и работе со строками разной ширины.
Традиционно, ссылки:
kovserg
Я бы использовл для такого шаблонизатора какой-нибудь lua. Потому как генерить boilerplate-код надо из каки-то входных данных, которые тоже надо парсить. И если это сделано на скриптовом языке это можно менять по ходу ничего не пересобирая. Да и в отличии от питона lua гораздо проще и имеет меньшие накладные расходы всего 250кб.
x=10
y=<&>
FlexFerrum Автор
Тут как бы дело в том, что в качестве парсера «входных данных» у меня clang frontend полным ростом. Можно было бы, конечно, сделать биндинг из него в питон или в Lua… Но таки хотелось, чтобы шаблоны выглядели шаблонами, а не программами на питоне/Lua. :)
kovserg
Уважаю. У меня бы терпения не хватило. И процентах в 99 обычных скриптов хватает.