如何减少序列化当前所需的样板文件

How to reduce boilerplate currently necessary for serialization

提问人:sbi 提问时间:5/15/2018 最后编辑:sbi 更新时间:6/22/2018 访问量:1522

问:

我们的软件正在抽象出硬件,我们有表示该硬件状态的类,并且具有该外部硬件的所有属性的大量数据成员。我们需要定期更新有关该状态的其他组件,为此,我们通过 MQTT 和其他消息传递协议发送 protobuf 编码的消息。有不同的消息描述了硬件的不同方面,因此我们需要发送这些类数据的不同视图。下面是一个草图:

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

假设我们需要发送一条包含 和 的消息,以及一条包含 和 的消息。我们目前的做法是很多样板:foobarbarbaz

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;
  }
};

然后可以发送:

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)) );
}

鉴于要发送的数据集通常比两个项目大得多,我们也需要解码这些数据,并且我们有大量这样的消息,因此涉及的样板比这个草图中的内容要多得多。所以我一直在寻找一种方法来减少这种情况。这是第一个想法:

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)) );
}

我让它工作,它大大减少了样板。(不是在这个小例子中,但如果你想象十几个数据点被编码和解码,很多重复的数据成员列表消失会产生很大的不同)。但是,这有两个缺点:

  1. 这依赖于 、 和 是不同的类型。如果它们都是,我们需要向元组添加一个虚拟标签类型。FooBarBazint

    这是可以做到的,但它确实使整个想法的吸引力大大降低。

  2. 旧代码中的变量名称在新代码中变成了注释和数字。这是非常糟糕的,并且考虑到编码和解码中可能存在混淆两个成员的错误,因此无法在简单的单元测试中捕获它,而是需要通过其他技术(因此集成测试)创建的测试组件来捕获此类错误。

    我不知道如何解决这个问题。

有没有人比我们更清楚如何减少样板?

注意:

  • 目前,我们只能使用 C++03。是的,你没看错。对我们来说,这是.没有 lambda。也没有。std::tr1::tupleauto
  • 我们有大量代码使用这些序列化特征。我们不能抛弃整个计划,做一些完全不同的事情。我正在寻找一种解决方案来简化适合现有框架的未来代码。任何需要我们重写整个事情的想法都很可能被驳回。
C++ 样板 冗余 stdtuple

评论

2赞 Jesper Juhl 5/15/2018
听起来你想编写一个程序,用一种简单的语言读取一个文件,然后为你生成所有的 c++ 样板,然后你编译。获胜的代码生成器。具有简单语法的简单 yacc/bison 解析器甚至可以做到。
0赞 sbi 5/15/2018
@JesperJuhl:这确实是我们一直在寻找的解决方案之一。不过,我宁愿在 C++ 中找到解决方案,然后在我们的构建过程中添加另一个代码生成器,当当前的程序员早已退休时,人们将不得不维护它......
0赞 Jens 5/15/2018
既然消息是 protobuf 编码的,为什么不使用 protobuf 生成代码呢?
0赞 sbi 5/15/2018
@Jens:关于如何编写 protobuf 后端,您有什么建议吗?然而,这是这个问题的众多简化之一:我们的消息大多是原型编码的。目前。我们已经有几个项目发送 JSON 消息。谁知道我们将来会做什么......
2赞 Jesper Juhl 5/15/2018
"...当现在的程序员早就退休了......“的时候,你就是那些”现在的程序员“之一。当你退休后,你不再需要关心;-)(请注意眨眼的笑脸)。

答:

13赞 Acorn 5/15/2018 #1

