我有一个传输二进制数据的设备。 为了解释数据,我定义了一个与数据格式匹配的struct
。struct
与LayoutKind.Sequential
有StuctLayoutAttribute
。这按预期工作,例如:
[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
。