当class遇上union

今天同事又踩到一个以前设计时留下的坑,这次是关于union和class中的。 虽然这种设计我并不认同, 但是至少我觉得设计者对于c++的成员内存布局相当了解。

由于面向对象的存在, 在代码中常常有这样一种用于存储属性的类,类A,类B, 类C,类B继承自类A,类C继承自类B。 而类A, 类B, 类C等这些类的实例都是从socket层传过来的。

作者在设计时为了代码的复用性, 采用了如下设计:

union object {
class A a;
class B b;
class C c;
};

//read_objectX_from_socket函数为伪码, 其实现为逐个读出某个类的成员, 至于这个函数为什么会是这样实现, 这是socket层上的另一个设计问题了, 暂且不谈

void readA(union object &o)
{
read_objectA_from_socket(o.a);
}

void readB(union object &o)
{
readA(o);
read_objectB_from_socket(o.b);
}

void readC(union object &o)
{
readA(o);
readB(o);
read_objectC_from_socket(o.c);
}

从上面看出作者对于C++中的成员变量的内存布局相当有信心, 才会想到使用union的方式来复用代码。

假设这三个类的成员定义如下:class A {int a;}, class B : public A {int b;}, class C : public B {int c;}.

那么此union中的内存布局其实就是A::a, B::b, C::c。
object::a所占的空间就是A::a在union中所占的内存
object::b所占的空间就是A::a和B::b在union中所占的内存
object::c所占的空间就是A::a和B::b和B::c在union中所占的内存

设计者巧妙的利用了union的重叠特性和class的继承特性来完成了代码复用。

大约在上学的时候我也喜欢去hack内存布局(当然没有这种用法这么巧妙), 后来我便渐渐不大喜欢这种做法了.

因为这种代码虽然写起来有种炫技的自豪感, 但事实上一旦出了bug是极难发现的, 人类在汇编语言基础之上又发明了高级语言, 我想也正是因为他们觉得人们需要更多的规则来帮人们减少出错的可能性, 所以我后来便一直主张写出更多可以让编译器检查出错误的代码。


同事踩的坑也正验证了hack内存布局易错不易查的事实。 由于某种偷懒原因, 他实现了class D : public B {int d;}, class F : public C, public D { int f;}。而readF的实现代码如下:

void readF(union object &o)
{
readA(o);
readB(o);
readC(o);
readD(o);

read_objectF_from_socket(o.c);
}

在实现readD时代码看起来依然正常运行, 但是在实现readF时, 明明看到有数据读入,但是类F中继承自C和D的成员总是莫名其妙乱掉(当然这不是我发现的, 这个bug只是我事后知道的罢了)。

此时重新看一下union, 那么C::c和D::d占用的是同一片内存, 那么其实在readF中调用readC和readD时, 覆盖的总是同一块内存。

再看class F的内存布局应该是A::a, b::b, C::c, D::d, f, 也就是说整个readF执行下来, 其实F::D::d这个变量从来就没被操作过, 也就不可能赋值, 由于栈中的随机数, 所以F::D::d这个变量也就变得随机了, 由于object::C::c所占的内存总是会被readC和readD同时操作。因此看上去数据也不那么的有迹可寻。


当第一眼看到这种设计时, 虽然觉得不妥, 但是我并没有找到一种可以不通过hack内存来达到最大代码复用的方式。

在下班回来的路上, 我总觉得应该可以不通过hack内存来达到同样的目的, 终于在快到住的地方时被我想到的了。 其实很简单, 之所以想不到一方面是我不常用C++, 另一方面大概是之前看了这段代码, 一时间先入为主, 思维没有缓过劲来。

其实只需要按照C++最常规的dynamic_cast就可以完成代码的最大复用。

代码接口大概实现如下:


void readA(class A *a)
{
//read a members
}

....

void readD(class F *a)
{
readA(a);
readB(a);
//read D members
}

由于是dynamic_cast, 因此编译器可以帮我们自动去计算每个成员的偏移量, 避免了手动hack可能出现的各种错误。

btw,使用dynamic_cast的方式仅仅能依靠编译器发现这种hack内存时容易出现的bug。 在碰到类似class F这种使用多重继承机制的类时,编译器仅仅会报语法错误,并不能对其父类的readX函数进行复用。

发表评论

× one = three