☰
VB.NET中Dictionary用Structure做Key:修改后就查不到?
2026/10/8 3:39:10 网站建设 项目流程

做 VB.NET 开发好些年了,我发现自己遇到最憋屈的 bug,往往不是复杂算法,而是Dictionary的 Key。一个很典型的场景:你在Dictionary(Of TKey, TValue)里放了一个自定义Structure,用得好好的,程序跑到后面改了下结构体里的某个字段,再拿这个变量去ContainsKey,居然返回False。可Count明明显示数据还在字典里。那种“数据没丢但就是找不到”的感觉,真的能把人逼疯。这个陷阱的根源,就是 VB.NET 里Structure和Class在内存语义上的本质区别——一个是值类型,一个是引用类型,它们在复制、相等判断、哈希码计算上完全是两套逻辑。这篇文章会把原理、复现代码、排查办法一次讲清楚,适合所有用 VB.NET 写Dictionary、HashSet、缓存或者做对象映射的开发者参考。

1. 还原现场:一个让程序员怀疑人生的 False

1.1 最小复现代码

先看一个最小例子,几乎把这个问题压到了最简。我们定义一个订单 Key 结构体,里面有两个字段,存在字典里,运行一段时间后修改其中一个字段,再用修改后的 Key 去查找:

Public Structure OrderKey Public OrderId As Integer Public UserId As Integer End Structure Sub Test() Dim dict As New Dictionary(Of OrderKey, String) Dim key As New OrderKey With {.OrderId = 1001, .UserId = 88} dict.Add(key, "已支付") ' 模拟后续业务逻辑:UserId 录入错误,需要修正 key.UserId = 99 ' 抱着“肯定查得到”的心态去查询 Console.WriteLine(dict.ContainsKey(key)) ' 输出 False Console.WriteLine(dict.Count) ' 输出 1,数据明明还在 End Sub

第一次跑这段代码的人,大概率会盯着False发呆。字典里有一条数据,而且Count是 1,为什么ContainsKey就是找不到?难道字典把 Key 丢了?并不是,字典没有丢数据,它只是在“错误的桶”里找你。

1.2 翻车点:字典放进去的是一个“快照”

要理解这个现象,先得记住一个结论:Structure是值类型,当你把key作为参数传给dict.Add(key, "已支付")时,字典里保存的是key的完整副本,而不是key本身。也就是说,dict.Add执行的一瞬间,OrderKey的两个字段值(1001 和 88)被原封不动地复制进了字典内部的那份 Key 副本。

之后你执行key.UserId = 99,改的只是你自己持有的局部变量key,字典里的副本仍然是OrderId = 1001, UserId = 88。等你想用修改后的key去查找时,字典拿到的临时 Key 是1001, 99。这两个 Key 在哈希码和相等判断上都不一致,自然就“找不到”了。

顺着这个思路,如果我用一个全新的OrderKey,字段值构造为原来的1001, 88,再去查,结果是True:

Dim oldKey As New OrderKey With {.OrderId = 1001, .UserId = 88} Console.WriteLine(dict.ContainsKey(oldKey)) ' 输出 True

所以这不是“字典丢数据”,而是“你用了错误的钥匙去开锁”。这背后牵扯到的,正是接下来要展开的Structure和Class的本质区别。

2. Structure 与 Class 的本质区别:值语义 vs 引用语义

2.1 值类型:每次传递都在“复印”

Structure在 VB.NET 里是值类型。变量本身直接保存数据,不是指向数据的指针。把结构体变量赋给另一个变量、传给方法、塞进集合,都会触发一次“逐字段复制”。如果你在纸上写了一个订单号,然后拿复印机复印了一份递进保险箱,原始纸上的笔迹后来改了,保险箱里的复印件并不会跟着改。

这个特性在普通变量赋值里很直观:

Dim a As New OrderKey With {.OrderId = 1} Dim b As OrderKey = a b.OrderId = 2 ' 这里 a.OrderId 仍然是 1,b 是独立副本

字典的Add方法接收 TKey 参数,本质上就是在栈上或寄存器里复制一份结构体,再存到内部数组里。因此,任何后续对原变量的修改都不会反馈到字典内部的 Key 上。这是“快照”现象的直接原因。

2.2 引用类型:大家都指着同一份原件

