28 votes

Comment réduire le boilerplate actuellement nécessaire à la sérialisation ?

Notre logiciel fait abstraction du matériel, et nous avons des classes qui représentent l'état de ce matériel et ont beaucoup de membres de données pour toutes les propriétés de ce matériel externe. Nous devons régulièrement mettre à jour d'autres composants sur cet état, et pour cela nous envoyons des messages codés en protobuf via MQTT et d'autres protocoles de messagerie. Il y a différents messages qui décrivent différents aspects du matériel, donc nous devons envoyer différentes vues des données de ces classes. Voici une esquisse :

struct some_data {
  Foo foo;
  Bar bar;
  Baz baz;
  Fbr fbr;
  // ...
};

Supposons que nous ayons besoin d'envoyer un message contenant foo y bar et un autre contenant bar y baz . Notre façon actuelle de procéder consiste en une multitude de textes passe-partout :

struct foobar {
  Foo foo;
  Bar bar;
  foobar(const Foo& foo, const Bar& bar) : foo(foo), bar(bar) {}
  bool operator==(const foobar& rhs) const {return foo == rhs.foo && bar == rhs.bar;}
  bool operator!=(const foobar& rhs) const {return !operator==(*this,rhs);}
};

struct barbaz {
  Bar bar;
  Baz baz;
  foobar(const Bar& bar, const Baz& baz) : bar(bar), baz(baz) {}
  bool operator==(const barbaz& rhs) const {return bar == rhs.bar && baz == rhs.baz;}
  bool operator!=(const barbaz& rhs) const {return !operator==(*this,rhs);}
};

template<> struct serialization_traits<foobar> {
  static SerializedFooBar encode(const foobar& fb) {
    SerializedFooBar sfb;
    sfb.set_foo(fb.foo);
    sfb.set_bar(fb.bar);
    return sfb;
  }
};

template<> struct serialization_traits<barbaz> {
  static SerializedBarBaz encode(const barbaz& bb) {
    SerializedBarBaz sbb;
    sfb.set_bar(bb.bar);
    sfb.set_baz(bb.baz);
    return sbb;
  }
};

Celui-ci peut alors être envoyé :

void send(const some_data& data) {
  send_msg( serialization_traits<foobar>::encode(foobar(data.foo, data.bar)) );
  send_msg( serialization_traits<barbaz>::encode(barbaz(data.foo, data.bar)) );
}

Étant donné que les ensembles de données à envoyer sont souvent beaucoup plus grands que deux éléments, que nous devons également décoder ces données et que nous avons des tonnes de ces messages, il y a beaucoup plus de texte passe-partout impliqué que ce qui se trouve dans ce croquis. J'ai donc cherché un moyen de réduire ce nombre. Voici une première idée :

typedef std::tuple< Foo /* 0 foo */
                  , Bar /* 1 bar */
                  > foobar;
typedef std::tuple< Bar /* 0 bar */
                  , Baz /* 1 baz */
                  > barbaz;
// yay, we get comparison for free!

template<>
struct serialization_traits<foobar> {
  static SerializedFooBar encode(const foobar& fb) {
    SerializedFooBar sfb;
    sfb.set_foo(std::get<0>(fb));
    sfb.set_bar(std::get<1>(fb));
    return sfb;
  }
};

template<>
struct serialization_traits<barbaz> {
  static SerializedBarBaz encode(const barbaz& bb) {
    SerializedBarBaz sbb;
    sfb.set_bar(std::get<0>(bb));
    sfb.set_baz(std::get<1>(bb));
    return sbb;
  }
};

void send(const some_data& data) {
  send_msg( serialization_traits<foobar>::encode(std::tie(data.foo, data.bar)) );
  send_msg( serialization_traits<barbaz>::encode(std::tie(data.bar, data.baz)) );
}

