C# IEquatable<T>接口与可用与作Key的条件

一、C# Dictionary的key可以是一个自定义的struct吗?

在C#中,Dictionary<TKey, TValue>TKey 可以是任何类型,包括自定义的 struct(结构体)。但必须满足:

  1. 不可变:作为键的 struct一旦创建,字段值不能被修改。字典的键需要保持不变,以便字典正确地定位和检索值。
  2. 重写 GetHashCode 方法:确保相同 struct 的内容生成的哈希码保持一致。
  3. 重写 Equals 方法:字典中查找键时用于比较两个 struct 是否相等
using System;
using System.Collections.Generic;

public struct MyKey
{
    public int Part1;
    public int Part2;

    public MyKey(int part1, int part2)
    {
        Part1 = part1;
        Part2 = part2;
    }

    public override bool Equals(object obj)
    {
        if (obj is MyKey)
        {
            MyKey other = (MyKey)obj;
            return Part1 == other.Part1 && Part2 == other.Part2;
        }
        return false;
    }

    public override int GetHashCode()
    {
        return Part1.GetHashCode() ^ Part2.GetHashCode();
    }
}

二、实现IEquatable<T>接口保所有相等性测试返回一致的结果

实现IEquatable<T>声明了一个实现实例比较的函数bool IEquatable<MyClass>.Equals(MyClass other)必须要被实现,具体有如下两种实现方法:

  1. 显式实现(Explicit Implementation):
    显式实现接口成员意味着这些成员只在接口内部可见,对于类的实例调用是不可见的。显式实现的Equals方法只能通过IEquatable<MyClass>接口的引用来调用:

    public class MyClass : IEquatable<MyClass>
    {
        // 显式实现接口的Equals方法
        bool IEquatable<MyClass>.Equals(MyClass other)
        {
            // 实现比较逻辑
            return this.Property == other.Property;
        }
    }
    
  2. 隐式实现(Implicit Implementation):
    隐式实现的接口成员同时是类的公共成员。当类的成员与接口的成员具有相同的签名时,这些成员可以隐式地实现接口。这种方式使得接口的成员可以直接通过类的实例调用。

    public class MyClass : IEquatable<MyClass>
    {
        // 隐式实现接口的Equals方法:
        public bool Equals(MyClass other)
        {
            // 实现比较逻辑
            return this.Property == other.Property;
        }
    }
    

    所谓的隐式实现指的就是当bool IEquatable<MyClass>.Equals(MyClass other)没有被实现的时候会用public bool Equals(MyClass other)来替代执行Equals相等操作。
    实现IEquatable则语法上bool IEquatable.Equals(MyKey other)与public bool Equals(MyKey other)必须要至少实现一个

确保所有相等性测试返回一致的结果

实现IEquatable<T>,还应重写GetHashCode()Equals(Object)的基类实现,使其行为与方法Equals(T)的行为一致。 如果确实重写Equals(Object),则类上的静态方法Equals(System.Object, System.Object)的调用中会调用重写的实现。 此外,应重载op_Equalityop_Inequality运算符。 这可确保所有相等性测试返回一致的结果。

说白了就是实现IEquatable<T>的时候把如下几个函数都重写一遍:

bool IEquatable<T>.Equals(T other){...}
public bool Equals(T other){...}
public override bool Equals(object obj){...}
public static bool operator !=(T left, T right){...}
public static bool operator ==(T left, T right){...}
public override int GetHashCode(){...}

其中bool IEquatable<T>.Equals(T other)可有可无,因为不存在的情况下会自动被public bool Equals(T other){...}替代

范例

using System;

namespace MyNamespace
{
    public struct MyKey : IEquatable<MyKey>
    {
        public int Part1;
        public int Part2;

        public MyKey(int part1, int part2)
        {
            Part1 = part1;
            Part2 = part2;
        }

        bool IEquatable<MyKey>.Equals(MyKey other)
        {
            if (ReferenceEquals(null, other)) return false;
            if (ReferenceEquals(this, other)) return true;
            return other.Part1 == this.Part1 && other.Part2 == this.Part2;
        }

        public bool Equals(MyKey other)
        {
            if (ReferenceEquals(null, other)) return false;
            if (ReferenceEquals(this, other)) return true;
            return other.Part1 == this.Part1 && other.Part2 == this.Part2;
        }

        public override bool Equals(object obj)
        {
            if (ReferenceEquals(null, obj)) return false;
            if (ReferenceEquals(this, obj)) return true;
            if (obj.GetType() != this.GetType()) return false;
            return ((MyKey)this).Equals((MyKey)obj);
        }

        public static bool operator !=(MyKey left, MyKey right)
        {
            return !left.Equals(right);
        }