Class是引用类型。变量的“值”并不是对象本身,而是一个指向堆上对象的引用。把一个类对象赋给另一个变量,两个变量引用的是同一个堆对象;通过其中一个变量修改属性,另一个变量再读,能看到修改后的值。

用档案室来打比方:Class实例是一个实体档案盒,变量只是盒子上面的标签纸条。你把档案盒存进字典,其实存的是这张引用;以后不管谁在原来的标签上写字,档案盒本身的内容都会变,字典里那个引用指向的仍然是同一个档案盒。所以对Class来说,并不存在“副本”问题,大家操作的永远是同一份数据。

这个差异,直接决定了同一个修改操作对字典 Key 的影响方式完全不同。

2.3 默认的 Equals 和 GetHashCode 有什么不同

Dictionary查找 Key 时依赖两个方法:Equals和GetHashCode。Structure和Class在默认实现上就有巨大差异。

Structure默认继承自ValueType,VB.NET 默认的Equals会按字段逐个比较。也就是说,两个结构体只要每个字段都相同,Equals就返回True。默认的GetHashCode也基于字段值去计算,所以字段一变,哈希码基本一定会变。换句话说,结构体天然是“值相等”语义。

Class默认继承自Object,Equals比较的是引用,只有两个变量引用同一个对象时才相等。GetHashCode是基于对象身份算出来的,每个实例有自己独立的一个值,和对象内部的字段没有任何关系。即使两个类对象所有字段都一样,它们的Equals也是False,哈希码也不同。

这里就出现了一个很容易迷惑人的局面:

  • 结构体 Key 会在开始时“拍快照”,但你拿修改后的变量去查,哈希码已经变了,找不到;
  • 类 Key 默认哈希码和字段无关,所以你修改属性后,同一个引用再拿去查,反而可以找到;但你要是new一个新对象,哪怕内容一模一样,也绝对找不到。

这两种行为都容易踩坑,只是坑的形状不一样。

3. Key 为什么对哈希码如此敏感

3.1 Dictionary 内部到底怎么找人

要彻底理解问题,得先清楚Dictionary的查找流程。它本质上是一个哈希表。内部有一批“桶”(Buckets),每个桶里挂着若干条目。当你调用ContainsKey时,它并不挨个比对所有条目,而是先拿传入 Key 的GetHashCode()计算出一个哈希码,再根据哈希码通过特定算法定位到某一号桶,然后只在这个桶里逐个调用Equals做精确匹配。

这个机制跟图书馆找书很像:先看索书号,决定去哪一个书架的哪一层,然后在那个小范围内翻找。如果你给的索书号和实际藏书位置不一致,哪怕书就在隔壁书架,你也只能空手而归。

正因为哈希码承担了“找桶”的职责,它必须在一段预期使用时间内保持稳定。如果同一个对象,第一次算哈希码是 5,第二次变成 9,那字典第一次把它放在 5 号桶,第二次就会跑到 9 号桶找人,结果必然是扑空。

3.2 哈希码一变,散列桶就找不到门

回到结构体的例子上。OrderKey内部有两个字段,默认GetHashCode会基于OrderId和UserId生成。当key.UserId = 88时,它进字典时哈希码假设是A;你改成99后,局部变量哈希码变成了B。字典内部保存的 Key 副本仍然是A,且处于A对应的桶里。

你拿着哈希码B的钥匙去访问,字典直接去了B桶,那儿要么为空,要么全是别的数据。所以哪怕Equals理论上可能认为某个对象与你修改后的对象“内容不同”也没用,因为压根没机会被比较。

这才是问题真正的核心:不是 Equals 不相等,而是 GetHashCode 不稳定导致桶定位错位。这也不是 Dictionary 的 bug,而是哈希表数据结构的基本约束。任何更改会参与哈希码计算的字段,都会破坏这个约束。

3.3 可变 Class 重写后的“更隐蔽版本”

结构体的问题至少还有个“快照”帮你挡住对字典内部的影响,但如果你用Class做 Key,并且重写了Equals和GetHashCode,让它基于某个可变字段计算,情况会更隐蔽。看这个例子:

