Serialisasi dalam C ++

Artikel ini akan fokus pada mengotomatiskan proses serialisasi di C ++. Pada awalnya, kami akan mempertimbangkan mekanisme dasar yang membuatnya lebih mudah untuk membaca / menulis data ke input / output stream, setelah itu deskripsi sistem pembuatan kode primitif berbasis libclang akan diberikan. Tautan ke repositori dengan versi demo perpustakaan terletak di akhir artikel.

Di ruSO, pertanyaan muncul secara berkala mengenai serialisasi data dalam C ++, kadang-kadang pertanyaan ini bersifat umum, ketika TC pada dasarnya tidak tahu harus mulai dari mana, kadang-kadang ini adalah pertanyaan yang menjelaskan masalah tertentu. Tujuan artikel ini adalah untuk merangkum salah satu cara yang mungkin untuk mengimplementasikan serialisasi dalam C ++, yang akan memungkinkan Anda untuk mengikuti langkah-langkah membangun sistem dari langkah awal hingga beberapa kesimpulan logis, ketika sistem ini sudah dapat digunakan dalam praktik.

1. Informasi Awal


Artikel ini akan menggunakan format data biner, struktur yang ditentukan berdasarkan jenis objek serial. Pendekatan ini menyelamatkan kita dari penggunaan pustaka pihak ketiga, membatasi diri kita hanya pada alat yang disediakan oleh pustaka C ++ standar.

Karena proses serialisasi terdiri dalam mengubah keadaan suatu objek menjadi aliran byte, yang jelas harus disertai dengan operasi tulis, yang terakhir akan digunakan sebagai pengganti istilah "serialisasi" ketika menggambarkan detail tingkat rendah. Demikian pula untuk baca / deserialize.

Untuk mengurangi volume artikel, hanya contoh serialisasi objek yang akan diberikan (kecuali dalam kasus di mana deserialisasi mengandung beberapa detail yang layak disebutkan). Kode lengkap dapat ditemukan di repositori di atas.

2. Jenis yang didukung


Pertama-tama, ada baiknya memutuskan jenis yang akan kami dukung - ini secara langsung tergantung pada bagaimana perpustakaan akan dilaksanakan.

Misalnya, jika pilihan terbatas pada tipe dasar C ++, maka templat fungsi (yang merupakan kumpulan fungsi untuk bekerja dengan nilai-nilai tipe integer) dan spesialisasi eksplisitnya sudah cukup. Template primer (digunakan untuk tipe std :: int32_t, std :: uint16_t, dll.):

template<typename T> auto write(std::ostream& os, T value) -> std::size_t { const auto pos = os.tellp(); os.write(reinterpret_cast<const char*>(&value), sizeof(value)); return static_cast<std::size_t>(os.tellp() - pos); } 

Catatan : jika data yang diperoleh selama serialisasi direncanakan akan ditransfer antara mesin dengan pesanan byte yang berbeda, maka perlu, misalnya, untuk mengubah nilai dari pesanan byte lokal ke byte jaringan, dan kemudian melakukan operasi terbalik pada mesin jarak jauh, sehingga membuat perubahan akan diperlukan seperti untuk fungsi tulis data ke aliran output, dan untuk fungsi membaca dari aliran input.

Spesialisasi untuk bool:

 constexpr auto t_value = static_cast<std::uint8_t>('T'); constexpr auto f_value = static_cast<std::uint8_t>('F'); template<> auto write(std::ostream& os, bool value) -> std::size_t { const auto pos = os.tellp(); const auto tmp = (value) ? t_value : f_value; os.write(reinterpret_cast<const char*>(&tmp), sizeof(tmp)); return static_cast<std::size_t>(os.tellp() - pos); } 

Pendekatan ini mendefinisikan aturan berikut: jika nilai tipe T dapat direpresentasikan sebagai urutan byte ukuran panjang (T), definisi templat utama dapat digunakan untuk itu, jika tidak, maka perlu untuk menentukan spesialisasi. Persyaratan ini dapat ditentukan oleh fitur representasi objek bertipe T dalam memori.