J'ai réussi à faire fonctionner ce système, et il réduit considérablement le texte passe-partout. (Pas dans ce petit exemple, mais si vous imaginez qu'une douzaine de points de données sont codés et décodés, la disparition de nombreuses listes répétitives de membres de données fait une grande différence). Cependant, cela présente deux inconvénients :

  1. Cela repose sur Foo , Bar y Baz étant des types distincts. S'ils sont tous int nous devons ajouter un type de balise fictif au tuple.

    C'est possible, mais cela rend cette idée beaucoup moins attrayante.

  2. Ce qui était des noms de variables dans l'ancien code devient des commentaires et des numéros dans le nouveau code. C'est plutôt mauvais, et étant donné qu'il est probable qu'un bogue confondant deux membres soit présent aussi bien dans l'encodage que dans le décodage, il ne peut pas être attrapé dans de simples tests unitaires, mais nécessite des composants de test créés par d'autres technologies (donc des tests d'intégration) pour attraper de tels bogues.

    Je n'ai aucune idée de la façon de réparer cela.

Quelqu'un a-t-il une meilleure idée de la façon de réduire le texte passe-partout pour nous ?

Note :

  • Pour l'instant, nous sommes coincés avec C++03. Oui, vous avez bien lu. Pour nous, c'est std::tr1::tuple . Pas de lambda. Et pas de auto soit.
  • Nous avons une tonne de code qui utilise ces traits de sérialisation. Nous ne pouvons pas jeter tout le système et faire quelque chose de complètement différent. Je suis à la recherche d'une solution pour simplifier l'intégration du code futur dans le cadre existant. Toute idée qui nous oblige à tout réécrire sera très probablement rejetée.

2 votes

Il semble que vous souhaitiez écrire un programme qui lise un fichier dans un langage simple et génère ensuite tout le code C++ pour vous, que vous compilez ensuite. Un générateur de code pour la victoire. Un simple analyseur yacc/bison avec une grammaire simple peut même faire l'affaire.

0 votes

@JesperJuhl : C'est en effet l'une des solutions que nous avons envisagées. Je préfère cependant trouver une solution en C++, plutôt que d'ajouter encore un autre générateur de code à notre processus de construction que les gens devront maintenir lorsque les programmeurs actuels auront tous pris leur retraite depuis longtemps...

0 votes

Puisque les messages sont codés en protobuf, pourquoi ne pas générer le code avec protobuf ?

12voto

Acorn Points 6838

À mon avis, la meilleure solution globale est un générateur de code C++ externe dans un langage de script. Il présente les avantages suivants :

  • Flexibilité : il vous permet de modifier le code généré à tout moment. C'est une excellente chose pour plusieurs raisons :

    • Corriger rapidement les bogues dans toutes les anciennes versions prises en charge.
    • Utilisez les nouvelles fonctionnalités de C++ si vous passez à C++11 ou plus tard dans le futur.
    • Générer du code pour une autre langue. C'est très, très utile (surtout si votre organisation est grande et/ou si vous avez de nombreux utilisateurs). Par exemple, vous pouvez produire une petite bibliothèque de scripts (par exemple, un module Python) qui peut être utilisée comme outil CLI pour interfacer avec le matériel. D'après mon expérience, cela a été très apprécié par les ingénieurs en matériel.
    • Générer un code d'interface graphique (ou des descriptions d'interface graphique, par exemple en XML/JSON ; ou même une interface web) -- utile pour les personnes utilisant le matériel final et les testeurs.
    • Génération d'autres types de données. Par exemple, des diagrammes, des statistiques, etc. Ou même les descriptions de protobuf elles-mêmes.
  • Maintenance Il sera plus facile à maintenir qu'en C++. Même s'il est écrit dans un langage différent, il est généralement plus facile d'apprendre ce langage que de demander à un nouveau développeur C++ de se plonger dans la métaprogrammation des templates C++ (surtout en C++03).

  • Performance Il peut facilement réduire le temps de compilation de la partie C++ (puisque vous pouvez produire un C++ très simple, voire un simple C). Bien sûr, le générateur peut compenser cet avantage. Dans votre cas, cela peut ne pas s'appliquer, puisqu'il semble que vous ne pouvez pas modifier le code client.

J'ai utilisé cette approche dans quelques projets/systèmes et cela s'est très bien passé. En particulier, les différentes alternatives pour l'utilisation du matériel (librairie C++, librairie Python, CLI, GUI...) peuvent être utilisées. très apprécié.


