12

在以前的雇主中,我们正在编写必须“通过网络”传输到其他计算机的二进制消息。每条消息都有一个标准标题,例如:

class Header
{
    int type;
    int payloadLength;
};

所有数据都是连续的(标题,紧随其后的数据)。鉴于我们有一个指向标头的指针,我们想要访问有效负载。传统上,您可能会这样说:

char* Header::GetPayload()
{
    return ((char*) &payloadLength) + sizeof(payloadLength);
}

甚至:

char* Header::GetPayload()
{
    return ((char*) this) + sizeof(Header);
}

这似乎有点冗长,所以我想出了:

char* Header::GetPayload()
{
    return (char*) &this[1];
}

起初它似乎相当令人不安,使用起来可能太奇怪了——但非常紧凑。关于它是聪明的还是可憎的,有很多争论。

那么它是什么 - 对编码的犯罪,还是很好的解决方案?你有过类似的取舍吗?

-更新:

我们确实尝试了零大小的数组,但当时编译器给出了警告。我们最终采用了继承的技术:Message 派生自 Header。它在实践中效果很好,但原则上你说的是一条消息 IsA Header - 这似乎有点尴尬。

4

17 回答 17

42

我会反对编码犯罪。

两种方法都将生成完全相同的目标代码。第一个明确了它的意图。第二个非常令人困惑,唯一的优点是它节省了几次击键。(只要学着打字)。

另外,请注意,这两种方法都不能保证有效。对象的 sizeof() 包括用于字对齐的填充,因此如果标题是:

class Header
{
    int type;
    int payloadLength;
    char  status;
};

您描述的两种方法都将有效负载从 Header+12 开始,而实际上它很可能从 Header+9 开始。

于 2008-10-21T19:48:45.597 回答
14

就我个人而言,我认为如果有犯罪,它会向头部询问有效载荷。

但只要你打算那样做,'this+1' 就和任何方法一样好。

理由:'&this[1]' 是一段通用代码,不需要您深入研究类定义即可完全理解,并且不需要在有人更改类的名称或内容时进行修复。

顺便说一句,第一个例子是真正的反人类罪。在类的末尾添加一个成员,它会失败。在班级周围移动成员,它会失败。如果编译器填充类,它将失败。

此外,如果您要假设编译器的类/结构布局与您的数据包布局相匹配,那么您应该了解相关编译器的工作原理。例如。在 MSVC 上,您可能想了解#pragma pack.

PS:有多少人认为“this+1”或“&this[1]”难以阅读或理解,这有点可怕。

于 2008-10-21T19:47:28.237 回答
14

您依赖编译器以特定方式布局您的类。我会将消息定义为一个结构(由我定义布局),并有一个封装消息并为其提供接口的类。清晰的代码 = 好的代码。“可爱”的代码 = 糟糕(难以维护)的代码。

struct Header
{
    int type;
    int payloadlength;
}
struct MessageBuffer
{
   struct Header header;
   char[MAXSIZE] payload;
}

class Message
{
  private:
   MessageBuffer m;

  public:
   Message( MessageBuffer buf ) { m = buf; }

   struct Header GetHeader( )
   {
      return m.header;
   }

   char* GetPayLoad( )
   {
      return &m.payload;
   }
}

自从我编写任何 C++ 以来已经有一段时间了,所以请原谅任何语法问题。只是试图传达总体思路。

于 2008-10-21T20:00:23.220 回答
13

这是一个常见的问题,但你真正想要的是这个。

class Header
{
    int type;
    int payloadLength;
    char payload[0];

};

char* Header::GetPayload()
{
    return payload;
}
于 2008-10-21T22:24:18.577 回答
5

我的投票是编码恐怖。不要误会我的意思,它很聪明——但是你为自己节省了一个完整的加法运算,代价是使代码更难理解和阅读。我不认为这种权衡是值得的。

于 2008-10-21T19:48:42.587 回答
5

如果标头需要“返回”未包含在其中的数据,我认为这从一开始就有缺陷。

由于您已经将自己置于这些骇人听闻的基础上,因此我真的很喜欢您的想法。

但请注意,这不是选美比赛。您应该找到一个完全不同的解决方案。对于您提供的所有三个版本的 GetPayload(),如果没有您的进一步解释,我将无法理解那里到底发生了什么。

于 2008-10-21T19:50:52.567 回答
4

您是否考虑过“空数组成员”技巧?我记得经常看到它,甚至使用过一次或两次,但我似乎找不到任何真正好的参考资料(也许除了下面提到的那个)。

诀窍是将您的结构声明为

struct bla {
    int i;
    int j;
    char data[0];
}

然后,“数据”成员只是指向标题后面的任何内容。我不确定它的便携性;我也看到它以 '1' 作为数组大小。

(使用下面的 URL 作为参考,使用 '[1]' 语法,似乎不起作用,因为它太长了。这是链接:)

http://developer.apple.com/documentation/DeveloperTools/gcc-4.0.1/gcc/Zero-Length.html

于 2008-10-21T20:51:23.770 回答
3

如果它有效——始终如一——那么它就是一个优雅的解决方案。

它通常会在内存中工作,因为编译器会处理对齐问题,并且您可以假设 Payload 在正确对齐的内存空间中跟随标头。

当 Header/Payload 对象“通过网络”流式传输时,我可以看到这种情况分崩离析,因为您使用的流式传输机制可能不关心在任何特定边界上对齐对象。因此,Payload 可以直接跟在 Header 后面而没有填充,以强制它进行特定的对齐。

用一句话来说,优雅就是优雅。因此,只要您小心流式传输它,它就很优雅。