        public static bool operator ==(MyKey left, MyKey right)
        {
            return left.Equals(right);
        }

        public override int GetHashCode()
        {
            int hash = 17;
            hash = hash * 23 + Part1;
            hash = hash * 23 + Part2;
            return hash;
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            MyKey key1 = new MyKey(1, 2);
            MyKey key2 = new MyKey(1, 2);
            IEquatable<MyKey> other = new MyKey(1, 2);

            key1.Equals(key2); 
            // 上面这一行会调用public bool Equals(MyKey other)

            key1.Equals(other); 
            // 上面这一行会调public override bool Equals(object obj)

            other.Equals(key1);
            // 实现IEquatable<MyKey>则语法上bool IEquatable<MyKey>.Equals(MyKey other)与public bool Equals(MyKey other)必须要至少实现一个
            // 如果bool IEquatable<MyKey>.Equals(MyKey other)被实现了上面这一行会调bool IEquatable<MyKey>.Equals(MyKey other)
            // 如果bool IEquatable<MyKey>.Equals(MyKey other)没有被实现上面这一行会调public bool Equals(MyKey other)            
              
            Console.ReadKey();
        }
    }
}

三、结构体GetHashCode函数实现方法

通常的做法是将这些数值成员的哈希代码组合起来生成一个唯一的哈希代码。这样,即使结构体的成员类型不同,也可以生成一个相对一致的哈希值:

using System;

public struct MyStruct
{
    public int IntMember;
    public double DoubleMember;
    public long LongMember;

    public override int GetHashCode()
    {
        unchecked // Overflow is fine, just wrap
        {
            int hash = 17;
            // Suitable nullity checks etc, of course :)
            hash = hash * 23 + IntMember.GetHashCode();
            hash = hash * 23 + DoubleMember.GetHashCode();
            hash = hash * 23 + LongMember.GetHashCode();
            return hash;
        }
    }
}

这里GetHashCode方法首先初始化一个基本的哈希值,然后通过将每个成员的哈希值乘以一个质数(这里使用23)并加上成员的哈希值来组合哈希值。使用unchecked上下文允许整数溢出,这通常在哈希代码计算中是可以接受的。

对于double类型的成员,直接调用GetHashCode可能会因为浮点数的精度问题导致不同的double值产生相同的哈希代码。如果需要,你可以先将double值转换为一种更稳定的表示形式,比如将其位模式转换为long类型,然后再计算哈希值。

但是这里就有个问题,一方面这样实现方法过于复杂,另外一方面每个成员都调用一个自身的GetHashCode也会导致效率很低

高效的方法是尽量直接计算成员的哈希值,而不是使用GetHashCode方法,这样可以避免不必要的方法调用开销。
以下数值类型数值类型的大小通常在32位或更少,这意味着它们的值可以直接用作哈希码,而不需要额外的计算。

  1. int - 32位有符号整数。
  2. uint - 32位无符号整数。
  3. short - 16位有符号整数。
  4. ushort - 16位无符号整数。
  5. byte - 8位有符号整数。
  6. sbyte - 8位无符号整数。
  7. char - Unicode字符,内部表示为16位无符号整数。

但是对于如下的数值类型长度大于32位则没法直接用自己的值作为hash

  1. long - 64位有符号整数。
  2. ulong - 64位无符号整数。
  3. float - 32位浮点数。
  4. double - 64位浮点数。

如果是在.NET Core中还可以使用HashCode.Combine

public override int GetHashCode()
{
    return HashCode.Combine(Member1, Member2, Member3, Member4);
}

HashCode.Combine可以减少手动计算哈希值的复杂性,并提高代码的可读性。这种方法在处理多个成员时特别有用,因为它自动处理了哈希值的组合,这种方法有几个优点:

  1. 简洁性:代码更加简洁,易于阅读和维护。
  2. 效率HashCode.Combine 内部实现了高效的哈希码组合算法,减少了手动计算的需要。
  3. 可扩展性:可以很容易地添加或删除要组合的成员,而不需要修改哈希码计算的逻辑。

然而,使用 HashCode.Combine 也有一些需要注意的地方:

  • 类型转换:如果组合的值中包含非 int 类型的值(如示例中的 long),则需要显式转换为 int。这可能会导致数据丢失,特别是当值超出 int 的范围时。在这种情况下,你可能需要考虑其他方法来处理值,以避免哈希冲突。
  • 参数顺序HashCode.Combine 对参数的顺序敏感。如果你更改了结构体成员的顺序,你需要相应地更新 GetHashCode 方法中的参数顺序,以确保哈希码的一致性。
posted @ 2024-06-29 07:15  蛮哥哥  阅读(7)  评论(0编辑  收藏  举报