Remarque : si une partie de la génération nécessite une analyse syntaxique déjà existant du code C++ (par exemple, les en-têtes avec les types de données à sérialiser, comme dans le cas de l'OP avec l'élément Serialized ) ; une solution très intéressante consiste alors à utiliser L'outillage de LLVM/clang pour le faire.

Dans un projet particulier sur lequel je travaillais, nous devions sérialiser automatiquement des dizaines de types C++ (qui étaient susceptibles d'être modifiés à tout moment par les utilisateurs). Nous avons réussi à générer automatiquement le code correspondant en utilisant simplement les liaisons Python de clang et en l'intégrant au processus de construction. Bien que les liaisons Python n'aient pas exposé tous les détails de l'AST (à l'époque, du moins), elles ont suffi à générer le code de sérialisation requis pour tous nos types (qui comprenaient des classes modélisées, des conteneurs, etc.)

0 votes

0 votes

@sbi : Je vois. Eh bien, cette réponse tente de vous donner un aperçu de la raison pour laquelle cette voie peut être meilleure qu'une solution C++. En tout cas, rejeter un outil parce qu'il y a trop d'outils (ou d'étapes de construction) déjà dans le projet n'est pas un argument très solide (à mon avis) -- plus un système est complexe, plus la gestion de sa complexité est impliquée. Essayer de faire entrer toute la complexité dans le code C++03 alors qu'elle pourrait facilement être en dehors de celui-ci n'est pas une bonne idée à long terme, d'après mon expérience :-)

0 votes

Nous utilisons déjà plusieurs outils de génération de code. Tous sont une source de tracas. Quand un nouveau membre de l'équipe arrive, il doit installer toutes sortes d'outils arbitraires qui sont stockés dans des dépôts différents (comme un dépôt pip pour le matériel python) de notre code. Ou encore, un développeur met à jour un outil, mais oublie de mettre à jour l'esclave de construction Jenkins, ce qui fait que le travail de Jenkins échoue de manière apparemment aléatoire... En gros, tout ce qui vous empêche de faire un bootstrap en trois étapes (1. vérifier le code, 2. installer l'outil de construction, 3. faire une version) causera tôt ou tard des problèmes. Le moins possible, le mieux.

7voto

linuxfever Points 165

Je vais me baser sur la solution que vous proposez, mais utiliser boost::fusion::tuples à la place (en supposant que cela soit autorisé). Supposons que vos types de données sont

struct Foo{};
struct Bar{};
struct Baz{};
struct Fbr{};

et vos données sont

struct some_data {
    Foo foo;
    Bar bar;
    Baz baz;
    Fbr fbr;
};

D'après les commentaires, je comprends que vous n'avez aucun contrôle sur les classes SerialisedXYZ mais qu'elles ont une certaine interface. Je suppose que quelque chose comme ceci est assez proche ( ?):

struct SerializedFooBar {

    void set_foo(const Foo&){
        std::cout << "set_foo in SerializedFooBar" << std::endl;
    }

    void set_bar(const Bar&){
        std::cout << "set_bar in SerializedFooBar" << std::endl;
    }
};

// another protobuf-generated class
struct SerializedBarBaz {

    void set_bar(const Bar&){
        std::cout << "set_bar in SerializedBarBaz" << std::endl;
    }

    void set_baz(const Baz&){
        std::cout << "set_baz in SerializedBarBaz" << std::endl;
    }
};

Nous pouvons maintenant réduire le texte passe-partout et le limiter à un typedef par permutation de type de données et à une surcharge simple pour chaque membre set_XXX de la classe SerializedXYZ, comme suit :

typedef boost::fusion::tuple<Foo, Bar> foobar;
typedef boost::fusion::tuple<Bar, Baz> barbaz;
//...

template <class S>
void serialized_set(S& s, const Foo& v) {
    s.set_foo(v);
}

template <class S>
void serialized_set(S& s, const Bar& v) {
    s.set_bar(v);
}

template <class S>
void serialized_set(S& s, const Baz& v) {
    s.set_baz(v);
}

template <class S, class V>
void serialized_set(S& s, const Fbr& v) {
    s.set_fbr(v);
}
//...

