使用类与结构体作为字典键

本文关键字:字典 结构体 | 更新日期: 2023-09-27 18:13:00

假设我有以下类和结构定义,并将它们分别用作字典对象中的键:

public class MyClass { }
public struct MyStruct { }
public Dictionary<MyClass, string> ClassDictionary;
public Dictionary<MyStruct, string> StructDictionary;
ClassDictionary = new Dictionary<MyClass, string>();
StructDictionary = new Dictionary<MyStruct, string>();

为什么这是有效的:

MyClass classA = new MyClass();
MyClass classB = new MyClass();
this.ClassDictionary.Add(classA, "Test");
this.ClassDictionary.Add(classB, "Test");

但是这个在运行时崩溃了:

MyStruct structA = new MyStruct();
MyStruct structB = new MyStruct();
this.StructDictionary.Add(structA, "Test");
this.StructDictionary.Add(structB, "Test");

表示键已经存在,但仅适用于结构体。类将其视为两个单独的条目。我认为这与作为参考而不是值的数据有关,但我想要更详细地解释为什么。

使用类与结构体作为字典键

Dictionary<TKey, TValue>使用IEqualityComparer<TKey>来比较键。如果在构造字典时没有显式指定比较器,它将使用EqualityComparer<TKey>.Default

由于MyClassMyStruct都没有实现IEquatable<T>,默认的相等比较器将调用Object.EqualsObject.GetHashCode来比较实例。MyClass来源于Object,因此实现将使用引用相等来进行比较。另一方面,MyStruct派生自System.ValueType(所有结构的基类),因此它将使用ValueType.Equals来比较实例。该方法的文档说明如下:

ValueType.Equals(Object)方法覆盖Object.Equals(Object),并为。net框架中所有值类型提供值相等的默认实现。

如果当前实例和obj的字段都不是引用类型,Equals方法在内存中执行两个对象的逐字节比较。否则,使用反射比较obj和本实例对应的字段。

这个异常的发生是因为IDictionary<TKey, TValue>.Add抛出了一个ArgumentException如果"具有相同键的元素已经在[字典]中存在"。当使用结构体时,ValueType.Equals所做的逐字节比较会导致两个调用都试图添加相同的键。

  1. new object() == new object()false,因为引用类型具有引用相等性,两个实例不是同一个引用

  2. new int() == new int()true,因为值类型的值相等,且两个默认整数的值相同。注意,如果在结构体中有增量的引用类型或默认值,那么对于结构体来说,默认值也可能不相等。

如果你不喜欢默认的相等行为,你可以覆盖EqualsGetHashCode方法以及结构和类的相等操作符。

另外,如果你想要一个安全的方式来设置字典值,无论如何,你可以执行dictionary[key] = value;,它将添加新值或使用相同的键更新旧值。

更新

@280Z28发表了一条评论,指出这个答案可能会误导人,我认识到这一点,并想要解决。重要的是要知道

  1. 默认情况下,引用类型的Equals(object obj)方法和==操作符在后台调用object.ReferenceEquals(this, obj)

  2. 操作符和实例方法最终需要被覆盖以传播行为。(例如,改变Equals的实现不会影响==的实现,除非显式地添加嵌套调用)。

  3. 所有默认的。net泛型集合使用IEqualityComparer<T>实现来确定相等性(而不是实例方法)。IEqualityComparer<T>可能(并且经常)在其实现中调用实例方法,但这不是您可以指望的。使用的IEqualityComparer<T>实现有两个可能的来源:

    1. 可以在构造函数中显式地提供。

    2. 将自动从EqualityComparer<T>.Default中检索(默认)。如果你想全局配置默认的IEqualityComparer<T>EqualityComparer<T>.Default访问,你可以使用Undefault(在GitHub上)。

通常有三种很好的字典键:可变类对象的标识、不可变类对象的值或结构的值。请注意,具有公开公共字段的结构与不具有公开公共字段的结构一样适合用作字典键,因为存储在字典中的结构副本发生变化的唯一方式是该结构被读出、修改和写回。相比之下,具有暴露的可变属性的类通常会成为糟糕的字典键,除非希望按对象的标识键,而不是按其内容键。

为了使一个类型用作字典键,它的EqualsGetHashCode方法必须具有所需的语义,否则必须给Dictionary的构造函数一个实现所需语义的IEqualityComparer<T>。类的默认EqualsGetHashCode方法将以对象标识为关键字(如果希望以可变对象的标识为关键字,则很有用;否则就没什么用了)。值类型的默认EqualsGetHashCode方法通常以其成员的EqualsGetHashCode方法为关键字,但有一些问题:

  • 在结构体上使用默认方法的代码通常比使用自定义方法的代码运行慢得多(有时是一个数量级)。

  • 只包含基本类型的结构体执行浮点比较的方式与包含其他类型的结构体不同。例如,值posZero=(1.0/(1.0/0.0))和negZero=(-1.0/(1.0/0.0))比较时相等,但如果存储在只包含原语的结构体中,它们比较时不相等。请注意,即使两个值比较相等,它们在语义上也不相同,因为计算1.0/posZero将产生正无穷大,而计算1.0/negZero将产生负无穷大。

如果性能不是至关重要的,可以定义一个简单的结构体[简单地声明适当的公共字段]并将其扔到Dictionary中,并使其表现为基于值的键。它不会非常有效率,但它会起作用。字典通常会更有效地处理不可变类对象,但定义和使用不可变类对象有时会比定义和使用"普通的旧数据结构"更麻烦。

因为struct不像class那样被引用。

struct创建自身的副本,而不是像类那样解析引用。

因此,如果你尝试这样做:

var a =  new MyStruct(){Prop = "Test"};
var b =  new MyStruct(){Prop = "Test"};
Console.WriteLine(a.Equals(b));

//输出true

如果你对一个类做同样的事情:

var a =  new MyClass(){Prop = "Test"};
var b =  new MyClass(){Prop = "Test"};
Console.WriteLine(a.Equals(b));

//将打印false!(假设你没有实现一些比较函数)因为引用不相同

引用类型 key (class)指向不同的引用;值类型键(结构体)指向相同的值。我想这就是为什么会出现异常