Public Class ProductKey Public Id As Integer Public Overrides Function Equals(obj As Object) As Boolean Dim other = TryCast(obj, ProductKey) Return other IsNot Nothing AndAlso other.Id = Id End Function Public Overrides Function GetHashCode() As Integer Return Id End Function End Class Sub Test2() Dim dict As New Dictionary(Of ProductKey, String) Dim key As New ProductKey With {.Id = 1} dict.Add(key, "商品") key.Id = 2 ' 直接修改了字典内部 Key 对象的 Id Dim look As New ProductKey With {.Id = 2} Console.WriteLine(dict.ContainsKey(look)) ' False End Sub

这里没有“快照”,因为存进字典的是同一个对象引用。你执行key.Id = 2,字典内部的 Key 对象 Id 也变成了 2。但字典内部存放这个条目时,桶的位置是根据Id = 1算出来的。现在你拿一个Id = 2的新对象去查,哈希码是 2,字典去 2 号桶找,然而那个条目还在 1 号桶。遍历dict.Keys时,你又能看到Id = 2的 Key 安安静静躺在集合里——数据明明在,就是搜不到。

这种局面比结构体更让人崩溃,因为你会反复确认“Key 的 Id 就是 2,字典里的 Key 也是 2,为什么找不到?”答案还是在桶上:对象状态和它所在的桶位置已经错位了。所以可变Class一旦重写哈希相关方法,破坏性比结构体更直接。

4. 正确设计 Key 的完整方案

4.1 第一原则:Key 必须不可变

绕了一大圈,结论其实非常朴素:作为 Dictionary Key 的对象,生命周期内绝不能改变任何影响哈希码的数据。要么让对象整体不可变,要么至少保证参与GetHashCode的字段不可变。这也是为什么String、Integer、Guid这类类型做 Key 非常安全——它们天生不可变,字符串的任何“修改”都会产生新对象,原来的对象保持不变。

对这个原则,我实际开发中的执行标准很简单:任何自定义 Key 类型,只有ReadOnly属性或只读字段,不提供任何能修改关键字段的方法。Class就算要可变,也要把关键标识字段设置成ReadOnly。这个方法能直接从源头上消灭类别的问题。

4.2 结构体 Key 的教科书式写法

推荐用不可变结构体做业务 Key,并在结构体里显式重写Equals和GetHashCode。这样做既把哈希码控制在自己手里,又避免ValueType默认实现可能出现的反射开销。下面是一个可以直接抄的模板:

Public Structure ProductKey Private ReadOnly m_categoryId As Integer Private ReadOnly m_productId As Integer Public Sub New(categoryId As Integer, productId As Integer) m_categoryId = categoryId m_productId = productId End Sub Public ReadOnly Property CategoryId As Integer Get Return m_categoryId End Get End Property Public ReadOnly Property ProductId As Integer Get Return m_productId End Get End Property Public Overrides Function Equals(obj As Object) As Boolean If TypeOf obj Is ProductKey Then Dim other As ProductKey = CType(obj, ProductKey) Return m_categoryId = other.m_categoryId AndAlso m_productId = other.m_productId End If Return False End Function Public Overrides Function GetHashCode() As Integer Dim hash As Integer = m_categoryId hash = (hash * 397) Xor m_productId Return hash End Function End Structure

使用方式没有任何变化,但安全性高了一个数量级:

Dim dict As New Dictionary(Of ProductKey, String) Dim key As New ProductKey(10, 1001) dict.Add(key, "示例数据") ' 即使有代码想尝试修改 key 也会编译失败,因为字段是只读的 ' key.m_categoryId = 20 ' 编译错误 ' 用同一个值重新构造查找 Key,结果稳定为 True Console.WriteLine(dict.ContainsKey(New ProductKey(10, 1001)))

这里组合哈希码用了乘法加异或,够用也简单。如果你用的是较新的 .NET 版本,可以更省事,直接调用HashCode.Combine(m_categoryId, m_productId),效果类似且内置算法分布更好。

4.3 什么时候可以放心用 Class 做 Key

Class做 Key 不是不行,但要想清楚自己依赖的是引用相等还是值相等。

如果你的业务就是“同一个对象实例作为身份标识”,比如做一个以控件为 Key 的映射表,那么直接用默认引用相等完全没问题。你手里只有那个控件实例,传进去,再拿同一个实例来查,因为哈希码基于身份,字段怎么改都不影响查找。