La bonne nouvelle est que vous n'avez plus besoin de spécialiser vos traites de sérialisation. L'exemple suivant utilise la fonction boost::fusion::fold, qui, je suppose, peut être utilisée dans votre projet :

template <class SerializedX>
class serialization_traits {

    struct set_functor {

        template <class V>
        SerializedX& operator()(SerializedX& s, const V& v) const {
            serialized_set(s, v);
            return s;
        }
    };

public:

    template <class Tuple>
    static SerializedX encode(const Tuple& t) {
        SerializedX s;
        boost::fusion::fold(t, s, set_functor());
        return s;
    }
};

Et voici quelques exemples de son fonctionnement. Notez que si quelqu'un essaie de lier un membre de données de some_data qui n'est pas conforme à l'interface SerializedXYZ, le compilateur vous en informera :

void send_msg(const SerializedFooBar&){
    std::cout << "Sent SerializedFooBar" << std::endl;
}

void send_msg(const SerializedBarBaz&){
    std::cout << "Sent SerializedBarBaz" << std::endl;
}

void send(const some_data& data) {
  send_msg( serialization_traits<SerializedFooBar>::encode(boost::fusion::tie(data.foo, data.bar)) );
  send_msg( serialization_traits<SerializedBarBaz>::encode(boost::fusion::tie(data.bar, data.baz)) );
//  send_msg( serialization_traits<SerializedFooBar>::encode(boost::fusion::tie(data.foo, data.baz)) ); // compiler error; SerializedFooBar has no set_baz member
}

int main() {

    some_data my_data;
    send(my_data);
}

Code aquí

EDIT :

Malheureusement, cette solution ne résout pas le problème n°1 du PO. Pour y remédier, nous pouvons définir une série de balises, une pour chacun de vos membres de données et suivre une approche similaire. Voici les balises, ainsi que la version modifiée serialized_set fonctions :

struct foo_tag{};
struct bar1_tag{};
struct bar2_tag{};
struct baz_tag{};
struct fbr_tag{};

template <class S>
void serialized_set(S& s, const some_data& data, foo_tag) {
    s.set_foo(data.foo);
}

template <class S>
void serialized_set(S& s, const some_data& data, bar1_tag) {
    s.set_bar1(data.bar1);
}

template <class S>
void serialized_set(S& s, const some_data& data, bar2_tag) {
    s.set_bar2(data.bar2);
}

template <class S>
void serialized_set(S& s, const some_data& data, baz_tag) {
    s.set_baz(data.baz);
}

template <class S>
void serialized_set(S& s, const some_data& data, fbr_tag) {
    s.set_fbr(data.fbr);
}

Le texte passe-partout est à nouveau limité à un serialized_set par membre de données et s'échelonne linéairement, comme dans ma réponse précédente. Voici les traites de sérialisation modifiées :

// the serialization_traits doesn't need specialization anymore :)
template <class SerializedX>
class serialization_traits {

    class set_functor {

        const some_data& m_data;

    public:

        typedef SerializedX& result_type;

        set_functor(const some_data& data)
        : m_data(data){}

        template <class Tag>
        SerializedX& operator()(SerializedX& s, Tag tag) const {
            serialized_set(s, m_data, tag);
            return s;
        }
    };

public:

    template <class Tuple>
    static SerializedX encode(const some_data& data, const Tuple& t) {
        SerializedX s;
        boost::fusion::fold(t, s, set_functor(data));
        return s;
    }
};

et voici comment cela fonctionne :

void send(const some_data& data) {

    send_msg( serialization_traits<SerializedFooBar>::encode(data,
    boost::fusion::make_tuple(foo_tag(), bar1_tag())));

    send_msg( serialization_traits<SerializedBarBaz>::encode(data,
    boost::fusion::make_tuple(baz_tag(), bar1_tag(), bar2_tag())));
}

Code mis à jour aquí

0 votes

