ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

02-05-历史-CSharp的类型系统-值类型-引用类型与数据布局

02-05-历史-CSharp的类型系统-值类型-引用类型与数据布局 C# 的类型系统值类型、引用类型与数据布局系列C# 与常用数据结构源码剖析 · 历史与演化篇版本边界语言示例固定为 C# 12BCL 与 CoreCLR 例证固定为 .NET 8阅读原则语言/CLI 语义可以依赖对象头、字段偏移、调用 stub 与 JIT 优化均是目标运行时实现一、真正的分界是值与身份不是栈与堆“struct 在栈上class 在堆上”把类型分类误写成存储位置。C# 真正保证的是值类型变量直接包含一个值普通赋值、按值传参与按值返回复制该值引用类型变量包含引用普通赋值复制引用多个变量可观察同一对象身份存储位置由上下文与实现决定不由struct/class关键字单独决定。public struct Position { public int X, Y; } public sealed class Actor { public Position Position; } Position first new() { X 1, Y 2 }; Position second first; second.X 99; Console.WriteLine(first.X); // 1值被复制 Actor left new() { Position first }; Actor right left; right.Position.X 77; Console.WriteLine(left.Position.X); // 77引用指向同一 Actorfirst可能被放在寄存器、栈槽或被优化掉Position字段内联在 GC 管理的Actor对象中Position[]的元素内联在数组对象中装箱后的Position位于一个托管对象的有效负载中。语义告诉我们复制和别名行为JIT/运行时才决定物理实现。本文把证据分为C# / CLI 契约复制、装箱、类型安全、byref 生命周期等.NET 8 公共 APIEqualityComparerT、RuntimeHelpers、Marshal等固定实现观察CoreCLR 私有对象布局、JIT 消除复制或去虚拟化实验结果仅对记录过 runtime、架构、构建模式的运行成立。二、值类型仍然是完整的运行时类型值类型可以有字段、构造器、方法、属性、接口实现、泛型参数、特性和运行时TypeConsole.WriteLine(typeof(Position).IsValueType); // True Console.WriteLine(default(Position).GetType()); // Position调用形态可能装箱“值类型没有 MethodTable”只能在很窄的语境下说一个未装箱的值通常没有随每个实例携带对象头/类型指针编译器、JIT 和调用上下文已经知道其类型。运行时仍为Position维护类型描述typeof(Position)、泛型字典、数组元素描述、GC layout 和反射都需要它。值被装箱后生成的托管对象具有运行时类型身份CoreCLR 可通过对象的类型指针识别它。值类型隐式派生自System.ValueType枚举进一步派生自System.EnumC# 不允许自定义 struct 继承链但 struct 可以实现接口并重写从object可见的方法。不能因“未装箱值没有每实例 vtable”推导它不能多态调用CLI 的constrained.、泛型上下文和装箱路径都能实现接口/虚方法语义。2.1 值类型也可能包含托管引用public struct Entry { public int Id; public object? Payload; } Entry[] entries new Entry[1024]; entries[0].Payload new object();数组元素仍内联但 CoreCLR GC 必须按Entry的 GC 描述扫描每个Payload槽位赋值还需要符合写屏障协议。因此“struct 不受 GC 管理”“值类型数组不扫描”“值类型写入没有写屏障”都不成立。更准确地说值本身没有独立对象身份时通常不是独立 GC 对象但它所在的 owner、内部引用和活跃 byref 都要被 runtime 正确追踪。.NET 8可用以下能力探针区分含引用与纯值字段using System.Runtime.CompilerServices; Console.WriteLine(RuntimeHelpers.IsReferenceOrContainsReferencesint()); // False Console.WriteLine(RuntimeHelpers.IsReferenceOrContainsReferencesEntry()); // True这比“字段看起来像 primitive”可靠泛型算法可据此决定是否需要清理池化槽位。但返回值不提供字段偏移也不是 blittable/unmanaged 的完整判断。三、布局没有通用对象开销也没有 struct 大小红线3.1 托管布局与互操作布局不是一回事CoreCLR 引用对象通常有运行时头部与类型信息字段之后还可能有对齐填充值类型存储通常包含字段与 padding。具体大小受 x86/x64/ARM64、运行时、字段类型、布局属性、泛型实例化和 GC 策略影响。不能把“对象固定多出 16 字节”“字段严格按声明顺序”写成 C# 契约。Unsafe.SizeOfT()可观察当前构建中T的托管表示大小但不含把它装箱后的对象开销Marshal.SizeOfT()面向非托管封送大小二者可能不同。Marshal.OffsetOfT()只应对具有可封送、顺序或显式布局的类型解释不能用于推断任意 auto-layout class 的 CoreCLR 私有布局。using System.Runtime.CompilerServices; using System.Runtime.InteropServices; [StructLayout(LayoutKind.Sequential)] public struct PacketHeader { public ushort Kind; public int Length; } Console.WriteLine($managed sizeof{Unsafe.SizeOfPacketHeader()}); Console.WriteLine($marshal sizeof{Marshal.SizeOfPacketHeader()}); Console.WriteLine($Length offset{Marshal.OffsetOfPacketHeader(nameof(PacketHeader.Length))});输出必须带 runtime、OS、architecture 和 struct 定义一起报告。即使当前输出稳定也只在明确的 ABI/序列化协议中把布局作为契约。3.2StructLayout解决边界协议不是普通优化旋钮LayoutKind.Auto运行时可选择布局不适合直接跨 native ABILayoutKind.Sequential按字段顺序与 packing 规则布局常用于 P/InvokeLayoutKind.ExplicitFieldOffset显式指定非托管布局/union 形态。显式重叠包含托管引用与非引用字段会破坏 GC 类型安全运行时会施加限制。Pack1虽可减少 padding却可能造成未对齐访问、平台 ABI 不匹配或原生端定义不一致。网络/磁盘格式还涉及字节序、版本、布尔与字符宽度不能直接把进程内 struct 字节 dump 成永久协议。3.3 “小于 16/32/64 字节就用 struct”不是规则类型选择首先看语义它是否是没有独立身份的值是否应支持null、继承、共享可变状态性能则取决于复制频率、数组密度、调用约定、寄存器返回、装箱、GC 引用和访问模式。相同大小的两个 struct含引用与不含引用的 GC 成本不同一个较大但总由in/ref访问的值可能比一个频繁分配的小对象更合适。设计指南里的大小建议是启发式不是 CLR 临界点。用真实调用链的反汇编、分配和缓存指标决定而不是写一个永久字节阈值。四、复制与别名可变 struct 的陷阱来自“复制成功了”4.1 按值复制会产生独立字段状态public struct Counter { public int Value; public void Increment() Value; } Counter a new() { Value 1 }; Counter b a; b.Increment(); Console.WriteLine((a.Value, b.Value)); // (1, 2)若字段本身是引用复制的是引用因此两个 struct 副本可共享内部对象public struct Bucket { public Listint Items; } Bucket x new() { Items new Listint() }; Bucket y x; y.Items.Add(42); Console.WriteLine(x.Items.Count); // 1共享同一个 Listint所以“值类型 深复制/不可变”也是错误的。复制只逐字段遵循各字段自己的值语义数组、string、List 引用仍是引用。4.2 属性与索引器可能返回临时副本public sealed class Player { public Counter Score { get; set; } } var player new Player(); // player.Score.Increment(); // 编译器拒绝属性返回的值不是可写变量 Counter score player.Score; score.Increment(); player.Score score;集合索引器也可能按值返回 struct。若要原位修改API 必须明确暴露ref返回或调用者读出、修改、写回。随意把值类型改为 mutable 往往让复制位置变得难以审查优先使用readonly struct与构造新值。五、装箱与拆箱改变表示与身份5.1box创建对象unbox.any取回值Position value new() { X 3, Y 4 }; object boxed value; Position copy (Position)boxed;典型 IL 形状ldloc value box Position stloc boxed ldloc boxed unbox.any Position stloc copybox为值建立引用类型表示并复制值装箱对象拥有独立身份。unbox.any对非 nullable 值类型验证对象的精确兼容类型再把值取出。unbox则返回指向装箱有效负载的 managed pointer常由编译器/runtime 在需要原位访问时使用。错误类型抛InvalidCastException对不允许的 null 拆箱会失败。Position original new() { X 1 }; object box original; original.X 9; Console.WriteLine(((Position)box).X); // 1箱中保存的是装箱时副本装箱通常意味着托管分配与复制但实际分配量不能按固定对象头计算。JIT 可能在某些封闭上下文优化临时对象API 设计仍应按装箱可观察语义编写。5.2 常见装箱要逐个看调用形状把 struct 赋给object或接口变量装箱把 struct 放进ArrayList装箱放进ListT且T为该 struct元素内联泛型约束调用可能通过constrained.避免装箱但不是所有泛型/接口路径都保证通过非泛型IEnumerable取得 struct current 或枚举器可能装箱字符串插值在现代 handler/泛型格式化路径中不应笼统说“一定装箱”捕获 struct 实例方法为 delegate 的 target 表示由编译器/运行时决定不宜声称所有 struct delegate 一律装箱。用 IL 与GC.GetAllocatedBytesForCurrentThread观察具体代码避免根据语法猜测。5.3NullableT有特殊装箱规则不应写死字段顺序T?当T : struct是NullableT语法。公共语义包括HasValue、Value、GetValueOrDefault和 lifted operators。其概念状态是“是否有值 一个 T”但私有字段顺序、padding 与总大小不是公共 ABI。装箱具有特殊规则无值的 nullable 装箱为null有值时装箱为底层T的箱而不是可观察的NullableT箱。int? none null; int? some 42; object? a none; object? b some; Console.WriteLine(a is null); // True Console.WriteLine(b?.GetType()); // System.Int32不要声称HasValue false时内部值“未定义”。按普通默认初始化default(NullableT)的底层存储处于默认状态公共代码仍只能通过 nullable API 解释它。六、接口、虚调用与constrained.值类型可以实现接口public readonly struct Meter : IComparableMeter, IEquatableMeter { public Meter(int value) Value value; public int Value { get; } public int CompareTo(Meter other) Value.CompareTo(other.Value); public bool Equals(Meter other) Value other.Value; public override bool Equals(object? obj) obj is Meter other Equals(other); public override int GetHashCode() Value; }传给接收IComparableMeter的非泛型参数需要把Meter表示成接口引用通常装箱。泛型约束可保留实际Tstatic int CompareT(T left, T right) where T : IComparableT left.CompareTo(right);典型 IL 使用constrained. !!T再callvirt。CLI 允许运行时根据T选择无需装箱的值类型实现、引用类型虚分派或必要的 fallback。它提供避免装箱的机会不保证所有方法、后端和上下文最终都是直接调用JIT 内联、共享泛型代码、显式接口实现和默认接口方法都会影响形态。IL2CPP 也理解 CLI 泛型和 constrained 语义并有自身的 generic sharing/代码生成策略“IL2CPP 中 struct 接口调用总是装箱”是错误的绝对结论。必须在指定 Unity Player、具体调用形状下用生成代码、Profiler 分配和功能测试验证。虚方法与接口分派的固定版本实现见 01-08虚方法分派与接口调用类型描述见 01-02MethodTable。七、相等性与哈希值语义必须显式设计7.1 默认ValueType.Equals不是统一的“反射逐字段”未重写相等性的 struct 继承ValueType.Equals/GetHashCode。.NET 8的实际实现可根据类型布局、字段和 runtime helper 选择不同路径某些类型可按位比较另一些按字段处理调用形态也可能涉及装箱。不能把它统一描述为“每次用反射遍历字段”更不能承诺固定性能。默认逐字段式语义还可能不符合业务。例如浮点 NaN、字符串比较规则、忽略缓存字段、规范化 ID 都需要明确策略。7.2 实现IEquatableT并保持哈希一致作为DictionaryTKey,TValue键或HashSetT元素的值类型应优先实现IEquatableT重写Equals(object)与GetHashCode并确保若a.Equals(b)为真则哈希相等对象作为键期间参与相等/哈希的状态不应改变。public readonly record struct Cell(int X, int Y); var occupied new HashSetCell { new(2, 3) }; Console.WriteLine(occupied.Contains(new Cell(2, 3)));record struct 可生成值相等成员但仍是浅层字段若是可变引用复制和相等遵循该成员定义。生成代码也应按 C# 12 编译器输出审查不把语法糖当深不可变。泛型集合通常通过EqualityComparerT.Default获取类型适配比较器对实现IEquatableT的值类型可走强类型路径避免把每个值提升为object。自定义 comparer 仍可覆盖默认语义。八、readonly、in与防御性复制8.1 readonly 接收者不能被可变成员偷偷修改当 struct 位于 readonly 字段、in参数或 readonly 局部中调用非 readonly 实例成员可能先生成防御性副本以保证原值不被方法修改public struct RangeCounter { public int Value; public int Read() Value; // 未标 readonly编译器需保守看待 this } static int Get(in RangeCounter counter) counter.Read();C# 支持把不会修改实例状态的成员标记为readonly或把整个类型声明为readonly struct。这样编译器更容易避免副本并表达不可变意图。public readonly struct Range { public Range(int start, int length) (Start, Length) (start, length); public int Start { get; } public int Length { get; } public int End checked(Start Length); }8.2in不是无条件零复制优化in传递 readonly byref但调用点可为表达式创建临时值ABI、内联和寄存器传参可能让小 struct 按值更优访问非 readonly 成员又会制造防御性副本。使用in的首要语义是“被调用方通过该别名不能修改值”性能需要测量。同理ref readonly返回可避免按值返回并禁止调用者写但 owner 生命周期必须足够长。返回局部变量的引用、返回池中已归还数组的元素或把可变容器内部引用长期暴露都会产生生命周期或封装问题。九、managed byref、ref struct 与 Span 生命周期ref T/in T/out T在 CLI 中对应 managed byref 语义。它可指向局部、字段、数组元素或其他合法存储若指向托管对象内部runtime/JIT 必须让 GC 正确理解其存活。它不同于裸 native pointerC# ref safety 限制逃逸普通代码不能任意地址运算或长期保存。static ref int Find(int[] values, int index) { if ((uint)index (uint)values.Length) throw new ArgumentOutOfRangeException(nameof(index)); return ref values[index]; }返回 ref 让调用者形成数组元素别名并让数组在需要的生命周期内保持可达它不提供锁也不阻止别处 Resize 后变量改指向新数组。ref struct是更严格的值类型类别C# 12 阻止它装箱、作为普通 class/array 字段、用于普通泛型类型参数以及跨越不安全的 async/yield 生命周期。核心目的不是宣称“所有字节永远位于物理栈”而是防止可能含 byref 的值逃入普通托管堆和超过 referent 寿命。int[] data { 1, 2, 3 }; Spanint view data.AsSpan(1); // 描述符受 ref-like 限制底层数据是数组 view[0] 99; Console.WriteLine(data[1]);Span 可指向数组、stackalloc、string 的只读区域或 native memory。切片通常不复制后备数据却不等于整个操作零分配ToArray、owner 创建、delegate 或异步适配仍可能分配。更多生命周期细节见 03-04Span 与 Memory。十、delegate*省去 delegate 对象不等于直接调用C# 9 在 unsafe 上下文支持函数指针unsafe static int Add(int left, int right) left right; unsafe { delegate* managedint, int, int operation Add; Console.WriteLine(operation(20, 22)); }函数指针调用通常生成calli它按指针值进行间接调用CPU 仍要预测/执行间接目标。它没有普通 delegate 对象的 target/invocation list 语义取得静态 managed 方法地址的简单路径不需要创建 delegate但不能由此宣称“没有间接跳转、没有任何 GC 参与、一定更快”。还必须区分delegate* managed与delegate* unmanaged[CallingConvention]后者的 ABI、寄存器、栈清理和异常边界必须与 native 函数严格匹配。函数指针不为任意 native owner 管理寿命回调到托管代码也需按目标 runtime 的 AOT/interop 规则保留入口。它适合底层互操作与已测量的 dispatch不是事件/闭包/多播委托的通用替代品。十一、泛型集合值内联但容量与复制仍有代价ListPoint的后备存储是Point[]元素内联ListPointClass的后备数组连续存放引用对象另行分配。泛型避免ArrayList把每个值存成object时的装箱但不意味着“零分配”List 自身和后备数组仍是托管对象扩容要分配新数组并复制元素。var points new ListPosition(capacity: 1024); points.Add(new Position { X 1, Y 2 });预估容量可减少扩容次数大 struct 会增加复制带宽含引用 struct 数组会增加 GC 扫描范围从ListT索引器取值返回副本不能原位修改其字段。选择 class 或 struct 要把这些因素一起测量。结构体枚举器也只在调用形状保留具体类型时发挥价值直接foreach (ListT)常可使用 struct enumerator先转为IEnumerableT可能需要接口对象表示/装箱JIT 有时可去虚拟化但没有公共保证。List 和数组实现细节见 03-01Array 与 03-02List。十二、AoS 与 SoA布局必须匹配访问模式Array of StructsAoS把同一实体的字段放在一起public struct Particle { public float X, Y, Z; public float VelocityX, VelocityY, VelocityZ; public int Color; } Particle[] particles new Particle[count];Structure of ArraysSoA把同字段聚合float[] x new float[count]; float[] y new float[count]; float[] z new float[count]; float[] velocityX new float[count];更新一个粒子的全部字段时AoS 往往更符合局部性只扫描所有 X/速度、做 SIMD 或避免载入冷字段时SoA 可能更好。引用类型数组还要追随对象指针但 class 可能更适合共享身份、稀疏对象和多态。缓存行大小、自动向量化和固定性能倍数都不能写死为语言结论。数据规模、字段排列、过滤比例、CPU、JIT/AOT 与并行写入会改变结果。用真实工作负载的 cache miss、带宽和帧时验证。十三、Unity、IL2CPP 与 Burst 边界13.1 IL2CPP 不复制 CoreCLR 私有布局Unity IL2CPP 将 IL 转为 C 与相关元数据/运行时支持再由平台工具链编译。C# 的值/引用、装箱、泛型和接口公共语义仍需成立但生成的 C 形态、generic sharing、对象头、调用 thunk、GC 和 ABI 不是 CoreCLRMethodTable模型的逐字段翻译。P/Invoke/原生插件结构应按 Unity 版本、平台 C ABI、StructLayout、字符集、bool 宽度与 packing 对齐。一个 struct 在 Windows x64 匹配不证明在 iOS ARM64 或 WebGL 匹配。IL2CPP 生成 C 仅是实现途径不把 auto-layout managed struct 自动变成稳定 native ABI。13.2 Burst 需要可编译的数据闭包Burst 面向高性能可静态编译代码常配合NativeArrayT和 unmanaged/blittable 数据普通托管引用、class、多数托管异常/反射与 GC 分配受到限制。具体支持以 Unity 2022.3 所配 Burst/Collections 包版本为准。NativeArrayT是有 allocator 和 Dispose 责任的 native container不是“堆外 T[]”的语法替换。Job dependency 与 safety system 负责并发使用协议禁用安全检查可能改变测量却不会修复数据竞争。含托管引用的 struct 不能因外层是值类型就交给 Burst/native memory。13.3 用 Player 矩阵验证至少记录Unity 2022.3.x 补丁号、Editor/Player、Mono/IL2CPP、目标 OS/CPU、Development/Release、Managed Stripping Level、Burst 版本与安全检查。重点验证接口/泛型分配、P/Invoke 布局、序列化、AoS/SoA Job 性能和异常路径。Editor Mono 的结果不能代表设备 IL2CPP/Burst。十四、可编译实验IL、布局与分配分开测14.1 项目与探针Project SdkMicrosoft.NET.Sdk PropertyGroup OutputTypeExe/OutputType TargetFrameworknet8.0/TargetFramework LangVersion12/LangVersion Nullableenable/Nullable Optimizetrue/Optimize AllowUnsafeBlockstrue/AllowUnsafeBlocks /PropertyGroup /Projectusing System; using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; Console.WriteLine(RuntimeInformation.FrameworkDescription); Console.WriteLine(RuntimeInformation.ProcessArchitecture); Console.WriteLine($Position size{Unsafe.SizeOfPosition()}); Console.WriteLine($Entry has refs{RuntimeHelpers.IsReferenceOrContainsReferencesEntry()}); Position value new(1, 2); long before GC.GetAllocatedBytesForCurrentThread(); object boxed value; long after GC.GetAllocatedBytesForCurrentThread(); GC.KeepAlive(boxed); Console.WriteLine($Observed boxing allocation delta{after - before}); var set new HashSetPosition { value }; Console.WriteLine(set.Contains(new Position(1, 2))); public readonly struct Position : IEquatablePosition { public Position(int x, int y) (X, Y) (x, y); public int X { get; } public int Y { get; } public bool Equals(Position other) X other.X Y other.Y; public override bool Equals(object? obj) obj is Position other Equals(other); public override int GetHashCode() HashCode.Combine(X, Y); } public struct Entry { public int Id; public object? Payload; }单次 allocation delta 只用于证明“发生过什么”不能做纳秒级基准线程初始化、Console、JIT、逃逸优化都会污染结果。应先预热用 BenchmarkDotNet 的 MemoryDiagnoser 重复测量并用反汇编确认 box 是否保留。14.2 IL 检查任务用ildasm、ILSpy 或适合平台的 IL 工具检查三个方法static object Box(Position value) value; static Position Unbox(object value) (Position)value; static int CompareT(T left, T right) where T : IComparableT left.CompareTo(right);预期分别寻找box Position、unbox.any Position、constrained. !!Tcallvirt的语义形状。Release 编译器可能改变局部变量与短指令但这些关键 IL 能帮助区分装箱和约束调用。最终是否分配/内联仍看目标 JIT/AOT。14.3 布局和性能矩阵为 layout 实验分别记录Unsafe.SizeOfT、Marshal.SizeOfT、Marshal.OffsetOfT不要把三者混用。为 AoS/SoA、class/struct、按值/in比较建立矩阵.NET 8 完整 patch、x64/ARM64、Release、Tiered PGO元素数量、struct 字段、含引用与否、预分配容量时间分布、allocation、GC、cache miss/带宽与峰值内存Unity 则另测 Mono Editor、IL2CPP Player、Burst Job 和安全检查配置。若结论只在一个尺度或一个 CPU 成立就把适用边界写入文章不提炼成“struct 快多少倍”。十五、审查清单与选型结论设计类型或评审文章时逐项回答类型代表独立身份还是可复制的值复制后内部引用是否仍共享是否把 struct/class 错写成固定栈/堆位置是否误称值类型没有运行时类型信息、不能虚调用或永不被 GC 扫描是否写死对象头、字段偏移、Nullable 布局或 struct 大小红线API 是否意外装箱到 object/接口/非泛型集合泛型constrained.路径是否通过 IL 与目标后端验证而非假定总能避免装箱相等性、哈希、浮点/null/引用字段规则是否明确键是否保持稳定readonly/in调用是否产生防御性副本in是否真的改善目标路径ref/ref readonly/ref struct 是否满足 referent 与 owner 生命周期delegate*calling convention、间接调用和 native lifetime 是否正确StructLayout是否匹配每个目标 ABI而非把 managed size 当 wire formatAoS/SoA 是否依据实际字段访问、SIMD 和缓存数据选择Unity 结论是否固定 Player、IL2CPP/Burst 版本与平台值类型和引用类型不是“快类型与慢类型”的对立。值类型提供复制的值语义与内联存储机会引用类型提供身份、共享、可空和继承模型二者都参与类型系统、GC 与方法调用。先用语义确定正确模型再用布局协议保证边界最后用目标运行时实验优化复制、装箱和局部性才是可维护的数据结构设计。下一篇托管 GC 机制垃圾回收如何影响数据结构选择
返回列表