第一次看到这章的标题有点懵,啥是合式类型,是一种值类型和引用类型之外的类型么,以前也没有听说过呀?其实并不是,合式类型其实说白了就是合适的类型,如何定义类型,如何操作类型才更好,如何创建合适的值类型和引用类型?
这一章的内容比较杂,基本上类似于基础部分的终结之章,回顾下之前学习的章节,1-5章介绍了结构性编程的基础知识,6-10章来介绍面向对象的内容,加上接下来11章对异常处理的延伸学习后,基本内容部分相当于结束了!
重写Object的成员
回顾一下万类始祖的Object所具有的虚方法,除了Finalize方法不能直接调用以外,我们有三个虚方法可以用来重写,用来比较对象的Equals和GetHashCode,以及用于返回字符串的ToString。
重写ToSring
为啥要重写ToString,因为Object提供的默认ToString方法提供的是对当前类型的完全限定名输出,这个完全没有任何意义啊,我输出这个对象的字符串信息是想知道一些有用的信息,所以一定要重写。
例如我想输出这个坐标对象的具体坐标,就要通过重写的方式来获得:
重写ToString需要注意以下几条原则:
- 如需返回有用的、面向开发人员的诊断字符串,就要重写ToString()。
- 要使ToString()返回的字符串简短。
- 不要从ToString()返回空字符串来代表“空”(null)。
- 避免ToString()引发异常或造成可观察到的副作用(改变对象状态)。
- 如果返回值与语言文化相关或要求格式化(例如DateTime),就要重载ToString(string format)或实现IFormattable。
- 考虑从ToString()返回独一无二的字符串以标识对象实例。
总而言之就是要返回有用信息并且千万别在重写的方法里抛异常。
重写GetHashCode()
GetHashCode方法是用来获取和对象对应的哈希码,有两种情况必须重写该方法:
- 重写Equals()方法一定要重写GetHashCode(),否则编译器会有警告
- 将类作为hash表集合的键使用的时候也要重写GetHashCode()。
要获得良好的性能实现需要参照以下重写规则(“必须”是指必须满足的要求,“性能”是指为了增强性能而需要采取的措施,“安全性”是指为了保障安全性而需要采取的措施):
)。
- 必须:相等的对象必然有相等的哈希码(若a.Equals(b),则a.GetHashCode()==b.GetHashCode())。也就是相等的哈希码是对象相等性的必要不充分条件,对象相等则哈希码一定相等,哈希码相等对象不一定相等,还需要其它条件来满足
- 必须:在特定对象的生存期内,GetHashCode()始终返回相同的值,即使对象的数据发生了改变。许多时候应缓存方法的返回值,从而确保这一点。
- 必须:GetHashCode()不应引发任何异常;GetHashCode()总是成功返回一个值。
- 性能:哈希码应尽可能唯一。但由于哈希码只是返回一个int,所以只要一种对象包含的值比一个int能够容纳得多(这就几乎涵盖所有类型了),那么哈希码肯定存在重复。一个很容易想到的例子是long,因为long的取值范围大于int,所以假如规定每个int值都只能标识一个不同的long值,那么肯定剩下大量long值没法标识。
- 性能:可能的哈希码值应当在int的范围内平均分布。例如,创建哈希码时如果没有考虑到字符串在拉丁语言中的分布主要集中在初始的128个ASCII字符上,就会造成字符串值的分布非常不平均,所以不能算是好的GetHashCode()算法。
- 性能:GetHashCode()的性能应该优化。GetHashCode()通常在Equals()实现中用于“短路”一次完整的相等性比较(哈希码都不同,自然没必要进行完整的相等性比较了)。所以,当类型作为字典集合中的键类型使用时,会频繁调用该方法。
- 性能:两个对象的细微差异应造成哈希值的极大差异。理想情况下,1 bit的差异应造成哈希码平均16 bits的差异。这有助于确保不管哈希表如何对哈希值进行“装桶”(bucketing),也能保持良好的平衡性。
- 安全性:攻击者应难以伪造具有特定哈希码的对象。攻击手法是向哈希表中填写大量哈希为同一个值的数据。如哈希表的实现不高效,就易于受到DOS(拒绝服务)攻击。
补充说明一点:高效的哈希表实现就是指哈希值可以良好的均匀的随机分布。
namespace AddisonWesley.Michaelis.EssentialCSharp.Chapter10.Listing10_02 { public struct Coordinate { public Coordinate(Longitude longitude, Latitude latitude) { Longitude = longitude; Latitude = latitude; } public Longitude Longitude { get; } public Latitude Latitude { get; } public override int GetHashCode() { int hashCode = Longitude.GetHashCode(); // As long as the hash codes are not equal if(Longitude.GetHashCode() != Latitude.GetHashCode()) { hashCode ^= Latitude.GetHashCode(); // eXclusive OR } return hashCode; } public override string ToString() { return string.Format("{0} {1}", Longitude, Latitude); } } public struct Longitude { } public struct Latitude { } }
这里使用了异或运算,如果a、b两个值不相同,则异或结果为1。如果a、b两个值相同,异或结果为0。
- 首先向来自相关类型的哈希码应用XOR操作符,并确保操作数不相近或相等(否则结果全零)
- 在操作数相近或相等的情况下,考虑改为使用移位(bit shift)和加法(add)操作。其他备选的操作符——AND和OR——具有类似的限制,但这些限制会发生得更加频繁。多次应用AND,会逐渐变成全为0;而多次应用OR,会逐渐变成全为1。
这里的Longitude和Latitude都是只读自动属性,所以值不会变,如果是会发生改变的值,则应该对哈希码进行缓存,来满足生命周期内哈希码的唯一性原则。
重写Equals()
重写Equal和重写GetHashCode有一些区别,这要从对象同一性和相等的对象值说起:
- 两个引用假如引用同一个实例,就说这两个引用是同一的。object(因而延展到所有派生类型)提供名为**ReferenceEquals()**的静态方法来显式检查对象同一性
- 引用同一性只是“相等性”的一个例子。两个对象实例的成员值部分或全部相等,也可以说它们相等。
也就是同一个引用只是对象相等的一部分例子,两个对象实例的成员值部分或全部相等,也可以说它们相等。
例如重写Equals方法后,就可以认为对象相等:
public class Program { public static void Main() { TML tml1 = new TML("PV", "1000", "09187234"); TML tml2 = tml1; TML tml3 = new TML("PV", "1000", "09187234"); // 对象是不是引用同一 if (!TML.ReferenceEquals(tml1, tml2)) { throw new Exception("serialNumber1 does NOT " + "reference equal serialNumber2"); } // 不引用同一总相等吧 else if (!tml1.Equals(tml2)) { throw new Exception("serialNumber1 does NOT equal serialNumber2"); } else { Console.WriteLine( "serialNumber1 reference equals serialNumber2"); Console.WriteLine( "serialNumber1 equals serialNumber2"); } // 对象是不是引用同一 if (TML.ReferenceEquals(tml1, tml3)) { throw new Exception("serialNumber1 DOES reference " + "equal serialNumber3"); } // 不引用同一总相等吧 else if (!tml1.Equals(tml3) ||tml1!= tml3) { throw new Exception("serialNumber1 does NOT equal serialNumber3"); } Console.WriteLine("serialNumber1 equals serialNumber3"); } } public class TML { public TML(string name, string price, string number) { Name = name; Price = price; Number = number; } public string Name { get; } public string Price { get; } public string Number { get; } }
输出如下:
serialNumber1 reference equals serialNumber2 serialNumber1 equals serialNumber2 serialNumber1 equals serialNumber3
其中serialNumber1 和serialNumber2是引用同一,serialNumber1 和serialNumber3是重写Equals方法和!=操作符后的相等性验证通过,这个验证接下来会说到。注意这里的serialNumber1 和serialNumber3相等需要的场景也有很多,很多时候可以用于查重,如果不通过重写的方式验证相等则只能认定引用同一才是相等,这样通过不同方式创建的数据就都能逃过查重检验了。这里需要注意的两点:
- 只有引用类型才能使用ReferenceEquals方法判断,值类型的调用永远是false,因为值类型要调用该方法一定要装箱为object,而各自装箱产生的引用一定不是同一个。
- Object.Equals()的实现只是简单调用了一下ReferenceEquals方法,所以用处很有限。大多数情况下需要重写。