Ça m'a l'air très bien. Quelque chose comme l'idée d'utiliser boost::fusion et son fold() était exactement ce que je cherchais. Votre serialized_set() réduisent le texte passe-partout au strict minimum (invoke set_foo() ) avec très peu de surcharge syntaxique pour envelopper ces appels de manière générique. Je ne suis pas encore sûr que cette solution s'adapte de manière récursive, et comme je suis actuellement plongé dans deux autres choses, je devrai la tester plus tard. Mais comme vous avez même fourni un lien vers le code compilé, j'ai très peu de scrupules à vous décerner le prix. Merci pour votre temps et vos efforts !

1 votes

Je suis très heureux d'avoir pu vous aider et j'espère vraiment que cela vous sera utile. La récompense de la prime serait bien, mais ce n'est pas la fin du monde si vous ne pouvez pas le faire :)

0 votes

D'après ce que je peux voir, cela ne regarde que les types des membres, et échoue donc lorsqu'il y a deux membres du même type. (Imaginez bar y baz étant tous deux du même type). C'est le numéro 1 de ma question. (Voir aquí .)

3voto

Cusiman7 Points 94

Si votre modèle de référence n'est qu'un ensemble de structures de données simples avec des opérateurs de comparaison triviaux, vous pouvez probablement vous en sortir avec quelques macros.

#define POD2(NAME, T0, N0, T1, N1) \
struct NAME { \
    T0 N0; \
    T1 N1; \
    NAME(const T0& N0, const T1& N1) \
        : N0(N0), N1(N1) {} \
    bool operator==(const NAME& rhs) const { return N0 == rhs.N0 && N1 == rhs.N1; } 
\
    bool operator!=(const NAME& rhs) const { return !operator==(rhs); } \
};

L'usage serait le suivant :

POD2(BarBaz, Bar, bar, Baz, baz)

template <>
struct serialization_traits<BarBaz> {
    static SerializedBarBaz encode(const BarBaz& bb) {
        SerializedBarBaz sbb;
        sbb.set_bar(bb.bar);
        sbb.set_baz(bb.baz);
        return sbb;
    }
};

Vous auriez besoin de N macros où N est le nombre de permutations du nombre d'arguments que vous avez, mais ce serait un coût initial unique.

Vous pouvez également utiliser les tuples pour faire le gros du travail à votre place, comme vous l'avez suggéré. Ici, j'ai créé un modèle "NamedTuple" pour nommer les getters du tuple.

#define NAMED_TUPLE2_T(N0, N1) NamedTuple##N0##N1

#define NAMED_TUPLE2(N0, N1) \
template <typename T0, typename T1> \
struct NAMED_TUPLE2_T(N0, N1) { \
    typedef std::tuple<T0, T1> TupleType; \
    const typename std::tuple_element<0, TupleType>::type& N0() const { return std::get<0>(tuple_); } \
    const typename std::tuple_element<1, TupleType>::type& N1() const { return std::get<1>(tuple_); } \
    NAMED_TUPLE2_T(N0, N1)(const std::tuple<T0, T1>& tuple) : tuple_(tuple) {} \
    bool operator==(const NAMED_TUPLE2_T(N0, N1)& rhs) const { return tuple_ == rhs.tuple_; } \
    bool operator!=(const NAMED_TUPLE2_T(N0, N1)& rhs) const { return !operator==(rhs); } \
    private: \
        TupleType tuple_; \
}; \
typedef NAMED_TUPLE2_T(N0, N1)

Utilisation :

NAMED_TUPLE2(foo, bar)<int, int> FooBar;

template <>
struct serialization_traits<FooBar> {
    static SerializedFooBar encode(const FooBar& fb) {
        SerializedFooBar sfb;
        sfb.set_foo(fb.foo());
        sfb.set_bar(fb.bar());
        return sfb;
    }
};

0 votes

Malheureusement, je n'ai aucun contrôle sur le Serialized... types. Ils sont générés.

3voto

Puppy Points 90818

Ce que vous voulez, c'est quelque chose qui est de type tuple mais pas un tuple réel. En supposant que tous les tuple_like les classes mettent en œuvre tie() qui ne fait que lier leurs membres, voici mon code hypothétique :