于 2008-10-21T19:56:01.607 回答
2

首先,“反对编码的犯罪”和“好的解决方案”之间有很大的空间,但我想说这更接近前者。

Header 是它的 Payload 的守护者吗?

这是这里的基本问题——标头和有效负载都应该由另一个保存整个消息的对象管理,这是请求有效负载的适当位置。它可以在没有指针算术或索引的情况下这样做。

鉴于此,我赞成第二种解决方案,因为它更清楚发生了什么。

但我们一开始就处于这种情况似乎表明你们团队的文化重视聪明而不是清晰,所以我想所有的赌注都没有了。

如果你真的想变得可爱,你可以概括。

template<typename T. typename RetType>
RetType JustPast(const T* pHeader)
{
   return reinterpret_cast<RetType>(pHeader + sizeof(T));
}
于 2008-10-21T20:29:22.350 回答
1

就我而言,它们基本上是一样的。两者都是字节杂耍的形式,这总是有风险的,但并非不可能做到正确。第一种形式更容易被接受和识别。我会亲自写:

char* Header::GetPayload()
{
    return ((char*) this) + sizeof(*this);
}
于 2008-10-21T19:50:00.527 回答
1

不要忘记 VC++ 可能会对sizeof()类的值施加填充。由于提供的示例预计为 8 个字节,因此它会自动对齐 DWORD,所以应该没问题。检查#pragma pack

虽然,我同意,提供的例子是某种程度的编码恐怖。当可变长度数据跟随时,许多 Win32 数据结构在标头结构中包含一个指针占位符。一旦加载到内存中,这可能是引用此数据的最简单方法。MAPISRowSet结构就是这种方法的一个例子。

于 2008-10-21T20:03:44.943 回答
1

我实际上做了类似的事情,几乎所有的 MMO 或在线视频游戏也是如此。尽管他们有一个称为“数据包”的概念,并且每个数据包都有自己的布局。所以你可能有:

struct header
{
    short id;
    short size;
}

struct foo
{
    header hd;
    short hit_points;
}


short get_foo_data(char *packet)
{
    return reinterpret_cast<foo*>(packet)->hit_points;
}

void handle_packet(char *packet)
{
    header *hd = reinterpret_cast<header*>(packet);
    switch(hd->id)
    {
        case FOO_PACKET_ID:
            short val = get_foo_data(packet);
        //snip
    }
}

他们对大部分数据包都这样做。一些数据包显然具有动态大小,对于这些成员,它们使用长度前缀字段和一些逻辑来解析该数据。

于 2008-10-21T21:37:15.007 回答
1

我认为在当今时代,在 C++ 中,将 C 风格转换为 char* 会使您失去任何“出色设计理念”奖项的资格,而没有得到太多的聆听。

我可能会去:

#include <stdint.h>
#include <arpa/inet.h>

class Header {
private:
    uint32_t type;
    uint32_t payloadlength;
public:
    uint32_t getType() { return ntohl(type); }
    uint32_t getPayloadLength() { return ntohl(payloadlength); }
};

class Message {
private:
    Header head;
    char payload[1]; /* or maybe std::vector<char>: see below */
public:
    uint32_t getType() { return head.getType(); }
    uint32_t getPayloadLength() { return head.getPayloadLength(); }
    const char *getPayload() { return payload; }
};

当然,这是假设 C99-ish POSIX:要移植到非 POSIX 平台,您必须根据平台提供的任何内容自行定义 uint32_t 和 ntohl 之一或两者。通常并不难。

理论上,您可能需要两个类中的布局编译指示。在实践中,考虑到这种情况下的实际字段,我会感到惊讶。可以通过一次从 iostream 读取/写入数据一个字段来避免该问题,而不是尝试在内存中构造消息的字节然后一次性写入。这也意味着你可以用比 char[] 更有用的东西来表示有效负载,这反过来意味着你不需要有最大的消息大小,或者弄乱 malloc 和/或放置 new 等等。当然,它引入了一些开销。

于 2008-10-22T00:37:54.610 回答
0

也许您应该使用详细方法,但用#define 宏替换它?通过这种方式,您可以在键入时使用速记,但任何需要调试代码的人都可以毫无问题地跟进。

于 2008-10-21T19:48:47.563 回答
0

我投票给 &this[1]。我已经看到它在解析已加载到内存中的文件(同样可以包括接收到的数据包)时使用了很多。当你第一次看到它时,它可能看起来有点奇怪,但我认为它的含义应该立即显而易见:它显然是刚刚超过这个对象的内存地址。这很好,因为很难出错。

于 2008-10-21T23:39:40.890 回答
0

我不喜欢使用“犯罪”之类的词。我宁愿指出 &this[1] 似乎对编译器可能不同意的内存布局做出了假设。例如,任何编译器都可能出于自身原因(如对齐),在结构中的任何位置插入虚拟字节。如果编译器或选项发生更改,我更喜欢一种能够更多保证获得正确偏移量的技术。

于 2009-01-06T00:11:25.127 回答
0

除了上述之外,我想说这是对互操作性和良好的有线协议设计原则的犯罪。令人惊讶的是,有多少程序员不能/不愿意明确区分有线协议定义和它的实现。如果您的协议必须存活超过两天,它很可能必须存活超过两年/操作系统/编译器/语言/字节序,并且在某些时候它会中断,而不是迟早。因此,让其他人的生活更轻松,写下有线协议规范并编写适当的(反)序列化例程。否则,人们会在不太愉快的环境中不断提及您的名字。

于 2009-02-03T07:45:41.420 回答