Pertimbangkan wadah std :: string: jelas bahwa kita tidak dapat mengambil alamat objek dengan tipe yang ditentukan, melemparkannya ke pointer ke char dan menulisnya ke aliran output - itu artinya kita memerlukan spesialisasi:

 template<> auto write(std::ostream& os, const std::string& value) -> std::size_t { const auto pos = os.tellp(); const auto len = static_cast<std::uint32_t>(value.size()); os.write(reinterpret_cast<const char*>(&len), sizeof(len)); if (len > 0) os.write(value.data(), len); return static_cast<std::size_t>(os.tellp() - pos); } 

Dua hal penting yang ingin disampaikan di sini:

  1. Tidak hanya isi string ditulis ke aliran output, tetapi juga ukurannya.
  2. Cast std :: string :: size_type untuk mengetik std :: uint32_t. Dalam hal ini, perlu memperhatikan bukan pada ukuran jenis target, tetapi pada fakta bahwa itu adalah panjang tetap. Pengurangan seperti itu akan memungkinkan menghindari masalah dalam kasus ini, misalnya, jika data ditransmisikan melalui jaringan antara mesin dengan ukuran kata mesin yang berbeda.

Jadi, kami menemukan bahwa nilai tipe dasar (dan bahkan objek tipe std :: string) dapat ditulis ke aliran output menggunakan templat fungsi tulis . Sekarang mari kita menganalisis perubahan apa yang perlu kita lakukan jika kita ingin menambahkan kontainer ke daftar jenis yang didukung. Kami hanya memiliki satu opsi untuk kelebihan beban - gunakan parameter T sebagai jenis elemen wadah. Dan jika dalam kasus std :: vector ini akan berfungsi:

 template<typename T> auto write(std::ostream& os, const std::vector<T>& value) -> std::size_t { const auto pos = os.tellp(); const auto len = static_cast<std::uint16_t>(value.size()); os.write(reinterpret_cast<const char*>(&len), sizeof(len)); auto size = static_cast<std::size_t>(os.tellp() - pos); if (len > 0) { std::for_each(value.cbegin(), value.cend(), [&](const auto& e) { size += ::write(os, e); }); } return size; } 

, kemudian dengan std: map - no, karena std :: map templat membutuhkan setidaknya dua parameter - tipe kunci dan tipe nilai. Jadi, pada tahap ini, kita tidak bisa lagi menggunakan templat fungsi - kita membutuhkan solusi yang lebih universal. Sebelum kita mengetahui cara menambahkan dukungan kontainer, mari kita ingat bahwa kita masih memiliki kelas khusus. Jelas, bahkan menggunakan solusi saat ini, tidak bijaksana untuk membebani fungsi tulis untuk setiap kelas yang memerlukan serialisasi. Dalam kasus terbaik, kami ingin memiliki satu spesialisasi pola tulis yang berfungsi dengan tipe data khusus. Tetapi untuk ini perlu bahwa kelas memiliki kemampuan untuk secara mandiri mengontrol serialisasi, masing-masing, mereka harus memiliki antarmuka yang akan memungkinkan pengguna untuk membuat cerita bersambung dan deserialize objek kelas ini. Ternyata beberapa saat kemudian, antarmuka ini akan berfungsi sebagai "common denominator" untuk template tulis saat bekerja dengan kelas kustom. Mari kita definisikan.

 class ISerializable { protected: ~ISerializable() = default; public: virtual auto serialize(std::ostream& os) const -> std::size_t = 0; virtual auto deserialize(std::istream& is) -> std::size_t = 0; virtual auto serialized_size() const noexcept -> std::size_t = 0; }; 

Setiap kelas yang mewarisi dari ISerializable setuju untuk:

  1. Override serialize - status penulisan (anggota data) ke aliran output.
  2. Override deserialize - Baca status (inisialisasi anggota data) dari input stream.
  3. Override serialized_size - menghitung ukuran data serial untuk keadaan objek saat ini.

Jadi, kembali ke templat fungsi tulis : secara umum, kita dapat menerapkan spesialisasi untuk kelas ISerializable , tetapi kita tidak dapat menggunakannya, lihat:

 template<> auto write(std::ostream& os, const ISerializable& value) -> std::size_t { return value.serialize(os); } 