template<typename T> struct tuple_like {
    bool operator==(const T& rhs) const {
        return this->tie() == rhs.tie();
    }
    bool operator!=(const T& rhs) const {
        return !operator==(*this,rhs);
    }        
};
template<typename T, typename Serialised> struct serialised_tuple_like : tuple_like<T> {
};
template<typename T, typename Serialised>
struct serialization_traits<serialised_tuple_like<T, Serialised>> {
    static Serialised encode(const T& bb) {
        Serialised s;
        s.tie() = bb.tie();
        return s;
    }
};

Tant que les deux parties mettent en œuvre un tie() approprié, cela devrait aller. Si les classes source ou destination ne sont pas directement dans votre contrôle, nous vous recommandons de définir une classe héritée qui implémente tie() et de l'utiliser. Pour fusionner plusieurs classes, définissez une classe d'aide qui implémente tie() au niveau de ses membres.

0 votes

Mhmm. L'utilisation d'une classe héritée semble résoudre le problème de marquage des tuples syntaxiquement égaux mais sémantiquement différents, c'est donc une bonne idée ( +1 de moi). Cependant, je ne peux pas ajouter un tie() a Serialized car il s'agit d'une classe (générée par protobuf) qui n'est pas sous mon contrôle. (Fondamentalement, serialization_traits<>::encode() est ce tie() ce qui nous ramène au problème n° 2 de ma question. Voyez-vous un moyen de contourner ce problème ?

2voto

Charlie Points 1209

Avez-vous envisagé une approche légèrement différente ? Plutôt que d'avoir une représentation séparée de FooBar et de BarBaz, envisagez un FooBarBaz similaire à

message FooBarBaz {
  optional Foo foo = 1;
  optional Bar bar = 2;
  optional Baz baz = 3;
}

Et ensuite, dans votre code d'application, vous pouvez en tirer parti comme suit :

FooBarBaz foo;
foo.set_foo(...);
FooBarBaz bar;
bar.set_bar(...);
FooBarBaz baz;
baz.set_baz(...);
FooBarBaz foobar = foo;
foobar.MergeFrom(bar);
FooBarBaz barbaz = bar;
barbaz.MergeFrom(baz);

Alternativement, vous pouvez tirer parti de l'encodage protobuf et sérialiser les messages. (le protobuf lui-même n'est pas réellement sérialisé, vous l'obtiendrez en appelant l'une des méthodes ToString sur lui).

// assume string_foo is the actual serialized foo from above, likewise string_bar
string serialized_foobar = string_foo + string_bar;
string serialized_barbaz = string_bar + string_baz;

FooBarBaz barbaz;
barbaz.ParseFromString(serialized_barbaz);

Cela suppose que vous puissiez éloigner la plupart de vos apis des ensembles explicites de champs et les orienter vers des messages communs avec des champs facultatifs pour n'envoyer que ce dont vous avez besoin. Vous pouvez vouloir envelopper les bords de votre système afin d'affirmer que les champs requis pour un processus particulier sont définis avant d'essayer de l'utiliser, mais cela pourrait conduire à moins de texte passe-partout ailleurs. L'astuce de la concaténation de chaînes de caractères peut également être pratique dans les cas où vous passez par un système qui ne se soucie pas vraiment de ce qu'elles contiennent.

0 votes

Je n'ai aucun contrôle sur les messages. Actuellement, protobuf est le schéma d'encodage le plus utilisé, mais JSON est également utilisé. Et qui sait ce que nous utiliserons l'année prochaine. Non, le but des traits de sérialisation est de découpler notre code de cela.

0 votes

Je crois que ce que j'ai lu, c'est que vous avez une sorte d'état du système, et une sorte d'états partiels du système. Ma suggestion était d'utiliser un état système incomplet pour représenter les états partiels plutôt que d'avoir plusieurs définitions qui se chevauchent et qui sont essentiellement des états partiels. Protobuf a quelques caractéristiques qui rendent cela possible (et elles pourraient encore être enterrées dans les implémentations de l'encodeur). Cela ne fonctionne pas aussi bien une fois que vous avez impliqué json, bien qu'il y ait des bibliothèques protobuf vers json. En théorie, vous pourriez utiliser protobuf en interne, le sérialiser avec la réflexion.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X