使用 [StructLayout(LayoutKind.Sequential)] 解组时,C# 将结构的 vtable 存储在何处



我有一个传输二进制数据的设备。 为了解释数据,我定义了一个与数据格式匹配的structstructLayoutKind.SequentialStuctLayoutAttribute。这按预期工作,例如:

[StructLayout(LayoutKind.Sequential)]
struct DemoPlain
{
public int x;
public int y;
}
Marshal.OffsetOf<DemoPlain>("x");    // yields 0, as expected
Marshal.OffsetOf<DemoPlain>("y");    // yields 4, as expected
Marshal.SizeOf<DemoPlain>();         // yields 8, as expected

现在我希望处理一个结构类似于另一个结构,所以我尝试了实现接口的结构:

interface IDemo
{
int Product();
}

[StructLayout(LayoutKind.Sequential)]
struct DemoWithInterface: IDemo
{
public int x;
public int y;
public int Product() => x * y;
}
Marshal.OffsetOf<DemoWithInterface>("x").Dump();    // yields 0
Marshal.OffsetOf<DemoWithInterface>("y").Dump();    // yields 4
Marshal.SizeOf<DemoWithInterface>().Dump();         // yields 8

令我惊讶的是,DemoWithInterface的偏移量和大小与DemoPlain相同,并将相同的二进制数据从设备转换为DemoPlain数组或DemoWithInterface数组,两者都有效。 这怎么可能?

C++实现通常使用 vtable(请参阅 vtable 存储在内存中的什么位置?)来破坏虚拟方法。 我相信在接口中发布的 C# 方法和声明virtual的方法类似于 C++ 中的虚拟方法,并且需要类似于 vtable 的东西才能找到正确的方法。 这是正确的还是 C# 的做法完全不同? 如果正确,类似 vtable 的结构存储在哪里? 如果不同,C# 是如何在接口继承和虚拟方法方面实现的?

基本上,"不适用"。如前所述,C# 中的结构不支持继承,因此不需要向量表。

字段布局是字段布局。很简单:实际字段在哪里。实现接口根本不会更改字段,也不需要对布局进行任何更改。所以这就是为什么大小和布局不受影响的原因。

有一些虚拟方法,结构可以(并且通常应该)覆盖 -ToString()等。所以你可以合理地问"那是如何工作的?"——答案是:烟雾和镜子。也称为约束调用。这会将"虚拟调用与静态调用"的问题推迟到 JIT。JIT 完全了解该方法是否被重写,并且可以发出适当的操作码 - 盒子和虚拟调用(盒子是一个对象,所以有一个 v-table),或者直接静态调用。

可能很容易认为编译器应该这样做,而不是 JIT - 但结构通常位于外部程序集中,如果编译器发出静态调用,那将是灾难性的,因为它可以看到被覆盖的ToString()等,然后有人更新库而不重新生成应用程序,并且它得到一个没有的版本覆盖 (MissingMethodException) - 因此约束调用更可靠。即使对于装配内类型,也做同样的事情,只是更简单,更容易支持。

这种约束调用也发生在泛型(<T>)方法上 - 因为T可能是一个struct。回想一下,JIT 在泛型方法上按T执行值类型T,因此它可以按类型应用此逻辑,并在实际已知的静态调用位置进行烘焙。如果你正在使用类似.ToString()的东西,并且你的T是一个不会覆盖它的结构:它将代替盒子和虚拟调用。

请注意,将结构分配给接口变量后 - 例如:

DemoWithInterface foo = default;
IDemo bar = foo;
var i = bar.Product();

您已经"装箱"了它,现在一切都在盒子上虚拟调用。一个盒子有一个完整的V表。这就是为什么具有泛型类型约束的泛型方法通常更可取的原因:

DemoWithInterface foo = default;
DoSomething(foo);
void DoSomething<T>(T obj) where T : IDemo
{
//...
int i = obj.Product();
//...
}

将始终使用受约束的调用,并且不需要框,尽管访问接口成员。JIT 在执行时解析特定T的静态调用选项。

默认编组行为 |Microsoft文档,尤其是平台调用中使用的值类型部分提供了答案:

封送到非托管代码时,这些格式化类型将封送为 C 样式结构。

将格式化类型封送为结构时,只能访问类型中的字段。如果类型具有方法、属性或事件,则无法从非托管代码访问它们。

因此,C#struct的(虚拟)方法被剥离,只传输了一个普通的 C 结构。 在OP的情况下,设备发送包含普通C结构的字节,Marshal.PtrToStructure<T>(IntPtr)将字节转换为C#结构,并在DemoWithInterface的情况下附加Product方法和vtable(或其他元数据)以使结构实现IDemo

最新更新