Este artigo focará na automatização do processo de serialização em C ++. No início, discutiremos os mecanismos básicos para simplificar a leitura / gravação de dados em fluxos de entrada / saída, após o qual será fornecida uma descrição de um sistema primitivo de geração de código baseado em libclang. Um link para o repositório com uma versão demo da biblioteca está localizado no final do artigo.
No ruSO, surgem perguntas periodicamente sobre serialização de dados em C ++, algumas vezes essas questões são de natureza geral, quando o TC basicamente não sabe por onde começar, outras são perguntas que descrevem um problema específico. O objetivo deste artigo é resumir uma das maneiras possíveis de implementar a serialização em C ++, que permitirá seguir as etapas de criação de um sistema desde as etapas iniciais até algumas conclusões lógicas, quando esse sistema já puder ser usado na prática.
1. Informação Inicial
Este artigo usará um formato de dados binários, cuja estrutura é determinada com base nos tipos de objetos serializáveis. Essa abordagem evita o uso de bibliotecas de terceiros, limitando-nos apenas às ferramentas fornecidas pela biblioteca C ++ padrão.
Como o processo de serialização consiste em converter o estado de um objeto em um fluxo de bytes, que obviamente deve ser acompanhado por operações de gravação, o último será usado em vez do termo “serialização” ao descrever detalhes de baixo nível. Da mesma forma para leitura / desserialização.
Para reduzir o volume do artigo, serão fornecidos apenas exemplos de serialização de objetos (exceto nos casos em que a desserialização contiver alguns detalhes que vale a pena mencionar). O código completo pode ser encontrado no repositório acima.
2. Tipos suportados
Antes de tudo, vale a pena decidir sobre os tipos que planejamos oferecer suporte - depende diretamente de como a biblioteca será implementada.
Por exemplo, se a escolha for limitada aos tipos fundamentais de C ++, será suficiente um modelo de função (que é uma família de funções para trabalhar com os valores de tipos inteiros) e suas especializações explícitas. Modelo primário (usado para os tipos std :: int32_t, std :: uint16_t etc.):
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); }
Nota : se os dados obtidos durante a serialização estiverem planejados para serem transferidos entre máquinas com ordens de bytes diferentes, é necessário, por exemplo, converter um valor da ordem de bytes local em um byte de rede e, em seguida, executar a operação reversa na máquina remota, portanto, serão necessárias alterações para a função de gravação. dados para o fluxo de saída e para a função de leitura do fluxo de entrada.
Especialização para 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); }
Essa abordagem define a seguinte regra: se um valor do tipo T pode ser representado como uma sequência de bytes de tamanho sizeof (T), a definição do modelo primário pode ser usada para isso; caso contrário, é necessário determinar a especialização. Esse requisito pode ser ditado pelos recursos da representação de um objeto do tipo T na memória.
Considere o contêiner std :: string: é óbvio que não podemos pegar o endereço de um objeto do tipo especificado, convertê-lo em um ponteiro para char e gravá-lo no fluxo de saída - isso significa que precisamos de especialização:
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); }
Dois pontos importantes a serem destacados aqui:
- Não apenas o conteúdo da sequência é gravado no fluxo de saída, mas também seu tamanho.
- Crie std :: string :: size_type para digitar std :: uint32_t. Nesse caso, você deve prestar atenção não ao tamanho do tipo de destino, mas ao fato de ser de um comprimento fixo. Essa redução permitirá evitar problemas no caso, por exemplo, se dados forem transmitidos por uma rede entre máquinas com tamanhos de palavras diferentes.
Portanto, descobrimos que valores de tipos fundamentais (e até objetos do tipo std :: string) podem ser gravados no fluxo de saída usando o modelo de função de
gravação . Agora vamos analisar as alterações que precisamos fazer se quisermos adicionar contêineres à lista de tipos suportados. Temos apenas uma opção para sobrecarga - use o parâmetro T como o tipo de elementos do contêiner. E se no caso de std :: vector isso funcionará:
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; }
, com std: map - não, porque o modelo std :: map requer pelo menos dois parâmetros - o tipo de chave e o tipo de valor. Portanto, neste estágio, não podemos mais usar o modelo de função - precisamos de uma solução mais universal. Antes de descobrirmos como adicionar suporte a contêiner, lembremos que ainda temos classes personalizadas. Obviamente, mesmo usando a solução atual, não seria prudente sobrecarregar a função de
gravação para cada classe que requer serialização. Na melhor das hipóteses, gostaríamos de ter uma especialização do padrão de
gravação que funcione com tipos de dados personalizados. Mas para isso, é necessário que as classes tenham a capacidade de controlar independentemente a serialização, respectivamente, elas devem ter uma interface que permita ao usuário serializar e desserializar objetos dessa classe. Como acontece um pouco mais tarde, essa interface servirá como um "denominador comum" para o modelo de
gravação ao trabalhar com classes personalizadas. Vamos definir isso.
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; };
Qualquer classe que herda de
ISerializable concorda em:
- Substituir estado de serialização - gravação (membros de dados) no fluxo de saída.
- Substituir desserialização - leia o estado (inicialização dos membros de dados) do fluxo de entrada.
- Substituir serialized_size - calcula o tamanho dos dados serializados para o estado atual do objeto.
Então, voltando ao modelo da função de
gravação : em geral, podemos implementar a especialização para a classe
ISerializable , mas não podemos usá-la, dê uma olhada:
template<> auto write(std::ostream& os, const ISerializable& value) -> std::size_t { return value.serialize(os); }
Cada vez, teríamos que
converter o tipo de herdeiro em
ISerializable para tirar proveito dessa especialização. Deixe-me lembrá-lo que, no início, estabelecemos como nosso objetivo simplificar a escrita de código relacionado à serialização, e não vice-versa, para complicá-lo. Portanto, se os tipos suportados pela nossa biblioteca não se limitarem aos tipos fundamentais, devemos procurar outra solução.
3. stream_writer
Usar modelos de função para implementar uma interface universal para gravar dados em um fluxo não era uma solução completamente adequada. A próxima opção que devemos verificar é o modelo de classe. Seguiremos a mesma metodologia usada com o modelo de função - o modelo primário será usado por padrão e especializações explícitas serão adicionadas para dar suporte aos tipos necessários.
Além disso, devemos levar em consideração tudo o que foi
dito acima sobre
ISerializable - obviamente, não conseguiremos resolver o problema com muitas classes sucessoras sem recorrer a type_traits: começando com C ++ 11, o modelo std :: enable_if apareceu na biblioteca padrão, o que permite ignorar as classes de modelo quando certas condições durante a compilação - e é exatamente disso que vamos aproveitar.
Modelo de classe
Stream_writer :
template<typename T, typename U = void> class stream_writer { public: static auto write(std::ostream& os, const T& value) -> std::size_t; };
A definição do método de
gravação :
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); }
A especialização para
ISerializable será a seguinte:
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; };
onde only_if_serializable é um tipo auxiliar:
template<typename T> using only_if_serializable = std::enable_if_t<std::is_base_of_v<ISerializable, T>>;
Portanto, se o tipo T for uma classe derivada de
ISerializable , essa especialização será considerada candidata à instanciação, respectivamente, se o tipo T não estiver na mesma hierarquia de classes que
ISerializable , ela será excluída de possíveis candidatos.
Seria justo fazer a seguinte pergunta aqui: como isso funcionará? Afinal, o modelo primário terá os mesmos valores dos parâmetros típicos que sua especialização - <T, vazio>. Por que a especialização terá preferência e será? Resposta: será, pois esse comportamento é prescrito pelo padrão (
fonte ):
(1.1) Se exatamente uma especialização correspondente for encontrada, a instanciação será gerada a partir dessa especialização
A especialização para std :: string ficará assim:
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); }
onde only_if_string é declarado como:
template<typename T> using only_if_string = std::enable_if_t<std::is_same_v<T, std::string>>;
É hora de retornar aos contêineres. Nesse caso, podemos usar o tipo de contêiner parametrizado com algum tipo de U, ou <U, V>, como no caso de std :: map, diretamente como o valor do parâmetro T do modelo da classe
stream_writer . Assim, nada muda na interface em nossa interface - é isso que pretendemos. No entanto, surge a pergunta: qual deve ser o segundo parâmetro do modelo para a classe
stream_writer para que tudo funcione corretamente? Isso está no próximo capítulo.
4. Conceitos
Primeiro, darei uma breve descrição dos conceitos utilizados e somente então mostrarei exemplos atualizados.
template<typename T> concept String = std::is_same_v<T, std::string>;
Honestamente, esse conceito foi definido para fraude, que veremos na próxima linha:
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; };
O contêiner contém os requisitos que "fazemos" para o tipo para realmente garantir que ele seja um dos tipos de contêiner. Esse é exatamente o conjunto de requisitos que precisaremos ao implementar o
stream_writer ; o padrão tem muito mais requisitos, é claro.
template<typename T> concept SequenceContainer = Container<T> && requires (T a, typename T::size_type count) { { a.resize(count) } -> void; };
Conceito para contêineres seqüenciais: std :: vector, std :: list, etc.
template<typename T> concept AssociativeContainer = Container<T> && requires (T a) { typename T::key_type; };
Conceito para contêineres associativos: std :: map, std :: set, std :: unordered_map, etc.
Agora, para determinar a especialização para contêineres consecutivos, tudo o que resta fazer é impor restrições ao tipo 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();
Contêineres suportados:
- std :: vector
- std :: deque
- std :: list
- std :: forward_list
Da mesma forma para contêineres associativos:
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; }
Contêineres suportados:
- std :: map
- std :: unordered_map
- std :: set
- std :: unordered_set
No caso do mapa, há uma pequena nuance, que diz respeito à implementação de
stream_reader . O value_type para std :: map <K, T> é std :: pair <const K, T>, respectivamente, quando tentamos converter um ponteiro para const K em um ponteiro para char ao ler a partir do fluxo de entrada, obtemos um erro de compilação. Podemos resolver esse problema da seguinte maneira: sabemos que para contêineres associativos value_type é um único tipo K ou std :: pair <const K, V>, podemos escrever pequenas classes auxiliares de modelo que serão parametrizadas por value_type e por dentro determine o tipo que precisamos.
Para std :: set, tudo permanece inalterado:
template<typename U, typename V = void> struct converter { using type = U; };
Para std :: map - remove 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>; };
A definição de
leitura para contêineres associativos:
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. Funções auxiliares
Considere um exemplo:
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 {}; };
A definição do método serialize (std :: ostream &) para esta classe deve ter a seguinte aparência:
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; }
No entanto, você deve admitir que é inconveniente indicar sempre que o tipo de objeto que é gravado no fluxo de saída. Escrevemos uma função auxiliar que deduziria automaticamente o tipo T:
template<typename T> auto write(std::ostream& os, const T& value) -> std::size_t { return stream_writer<T>::write(os, value); }
Agora a definição é a seguinte:
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; }
O capítulo final exigirá mais algumas funções auxiliares:
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...); }
A função
write_all permite listar todos os objetos a serem serializados de uma vez, enquanto
write_recursive garante a ordem correta de gravação no fluxo de saída. Se a ordem dos cálculos fosse definida para
expressões de dobra (desde que usássemos o operador binário +), poderíamos usá-las. Em particular, na função
size_of_all (não foi mencionada anteriormente, é usada para calcular o tamanho dos dados serializados), são as expressões de dobra usadas devido à ausência de operações de entrada e saída.
6. Geração de Código
A API libclang-C para clang é usada para gerar o código. De alto nível, essa tarefa pode ser descrita da seguinte maneira: precisamos percorrer recursivamente o diretório com o código-fonte, verificar todos os arquivos de cabeçalho em busca de classes marcadas com um atributo especial e, se houver, verificar os membros de dados para o mesmo atributo e compilar a sequência a partir dos nomes dos membros de dados. listado com uma vírgula. Tudo o que resta fazer é escrever os modelos de definição para as funções da classe
ISerializable (na qual só podemos colocar a enumeração dos membros de dados necessários).
Um exemplo de uma classe para a qual o código será gerado:
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 {}; };
Os atributos são escritos no estilo GNU porque a libclang se recusa a reconhecer o formato de atributo do C ++ 20 e também não suporta atributos não anotados. Traversal do diretório de origem:
for (const auto& file : fs::recursive_directory_iterator(argv[1])) { if (file.is_regular_file() && file.path().extension() == ".hpp") { processTranslationUnit(file, dst); } }
A definição da função
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()); } }
Nesta função, apenas o
ClassExtractor nos interessa - tudo o mais é necessário para formar o AST. A definição da função
extrair é a seguinte:
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); }
Aqui já vemos diretamente as funções da API C para clang. Intencionalmente, deixamos apenas o código necessário para entender como a libclang é usada. Tudo o que resta nos bastidores não contém informações importantes - é apenas um registro de nomes de classe, membros de dados, etc. Um código mais detalhado pode ser encontrado no repositório.
E, finalmente, na função
processClass , a presença de atributos de serialização de cada classe encontrada é verificada e, se houver, um arquivo é gerado com a definição das funções necessárias. O repositório fornece exemplos específicos: onde obter o (s) nome (s) do espaço para nome (essas informações são armazenadas diretamente na classe
Class ) e o caminho para o arquivo de cabeçalho.
Para a tarefa mencionada acima, é usada a biblioteca Argentum, que, infelizmente, não recomendo que você use - comecei a desenvolvê-la para outros fins, mas porque para essa tarefa eu só precisava da funcionalidade implementada lá e estava com preguiça, Não reescrevi o código, simplesmente o publiquei no Bintray e o conectei ao arquivo CMake através do gerenciador de pacotes Conan. Tudo o que essa biblioteca fornece são invólucros simples sobre a API C do clang para classes e membros de dados.E mais uma pequena observação - não forneço uma biblioteca pronta, apenas digo como escrevê-la.
UPD0 : cppast pode ser usado em
vez de libclang . Obrigado à
masterspline pelo link fornecido.
1.
github.com/isnullxbh/dsl2.
github.com/isnullxbh/Argentum