在我看来,最好的全能解决方案是脚本语言中的外部 C++ 代码生成器。它具有以下优点:

  • 灵活性:它允许您随时更改生成的代码。这非常好,有几个子原因:

    • 轻松修复所有受支持的旧版本中的错误。
    • 如果将来迁移到 C++11 或更高版本,请使用新的 C++ 功能。
    • 为其他语言生成代码。这是非常非常有用的(特别是如果您的组织很大和/或您有很多用户)。例如,您可以输出一个小型脚本库(例如 Python 模块),该库可用作 CLI 工具与硬件交互。根据我的经验,硬件工程师非常喜欢这一点。
    • 生成 GUI 代码(或 GUI 描述,例如 XML/JSON 格式,甚至是 Web 界面)——对于使用最终硬件和测试人员很有用。
    • 生成其他类型的数据。例如,图表、统计数据等。甚至是 protobuf 描述本身。
  • 维护:它比 C++ 更容易维护。即使它是用不同的语言编写的,学习该语言通常也比让新的 C++ 开发人员深入研究 C++ 模板元编程(特别是在 C++03 中)更容易。

  • 性能:它可以轻松减少 C++ 端的编译时间(因为您可以输出非常简单的 C++ - 甚至是普通的 C)。当然,发电机可能会抵消这一优势。在您的情况下,这可能不适用,因为您看起来无法更改客户端代码。

我已经在几个项目/系统中使用了这种方法,结果非常好。特别是使用硬件的不同替代方案(C++ 库,Python库,CLI,GUI...)可以非常赞赏。


旁注:如果生成的一部分需要解析已经存在的 C++ 代码(例如,带有要序列化的数据类型的标头,就像 OP 的类型一样);那么一个非常好的解决方案是使用 LLVM/clang 的工具来做到这一点。Serialized

在我参与的一个特定项目中,我们必须自动序列化数十种 C++ 类型(用户随时可以更改这些类型)。我们设法通过仅使用 clang Python 绑定自动为其生成代码,并将其集成到构建过程中。虽然 Python 绑定没有公开所有 AST 细节(至少在当时),但它们足以为我们的所有类型(包括模板化类、容器等)生成所需的序列化代码。

评论

0赞 Acorn 5/15/2018
@sbi:我明白了。好吧,这个答案试图给你一个概述,为什么这条路线可以比C++解决方案更好。无论如何,因为项目中已经有太多的工具(或构建步骤)而拒绝一个工具并不是一个非常可靠的论据(在我看来)——系统越复杂,管理其复杂性就越多。根据我的经验,试图将所有复杂性融入 C++03 代码,而它很容易在它之外,从长远来看不是一个好主意:-)
1赞 Useless 6/15/2018
用(例如)Python 编写代码生成。将 codegen 脚本与代码的其余部分(或构建系统的其余部分)一起签入。无需外部工具,无需安装。
1赞 Useless 6/18/2018
@sbi - IME,你错了。我以前编写过功能完善的 Python codegen,没有外部包,为预先存在的有线协议生成多种语言绑定。codegen 脚本与源代码一起签入,作为构建的一部分运行,这甚至并不困难。
1赞 Acorn 6/18/2018
@Useless:同意。完全相同的体验:Python codegen,签入,在构建时运行,使用 Linux 发行版打包的任何 Python 版本。甚至在 OS X 中使用他们捆绑的 Python 工作。实际上,Python 是一个非常好的平台,可以编写整个构建系统(例如 Meson、SCons......
3赞 Puppy 5/15/2018 #2

你想要的是类似元组的东西,但不是实际的元组。假设所有类的实现基本上只是绑定它们的成员,这是我的假设代码:tuple_liketie()

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;
    }
};

只要双方都实现适当的 tie(),这应该没问题。如果源类或目标类不直接在您的控制范围内,建议定义一个实现 tie() 的继承类并使用它。要合并多个类,请定义一个帮助类,该类根据其成员实现 tie()。

评论

0赞 sbi 6/10/2018
使用继承类听起来可以解决语法相同但语义不同的元组的标记问题,所以这是一个好主意(来自我)。但是,我不能添加一个 ,因为它是一个(protobuf生成的)类,不受我的控制。(基本上,就是这个函数,它让我们回到了我问题中的问题#2。你有什么办法可以解决这个问题吗?+1tie()Serializedserialization_traits<>::encode()tie()
3赞 Cusiman7 6/10/2018 #3

如果你的样板真的只是一堆普通的旧数据结构,带有简单的比较运算符,你可能会得到一些宏。

#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); } \
};

用法如下所示:

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;
    }
};

您将需要 N 个宏,其中 N 是您拥有的参数计数的排列数,但这将是一次性的前期成本。

或者,您可以像您建议的那样利用元组为您完成许多繁重的工作。在这里,我创建了一个“NamedTuple”模板,用于命名元组的 getter。

#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)

