尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

04-04-哈希-Dictionary-TKey-TValue-高级话题-自定义Comparer与序列化

04-04-哈希-Dictionary-TKey-TValue-高级话题-自定义Comparer与序列化 DictionaryTKey, TValue 高级话题自定义 Comparer 与序列化系列C# 与常用数据结构源码剖析 · 数据结构-哈希与映射篇前置知识04-01Hash 原理、04-02/04-03Dictionary 结构与操作本文目标把“什么算同一个键”与“如何把键持久化”视为同一个设计问题DictionaryTKey, TValue不只是一张快速查找表。它同时包含两层语义数据层保存键值对比较器层则定义哪些键属于同一个等价类。如果存档只保存了前者却丢掉后者恢复出来的集合在类型上仍是Dictionary在业务上却可能已经是另一种数据结构。游戏项目尤其容易遇到这类问题配置 ID 是否区分大小写地图坐标是否包含楼层账号名是显示文本还是稳定标识热更前后是否仍采用相同的键规范化规则这些问题都不能由“序列化成功”替你回答。一、比较器定义的是键空间1.1 相等与哈希的真正契约IEqualityComparerT向字典提供两个操作判定两个值是否相等以及为键生成哈希码。从数学上看Equals应当形成等价关系自反性有效键x应与自身相等。对称性Equals(x, y)与Equals(y, x)结果相同。传递性若x y且y z则x z。稳定性键留在字典中时影响相等与哈希的状态不能改变。哈希一致性只要两键相等它们的哈希码必须相同。最后一条可写成Equals(x, y) true GetHashCode(x) GetHashCode(y)反过来不成立。两个不相等的键完全可以有相同哈希码字典会在同一桶的候选项中再调用Equals。因此“Equals和GetHashCode必须使用完全相同的字段集”只是一条便于审查的经验不是精确契约。例如相等性同时比较Zone和Id哈希只使用Id仍然正确但可能因分布较差而产生更多碰撞。反之相等只看Id哈希却加入Name就会让相等的键落入不同查找路径这是功能性错误。public readonly record struct ItemKey(int Zone, int Id); public sealed class ItemKeyComparer : IEqualityComparerItemKey { public bool Equals(ItemKey x, ItemKey y) x.Zone y.Zone x.Id y.Id; public int GetHashCode(ItemKey value) HashCode.Combine(value.Zone, value.Id); }HashCode.Combine是应用代码的常用写法它能避免手写简单乘加公式时的许多分布问题。但哈希码不是数据的持久 ID也不是密码学摘要不应将它写入存档、用作网络协议字段或用来证明内容未被篡改。1.2 Dictionary 如何使用契约插入时字典先用比较器计算哈希根据容量定位桶再沿桶内链接的条目检查是否已有相等键。查找也重复同一套过程。这解释了为什么“改变已入表键”会很危险条目仍留在旧哈希对应的位置新状态却引导查找走向另一个位置。不要把“哈希相同”误解为“字典不会调用Equals”也不要把平均情况的 O(1) 误解为任何输入下的恒定步数。自定义哈希如果把大量键映射到少数哈希值每次查找就会扫描更多候选项。二、默认比较器与自定义比较器2.1 EqualityComparer.Default 不是“反射比较”没有显式传入 comparer 时字典使用EqualityComparerTKey.Default。默认比较器会按TKey的类型能力选择实现例如利用IEquatableT而不是在每次查找时通过反射遍历字段。具体实现类、特殊化路径和 JIT 去虚拟化能力都可能随运行时、AOT 后端和类型而变。因此不应把“某版本完全内联、零开销”写成跨平台保证。选择自定义 comparer 的主要理由应该是业务语义而不是猜测它一定比 Default 快。性能问题要在真实目标环境中测量Unity Editor 的 Mono 结果不能直接代表 IL2CPP Player桌面 CoreCLR 的 JIT 结果也不能直接代表移动端 AOT。2.2 字符串键Ordinal 与文化规则字符串不存在一个万能的“正确比较”。先判断键是机器标识还是人类语言语义常用选择原因配置 ID、资源名、命令字、协议键StringComparer.Ordinal按字符序列比较规则明确不随用户文化变化不区分大小写的机器标识StringComparer.OrdinalIgnoreCase使用序数大小写规则适合稳定标识依据当前用户语言查找显示文本StringComparer.CurrentCulture系列结果符合当前文化但不适合持久 ID需要固定文化规则的自然语言数据明确设计的文化比较必须同时固定规则、版本与迁移策略var itemById new Dictionarystring, ItemDefinition( StringComparer.Ordinal); var commandByName new Dictionarystring, ICommand( StringComparer.OrdinalIgnoreCase);大小写折叠不等于“先对两边调用ToUpperInvariant()”。手写转大写会创建临时字符串还容易与实际比较规则脱节。直接把StringComparer.OrdinalIgnoreCase同时用于Equals和GetHashCode更安全public sealed class AssetPathComparer : IEqualityComparerstring { private static readonly StringComparer Inner StringComparer.OrdinalIgnoreCase; public bool Equals(string? x, string? y) Inner.Equals(x, y); public int GetHashCode(string value) Inner.GetHashCode(value); }这只是“路径业务规则确实不区分大小写”时的示意。不同文件系统的大小写、Unicode 正规化、分隔符与相对路径规则并不相同不应仅凭一个 comparer 就声称已实现跨平台路径等价。2.3 可变键逻辑上“丢失”的条目public sealed class MutableKey { public int Id { get; set; } public override bool Equals(object? obj) obj is MutableKey other Id other.Id; public override int GetHashCode() Id; } var key new MutableKey { Id 7 }; var map new DictionaryMutableKey, string { [key] sword }; key.Id 8; bool found map.TryGetValue(key, out _); // 不要依赖这个结果字典没有监听键对象的属性变化也不会在键变化后自动重新分桶。安全的方案是使用不可变键整数 ID、不可变字符串、readonly record struct或只读小型值类型。如果业务必须修改构成键的字段应先用旧键删除完成修改再用新键插入还要定义新键已存在时是拒绝、覆盖还是合并。2.4 复合键应该是显式模型不要为了方便而把zone : id拼成键。这会引入分隔符转义、额外分配、数字格式和版本迁移问题。用专用类型把键的维度固定下来public readonly record struct CellKey(int MapId, short Floor, int X, int Y); var occupants new DictionaryCellKey, EntityId();record struct的生成相等性适合“所有位置成员都参与身份”的键。如果只有部分成员参与最好将非身份数据移出键类型而不是用 comparer 悄悄忽略它们。如果必须忽略要用类型名和测试明确这个决定。三、碰撞、输入边界与安全性一个只返回常量的 comparer 在功能上仍可满足契约但所有键都进入同一碰撞集合使查找逐渐退化为线性检查。所以比较器需要同时满足两类目标正确性相等键的哈希一致比较符合等价关系。工程质量对真实数据有合理分布计算成本可控不会为每次查找创建临时对象。对来自网络、Mod、聊天、用户定义配置的键不能只依赖“运行时可能会做碰撞缓解”。应同时限制键长度、条目总数和请求成本并避免自制过于薄弱的哈希。字符串哈希的具体随机化策略是运行时实现细节不应转化成应用层的安全保证。也不要使用普通哈希码作为签名、防作弊凭据或去重的唯一依据。碰撞是哈希表设计中被允许的现象安全校验应该使用适合威胁模型的密码学原语。四、Alternate Lookup用另一种键表示查找4.1 它解决什么问题假设字典的持久键类型是string但解析器当前拿到的是ReadOnlySpanchar。传统做法需要先创建字符串再执行查找ReadOnlySpanchar token input.AsSpan(start, length); string temporaryKey token.ToString(); if (map.TryGetValue(temporaryKey, out var value)) { // use value }如果这是经分析确认的高频路径alternate lookup 允许比较器直接对“候选键表示”计算哈希并与已存的主键比较从而免去仅为查找而构造主键。它不是第二张字典也不是修改字典的相等语义两种键表示必须对应同一组等价类。4.2 必须写清目标框架和 API 形状IAlternateEqualityComparerTAlternate, T和DictionaryTKey, TValue.GetAlternateLookupTAlternate()属于新版 .NET 的 alternate lookup API不应标注为“.NET 6 可直接调用dictionary.TryGetValue(span)”。以 .NET 9 目标框架的公开 API 形状为例查找要先获取 alternate lookup 视图// 示意前提target framework 与引用集确实提供该 API // comparer 也支持 ReadOnlySpanchar 作为 alternate key。 var map new Dictionarystring, int(StringComparer.OrdinalIgnoreCase) { [player.health] 100 }; var lookup map.GetAlternateLookupReadOnlySpanchar(); ReadOnlySpanchar candidate PLAYER.HEALTH; if (lookup.TryGetValue(candidate, out int health)) { Console.WriteLine(health); }接口的概念形状是对 alternate key 提供Equals、GetHashCode并在需要真正存入时将它创建为主键。此处讲的是 API 契约不是复制某一个dotnet/runtime版本的内部源码。如果要做源码级分析必须在文中固定仓库 tag 或 commit并与项目实际使用的 reference assembly 对照不能从主分支的一份文件反推旧 LTS 一定具备相同 API。这里还有四个工程边界并非任意 comparer 都支持任意 alternate key。应在创建查找视图前确认对应组合受支持或使用相应的TryGetAlternateLookup形状以目标框架文档为准。“无临时主键分配”不等于整个调用链绝对零分配。调用方、日志、闭包和编码转换都可能分配。查找可以避免创建主键但插入通常必须产生能长期存储的TKey这是 comparer 中“创建主键”操作存在的原因。Unity 项目能否使用它取决于 Unity 版本、API Compatibility Level、后端与随包类库不能仅根据 C# 语言版本判断。最直接的验证是在实际 Player 目标下编译最小用例。如果目标环境没有该 API优先保持正确、直接的字符串查找并用 profiler 确认临时键真是瓶颈后再考虑调整解析器所有权、缓存已规范化的键或重新设计索引。不要为模拟新 API 而引入一套难以验证的自制哈希表。五、序列化的对象应是业务状态5.1 不要持久内部布局字典的 buckets、entries、空闲链、版本号和容量是实现状态不是存档模型。把它们写入文件会把数据绑定到某一个运行时实现而且不能保证在新进程里仍有意义。正确的持久边界是写出必要的键值数据和显式 schema 版本。读取时用当前容器和当前比较器重建索引。在重建时检测空键、非法键和按新语义重复的键。对重复明确选择拒绝、首个优先、最后一个优先或业务合并不依赖巧合的覆盖顺序。历史上某些 .NET 序列化机制会调用ISerializable或特殊构造过程。这些机制不应被当作现代存档设计模板更不应为了复用内部桶而导入不受信任数据。对已存在的旧二进制格式应将读取器隔离在受控迁移工具中转成明确、可审查的新 schema而不是继续扩散运行时对象图序列化。5.2 枚举顺序不是持久语义某些现代实现中字典枚举经常呈现插入顺序但这不能自动变成文件格式的长期契约。删除后重新插入、迁移工具的重建策略、容器类型变更或另一语言的解析器都可能导致顺序变化。如果顺序属于业务就保存显式Order字段或使用有序条目列表。如果只是为了生成稳定 diff、签名或构建产物在写出边界按明确的 comparer 排序并将该排序规则纳入格式规范。var stableEntries map .OrderBy(pair pair.Key, StringComparer.Ordinal) .Select(pair new EntryDto(pair.Key, pair.Value)) .ToArray();这里的稳定性来自写出前的显式排序而不是对字典内部布局的假设。六、System.Text.Json 的键边界6.1 JSON object 的属性名本质上是字符串Dictionarystring, TValue可以自然映射成 JSON objectvar source new Dictionarystring, int(StringComparer.Ordinal) { [sword] 3, [shield] 1 }; string json JsonSerializer.Serialize(source); var restored JsonSerializer.DeserializeDictionarystring, int(json);但 JSON 对象的属性名都是文本。System.Text.Json对一部分常见非字符串键提供内置转换不代表任意TKey都能无损地变成属性名。自定义 key converter 也不只需要普通值的Read/Write还要正确实现属性名路径即相应的ReadAsPropertyName与WriteAsPropertyName。一个键的文本编码必须是无歧义、可逆和版本化的。复合键直接拼接往往无法满足这些条件。对游戏存档而言更清晰的默认形状是条目数组public sealed record InventoryEntryDto( int ItemId, int Quality, int Count); public sealed record InventorySaveDto( int SchemaVersion, InventoryEntryDto[] Entries);数组形状保留了键的结构方便添加字段、校验范围和报告精确错误。代价是文本略长且读取后需要显式重建字典这种显式性对长期存档通常是优点。6.2 序列化器不会自动保存 comparer 语义常规 JSON 数据保存键值不保存运行时 comparer 对象。将忽略大小写的字典序列化后再直接反序列化成普通Dictionarystring, T得到的容器不应被假设为自动具有原 comparer。注意下面这种原稿常见示例本身就无法表达“同时保存两个键”var map new Dictionarystring, int(StringComparer.OrdinalIgnoreCase); map[A] 1; map[a] 2; // 字典只有一个等价类第二次赋值更新了既有条目。问题会在反方向更明显旧数据可能原本允许A和a同时存在新版本改用忽略大小写的语义后两者变成冲突。不能用简单下标器赋值悄悄让后者覆盖前者。一种可审查的重建方式如下static Dictionarystring, int Rebuild( IEnumerableKeyValuePairstring, int entries) { var result new Dictionarystring, int( StringComparer.OrdinalIgnoreCase); foreach (var (key, value) in entries) { if (string.IsNullOrWhiteSpace(key)) throw new InvalidDataException(Inventory key is empty.); if (!result.TryAdd(key, value)) throw new InvalidDataException($Duplicate inventory key: {key}); } return result; }如果应用必须直接反序列化为字典可编写包装类型或JsonConverter由 converter 显式创建带指定 comparer 的字典遍历 JSON 属性并执行冲突策略。其原理不是“把 comparer 序列化进 JSON”而是“由 schema 或目标类型选择已知语义再重建索引”。6.3 Converter 不应成为隐形业务层自定义 converter 适合解决稳定的语法映射例如将经过规范化的ItemKey写成属性名。但数据迁移、多键合并、缺省值推导和旧版 bug 修复通常更适合放在显式的 migration 步骤中。否则 converter 会变成一个难以单测、难以观测并在所有反序列化场景中偷偷修数据的隐形业务层。还要将不受信任 JSON 当作输入而不是对象内存镜像限制文件大小、嵌套深度、条目数和单键长度拒绝无效枚举与越界数值并确保重复键策略不依赖解析器的偶然行为。七、版本迁移Comparer 变更是 schema 变更当比较器改变时键空间也改变了。从 Ordinal 切换到 OrdinalIgnoreCase会合并一些原本不同的等价类从“仅 ItemId”切换到“MapId ItemId”则可能拆分原来的等价类。这不是换一个构造函数参数那么简单而是数据 schema 迁移。建议把流程拆成可观测的阶段用旧 schema 读取为 DTO不直接灌入新字典。校验旧数据的结构与范围记录可追踪的错误位置。将每个旧键转换成新键记录规范化和丢失性转换。在带新 comparer 的字典中使用TryAdd检测冲突。按业务策略拒绝、合并或提示用户修复不静默覆盖。迁移成功后写出新 schema保留可恢复的旧文件或原子替换策略。对服务器数据还应统计冲突数、丢弃数和各版本迁移耗时。对本地存档应在不覆盖原件的情况下测试中断恢复。“能解析 JSON”只是迁移的第一步不是迁移成功的充分条件。八、Unity 中的存档与配置边界Unity 的“可序列化”至少要区分三个场景Editor/Inspector 数据、项目配置资产以及运行时玩家存档。它们的兼容性周期、威胁模型和失败策略不同不应共用一个“把所有运行时对象序列化”的万能方案。Unity 内置序列化和JsonUtility的支持模型不等同于System.Text.Json对普通Dictionary不应预设它会像列表一样直接往返。常见做法是使用可序列化的条目 DTO 列表在OnAfterDeserialize或明确加载阶段重建运行时字典。两条并行列表keys/values必须额外验证长度一致条目 DTO 通常更容易维护。[Serializable] public sealed class ItemCountEntry { public string itemId ; public int count; } [Serializable] public sealed class InventorySaveData { public int schemaVersion 1; public ListItemCountEntry entries new(); }加载后的字典是派生索引而不是原始存档。重建时需要先校验itemId再按明确的StringComparer建表用TryAdd捕获重复最后只将完整成功的结果交给游戏状态。不要边解析边修改正在运行的背包否则中途失败会留下半加载状态。配置表和 ScriptableObject 则可以在构建前做更严格的离线验证检查重复 ID、空白、Unicode 正规化差异、大小写冲突和跨表引用。将这些问题留到手机上的首次查找才暴露既难诊断也难修复。IL2CPP 下还要确认序列化库是否依赖运行时代码生成、反射或可被剪裁的类型。这与 comparer 契约是两个问题即使格式语义完全正确仍应在真实 Player 构建中做往返测试不能只用 Editor 成功作为发布证据。九、测试清单9.1 Comparer 属性测试不要只测一个典型样例。为比较器准备有效键集系统性检查自反、对称、传递和哈希一致性。字符串键还应包含空串、长字符串、大小写对、非 ASCII 文本、组合字符和未规范化形式。不要默认 Unicode 视觉相同就意味着序列相同是否规范化必须由业务规则决定。属性测试的核心断言是if (comparer.Equals(x, y)) { Assert.Equal( comparer.GetHashCode(x), comparer.GetHashCode(y)); }还要在真实字典中测试Add、TryAdd、索引器更新、ContainsKey、TryGetValue和Remove因为它们体现了业务对重复键的不同期望。9.2 往返与迁移测试序列化测试不能只比较 JSON 字符串完全相同。属性顺序、空白和转义方式可能改变更关键的是读回后的业务语义所有条目是否保留值是否符合预期。恢复字典是否使用指定 comparer。不同大小写或复合键的查找是否体现原语义。重复键、空键、非法值、未知 schema 是否按设计失败。从每个已发布旧版本到当前版本的迁移是否可重复执行。写入中断、文件截断和非法输入是否会破坏上一份有效存档。如果文件需要稳定文本以供版本管理再增加“不同插入顺序产生相同规范化输出”的测试。这个测试应验证写出层的排序规则而不是锁定字典的偶然枚举顺序。9.3 性能与平台测试性能测试要记录键分布、命中率、字符串长度、容量、运行时版本、编译模式和目标硬件。同时观察吞吐、尾延迟和分配不用一次未预热的计时支撑精确倍数结论。对 alternate lookup至少比较“现有字符串键”、“每次从 span 创建字符串”和“alternate lookup”三条真实路径并确保结果消费方式一致。Unity 项目要在目标设备的 Development Player 和接近发布的构建配置中复测。本文不给出未附基准工程、环境与原始结果的性能倍数。十、源码阅读方法别把示意当实现阅读Dictionary与 comparer 源码时先记录四个坐标仓库、tag/commit、目标框架和文件路径。主分支表示未来的开发状态不是任何已发布 Unity 项目的自动证据。公开接口、reference assembly 和内部实现也要分开前者是应用可编程的契约后者用于解释当前行为可在版本间变化。本文代码分为三类普通 comparer、DTO 和重建函数是应用级示例仍需根据项目的空值策略和目标框架编译。alternate lookup 片段展示 .NET 9 公开 API 的用法边界使用前必须由实际目标的 reference assembly 确认。对桶、条目和重建的文字是概念模型不是从某个未注明版本的源码逐行复制。这种区分能让文章在实现更新后仍保留原理价值也能让读者知道哪些结论可直接依赖哪些必须在自己的运行时上复核。十一、总结Comparer 不是可有可无的性能插件而是字典键语义的一部分。它必须保持等价关系与哈希一致性而键本身在入表期间必须保持身份稳定。对字符串应根据机器标识与人类语言的区别选择 ordinal 或文化规则而不用“统一忽略大小写”替代领域建模。Alternate lookup 是针对高频临时键的有用工具但它有清晰的目标框架与 comparer 能力边界。不要将它误报为 .NET 6 中普通TryGetValue的自动重载也不要在没有 Player 测量时承诺端到端零分配。序列化时应持久业务数据和 schema不持久桶、哈希码、枚举偶然顺序或 comparer 对象图。读取后在明确的比较规则下重建字典并将冲突当作需要设计和测试的业务事件。做到这些Dictionary才不仅在当前进程中查得快也能在热更、跨平台和长期存档中保持可解释的正确性。延伸阅读04-01Hash 原理04-02/04-03DictionaryTKey, TValue的数据布局与查找/扩容路径Microsoft LearnIEqualityComparerT、DictionaryTKey, TValue、System.Text.Json支持的键类型与自定义 converter研究 alternate lookup 时同时固定dotnet/runtimetag/commit 和应用的 target framework下一篇HashSet
返回列表