为什么 LayoutKind.Sequential 在结构体包含 DateTime 字段时会有不同的表现?

25

如果一个结构体包含 DateTime 字段,为什么 LayoutKind.Sequential 的工作方式会有所不同?

考虑以下代码(必须启用“不安全”编译的控制台应用程序):

using System;
using System.Runtime.InteropServices;

namespace ConsoleApplication3
{
    static class Program
    {
        static void Main()
        {
            Inner test = new Inner();

            unsafe
            {
                Console.WriteLine("Address of struct   = " + ((int)&test).ToString("X"));
                Console.WriteLine("Address of First    = " + ((int)&test.First).ToString("X"));
                Console.WriteLine("Address of NotFirst = " + ((int)&test.NotFirst).ToString("X"));
            }
        }
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct Inner
    {
        public byte First;
        public double NotFirst;
        public DateTime WTF;
    }
}

现在,如果我运行上面的代码,我会得到类似以下的输出:
结构体的地址 = 40F2CC First 的地址 = 40F2D4 NotFirst 的地址 = 40F2CC
请注意,First 的地址与结构体的地址不同;但是,NotFirst 的地址与结构体的地址相同
现在注释掉结构体中的 "DateTime WTF" 字段,然后再次运行它。 这一次,我得到类似以下的输出:
结构体的地址 = 15F2E0 First 的地址 = 15F2E0 NotFirst 的地址 = 15F2E8
现在,"First" 的地址与结构体相同。
考虑到使用了 LayoutKind.Sequential,我发现这种行为令人惊讶。有人能提供解释吗?在与使用 Com DATETIME 类型的 C/C++ 结构体进行互操作时,这种行为是否有任何影响?
[编辑] 注意:我已经验证当您使用 Marshal.StructureToPtr() 将结构体编组时,数据以正确的顺序编组,"First" 字段排在第一位。这似乎表明它将与互操作正常工作。谜团是为什么内部布局会改变 - 但当然,内部布局从未被指定,所以编译器可以随心所欲。
[编辑2] 从结构声明中删除 "unsafe"(它是我进行一些测试时留下的)。
[编辑3] 这个问题的原始来源是来自 MSDN C# 论坛:

http://social.msdn.microsoft.com/Forums/en-US/csharplanguage/thread/fb84bf1d-d9b3-4e91-823e-988257504b30


1
我猜你已经回答了自己的问题 ;) - Doggett
1
好在我们在使用不安全代码时从来不必使用 DateTime。 :) - leppie
1
当你回答了一个问题并且得到了正确的答案时,你应该创建一个自己的答案并接受它。 - jgauffin
1
@Kell:静态成员不会影响布局,而且这是唯一使用字符串的地方。 - leppie
两点说明:(1)在C#中,对于“struct”类型,“LayoutKind.Sequential”是默认的,因此指定它实际上是多余的(但可能是有信息性的?)。 (2)这个“问题”可能与DateTime本身具有布局“Auto”的事实有关,因为如果您使用TimeSpan(大小相同的另一个结构)而不是DateTime作为字段WTF的类型,则“问题”就会消失。 - Jeppe Stig Nielsen
显示剩余5条评论
6个回答

20
为什么如果一个结构包含一个DateTime字段,LayoutKind.Sequential的工作方式会有所不同?
这与DateTime本身具有布局“自动”(通过我自己的SO问题链接)的“惊人”事实有关。此代码重现了您所看到的行为:
static class Program
{
    static unsafe void Main()
    {
        Console.WriteLine("64-bit: {0}", Environment.Is64BitProcess);
        Console.WriteLine("Layout of OneField: {0}", typeof(OneField).StructLayoutAttribute.Value);
        Console.WriteLine("Layout of Composite: {0}", typeof(Composite).StructLayoutAttribute.Value);
        Console.WriteLine("Size of Composite: {0}", sizeof(Composite));
        var local = default(Composite);
        Console.WriteLine("L: {0:X}", (long)(&(local.L)));
        Console.WriteLine("M: {0:X}", (long)(&(local.M)));
        Console.WriteLine("N: {0:X}", (long)(&(local.N)));
    }
}