Setiap kali, kita harus melemparkan tipe pewaris ke ISerializable untuk mengambil keuntungan dari spesialisasi ini. Biarkan saya mengingatkan Anda bahwa pada awalnya kami menetapkan sebagai tujuan kami untuk menyederhanakan penulisan kode yang terkait dengan serialisasi, dan bukan sebaliknya, untuk menyulitkannya. Jadi, jika tipe yang didukung oleh perpustakaan kami tidak terbatas pada tipe fundamental, maka kita harus mencari solusi lain.

3. stream_writer


Menggunakan templat fungsi untuk mengimplementasikan antarmuka universal untuk menulis data ke stream bukanlah solusi yang sepenuhnya cocok. Opsi selanjutnya yang harus kita periksa adalah templat kelas. Kami akan mengikuti metodologi yang sama seperti yang digunakan dengan templat fungsi - templat utama akan digunakan secara default, dan spesialisasi eksplisit akan ditambahkan untuk mendukung jenis yang diperlukan.

Selain itu, kita harus memperhitungkan semua hal di atas tentang ISerializable - jelas, kita tidak akan dapat menyelesaikan masalah dengan banyak kelas penerus tanpa beralih ke type_traits: dimulai dengan C ++ 11, template std :: enable_if telah muncul di perpustakaan standar, yang memungkinkan mengabaikan kelas template ketika kondisi tertentu selama kompilasi - dan inilah yang akan kita manfaatkan.

Template kelas Stream_writer :

 template<typename T, typename U = void> class stream_writer { public: static auto write(std::ostream& os, const T& value) -> std::size_t; }; 

Definisi metode penulisan :

 template<typename T, typename U> auto stream_writer<T, U>::write(std::ostream& os, const T& value) -> std::size_t { const auto pos = os.tellp(); os.write(reinterpret_cast<const char*>(&value), sizeof(value)); return static_cast<std::size_t>(os.tellp() - pos); } 

Spesialisasi untuk ISerializable adalah sebagai berikut:

 template<typename T> class stream_writer<T, only_if_serializable<T>> : public stream_io<T> { public: static auto write(std::ostream& os, const T& value) -> std::size_t; }; 

di mana only_if_serializable adalah tipe pembantu:

 template<typename T> using only_if_serializable = std::enable_if_t<std::is_base_of_v<ISerializable, T>>; 

Jadi, jika tipe T adalah kelas yang berasal dari ISerializable , maka spesialisasi ini akan dianggap sebagai kandidat untuk instantiasi, masing-masing, jika tipe T tidak dalam hierarki kelas yang sama dengan ISerializable , maka akan dikeluarkan dari calon yang mungkin.

Akan adil untuk mengajukan pertanyaan berikut di sini: bagaimana cara kerjanya? Setelah semua, templat utama akan memiliki nilai parameter khas yang sama dengan spesialisasi - <T, void>. Mengapa spesialisasi akan diberikan preferensi, dan apakah itu akan menjadi? Jawab: akan, karena perilaku tersebut ditentukan oleh standar ( sumber ):

(1.1) Jika tepat satu spesialisasi yang cocok ditemukan, instantiasi dihasilkan dari spesialisasi itu

Spesialisasi untuk std :: string sekarang akan terlihat seperti ini:

 template<typename T> class stream_writer<T, only_if_string<T>> { public: static auto write(std::ostream& os, const T& value) -> std::size_t; }; template<typename T> auto stream_writer<T, only_if_string<T>>::write(std::ostream& os, const T& value) -> std::size_t { const auto pos = os.tellp(); const auto len = static_cast<std::uint32_t>(value.size()); os.write(reinterpret_cast<const char*>(&len), sizeof(len)); if (len > 0) os.write(value.data(), len); return static_cast<std::size_t>(os.tellp() - pos); } 

di mana only_if_string dinyatakan sebagai:

 template<typename T> using only_if_string = std::enable_if_t<std::is_same_v<T, std::string>>; 

