如何强制HashSet重新散列成员?

4
在这种情况下,当一个成员被编辑成与另一个成员相等时,强制HashSet重新计算哈希值并清除重复项的正确方法是什么?
我知道不应该期望这种情况会自动发生,因此我尝试了一些方法,比如将HashSet与自身进行交集操作,然后将其重新分配给一个构造函数调用,该构造函数引用自身和相同的EqualityComparer。我本以为后者肯定会起作用,但实际上并没有。
唯一成功的方法之一是从其转换为其他容器类型(例如List)重建HashSet,而不是直接从它本身重建。
类定义:
public class Test {
    public int N;
    public override string ToString() { return this.N.ToString(); }
    }
public class TestClassEquality: IEqualityComparer<Test> {
    public bool Equals(Test x, Test y) { return x.N == y.N; }
    public int GetHashCode(Test obj) { return obj.N.GetHashCode(); }
    }

测试代码:

    TestClassEquality eq = new TestClassEquality();
    HashSet<Test> hs = new HashSet<Test>(eq);
    Test a = new Test { N = 1 }, b = new Test { N = 2 };
    hs.Add(a);
    hs.Add(b);
    b.N = 1;
    string fmt = "Count = {0}; Values = {1}";
    Console.WriteLine(fmt, hs.Count, string.Join(",", hs));
    hs.IntersectWith(hs);
    Console.WriteLine(fmt, hs.Count, string.Join(",", hs));
    hs = new HashSet<Test>(hs, eq);
    Console.WriteLine(fmt, hs.Count, string.Join(",", hs));
    hs = new HashSet<Test>(new List<Test>(hs), eq);
    Console.WriteLine(fmt, hs.Count, string.Join(",", hs));

输出:

"Count: 2; Values: 1,1"
"Count: 2; Values: 1,1"
"Count: 2; Values: 1,1"
"Count: 1; Values: 1"

根据最终成功的方法,我可能可以创建一个扩展方法,使 HashSet 自己转储到本地列表中,清除自身,然后从该列表重新填充。

这真的有必要吗?还是有更简单的方法吗?


5
问题在于你正在做一件明确不应该发生的事情,用于哈希和字典的键必须保持不变���因此,没有人使处理这种情况变得容易。 - Lasse V. Karlsen
3个回答

11
Lasse的评论是正确的:根据HashSet的合同,您被要求不这样做,因此询问在这种情况下该怎么做是行不通的。如果你这样做会受伤,就停止这样做如果在集合中对可变对象进行更改会导致其哈希值发生变化,则不应将其放入哈希集中。你陷入了自己制造的两难境地。
为了摆脱这个困境,你可以:
  • 在对象在哈希集中被更改之前将其移除,稍后再放回去,以此来停止更改对象。
  • 修复对象的相等性和哈希实现,使其在所有更改时保持一致。
  • 在创建哈希集时,提供一个自定义的哈希/相等算法,使其在对象发生变化时不改变其意见。
  • 实现自己的“set”类,在这种情况下具有任何所需的行为。这非常困难,所以要小心。(这就是为什么首先创建这个限制的原因!)

谢谢。TLDR非MVCE版本是,我最初有一个Dictionary<string,Foo>,其中键字符串也是Foo的“Name”属性--但后来意识到这是多余的,并且使得需要从调用环境重命名该Foo变得复杂。因此,我切换到了一个HashSet,其相等性基于该名称,并出现了上述问题。当前版本使用一个包装器类,其中包含一个私有List<Foo>和访问器函数,用于所需的按名称查找、重命名和避免重复项。预计计数保持过低,以至于线性查找效率不会成为问题。 - Custer Barnes

3

除了重新创建HashSet<>,没有其他方法。不幸的是,HashSet<>构造函数有一个优化,如果它是从另一个HashSet<>创建的,它会复制哈希码...所以我们可以欺骗它:

hs = new HashSet<Test>(hs.Skip(0), eq);
hs.Skip(0)是一个IEnumerable<>,而不是HashSet<>。这会破坏HashSet<>检查。
请注意,将来Skip()可能不保证实现以下条件:在0的情况下实现短路,例如:
if (count == 0)
{
    return enu;
}
else
{
    return count elements;
}

(参见Lippert的评论,这是一个错误的问题)

手动完成它的方法是:

var hs2 = new HashSet<Test>(eq);
foreach (var value in hs)
{
    hs2.Add(value);
}
hs = hs2;

所以手动枚举并重新添加。

我刚刚发现的另一件事是,使用同一个EqualityComparer类的不同实例也会避免这种优化。 hs = new HashSet<Test>(hs, eq2); - Custer Barnes
@CusterBarnes 是的。请参见HashSet<>源代码的此行 - xanatos
2
我很欣赏你认真思考这个问题,但实际上你不必担心。Skip(0)Select(x=>x)等标准实现都要求与底层集合不是引用相等的,因此你不必担心它们会改变。LINQ 的设计是为了防止你“强制转换”查询以返回原始对象;开发人员可能试图向消费者隐藏一个可变对象,而需要对集合进行不可变视图。 - Eric Lippert

2

正如您所看到的,当修改对象影响其哈希码或等同于其他对象时,HashSet不处理可变对象。只需将其删除并重新添加:

hs.Remove(b);
b.N = 1;
hs.Add(b);

2
请注意,在进行变异之前,您必须执行删除操作,就像您在这里所做的那样。如果对象被突变以使哈希值不同,则无法将其删除。这就是为什么这在第一次就是非法的全部原因! - Eric Lippert

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