Ini adalah bagian kedua dari cerita tentang porting mesin template Jinja2 ke C ++. Anda dapat membaca yang pertama di sini: Templat urutan ketiga, atau bagaimana saya mengirim Jinja2 ke C ++ . Ini akan fokus pada proses rendering template. Atau, dengan kata lain, tentang menulis dari awal, juru bahasa seperti python.
Rendering seperti itu
Setelah parsing, templat berubah menjadi pohon yang berisi simpul dari tiga jenis: teks biasa , ekspresi yang dihitung, dan struktur kontrol . Dengan demikian, selama proses rendering, teks biasa harus ditempatkan tanpa perubahan apa pun dalam aliran output, ekspresi harus dihitung, dikonversi menjadi teks, yang akan ditempatkan dalam aliran, dan struktur kontrol harus dijalankan. Pada pandangan pertama, tidak ada yang sulit dalam mengimplementasikan proses rendering: Anda hanya perlu berkeliling semua node pohon, menghitung semuanya, menjalankan semuanya dan menghasilkan teks. Semuanya sederhana. Persis selama dua kondisi terpenuhi: a) semua pekerjaan dilakukan dengan string hanya satu jenis (string atau wstring); b) hanya ekspresi sederhana dan dasar yang digunakan. Sebenarnya, dengan pembatasan seperti itulah inja dan Jinja2CppLight diimplementasikan. Dalam kasus Jinja2Cpp saya , kedua kondisi tidak berfungsi. Pertama, saya awalnya memberikan dukungan transparan untuk kedua jenis string. Kedua, seluruh pengembangan dimulai hanya untuk mendukung spesifikasi Jinja2 hampir secara penuh, dan ini, pada dasarnya, adalah bahasa scripting yang lengkap. Karena itu, saya harus menggali lebih dalam dengan rendering daripada dengan parsing.
Evaluasi Ekspresi
Templat tidak akan menjadi templat jika tidak bisa diparameterisasi. Pada prinsipnya, Jinja2 memungkinkan opsi templat "dengan sendirinya" - semua variabel yang diperlukan dapat diatur di dalam templat itu sendiri, dan kemudian merendernya. Tetapi bekerja dalam templat dengan parameter yang diperoleh "di luar" tetap merupakan kasus utama. Dengan demikian, hasil evaluasi ekspresi tergantung pada variabel (parameter) mana yang nilainya terlihat pada titik perhitungan. Dan hasilnya adalah bahwa di Jinja2 tidak hanya ruang lingkup (yang dapat disarangkan), tetapi juga dengan aturan rumit "transparansi". Sebagai contoh, ini adalah templat:
{% set param1=10 %} {{ param1 }}
Sebagai hasil rendernya, teks 10
akan diterima
Opsi ini sedikit lebih rumit:
{% set param1=10 %} {{ param1 }} {% for param1 in range(10) %}-{{ param1 }}-{% endfor %} {{ param1 }}
Memberikan sedini 10-0--1--2--3--4--5--6--7--8--9-10
Siklus menghasilkan lingkup baru di mana Anda dapat menentukan parameter variabel Anda sendiri, dan parameter ini tidak akan terlihat di luar lingkup, sama seperti mereka tidak akan menggiling nilai-nilai parameter yang sama di eksternal. Bahkan lebih rumit dengan ekstensi / blok konstruksi, tetapi lebih baik untuk membaca tentang ini di dokumentasi Jinja2.
Dengan demikian, konteks perhitungan muncul. Atau lebih tepatnya, rendering secara umum:
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; };
Dari sini
Konteksnya berisi pointer ke kumpulan nilai yang diperoleh ketika fungsi rendering dipanggil, daftar (tumpukan) lingkup, lingkup aktif saat ini, dan pointer ke antarmuka panggilan balik, dengan berbagai fungsi yang berguna untuk rendering. Tetapi tentang dia sedikit kemudian. Fungsi pencarian parameter secara berurutan naik daftar konteks ke yang eksternal sampai menemukan parameter yang diperlukan.
Sekarang sedikit tentang parameternya sendiri. Dari sudut pandang antarmuka eksternal (dan penggunanya), Jinja2 mendukung daftar tipe yang valid berikut ini:
- Bilangan (int, dobel)
- String (sempit, lebar)
- bool
- Array (lebih seperti tuple tanpa dimensi)
- Kamus
- Struktur C ++ yang Tercermin
Semua ini dijelaskan oleh tipe data khusus yang dibuat berdasarkan boost :: varian:
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; };
Dari sini
Tentu saja, elemen array dan kamus dapat berupa tipe yang terdaftar. Tetapi masalahnya adalah untuk penggunaan internal, rangkaian tipe ini terlalu sempit. Untuk menyederhanakan implementasi, diperlukan dukungan untuk jenis tambahan berikut:
- String dalam format target. Itu bisa sempit atau lebar tergantung pada jenis template apa yang diberikan.
- jenis yang bisa dipanggil
- Perakitan pohon AST
- Pasangan nilai kunci
Melalui perluasan ini, menjadi mungkin untuk mentransfer data layanan melalui konteks render, yang sebaliknya harus "disinari" di header publik, serta lebih berhasil menggeneralisasi beberapa algoritma yang bekerja dengan array dan kamus.
Boost :: varian tidak dipilih secara kebetulan. Kemampuannya yang kaya digunakan untuk bekerja dengan parameter tipe tertentu. Jinja2CppLight menggunakan kelas polimorfik untuk tujuan yang sama, sementara inja menggunakan sistem tipe perpustakaan nlohmann json. Kedua alternatif ini, sayangnya, tidak cocok untuk saya. Alasan: kemungkinan pengiriman n-ary untuk boost :: varian (dan sekarang - std :: varian). Untuk jenis varian, Anda dapat membuat pengunjung statis yang menerima dua jenis tersimpan tertentu dan mengaturnya terhadap sepasang nilai. Dan semuanya akan bekerja sebagaimana mestinya! Dalam kasus kelas polimorfik atau serikat sederhana, kemudahan ini tidak akan berfungsi:
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; } };
Dari sini
Pengunjung seperti itu disebut sangat sederhana:
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
sini adalah wrapper over boost::apply_visitor
, yang menerapkan pengunjung tipe yang ditentukan oleh parameter template ke sepasang nilai varian, yang sebelumnya membuat beberapa konversi jika perlu. Jika perancang pengunjung membutuhkan parameter, mereka akan diteruskan setelah objek yang pengunjung terapkan:
comparator = [](const KeyValuePair& left, const KeyValuePair& right) { return ConvertToBool(Apply2<visitors::BinaryMathOperation>(left.value, right.value, BinaryExpression::LogicalLt, BinaryExpression::CaseSensitive)); };
Dengan demikian, logika operasi dengan parameter keluar sebagai berikut: varian (s) -> membongkar menggunakan pengunjung -> melakukan tindakan yang diinginkan pada nilai spesifik jenis tertentu -> mengemas hasilnya kembali ke varian. Dan penyamaran sihir minimal. Mungkin saja untuk mengimplementasikan semuanya seperti pada js: melakukan operasi (misalnya, penambahan) dalam hal apa pun, memilih sistem tertentu untuk mengubah string menjadi angka, angka menjadi string, string ke daftar, dll. Dan mendapatkan hasil yang aneh dan tidak terduga. Saya memilih cara yang lebih sederhana dan lebih dapat diprediksi: jika operasi pada nilai (atau sepasang nilai) tidak mungkin atau tidak logis, maka hasil kosong dikembalikan. Oleh karena itu, ketika menambahkan angka ke string, Anda bisa mendapatkan string sebagai hasilnya hanya jika operasi penggabungan ('~') digunakan. Jika tidak, hasilnya akan menjadi nilai kosong. Prioritas operasi ditentukan oleh tata bahasa, oleh karena itu, tidak ada pemeriksaan tambahan yang diperlukan selama pemrosesan AST.
Filter dan Tes
Apa bahasa lain memanggil "perpustakaan standar" di Jinja2 disebut "filter." Intinya, filter adalah semacam operasi kompleks pada nilai di sebelah kiri tanda '|', yang hasilnya akan menjadi nilai baru. Filter dapat diatur dalam rantai dengan mengatur saluran pipa:
{{ menuItems | selectattr('visible') | map(attribute='title') | map('upper') | join(' -> ') }}
Di sini, hanya elemen-elemen dengan atribut terlihat diatur ke true akan dipilih dari array menuItems, maka atribut judul akan diambil dari elemen-elemen ini, dikonversi ke huruf besar, dan daftar garis yang dihasilkan akan direkatkan dengan pemisah '->' menjadi satu baris. Atau, katakanlah, sebagai contoh dari kehidupan:
{% macro MethodsDecl(class, access) %} {% for method in class.methods | rejectattr('isImplicit') | selectattr('accessType', 'in', access) %} {{ method.fullPrototype }}; {% endfor %} {% endmacro %}
Dari sini
Opsi alternatif {% macro MethodsDecl(class, access) %} {{ for method in class.methods | rejectattr('isImplicit') | selectattr('accessType', 'in', access) | map(attribute='fullPrototype') | join(';\n') }}; {% endmacro %}
Makro ini mengulangi semua metode dari kelas yang diberikan, membuang yang atribut isImplicit disetel ke true, memilih yang tersisa yang nilai atribut accessType cocok dengan salah satu yang diberikan, dan menampilkan prototipe mereka. Cukup jelas. Dan semuanya lebih mudah daripada siklus tiga lantai dan jika perlu dipagari. By the way, sesuatu yang mirip di C ++ dapat dilakukan dalam spesifikasi kisaran v.3 .
Sebenarnya, kesalahan utama dalam waktu dikaitkan dengan penerapan sekitar empat puluh filter, yang saya sertakan dalam set dasar. Untuk beberapa alasan saya mengambil bahwa saya bisa menanganinya dalam satu atau dua minggu. Itu terlalu optimis. Dan walaupun implementasi tipikal dari filter ini cukup sederhana: ambil nilai dan terapkan beberapa functor padanya, ada terlalu banyak, dan saya harus mengotak-atik.
Tugas menarik yang terpisah dalam proses implementasi adalah logika pemrosesan argumen. Dalam Jinja2, seperti dalam python, argumen yang diteruskan ke panggilan dapat berupa nama atau posisi. Dan parameter dalam deklarasi filter dapat berupa wajib atau opsional (dengan nilai default). Selain itu, tidak seperti C ++, parameter opsional dapat ditemukan di mana saja di iklan. Itu perlu untuk menghasilkan suatu algoritma untuk menggabungkan kedua daftar ini, dengan mempertimbangkan kasus-kasus yang berbeda. Di sini, katakanlah, ada fungsi range([start, ]stop[, step])
: range([start, ]stop[, step])
. Ini dapat dipanggil dengan cara berikut:
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)
Dan sebagainya. Dan saya sangat ingin itu dalam kode untuk menerapkan fungsi filter itu tidak perlu untuk mempertimbangkan semua kasus ini. Akibatnya, ia menentukan fakta bahwa dalam kode filter, tester, atau kode fungsi, parameter diperoleh secara ketat berdasarkan nama. Dan fungsi terpisah membandingkan daftar argumen aktual dengan daftar parameter yang diharapkan di sepanjang jalan dengan memeriksa bahwa semua parameter yang diperlukan diberikan dengan satu atau lain cara:
Sepotong besar kode 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;
Dari sini
Ini disebut cara ini (untuk, katakanlah, range
):
bool isArgsParsed = true; auto args = helpers::ParseCallParams({{"start"}, {"stop", true}, {"step"}}, m_params, isArgsParsed); if (!isArgsParsed) return InternalValue();
dan mengembalikan struktur berikut:
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; } };
argumen yang diperlukan dari mana ia diambil hanya dengan namanya:
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();
Mekanisme serupa digunakan ketika bekerja dengan makro dan penguji. Dan meskipun tampaknya tidak ada yang rumit dalam menggambarkan argumen dari setiap filter dan pengujian, tidak ada (bagaimana mengimplementasikannya), tetapi bahkan set "dasar", yang mencakup sekitar lima puluh dari mereka dan yang lainnya, ternyata cukup produktif untuk implementasi. Dan ini asalkan itu tidak termasuk segala macam hal yang rumit, seperti memformat string untuk HTML (atau C ++), menghasilkan nilai dalam format seperti xml atau json, dan sejenisnya.
Pada bagian selanjutnya, kita akan fokus pada implementasi pekerjaan dengan beberapa templat (ekspor, termasuk, makro), serta pada petualangan yang mempesona dengan implementasi penanganan kesalahan dan bekerja dengan string dengan lebar yang berbeda.
Secara tradisional, tautan:
Spesifikasi Jinja2
Implementasi Jinja2Cpp