Sudah waktunya untuk kembali ke wadah. Dalam kasus ini, kita dapat menggunakan tipe wadah parameter dengan beberapa tipe U, atau <U, V>, seperti dalam kasus std :: map, langsung sebagai nilai parameter T dari templat kelas stream_writer . Dengan demikian, tidak ada perubahan pada antarmuka di antarmuka kami - inilah yang kami tuju. Namun, muncul pertanyaan, apa yang harus menjadi parameter kedua dari template untuk kelas stream_writer sehingga semuanya berfungsi dengan benar? Ini ada di bab selanjutnya.

4. Konsep


Pertama, saya akan memberikan deskripsi singkat tentang konsep-konsep yang digunakan, dan hanya kemudian saya akan menunjukkan contoh yang diperbarui.

 template<typename T> concept String = std::is_same_v<T, std::string>; 

Sejujurnya, konsep ini didefinisikan untuk penipuan, yang akan kita lihat pada baris berikutnya:

 template<typename T> concept Container = !String<T> && requires (T a) { typename T::value_type; typename T::reference; typename T::const_reference; typename T::iterator; typename T::const_iterator; typename T::size_type; { a.begin() } -> typename T::iterator; { a.end() } -> typename T::iterator; { a.cbegin() } -> typename T::const_iterator; { a.cend() } -> typename T::const_iterator; { a.clear() } -> void; }; 

Wadah berisi persyaratan yang kami β€œbuat” untuk jenis untuk benar-benar memastikan bahwa itu adalah salah satu jenis wadah. Ini persis seperangkat persyaratan yang akan kita butuhkan ketika menerapkan stream_writer , standar memiliki lebih banyak persyaratan, tentu saja.

 template<typename T> concept SequenceContainer = Container<T> && requires (T a, typename T::size_type count) { { a.resize(count) } -> void; }; 

Konsep untuk wadah berurutan: std :: vector, std :: list, dll.

 template<typename T> concept AssociativeContainer = Container<T> && requires (T a) { typename T::key_type; }; 

Konsep untuk wadah asosiatif: std :: map, std :: set, std :: unordered_map, dll.