如果你需要“两个不同实例,因为某个业务标识相同,就认为是同一个 Key”,那就必须重写Equals和GetHashCode。但千万别忘了,参与计算的字段必须是不可变的。给一个安全版本:

Public Class CustomerKey Private ReadOnly m_customerId As Guid Public Sub New(customerId As Guid) m_customerId = customerId End Sub Public ReadOnly Property CustomerId As Guid Get Return m_customerId End Get End Property Public Overrides Function Equals(obj As Object) As Boolean Dim other = TryCast(obj, CustomerKey) Return other IsNot Nothing AndAlso other.m_customerId = m_customerId End Function Public Overrides Function GetHashCode() As Integer Return m_customerId.GetHashCode() End Function End Class

注意这个Class已经等同于值语义了:内容相同即相等,且关键字段只读。这样它和结构体在 Key 的表现上基本一致,也完全安全。

4.4 常见 BCL 类型逐个看

与其每次踩坑后排查,不如直接记住哪些类型能安全做 Key。我平时会习惯性地在脑子里过这张表:

类型是否不可变默认相等语义能否安全做 Key备注
String是值相等安全最常用的 Key,放心用
Integer/Long是值相等安全基础数值类型
Guid是值相等安全适合做唯一 ID
DateTime是值相等安全注意精度一致性
Tuple(Of T1, T2)是(元组元素初始化后不可改)值相等安全适合复合 Key,但字段多时可读性差
自定义 Structure取决于定义默认逐字段比较需谨慎必须让字段只读,并重写哈希相关方法
自定义 Class(未重写)通常可变引用相等表面可用每次 new 都是不同 Key,容易踩坑
自定义 Class(重写过)取决于定义值相等需谨慎只读字段才安全
数组 /List(Of T)否引用相等不安全内容可变且默认比较引用,不建议

这张表不是让你背,而是建议你在定义Dictionary(Of TKey, TValue)之前,先问一句:这个TKey类型有没有机会被“改动”?有机会,就不要让它做 Key。

5. 常见问题排查与避坑清单

5.1 问题:修改结构体字段后 ContainsKey 返回 False

现象:结构体加入字典后,你修改了字段,再用同一个变量去查,返回False。

原因:字典内部存储的是加入时的快照,修改原变量不会同步到字典内;同时修改后的哈希码与字典内 Key 不一致,导致桶定位失败。

处理办法:不要寄希望于“原地改 Key”。如果需要改变业务主键,正确姿势是先移除旧键,再添加新键,如下:

If dict.ContainsKey(oldKey) Then Dim value = dict(oldKey) dict.Remove(oldKey) dict.Add(newKey, value) End If

注意,这里的oldKey必须是修改前状态构造的键,不是修改后的变量。

5.2 问题:两个内容相同的 Class 对象当作 Key 查不到

现象:你new了两个ProductKey,字段值一样,拿其中一个做 Add,拿另一个做 ContainsKey,结果False。

原因:Class默认是引用相等,两个不同实例Equals永远是False。哪怕字段完全相同,也不是同一个 Key。

处理办法:如果希望按内容匹配,就必须在Class里重写Equals和GetHashCode;如果不希望按内容匹配,那就要保证整个使用周期内都用同一个实例变量去查询。

5.3 问题:只重写 Equals 没有重写 GetHashCode

现象:你按业务字段重写了Equals,两个内容相同的对象应该相等了,但Dictionary还是找不到。

原因:Dictionary先用哈希码定位桶,然后才用Equals。如果两个对象哈希码不同,根本不会进入同一个桶,“相等”也就没有比较的机会。只改Equals不改GetHashCode,相当于把一半的契约只完成了一半。

处理办法:永远把Equals和GetHashCode当作一对来重写。核心规则是:凡Equals认为相等的对象,GetHashCode必须返回相同的值;但哈希码相同不代表两个对象相等。

5.4 问题:所有 Key 的 GetHashCode 都返回同一个常数

现象:业务系统运行越来越慢,查看代码发现某个结构的GetHashCode直接Return 42。

原因:哈希码如果完全一样,所有条目都落在同一个桶里。此时Dictionary的查找复杂度从O(1)退化为O(n),相当于用线性表查数据,数据一多性能就崩。