用法:

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赞 sbi 6/13/2018
不幸的是,我无法控制类型。它们是生成的。Serialized...
7赞 linuxfever 6/14/2018 #4

我将基于您提出的解决方案,但改用 boost::fusion::tuples(假设这是允许的)。假设您的数据类型为

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

您的数据是

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

从评论中,我了解到您无法控制 SerialisedXYZ 类,但它们确实具有特定的接口。我会假设这样的事情已经足够接近(?):

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;
    }
};

现在,我们可以减少样板,并将其限制为每个数据类型排列一个 typedef,并限制为 SerializedXYZ 类的每个set_XXX成员的一个简单重载,如下所示:

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);
}
//...

现在的好处是,您不再需要专门化您的serialization_traits。下面使用了 boost::fusion::fold 函数,我认为可以在您的项目中使用它:

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;
    }
};

以下是它如何工作的一些示例。请注意,如果有人尝试从不符合 SerializedXYZ 接口的 some_data绑定数据成员,编译器将通知您:

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);
}

代码在这里

编辑:

不幸的是,此解决方案无法解决 OP 的问题 #1。为了解决这个问题,我们可以定义一系列标签,每个数据成员一个标签,并遵循类似的方法。以下是标签以及修改后的函数:serialized_set

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);
}

样板再次限制为每个数据成员一个,并且线性扩展,类似于我之前的答案。以下是修改后的serialization_traits:serialized_set

// 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;
    }
};

这是它的工作原理:

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())));
}

在此处更新了代码

评论

0赞 sbi 6/18/2018
这在我看来非常好。就像使用 boost::fusion 的想法一样,这正是我所追求的。您的函数将样板减少到绝对最小值 (invoke ),并且通常包装这些调用的语法开销非常小。我还不确定这个解决方案是否以递归方式扩展,并且由于我目前在另外两件事上达到了我的脖子,我稍后将不得不对此进行测试。但是,由于您甚至提供了指向编译代码的链接,因此我对授予您奖项几乎没有任何疑虑。感谢您的时间和精力!fold()serialized_set()set_foo()
1赞 linuxfever 6/18/2018
我很高兴我能提供帮助,真的希望你能发现这对你有所帮助。赏金奖励会很好,但如果你不能做到,那就不是世界末日:)
0赞 sbi 6/18/2018
据我所知,这仅查看成员的类型,因此当有两个相同类型的成员时会失败。(想象一下,两者都属于同一类型。这是我问题中的#1。(请参阅此处barbaz
2赞 linuxfever 6/19/2018
让我们在聊天中继续讨论
1赞 sbi 6/25/2018
所以聊天中的讨论归结为这一点,我对此感到非常高兴。
2赞 Charlie 6/21/2018 #5

您是否考虑过稍微不同的方法?与其使用单独的 FooBar 和 BarBaz 表示形式,不如考虑类似于

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

然后在应用程序代码中,您可以利用它,如下所示:

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);

或者,您可以利用 protobuf 编码并序列化消息。(protobuf 本身实际上并没有序列化,您可以通过调用其上的 ToString 方法之一来获得它)。

// 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);

这确实假设您可以将大部分 API 从显式字段集移开,转向带有可选字段的常见消息,以仅发送您需要的内容。您可能希望在尝试使用特定进程所需的字段之前对系统的边缘进行包装,以断言已设置特定进程所需的字段,但这可能会导致其他地方的样板更少。字符串 concat 技巧在您通过一个实际上并不关心其中内容的系统的情况下也很方便。

评论

0赞 sbi 6/21/2018
我无法控制这些消息。虽然目前 protobuf 是最常用的编码方案,但也使用 JSON。谁知道我们明年会用到什么。不,序列化特征的全部意义在于将我们的代码与此解耦。
0赞 Charlie 6/21/2018
我想我读到的是你有某种系统状态,以及某种部分系统状态。我的建议是使用不完整的系统状态来表示部分状态,而不是使用多个重叠的定义来表示部分状态。Protobuf 具有一些使这成为可能的功能(它们仍然可以隐藏在编码器实现中)。一旦你涉及 json,这就不那么好用了,尽管那里有 protobuf 到 json 库。从理论上讲,您可以在内部使用 protobuf,使用反射进行序列化。