Sekarang, untuk menentukan spesialisasi untuk peti kemas berturut-turut, yang tersisa untuk kita lakukan adalah memberlakukan batasan pada tipe T:

 template<typename T> requires SequenceContainer<T> class stream_writer<T, void> { public: static auto write(std::ostream& os, const T& value) -> std::size_t; }; template<typename T> requires SequenceContainer<T> auto stream_writer<T, void>::write(std::ostream& os, const T& value) -> std::size_t { const auto pos = os.tellp(); // to support std::forward_list we have to use std::distance() const auto len = static_cast<std::uint16_t>( std::distance(value.cbegin(), value.cend())); os.write(reinterpret_cast<const char*>(&len), sizeof(len)); auto size = static_cast<std::size_t>(os.tellp() - pos); if (len > 0) { using value_t = typename stream_writer::value_type; std::for_each(value.cbegin(), value.cend(), [&](const auto& item) { size += stream_writer<value_t>::write(os, item); }); } return size; } 

Kontainer yang didukung:

  • std :: vektor
  • std :: deque
  • std :: daftar
  • std :: forward_list

Demikian pula untuk wadah asosiatif:

 template<typename T> requires AssociativeContainer<T> class stream_writer<T, void> : public stream_io<T> { public: static auto write(std::ostream& os, const T& value) -> std::size_t; }; template<typename T> requires AssociativeContainer<T> auto stream_writer<T, void>::write(std::ostream& os, const T& value) -> std::size_t { const auto pos = os.tellp(); const auto len = static_cast<typename stream_writer::size_type>(value.size()); os.write(reinterpret_cast<const char*>(&len), sizeof(len)); auto size = static_cast<std::size_t>(os.tellp() - pos); if (len > 0) { using value_t = typename stream_writer::value_type; std::for_each(value.cbegin(), value.cend(), [&](const auto& item) { size += stream_writer<value_t>::write(os, item); }); } return size; } 

Kontainer yang didukung:

  • std :: map
  • std :: unordered_map
  • std :: set
  • std :: unordered_set

Dalam hal peta, ada sedikit nuansa, ini menyangkut implementasi stream_reader . Value_type untuk std :: map <K, T> adalah std :: pair <const K, T>, masing-masing, ketika kita mencoba untuk melemparkan sebuah pointer ke const K ke sebuah pointer ke char saat membaca dari aliran input, kita mendapatkan kesalahan kompilasi. Kita dapat memecahkan masalah ini sebagai berikut: kita tahu bahwa untuk wadah asosiatif value_type adalah tipe tunggal K atau std :: pair <const K, V>, maka kita dapat menulis kelas pembantu template kecil yang akan di-parameterisasi oleh value_type dan di dalamnya tentukan jenis yang kita butuhkan.

Untuk std :: set, semuanya tetap tidak berubah:

 template<typename U, typename V = void> struct converter { using type = U; }; 

Untuk std :: map - hapus const:

 template<typename U> struct converter<U, only_if_pair<U>> { using type = std::pair<std::remove_const_t<typename U::first_type>, typename U::second_type>; }; 

Definisi baca untuk wadah asosiatif:

 template<typename T> requires AssociativeContainer<T> auto stream_reader<T, void>::read(std::istream& is, T& value) -> std::size_t { const auto pos = is.tellg(); typename stream_reader::size_type len = 0; is.read(reinterpret_cast<char*>(&len), sizeof(len)); auto size = static_cast<std::size_t>(is.tellg() - pos); if (len > 0) { for (auto i = 0U; i < len; ++i) { using value_t = typename converter<typename stream_reader::value_type>::type; value_t v {}; size += stream_reader<value_t>::read(is, v); value.insert(std::move(v)); } } return size; } 


5. Fungsi bantu


Pertimbangkan sebuah contoh:

 class User : public ISerializable { public: User(std::string_view username, std::string_view password) : m_username(username) , m_password(password) {} SERIALIZABLE_INTERFACE protected: std::string m_username {}; std::string m_password {}; }; 

Definisi metode serialisasi (std :: ostream &) untuk kelas ini akan terlihat seperti ini:

 auto User::serialize(std::ostream& os) const -> std::size_t { auto size = 0U; size += stream_writer<std::string>::write(os, m_username); size += stream_writer<std::string>::write(os, m_password); return size; } 

Namun, Anda harus mengakui bahwa merepotkan untuk menunjukkan setiap kali jenis objek yang ditulis ke aliran output. Kami menulis fungsi bantu yang akan secara otomatis menyimpulkan tipe T:

 template<typename T> auto write(std::ostream& os, const T& value) -> std::size_t { return stream_writer<T>::write(os, value); } 

Sekarang definisinya adalah sebagai berikut:

 auto User::serialize(std::ostream& os) const -> std::size_t { auto size = 0U; size += ::write(os, m_username); size += ::write(os, m_password); return size; } 

Bab terakhir akan membutuhkan beberapa fungsi pembantu:

 template<typename T> auto write_recursive(std::ostream& os, const T& value) -> std::size_t { return ::write(os, value); } template<typename T, typename... Ts> auto write_recursive(std::ostream& os, const T& value, const Ts&... values) { auto size = write_recursive(os, value); return size + write_recursive(os, values...); } template<typename... Ts> auto write_all(std::ostream& os, const Ts&... values) -> std::size_t { return write_recursive(os, values...); } 

Fungsi write_all memungkinkan Anda untuk membuat daftar semua objek yang akan diserialisasi sekaligus, sementara write_recursive memastikan urutan penulisan yang benar untuk aliran output. Jika urutan perhitungan didefinisikan untuk ekspresi lipatan (asalkan kita menggunakan operator biner +), kita bisa menggunakannya. Secara khusus, dalam fungsi size_of_all (tidak disebutkan sebelumnya, digunakan untuk menghitung ukuran data serial), itu adalah ekspresi-lipat yang digunakan karena tidak adanya operasi input-output.

6. Pembuatan Kode


API libclang - C untuk dentang digunakan untuk menghasilkan kode. Tugas tingkat tinggi ini dapat digambarkan sebagai berikut: kita perlu secara rekursif berkeliling direktori dengan kode sumber, memeriksa semua file header untuk kelas yang ditandai dengan atribut khusus, dan jika ada, periksa anggota data untuk atribut yang sama dan kompilasi serangkaian nama anggota data terdaftar dengan koma. Yang tersisa untuk kita lakukan adalah menulis templat definisi untuk fungsi-fungsi kelas ISerializable (di mana kita hanya bisa memasukkan penghitungan anggota data yang diperlukan).

Contoh kelas di mana kode akan dihasilkan:

 class __attribute__((annotate("serializable"))) User : public ISerializable { public: User(std::string_view username, std::string_view password) : m_username(username) , m_password(password) {} User() = default; virtual ~User() = default; SERIALIZABLE_INTERFACE protected: __attribute__((annotate("serializable"))) std::string m_username {}; __attribute__((annotate("serializable"))) std::string m_password {}; }; 

Atribut ditulis dalam gaya GNU karena libclang menolak untuk mengenali format atribut dari C ++ 20, dan atribut itu juga tidak mendukung atribut yang tidak dianotasi. Sumber Direktori Traversal:

 for (const auto& file : fs::recursive_directory_iterator(argv[1])) { if (file.is_regular_file() && file.path().extension() == ".hpp") { processTranslationUnit(file, dst); } } 

Definisi fungsi processTranslationUnit :

 auto processTranslationUnit(const fs::path& path, const fs::path& targetDir) -> void { const auto pathname = path.string(); arg::Context context { false, false }; auto translationUnit = arg::TranslationUnit::parse(context, pathname.c_str(), CXTranslationUnit_None); arg::ClassExtractor extractor; extractor.extract(translationUnit.cursor()); const auto& classes = extractor.classes(); for (const auto& [name, c] : classes) { SerializableDefGenerator::processClass(c, path, targetDir.string()); } } 

Dalam fungsi ini, hanya ClassExtractor yang menarik bagi kami - yang lainnya diperlukan untuk membentuk AST. Definisi fungsi ekstrak adalah sebagai berikut:

   void ClassExtractor::extract(const CXCursor& cursor) { clang_visitChildren(cursor, [](CXCursor c, CXCursor, CXClientData data) { if (clang_getCursorKind(c) == CXCursorKind::CXCursor_ClassDecl) { /*   */ /* -    - */ /* -     */ } return CXChildVisit_Continue; } , this); } 

Di sini kita sudah melihat langsung fungsi C API untuk dentang. Kami sengaja meninggalkan hanya kode yang diperlukan untuk memahami bagaimana libclang digunakan. Segala sesuatu yang tertinggal di belakang layar tidak mengandung informasi penting - itu hanya registrasi nama kelas, anggota data, dll. Kode yang lebih terperinci dapat ditemukan di repositori.

Dan akhirnya, dalam fungsi processClass , keberadaan atribut serialisasi dari setiap kelas yang ditemukan diperiksa, dan jika ada, file dihasilkan dengan definisi fungsi yang diperlukan. Repositori memberikan contoh spesifik: di mana mendapatkan nama namespace (informasi ini disimpan langsung di kelas Class ) dan path ke file header.

Untuk tugas yang disebutkan di atas, perpustakaan Argentum digunakan, yang, sayangnya, saya tidak menyarankan Anda untuk menggunakannya - saya mulai mengembangkannya untuk tujuan lain, tetapi karena untuk tugas ini saya hanya memerlukan fungsionalitas yang diterapkan di sana, dan saya malas, Saya tidak menulis ulang kode, tetapi hanya mempostingnya di Bintray dan menghubungkannya ke file CMake melalui manajer paket Conan. Semua yang disediakan perpustakaan ini adalah pembungkus sederhana di atas dentang API C untuk kelas dan anggota data.

Dan satu lagi komentar kecil - Saya tidak menyediakan perpustakaan yang sudah jadi, saya hanya memberi tahu cara menulisnya.

UPD0 : cppast dapat digunakan sebagai pengganti libclang . Terima kasih kepada masterspline untuk tautan yang disediakan.

1. github.com/isnullxbh/dsl
2. github.com/isnullxbh/Argentum

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


All Articles