本文将重点介绍在C ++中自动执行序列化过程。 首先,我们将讨论简化将数据读/写到输入/输出流中的基本机制,然后将对基于原始libclang的代码生成系统进行描述。 本文末尾提供了带有库演示版本的存储库链接。
在ruSO中,会定期出现有关C ++中数据序列化的问题,有时这些问题本质上是通用的,当TC基本不知道从哪里开始时,有时这些问题描述了一个特定的问题。 本文的目的是总结在C ++中实现序列化的一种可能方法,当您已经可以在实践中使用该系统时,它将允许您按照从初始步骤到某些逻辑结论的顺序构建系统。
1.初始信息
本文将使用二进制数据格式,其结构取决于可序列化对象的类型。 这种方法使我们免于使用第三方库,从而仅将自己限于标准C ++库提供的工具。
由于序列化过程包括将对象的状态转换为字节流,显然应伴有写入操作,因此在描述低级详细信息时,将使用后者代替术语“序列化”。 对于读取/反序列化也是如此。
为了减少本文的篇幅,仅给出对象序列化的示例(反序列化包含一些值得一提的细节的情况除外)。 完整的代码可以在上述存储库中找到。
2.支持的类型
首先,值得决定我们计划支持的类型-它直接取决于库的实现方式。
例如,如果选择仅限于C ++的基本类型,则功能模板(用于处理整数类型值的函数家族)及其显式专业化就足够了。 主模板(用于类型std :: int32_t,std :: uint16_t等):
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); }
注意 :如果计划在序列化过程中获得的数据在具有不同字节顺序的机器之间传输,则有必要例如将值从本地字节顺序转换为网络字节,然后在远程机器上执行相反的操作,因此需要对写入功能进行更改数据传输到输出流,并具有从输入流读取数据的功能。
布尔的专业化:
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); }
此方法定义以下规则:如果可以将类型T的值表示为长度为sizeof(T)的字节序列,则可以使用主模板的定义,否则必须确定专门性。 此要求可以由内存中T类型的对象表示的特征来决定。
考虑一下容器std :: string:很明显,我们不能获取指定类型的对象的地址,不能将其转换为char的指针,然后将其写入输出流-这意味着我们需要专门化:
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); }
这里要说明两个重要的观点:
- 不仅字符串的内容被写入输出流,而且其大小也被写入输出流。
- 将std :: string :: size_type强制转换为std :: uint32_t。 在这种情况下,值得关注的不是目标类型的大小,而是其长度固定的事实。 这种减少将允许避免例如在具有不同机器字长的机器之间通过网络传输数据的情况下的问题。
因此,我们发现可以使用
write函数模板将基本类型的值(甚至std :: string类型的对象)写入输出流。 现在,让我们分析一下如果要将容器添加到支持的类型列表中,我们需要进行哪些更改。 我们只有一个重载选项-使用T参数作为容器元素的类型。 如果在std :: vector的情况下,它将起作用:
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; }
,然后使用std:map-否,因为std :: map模板至少需要两个参数-键类型和值类型。 因此,在此阶段,我们不能再使用功能模板-我们需要一个更通用的解决方案。 在弄清楚如何添加容器支持之前,让我们回想一下我们仍然有自定义类。 显然,即使使用当前解决方案,对于需要序列化的每个类重载
write函数也是不明智的。 最好的情况是,我们希望对可与自定义数据类型一起使用的
写模式进行专门化处理。 但是为此,有必要使这些类分别具有独立控制序列化的能力,它们应该具有一个接口,该接口将允许用户对此类的对象进行序列化和反序列化。 事实证明,在使用自定义类时,此接口将用作
写入模板的“通用标准”。 让我们定义一下。
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; };
任何从
ISerializable继承的类都同意:
- 覆盖序列化 -将状态(数据成员)写入输出流。
- 覆盖反序列化 —从输入流中读取状态(数据成员的初始化)。
- 覆盖serialized_size-计算对象当前状态的序列化数据的大小。
因此,回到
写函数模板:通常,我们可以为
ISerializable类实现专门化,但是我们不能使用它,请看一下:
template<> auto write(std::ostream& os, const ISerializable& value) -> std::size_t { return value.serialize(os); }
每次,我们都必须
将继承人类型转换为
ISerializable才能利用这种专业化优势。 让我提醒您,从一开始我们就以简化与序列化相关的代码的编写为目标,反之亦然。 因此,如果我们的库支持的类型不限于基本类型,那么我们应该寻找另一种解决方案。
3. stream_writer
使用功能模板实现将数据写入流的通用接口并不是一个完全合适的解决方案。 我们应该检查的下一个选项是类模板。 我们将采用与函数模板相同的方法-默认情况下将使用主模板,并将添加显式专业化以支持必要的类型。
此外,我们应该考虑所有上述有关
ISerializable的问题 -显然,如果不诉诸type_traits,我们将无法解决许多后继类的问题:从C ++ 11开始,std :: enable_if模板出现在标准库中,当您在以下情况下忽略模板类:编译期间的某些条件-这正是我们要利用的条件。
Stream_writer类
模板 :
template<typename T, typename U = void> class stream_writer { public: static auto write(std::ostream& os, const T& value) -> std::size_t; };
write方法的定义:
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); }
ISerializable的专业化如下:
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; };
其中only_if_serializable是帮助程序类型:
template<typename T> using only_if_serializable = std::enable_if_t<std::is_base_of_v<ISerializable, T>>;
因此,如果类型T是从
ISerializable派生的类,则该专业化将分别视为实例化的候选对象,如果类型T与
ISerializable不同 ,则将其排除在可能的候选对象之外。
在这里问以下问题将是公平的:这将如何工作? 毕竟,主模板将具有与其专业化相同的典型参数值-<T,void>。 为什么会优先选择专业化? 答案:将是这样,因为这种行为是由标准(
来源 )规定的:
(1.1)如果恰好找到一个匹配的专长,则从该专长生成实例化
现在,std :: string的特化将如下所示:
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); }
其中only_if_string声明为:
template<typename T> using only_if_string = std::enable_if_t<std::is_same_v<T, std::string>>;
现在该回到容器了。 在这种情况下,我们可以将使用某种类型的U或<U,V>参数化的容器类型(如在std :: map的情况下)直接用作
stream_writer类模板的参数T的值。 因此,界面中的界面没有任何变化-这就是我们的目标。 但是,问题来了,
stream_writer类的模板的第二个参数应该是什么,以
使一切正常工作? 这是下一章。
4.概念
首先,我将简要介绍所使用的概念,然后,我将显示更新的示例。
template<typename T> concept String = std::is_same_v<T, std::string>;
老实说,这个概念是为欺诈定义的,我们将在下一行看到:
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; };
容器包含我们对类型“制定”的要求,以真正确保它是容器类型之一。 这正是我们实现
stream_writer时将需要的一组要求,
当然 ,该标准还有更多要求。
template<typename T> concept SequenceContainer = Container<T> && requires (T a, typename T::size_type count) { { a.resize(count) } -> void; };
顺序容器的概念:std :: vector,std :: list等。
template<typename T> concept AssociativeContainer = Container<T> && requires (T a) { typename T::key_type; };
关联容器的概念:std :: map,std :: set,std :: unordered_map等。
现在,要确定连续容器的专业化,剩下要做的就是对类型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();
支持的容器:
- 性病::矢量
- 性病:: deque
- std ::列表
- 性病::转发列表
同样对于关联容器:
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; }
支持的容器:
- std ::地图
- std ::无序地图
- std ::集
- std :: unordered_set
在map的情况下,有一点细微差别,它涉及
stream_reader的实现。 std :: map <K,T>的value_type分别是std :: pair <const K,T>,当我们尝试从输入流中读取时将const K的指针转换为char的指针时,会出现编译错误。 我们可以如下解决这个问题:我们知道对于关联容器,value_type是单个类型K或std :: pair <const K,V>,那么我们可以编写小型模板帮助程序类,这些类将由value_type和内部进行参数化确定我们需要的类型。
对于std :: set,一切保持不变:
template<typename U, typename V = void> struct converter { using type = U; };
对于std :: map-删除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>; };
关联容器的
read定义:
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.辅助功能
考虑一个例子:
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 {}; };
此类的序列化方法(std :: ostream&)的定义应如下所示:
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; }
但是,您必须承认,每次指示写入输出流的对象类型都是不方便的。 我们编写了一个辅助函数,它将自动推导类型T:
template<typename T> auto write(std::ostream& os, const T& value) -> std::size_t { return stream_writer<T>::write(os, value); }
现在的定义如下:
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; }
最后一章将需要更多辅助功能:
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...); }
write_all函数使
您可以立即列出所有要序列化的对象,而
write_recursive可确保正确写入输出流的顺序。 如果为
折叠表达式定义了计算顺序(假设我们使用二进制+运算符),则可以使用它们。 特别是在函数
size_of_all中 (之前没有提到,它用于计算序列化数据的大小),由于没有输入输出操作,因此使用了fold表达式。
6.代码生成
libclang-用于clang的C API用于生成代码。 高层任务可以描述如下:我们需要递归地遍历目录和源代码,检查所有头文件中是否有标记有特殊属性的类,如果有,请检查数据成员是否具有相同的属性,并从数据成员的名称中编译字符串。用逗号列出。 剩下要做的就是为
ISerializable类的功能编写定义模板(在其中我们只能放入必要数据成员的枚举)。
将为其生成代码的类的示例:
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 {}; };
属性以GNU风格编写,因为libclang拒绝从C ++ 20中识别属性格式,并且它也不支持非注释属性。 源目录遍历:
for (const auto& file : fs::recursive_directory_iterator(argv[1])) { if (file.is_regular_file() && file.path().extension() == ".hpp") { processTranslationUnit(file, dst); } }
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()); } }
在此功能中,我们仅对
ClassExtractor感兴趣-形成AST所需的所有其他条件。
提取函数的定义如下:
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); }
在这里,我们已经直接看到了clang的C API函数。 我们特意只留下了了解如何使用libclang的代码。 幕后的所有内容都不包含重要信息-仅仅是类名,数据成员等的注册。 在存储库中可以找到更详细的代码。
最后,在
processClass函数中,检查每个找到的类的序列化属性是否存在,如果存在,则会生成一个包含必需函数定义的文件。 该存储库提供了一些具体示例:在何处获取名称空间的名称(此信息直接存储在
Class类中)和头文件的路径。
对于上述任务,使用的是Argentum库,不幸的是,我不建议您使用它-我开始将其开发用于其他目的,但是由于该任务我只需要在那里实现的功能,所以我很懒惰,我没有重写代码,只是将其发布在Bintray上,并通过柯南软件包管理器将其连接到CMake文件。 该库提供的所有内容只是对类和数据成员的clang C API的简单包装。还有一点要说的-我没有提供现成的库,我只告诉他们如何编写。
UPD0 :可以使用
cppast代替libclang 。 感谢
masterspline提供的链接。
1.
github.com/isnullxbh/dsl2.
github.com/isnullxbh/Argentum