[StructLayout(LayoutKind.Auto)]  // also try removing this attribute
struct OneField
{
    public long X;
}

struct Composite   // has layout Sequential
{
    public byte L;
    public double M;
    public OneField N;
}

示例输出:

64位:是的
OneField的布局:自动
复合物的布局:连续
复合物的大小:24
L:48F050
M:48F048
N:48F058

如果我们从OneField中删除属性,则会如预期地运行。例如:

64位:是的
OneField的布局:连续
复合物的布局:连续
复合物的大小:24
L:48F048
M:48F050
N:48F058

这些示例使用x64平台编译(因此大小为24,三倍为8,不足为奇),但是即使在x86上,我们也可以看到相同的“无序”指针地址。

因此,我想我可以得出结论,即使该复合结构本身具有Sequential布局,OneField(或您示例中的DateTime)的布局也会影响包含OneField成员的结构的布局。 我不确定这是否有问题(甚至是否需要)。


根据另一篇帖子中Hans Passant的评论,当成员为“Auto”布局结构时,它不再尝试保持顺序。

3
终于有一个对这个问题的回答让人觉得有道理了。 - CodesInChaos

7
请认真阅读布局规则的规范。布局规则仅在对象暴露在非托管内存中时才管控布局。这意味着编译器可以随意放置字段,直到对象实际导出为止。有点出乎我的意料,这甚至适用于FixedLayout!Ian Ringrose关于编译器效率问题的观点是正确的,但这与编译器忽略您的布局规范无关。一些人指出DateTime具有自动布局。这是你惊讶的最终原因,但原因有点难以理解。自动布局的文档说:“使用[Auto]布局定义的对象不能在托管代码之外公开。尝试这样做会生成异常。”还要注意DateTime是值类型。通过将具有自动布局的值类型合并到结构中,您无意中承诺永远不会将包含结构公开给非托管代码(因为这样做会公开DateTime,并且这将生成异常)。由于布局规则仅管理非托管内存中的对象,而且您的对象永远不会暴露给非托管内存,因此编译器在选择布局时没有受到约束,可以自由选择任何它想要的方式。在这种情况下,它会恢复自动布局策略,以实现更好的结构打包和对齐。顺便说一下,所有这些都可以在静态编译时识别。实际上,编译器正在识别它,以决定是否忽略您的布局指令。在识别它后,编译器应该发出警告。您实际上没有做错任何事情,但是得到通知有助于了解您编写的内容没有任何效果。在这种情况下,各种建议使用Fixed layout通常是很好的建议,但这种情况下可能没有任何效果,因为包括DateTime字段使编译器免于遵守布局。更糟糕的是:编译器不需要遵守布局,但是可以自由地遵守布局。这意味着CLR的连续版本可以在此方面表现不同。在我的看法中,布局的处理是CLI中的设计缺陷。当用户指定布局时,编译器不应该绕过它们。最好保持简单,让编译器按照指示执行操作,尤其是在涉及布局时。众所周知,“聪明”是一个四个字母的词。

1
在我看来,这不是CLI中的设计缺陷。它根本不应该改变托管结构的布局 - 这只是一种性能优化,允许编组器在某些情况下避免复制结构。您没有设置结构的布局,而是指示编组器以某种方式编组结构 - 并且它确实以这种方式编组(在这种情况下导致异常)。这在https://msdn.microsoft.com/en-us/library/system.runtime.interopservices.structlayoutattribute(v=vs.110).aspx#Anchor_7中有明确记录。 - Luaan
1
文档中关于ExplicitLayout的说明如下:使用带有LayoutKind.Explicit属性来控制每个数据成员的精确位置。这会影响托管和非托管布局,以及可平坦化和不可平坦化类型。对我来说,这意味着您可以控制(只要您不使用或包含“Auto”)。 - Abel
由于这里的讨论和混淆,我在此报告了文档和规范中覆盖范围的差异:https://github.com/dotnet/dotnet-api-docs/issues/4325 - Abel