处理办法:让哈希码尽量分散。多个字段组合时,用乘法加异或或者HashCode.Combine,不要把字段简单加在一起,也不要直接返回常量。简单相加容易碰撞,比如(1, 2)和(2, 1)就会得到相同哈希码,用乘法加异或能明显减少这类冲突。

5.5 问题:Key 里面含有数组或 List

现象:结构体或类里有一个Byte()数组作为标识,放进Dictionary,查找随机失败。

原因:数组是引用类型,默认Equals是比较引用,而且数组内容可变。List(Of T)同理。哪怕你重写了外层类型的GetHashCode,数组字段一变,哈希码照样变,同时数组本身作为字段也很容易被无意中修改。

处理办法:不要直接用数组或List参与 Key。如果标识本质是字节序列,先转成不可变字符串(如Convert.ToBase64String)或者复制一份到只读字段;如果内容是只读的,也要保证构造之后永远不会有人去改数组元素。最省心的做法是把数组转成“不可变表示”再放进 Key。

6. 几个我一直在用的调试与检测技巧

6.1 把 GetHashCode 打印出来对比

遇到ContainsKey莫名返回False时,我会在Add之前和查找之前分别打印 Key 的哈希码:

Console.WriteLine("Add 前 HashCode = " & key.GetHashCode()) dict.Add(key, value) ' ... 修改字段之后 ... Console.WriteLine("查找前 HashCode = " & key.GetHashCode())

两个数字不一样,问题立刻清晰。调试结构体时一定要知道,字典内部的 HashCode 是加入时的值,所以只打印查找前的还不够,最好用一个旧值构造的副本再打印一次。把三个值放在一起比对:

  • 变量修改后的 HashCode
  • 字典内部 Key 元素的 HashCode
  • 旧值新实例的 HashCode

正常情况应该和旧值新实例一致,而不是和修改后的变量一致。

6.2 遍历 Keys 集合查看“疑似凶手”

如果字典规模不大,可以直接遍历Keys,把每个 Key 的内部字段打印出来,和外部变量做对照。特别是用了可变Class重写哈希时,你会惊恐地发现字典内的 Key 已经被“污染”成了新值,但它还是老老实实待在原来的桶里。这个观察能帮你确认“不是字典坏了,而是桶和状态错位了”。

For Each k As ProductKey In dict.Keys Console.WriteLine($"字典内 Key: {k.Id}, HashCode = {k.GetHashCode()}") Next

看到字典内 Key 的 Id 已经变化时,就别再怀疑 Framework,赶紧检查你的业务代码里有没有对 Key 实例做原地修改。

6.3 给 Key 类型加一道“不可变断言的保险丝”

我自己处理复杂业务时,除了用只读字段,还会额外加一个“运行时保险”:在属性Set方法里直接抛异常。这样就算有人试图写key.Id = 2,程序会在第一时间炸给你看,而不是悄悄把字典变成一个定时炸弹。

Private m_id As Integer Public Property Id As Integer Get Return m_id End Get Set(value As Integer) Throw New InvalidOperationException("Key 对象不可变,禁止修改 Id") End Set End Property

在实际项目中,这种方式容易让人困惑,因为“看起来是个正常属性,一赋值就崩”。所以我更推荐直接用ReadOnly字段,让编译器在静态检查阶段就拦住问题。不过在一些历史遗留代码里,等价的运行时检查确实能救命。

7. 写在最后:我踩过坑后留下的几条习惯

这些年下来,我对Dictionary的 Key 只有一个态度:默认按最坏情况防。凡是自定义类型,一律先检查“能不能改”,能让字段只读就只读,不能只读就重写Equals和GetHashCode,并且把参与计算的字段全部锁死。写到这里,我忍不住想分享一个最土的排查技巧,它帮我破过不少奇奇怪怪的案子:把 Key 在Add前和查找前的GetHashCode()各打印一次,两个数不一样,问题当场暴露。不用看几百行日志,不用猜业务逻辑,哈希码一对比,字典的态度就清楚了。很多所谓的“Framework 坑”,最后都只是 Key 的哈希一致性没有守住而已。希望这篇总结能让你在之后碰到“字典找不到 Key”时,多一份淡定,少一次通宵。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询