3

一些因素:

  • 如果doubles(双精度浮点数)对齐,速度会更快
  • 如果struct中没有“空洞”,CPU缓存可能会更好地工作

因此,C#编译器具有一些未记录的规则,用于尝试获取“最佳”struct布局,这些规则可能考虑到struct的总大小和/或是否包含了另一个struct。 如果您需要知道struct的布局,则应自己指定它,而不是让编译器决定。

但是,LayoutKind.Sequential确实可以阻止编译器更改字段的顺序。


1
那你刚才自己说话相互矛盾了吗? - leppie
1
@Leppie,不,LayoutKind.Sequential的文档中说“...并且可以是非连续的”。 - Ian Ringrose
1
字段的更改顺序怎么办?检查这里涉及到的地址,不仅要注意它们之间的间距,还要注意它们的值/顺序。 - Lasse V. Karlsen
1
另一个可能的因素是:typeof(DateTime).IsAutoLayout 是不寻常的。如果你改成 TimeSpan,因为 typeof(TimeSpan).IsLayoutSequential,你就不会有那个“异常情况”了。 - Jeppe Stig Nielsen
2
BCL中有一个例子,即TimeZoneInfo.TransitionTime(又名System.TimeZoneInfo+TransitionTime)。按声明顺序排列的字段以及它们在反射中找到的其他字段是:DateTime m_timeOfDay; byte m_month; byte m_week; byte m_day; DayOfWeek m_dayOfWeek; bool m_isFixedDateRule;。但是,当使用unsafe指针查看时,它们按顺序排列为|dayOfWeek|month|week|day|isFixedDateRule|timeOfDay|。这些大小为4 + 1 + 1 + 1 + 1 + 8。此struct具有顺序布局,但其字段未按顺序排列。 - Jeppe Stig Nielsen
显示剩余9条评论

3

回答自己的问题(按建议):

问题:“在与使用Com DATETIME类型的C/C++结构进行互操作时,此行为是否会产生任何影响?”

答案:不会,因为在使用封送处理时会保留布局。(我通过实验证实了这一点。)

问题:“有人能提供解释吗?”

答案:我仍然不确定,但由于结构的内部表示未定义,编译器可以随意处理。


除了 ExplicitLayout,它适用于托管和非托管、可 blittable 和不可 blittable 类型。 - Abel

2
您正在检查地址,因为它们在托管结构中。Marshal属性不能保证托管结构内字段的排列顺序。
它能正确地转换为本机结构的原因是使用了由marshal值设置的属性将数据复制到本机内存中。
因此,托管结构的排列方式对本机结构的排列方式没有影响。只有属性会影响本机结构的排列方式。
如果使用marshal属性设置的字段以与本机数据相同的方式存储在托管数据中,则Marshal.StructureToPtr就没有意义了,您只需将数据进行字节复制即可。

1
请注意,编译器可以自由更改托管布局以与封送结构相同 - 这是一种有效的性能优化,允许封送程序在某些互操作场景中避免复制结构。但这不是契约行为 - 无论StructLayoutFieldOffset等如何,您都不应该对结构的托管布局做出任何假设。 - Luaan

1
如果你要与C/C++进行交互,我建议在StructLayout中始终明确指定。不要使用Sequential,而是使用Explicit,并使用FieldOffset指定每个位置。此外,添加Pack变量。
[StructLayout(LayoutKind.Explicit, Pack=1, CharSet=CharSet.Unicode)]
public struct Inner
{
    [FieldOffset(0)]
    public byte First;
    [FieldOffset(1)]
    public double NotFirst;
    [FieldOffset(9)]
    public DateTime WTF;
}

听起来 DateTime 无论如何都不能进行编组,只能转换为字符串 (类似于 Marshal DateTime)。

Pack 变量在可能在不同字长的不同系统上编译的 C++ 代码中特别重要。

我也会忽略使用不安全代码时可以看到的地址。只要编组正确,编译器做什么并不重要。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接