Effective系列丛书
点击查看第二章
More Effective C#:改善C#代码的50个有效方法
(原书第2版)
More Effective C#:50 Specific Ways to Improve Your C#, Second Edition
[美] 比尔·瓦格纳(Bill Wagner) 著
爱飞翔 译
第1章
处理各种类型的数据
C#语言原本是设计给面向对象的开发者使用的,这种开发方式会把数据与功能合起来处理。在C#逐渐成熟的过程中,它又添加了一些新的编程范式,以便支持其他一些常用的开发方式。其中有一种开发方式强调把数据存储方法与数据操作方法分开,这种方式随着分布式系统而兴起,此类系统中的应用程序分成多个小的服务,每个服务只实现一项功能,或者只实现一组相互联系的功能。如果要把数据的存储与操作分开,那么开发者就得有一些新的编程技术可供使用,正是这些需求促使C#语言添加了与之相应的一些特性。
本章会介绍怎样把数据本身与操纵或处理该数据的方法分开。此处所说的数据不一定都是对象,也有可能是函数或被动的数据容器。
第1条:使用属性而不是可直接访问的数据成员
属性一直是C#语言的特色,目前的属性机制比C#刚引入它的时候更为完备,这使得开发者能够通过属性实现很多功能,例如,可以给getter与setter设定不同的访问权限。与直接通过数据成员来编程的方式相比,自动属性可以省去大量的编程工作,而且开发者可以通过该机制轻松地定义出只读的属性。此外还可以结合以表达式为主体的(expression-bodied)写法将代码变得更紧凑。有了这些机制,就不应该继续在类型中创建公有(public)字段,也不应该继续手工编写get与set方法。属性既可以令调用者通过公有接口访问相关的数据成员,又可以确保这些成员得到面向对象式的封装。在C#语言中,属性这种元素可以像数据成员一样被访问,但它们其实是通过方法来实现的。
类型中的某些成员很适合用数据来表示,如顾客的名字、点的(x, y)坐标以及上一年的收入等。
如果用属性来实现这些成员,那么在调用你所创建的接口时,就可以像使用方法那样,通过这些属性直接访问数据字段。这些属性就像公有字段一样,可以轻松地访问。而另一方面,开发这些属性的人则可以在相关的方法中定义外界访问该属性时所产生的效果。
.NET Framework 会做出这样一种预设:它认为开发者都是通过属性来表达公有数据成员的。这可以通过其中与数据绑定(data binding)有关的类而得到印证,因为这些类是通过属性而非公有数据字段来提供支持的。WPF(Windows Presentation Foundation)、Windows Forms 以及 Web Forms 在把对象中的数据与用户界面中的控件绑定时,都以相关的属性为依据。数据绑定机制会通过反射在类型中寻找与名称相符的属性:
这段代码会把textBoxCity控件的Text属性与address对象的City属性绑定。假如你在address对象中用的是名为City的公有数据字段,而不是属性,那么这段代码将无法正常运作,因为Framework Class Library的设计者本来就没打算支持这种写法。直接使用公有数据成员是一种糟糕的编程方式,Framework Class Library 不为这种方式提供支持。这也是促使开发者改用属性来编程的原因之一。
数据绑定机制只针对那些其元素需要显示在用户界面(UI)中的类,然而,属性的适用范围却不仅仅局限于此。在其他的类与结构中,也应该多使用属性,这样可以让你在发现新的需求时,更为方便地修改代码。比方说,如果你现在决定Customer类型中的 name(名字)数据不应出现空白值,那么只需修改Name属性的代码即可:
假如当初没有通过公有属性来实现Name,而是采用了公有数据成员,那么现在就必须在代码库里找到设置过该成员的每行代码,并逐个修改,这会浪费很多时间。
由于属性是通过方法实现的,因此,开发者很容易就能给它添加多线程支持。例如可以像下面这样实现get与set访问器,使外界对Name数据的访问得以同步(本书第39条会详细讲解这个问题):
C#方法所具备的一些特性同样可以体现在属性身上,其中很明显的一条就是属性也可以声明为virtual:
请注意,刚才那几个例子在涉及属性的地方用的都是隐式写法。还有一种常见的写法,是通过属性来包装某个backing store(后援字段)。采用隐式写法时,开发者不用自己在属性的 getter 与 setter 中编写验证逻辑。也就是说,我们在用属性来表示比较简单的字段时,无须通过大量的模板代码来构建这个属性,编译器会为我们自动创建私有字段(该字段通常称为后援字段,并实现get与set这两个访问器所需的简单逻辑。
属性也可以是抽象的,从而成为接口定义的一部分,这种属性写起来与隐式属性相似。下面这段代码,就演示了怎样在泛型接口中定义属性。虽然与隐式属性的写法相似,但这种属性没有对应的实现物。定义该属性的接口只是要求实现本接口的类型都必须满足接口所订立的契约,也就是必须正确地提供Name及Value这两个属性。
对于类型中的属性来说,它的访问器分成 getter(获取器)与 setter(设置器)这两个单独的方法,这使我们能够对二者施加不同的修饰符,以便分别控制外界对该属性的获取权与设置权。由于这两种权限可以分开调整,因此我们能够通过属性更为灵活地封装数据元素:
属性不只适用于简单的数据字段。如果某个类型要在其接口中发布能够用索引来访问的内容,那么就可以创建索引器,这相当于带有参数的属性,或者说参数化的属性。下面这种写法很有用,用它创建出的属性能够返回序列中的某个元素:
与只代表单个元素的属性相似,索引器在 C# 语言中也受到很多支持。由于它们是根据你所编写的方法来实现的,因此可以在索引器的逻辑代码中进行相关的验证或计算。索引器可以是virtual(虚拟)的,也可以是abstract(抽象)的,可以声明在接口中,也可以设为只读或可读可写。若参数是整数的一维索引器,则可以参与数据绑定;若参数不是整数的一维索引器,则可以用来定义映射关系:
和C#中的数组类似,索引器也可以是多维的,而且对于每个维度使用的索引,其类型可以互不相同:
注意,索引器一律要用 this 关键字来声明。由于 C# 不允许给索引器起名字,因此同一个类型中的索引器必须在参数列表上有所区别,否则就会产生歧义。对于属性所具备的功能,索引器几乎都有,如索引器可以声明成 virtual 或 abstract,也可以为 setter 与 getter 指定不同的访问权限。然而有一个地方例外,那就是索引器必须明确地实现出来,而不能像属性那样可以由系统默认实现。
属性是个相当好的机制,而且它在当前的 C# 语言中所受的支持比在旧版 C# 语言中更多。尽管如此,有些人还是想先创建普通的数据成员,然后在确实有必要的情况下再将其替换成属性,以便利用属性所具备的优势。这种想法听上去很有道理,但实际上并不合适。例如,我们考虑下面这个类的定义代码:
这个类描述的是 Customer(客户),其中有个名为 Name 的数据成员,用来表示客户的名称。可以用大家很熟悉的写法来获取或设置这个成员:
上面这两种用法都很直观。有人可能就觉得,将来如果要把 Name 从数据成员换为属性,那么程序中的其他代码是不需要改动的。这种说法在某种程度上也对,因为从语法上来看,属性确实可以像数据成员那样来访问。然而,从 MSIL(MicroSoft Intermediate Language)的角度来看却不是这样,因为访问属性时所用的指令与访问数据成员时所用的指令是有区别的。
尽管属性与数据成员在源代码层面可以通用,但在二进制层面却无法兼容。这显然意味着:如果把公有的数据成员改成对应的公有属性,那么原来使用公有数据成员的代码就必须重新编译。C# 把二进制程序集视为语言中的“一等公民”,因为 C# 的一项目标就是让开发者能够只针对其中某个程序集来发布更新,而无须更新整个应用程序。如果把数据成员改成属性,那么就破坏了这种二进制层面的兼容机制,使得自己很难单独更新某个程序集。
看到了访问属性时所用的 MSIL 指令后,你可能会问:是以属性的形式来访问数据比较快,还是以数据成员的形式来访问比较快?其实,前者的效率虽然不会超过后者,但也未必落后于它。因为JIT编译器可能会对某些方法调用进行内联,这也包括属性访问器。如果 JIT 编译器对属性访问器做了内联处理,那么它的效率就会与数据成员相同。即便没有内联,两者在函数调用效率上的差别也可以忽略不计。只有在极个别的情况下,这种差别才会比较明显。
尽管属性需要由相关的方法来实现,但从主调方的角度来看,属性在代码中的用法其实与数据是一样的,因此,使用属性的人总是会认为自己能够像使用数据成员那样来使用它们,或者说,他们会认为访问属性跟访问数据成员没什么区别,因为这两种写法看起来是一样的。了解到这一点之后,你就应该清楚自己所写的属性访问器需要遵循用户对属性的使用习惯,其中,get 访问器不应产生较为明显的副作用;反之,set 访问器则应该明确地修改状态,使得用户能够看到这种变化。
除了要在写法与效果方面贴近数据字段,属性访问器在性能方面也应该给用户类似的感觉。为了使属性的访问速度能够与数据字段一样,你只应该在访问器中执行较为简单的数据访问操作,而不应该执行特别影响性能的操作;此外,也不应该执行非常耗时的运算或是跨应用程序的调用(如执行数据库查询操作)。总之,凡是让用户感到它与普通数据成员访问起来不太一样的操作都不要在属性的访问器中执行。
如果要在类型的公有或受保护(protected)接口中发布数据,那么应该以属性的形式来发布,对于序列或字典来说,应以索引器的形式发布。至于类型中的数据成员,则应一律设为私有(private)。做到了这一点,你的类型就能够参与数据绑定,而且以后也可以方便地修改相关方法的实现逻辑。在日常工作中,用属性的形式来封装变量顶多会占用你一到两分钟的时间,反之,如果你一开始没有使用属性,后来却想要改用属性来设计,那么就得用好几个小时去修正。现在多花一点时间,将来能省很多工夫。
第2条:尽量采用隐式属性来表示可变的数据
C# 为属性提供了很多支持,允许通过属性清晰地表达出自己的设计思路,而且当前的 C# 语言还允许我们很方便地修改这些属性。如果你一开始就能采用属性来编写代码,那么以后便可以从容地应对各种变化。
在向类中添加可供访问的数据时,要实现的属性访问器通常很简单,只是对相应的数据字段做一层包装而已。在这种情况下,其实可以采用隐式写法来创建属性,从而令代码变得更加简洁:
编译器会生成一个名字来表示与该属性相对应的后援字段。可以用属性的 setter 修改这个后援字段的值。由于该字段是编译器生成的,因此,即便在自己所写的类中,也得通过属性访问器进行操作,而不是直接修改字段本身。这种区别其实并不会造成太大影响,因为编译器所生成的属性访问器中只包含一条简单的赋值语句,因此,很有可能得到内联,这样一来,通过属性访问器来操纵数据就和直接操纵后援字段差不多了。从程序运行时的行为来看,访问隐式属性与访问后援字段是一样的,就算从性能角度观察,也是如此。
隐式属性也可以像显式实现的属性那样对访问器施加修饰符。例如,可以像下面这样缩小 set 访问器的使用范围:
隐式属性是通过后援字段来实现的,它与在早前版本的 C# 代码中手工创建出来的属性效果相同,好处在于写起来更加方便,而且类的代码也变得更加清晰。声明隐式属性可以准确呈现出设计者所要表达的意思,而不像手工编写属性时那样要添加很多其他代码,那些代码可能会掩盖真实的设计意图。
由于编译器为隐式属性所生成的代码与开发者显式编写出来的属性实现代码相同,因此,也可以用隐式属性来定义或覆盖 virtual 属性,或实现接口所定义的属性。
对于编译器所生成的后援字段,派生类是无法访问的,但派生类在覆盖基类的 virtual 属性时,可以像覆盖其他 virtual 方法那样调用基类的同名方法。如下面这段代码就用到了基类的 getter 与 setter:
使用隐式属性还有两个好处。第一,如果以后要自己实现这个属性,以便验证数据或执行其他处理,那么这种修改不会破坏二进制层面的兼容性。第二,数据的验证逻辑只需要写在一个地方就可以了。
使用旧版的 C# 编程时,很多开发者都在自己的类中直接访问后援字段,这样做会让源文件中出现大量的验证代码与错误检测代码。现在,我们不应该再这么写了。由于访问属性的后援字段相当于调用对应的属性访问器(这个访问器可能是私有的),因此,只需要把隐式属性改成显式实现的属性,并将验证逻辑放到自己新写的属性访问器中就可以了:
使用隐式属性,可以在一处创建所有的验证。如果继续使用访问器而不是直接访问后援字段,那么所有的验证只需要一次就够了。
隐式属性有一项重要的限制,就是无法在经过Serializable修饰的类型中使用。因为持久化文件的存储格式会用到编译器为后援字段所生成的字段名,而这种自动生成的字段名却不一定每次都相同。如果修改了包含该字段的类,那么编译器为这个字段所生成的名字就有可能发生变化。
尽管隐式属性有上述两个方面的问题需要注意,但总体来说,它还是具备很多优点的,例如,可以节省开发者的时间,可以产生更容易读懂的代码,还可以让开发者在有需要的时候把与该属性有关的修改及验证逻辑都放在一个地方来处理。借助隐式属性,可以写出更为清晰的代码,并有助于我们更好地维护这些代码。
第3条:尽量把值类型设计成不可变的类型
不可变的类型是个很容易理解的概念,这种类型的对象一旦创建出来,就始终保持不变。把构建该对象所用的参数验证好之后,可以确保这个对象以后将一直处于有效的状态中。由于它的内部状态无法改变,因此不可能陷入无效的状态中。这种对象创建出来之后,状态保持不变,于是无须再编写错误检测代码来阻止用户将其切换到某种无效的状态上。此外,不可变的类型本身就是线程安全的(或者说本身就具备线程安全性),因为多个线程在访问同一份内容时,看到的总是同样的结果,你用不着担心它们会看到彼此不同的值。在设计其他对象的时候,可以从对象中把这些类型的值发布给调用方,而无须担心后者会修改它们的内部状态。
不可变的类型很适合用在基于哈希的集合中。例如Object.GetHashCode()方法所返回的值必须是个实例不变式(也叫作对象不变式,参见第 10 条),而不可变的类型本身就能保证这一点。
实际工作中,很难把每一种类型都设计成不可变的类型,因此笔者的建议是,尽量把原子类型与值类型设计成不可变的类型。其他的类型应该拆分成小的结构,使每个结构都能够相当自然地同某个单一实体对应起来。例如 Address(地址)类型就可以算作单一实体,因为它虽然可以细分为很多小的字段,但只要其中一个字段发生变化,其他字段就很有可能也需要同步修改。反之,Customer(客户)类型则不是原子类型,因为它是由很多份信息组成的,这些信息能够各自独立地发生变化。例如,客户在修改电话号码的时候不一定同时要修改住址,而在修改住址的时候,也不一定要同时修改电话号码。同理,他在修改姓名的时候,依然可以沿用原来的地址与电话号码。这种对象虽然不是原子对象,但可以拆分成许多个不可变的值,或者说,它可以由许多个不可变的值通过组合来构建,例如可以拆分成地址、姓名以及一份联系方式清单,该清单中的每个条目都是由电话号码及类型所形成的值对。这些不可变的值可以通过原子类型来体现,这种类型就属于刚才说的单一实体:如果某个对象是原子类型的对象,那么不能单独修改其中的某一部分内容,而是要把整套内容全都替换掉。下面举例说明单独修改其中的某一个字段所引发的问题。
假设我们还是像往常那样,把 Address 类实现成可变类型:
上面这段代码在修改 a1 对象的内部状态时,有可能破坏该对象所要求的不变关系,因为设置完 City(城市)属性后,a1 对象会(暂时)处于无效的状态—此时的 ZipCode(邮编)与 State(州)无法与 City 相匹配。这种写法虽然看上去没有太大的问题,但要放在多线程的环境中执行就有可能引发混乱,因为系统可能在当前线程刚修改完 City 属性但还没来得及修改 ZipCode 与 State 时进行上下文切换,从而导致切换到的线程在获取 a1 对象的内容时,看到彼此不协调的 3 个属性。
就算不在多线程环境中执行,这种修改对象内部状态的写法也会导致错误。例如开发者在修改完 City 属性后,确实想到了自己应该同步修改 ZipCode 属性,然而他却给 ZipCode 设定了无效的值,于是程序就会在执行 setter 时抛出异常,从而令 a1 对象陷入无效的状态中。要想解决这个问题,必须在对象内部添加大量的验证代码,以确保构成该结构体的属性能够相互协调。这些验证代码会令项目膨胀,从而变得更加复杂。为了确保程序在抛出异常时也能够处于有效的状态中,必须在修改字段之前先给这些字段做一份拷贝,以防修改到一半的时候突然发生异常。此外,为了使程序支持多线程,还必须在每个属性访问器上进行大量的线程同步检查,set 与 get 访问器都要这样处理。总之,工作量特别大,并且还会随着新功能的增多而不断增多。
Address 这样的对象如果要设计成struct(结构体),那么最好是设计成不可变的 struct。首先,把所有的实例字段都改成外界只能读取而无法写入的字段。
现在,从公有接口的角度来看,Address 已经是不可变的类型了。为了使调用便于使用这个类型,必须提供适当的构造函数,以便能把 Address 结构体中的各项内容全都设置好。具体到本例来说,只需要提供一个构造函数,用来对 Address 中的每个字段进行初始化。不需要实现拷贝构造函数,因为赋值运算符已经够用了。要注意:默认的构造函数依然能够访问。在由那个函数所生成的地址中,每一个字符串型的字段都是 null,ZipCode 字段的值是0。
改为不可变的类型之后,调用方需要用另一种写法来修改地址对象的状态。具体到本例来说,就是要初始化一个新的 Address 对象,并将其赋给原来的变量,而不能直接修改原实例:
a1只可能有两种状态:要么是本来的取值,也就是 City 属性为 Anytown 时的状态;要么是更新之后的取值,也就是 City 属性为 Ann Arbor 时的状态。由于它的属性在设置完后便无法修改,因此不会像上一个例子那样,其中有些属性已经修改,另一些属性却尚未同步更新,而暂时陷入无效的状态。它只会在执行构造函数的那一小段时间内出现这种不协调的现象,然而这种现象在构造函数之外是看不出来的。只要新的Address对象构造完成,它的各项属性值就会固定下来,始终不发生变化。这种写法还能保证程序状态不会在抛出异常时陷入混乱,因为 a1 要么是原来的地址,要么就是新的地址。即便在构造新地址的过程中发生异常,程序的状态也依然稳固,因为此时的a1仍指向原来的旧地址。
创建不可变的类型时,要注意代码中是否存在漏洞导致客户代码可以改变该对象的内部状态。值类型由于没有派生类,因此无须防范通过派生类来修改基类内容的做法。但是,如果不可变类型中的某个字段引用了某个可变类型的对象,那么就要多加小心了。在给这样的不可变类型编写构造函数时,应该给可变类型的参数做一份拷贝。下面通过几段范例代码来说明这个问题。为了便于讨论,这些代码都假设 Phone 是值类型,而且是不可变的值类型。
数组(array)类是个引用类型。在本例中,PhoneList 结构体中的 phones 数组与该结构体外的 phones 数组其实指向同一块存储空间。因此,我们可以通过后者来修改数组的内容。如果想预防这个问题,那就需要把该数组在结构体中拷贝一份。还有一种办法是采用 System.Collections.Immutable 命名空间中的 ImmutableArray 类来取代 Array,该类与 Array 的功能相似,但它是不可变的。直接使用可变的集合有可能出现刚才说的这种问题,此外,假如 Phone 是个可变的引用类型,那么依旧会产生类似的问题。就本例来说,通过 readonly 来修饰 phones 数组只能保证数组本身不变,无法保证其中的元素不被替换。要想保证这一点,可以改用 ImmutableList 集合类型来实现 phones 字段:
不可变的类型应该怎样初始化,这取决于它本身是否较为复杂。有下面 3 种办法可供考虑。第一种办法是像 Address 结构体那样定义一个构造函数,使客户代码可以通过这个构造函数来初始化对象。提供一系列合适的构造函数给外界使用,这是最为简单的做法。
第二种办法是创建工厂方法,让外界通过该方法来对结构体做初始化。这种办法适合创建常用的值。例如 .NET Framework 中的 Color 类型就是用这种办法来初始化系统颜色的。该类型中有两个静态方法,分别叫作 Color.FromKnownColor() 与 Color.FromName(),它们可以根据某个已知的颜色或颜色名称来确定与这种系统颜色相对应的 Color 值,并返回该值的一份拷贝。
第三种办法是创建一个与不可变类型相配套的可变类,允许外界通过多个步骤来构建这个可变类的对象,进而将其转化为不可变类的对象。.NET 的 String 类就搭配有这样一个名为 System.Text.StringBuilder 的可变类,可以先多次操作该类的对象,以构建自己想要的字符串,等这些操作全都执行好之后,就可以把该字符串从 StringBuilder 对象中获取出来。
不可变类型的编写和维护比较容易,因此不要盲目地给类型中的每个属性都创建 get 访问器与 set 访问器。如果你的类型只用来保存数据,那就应该考虑将其实现成不可变的原子值类型。用这些类型充当实体可以更加顺利地构建出更为复杂的结构。
第4条:注意值类型与引用类型之间的区别
某个类型应该设计成值类型,还是设计成引用类型?是应该设计成结构体,还是应该设计成类?这些都是我们在编写C#代码时经常要考虑的问题。C#不像C++那样把所有的类型都默认视为值类型,同时允许开发者创建指向这些对象的引用,它也不像 Java 那样把所有的类型都视为引用类型(除非你是 C++ 或 Java 语言设计者)。对于 C# 来说,必须在创建类型时决定该类型的所有实例应该表现出什么样的行为。这是个很重要的决定。一旦做出,就得在后续的编程工作中遵守,因为以后如果要改动,可能导致许多代码都出现微妙的问题。刚开始创建类型时,只是在struct与class这两个关键字中挑选一个,并用它来定义该类型,然而稍后如果要修改这个类型,那么所有用到该类型的客户代码恐怕就全都要做出相应的更新了。
究竟应该定义成值类型,还是应该定义成引用类型,这没有固定的答案,而是要根据该类型的用法来判断。值类型不是多态的,因此,更适合用来存放应用程序所要操纵的数据,而引用类型则可以多态,因此,应该用来定义应用程序的行为。创建新类型的时候,首先要考虑该类型的职责,然后根据职责来决定它是值类型还是引用类型。如果用来保存数据,那就定义成结构体;如果用来展示行为,那就定义成类。
.NET 与 C# 之所以要强调值类型与引用类型之间的区别,是因为C++ 与 Java 代码经常会在这里出现问题。比方说,在 C++ 代码中,所有的参数与返回值都是按值传递的。这样做固然很有效率,但可能会导致局部拷贝,这种现象有时也叫作对象切割。如果在本来应该使用基类对象的地方用了派生类的对象,那么系统只会把该对象中与基类相对应的那一部分拷贝过去,这就意味着,对象中与派生类有关的信息全都丢失了。就算在这样的对象上调用虚函数,系统也会把该调用发送到基类的版本上。
Java 为了应对这个问题,把值类型从语言中几乎给抹掉了。它规定,由用户所定义的类型都是引用类型。所有参数与返回值都按引用传递。这么做的好处是程序表现得更加协调,但缺点则是降低了性能,因为实际上,并非所有类型都必须多态,而且有些类型根本就不需要多态。Java必须在堆上分配对象实例,而且最后还要对这些实例进行垃圾回收。此外,访问对象中的任何一个成员时,都必须对 this 进行解引用,这本身也要花时间。在 Java 中,所有的变量都是引用类型。
C#与这两种语言不同,你需要通过struct或class关键字来区分自己新创建的对象是值类型还是引用类型。较小的或者说轻量级的对象应该设计成值类型,而彼此之间形成一套体系的对象则应该以引用类型来表示。本节将通过这两种类型的用法来帮助你理解值类型与引用类型之间的区别。
首先,考虑下面这个类型。我们想在某个方法中把该类型的对象当成返回值使用:
如果MyData是值类型,那么系统会把Foo()方法所返回的内容复制到v所在的存储区域中。反之,如果MyData是引用类型,那么上述代码会把内部变量myData引用的MyData对象通过Foo()方法的返回值公布给外界,从而破坏封装。于是,客户代码可以绕过你所设计的 API,直接修改myData的内容(详情参见第17条)。
现在考虑另一种写法:
如果采用这种写法,那么系统会把myData复制一份存放到v中。由于MyData是引用类型,因此这将导致堆上出现两个对象,一个是本来的MyData对象,另一个是从该对象中复制出来的MyData对象。这样写确实不会暴露内部数据,但必须在堆上多创建一个对象,总之,这是一种效率比较低的写法。
通过公有方法导出的数据以及充当属性的数据都应该设计成值类型。这当然不是说所有的公有成员都必须返回值类型而不应该返回引用类型,这只是说,如果要返回的对象是用来存放数值的,那么应该把它设计成值类型。例如在早前的代码中,MyData 类型就是这样一个用来存放数值的类型,因此,应该设计成值类型。
下面这段代码演示了另外一种情况:
myType变量在这里充当的是Foo3()方法的返回值,然而此处提供这个变量并不是为了让人去访问其中的数值,而是为了通过该对象调用IMyInterface接口中所定义的DoWork()方法。
这段代码体现了值类型与引用类型之间的重要区别。前者是为了存储数值,而后者则用来定义行为。以类的形式来创建引用类型可以让我们通过各种机制定义出很多复杂的行为。例如可以实现继承,或是方便地管理这些对象的变化情况。把某个类型的对象当成接口类型来返回并不意味着一定会引发装箱与取消装箱等操作。与引用类型相比,值类型的运作机制比较简单,你可以通过这种类型来创建公有API,以确保某种不变关系,但若想通过它们表达较为复杂的行为则比较困难。这些较为复杂的行为最好是通过引用类型来建模。
现在,我们进一步观察这些类型在内存中的保存方式,以及由这些方式所引发的性能问题。考虑下面这个类:
这种写法创建了多少个对象?每个对象又是多大?这要依照具体情况来定。如果 MyType是值类型,那么就只需要做一次内存分配。分配的内存空间相当于MyType大小的两倍。如果MyType是引用类型,那么需要做 3 次内存分配,其中一次针对 C 类型的对象,另外两次分别针对该对象中的两个MyType对象。在采用32位指针的情况下,第一次分配的内存空间是8个字节,这是因为需要给C对象中的两个MyType各设立一个指针,而每个指针要占据4个字节。内存分配的次数之所以有区别,是因为值类型的对象会内联在包含它们的对象中(或者说,随着包含它们的对象一起分配),而引用类型则不会。如果某个变量表示的是引用类型的对象,那么必须为该引用分配空间。
为了更加清楚地理解这种区别,我们考虑下面这种写法:
如果MyType是值类型,那么只需要分配一次内存,而且分配的内存空间是单个 MyType对象的100倍。如果MyType是引用类型,那么也只分配一次内存,但是,在这种情况下,数组里的每一个元素都是 null。等到需要给这些元素做初始化的时候,就得再执行 100 次内存分配,因此,实际上需要分配 101 次内存,这样做花的时间比只分配 1 次要多。像这样频繁地给引用类型的对象分配内存空间会导致堆内存变得支离破碎,从而降低程序的性能。如果只是为了保存数值,那么就应该创建值类型,这样可以减少内存的分配次数。不过,在值类型与引用类型之间选择时,首先还是要根据类型的用法来判断,至于内存分配次数也是一项可供考虑的因素,但与用法相比,它并不是最为重要的因素。
一旦把某个类型实现成了值类型或引用类型,以后就很难改变了,因为那样做可能需要调整大量的代码。比方说,我们把Employee设计成了值类型:
这个类型很简单,只有一个方法,该方法用来支付薪酬。这种写法起初并没有问题,但是过了一段时间,公司的员工变多了,于是,你想把这些人分开对待,例如销售人员可以获取提成,管理人员可以得到奖金。为此,需要把Employee类型从结构体改为类:
修改之后,原来使用这个类型的代码可能就会出问题,因为按值传递变成了按引用传递,早前按值传递的参数现在也要按引用来传递了。比方说,下面这段代码的功能在修改之后就与早前有很大区别:
本来是打算给 CEO 发一次奖金,但修改之后,这段代码会把奖金永久地加到 CEO 的工资上,让他每次都能多领 10 000 元。之所以出现这种效果,是因为修改之前,这段代码只在拷贝出来的值上进行操作,而修改之后,则是在引用上进行操作,因此,实际上修改的是原对象本身。编译器当然会忠实地按照修改后的含义来做,CEO 可能也乐意看到这种效果,但掌管财务的 CFO显然不会同意,他肯定要汇报这个 bug。通过本例我们可以看到,值类型不能随意改成引用类型,因为这可能导致程序的行为也发生变化。
上面例子所演示的问题其实是由于Employee类型的用法而导致的。它名义上是个值类型,但实际上并没有遵守值类型的设计规范,因为除了存放数据元素,它还担负了一些职责,具体来说,就是担负了给雇员支付薪酬的职责。这些职责应该由类来实现才对,而不应该放在值类型中。类可以通过多态机制实现各种常用的功能,反之,结构体只应该用来存放数值,而不应该用来实现功能。
.NET文档建议根据类型的大小(size)来决定它是应该设计成值类型,还是应该设计成引用类型。但实际上,根据用法(use)来判断或许更加合适。简单的结构体与数据载体很适合设计成值类型。从内存管理的角度来看,这种类型的效率要比引用类型高,因为它不会导致堆中出现过多的碎片,也不会产生过多的垃圾,此外,它使用起来要比引用类型更为直接。最重要的一点在于,从方法或属性中返回值类型的对象时,调用方所收到的其实只是该对象的一份副本,这样你就不用担心类型内部的某些可变结构体会通过引用暴露给外界,从而令程序状态出现反常的变化。然而,使用值类型也是有缺点的,因为有许多特性都无法利用。如常见的面向对象技术就有很多无法用在这些类型上。比如,你无法通过值类型构建对象体系,因为所有的值类型都会自动设为sealed类型(密封类型),从而无法为其他类型所继承。值类型虽然能实现接口,但会引发装箱操作,令程序的性能变低。这个问题请参见《Effective C#》(第 3 版)第 9 条。总之,应该把值类型当成存储容器来用,而不要将其视为面向对象意义上的对象。
编程工作中需要创建的引用类型肯定比值类型要多。但如果对下面这 6 个问题都给出肯定的回答,那就应该考虑创建值类型。可以把这些问题放在刚才那个例子中思考一遍,看看它们能够怎样指导你在引用类型与值类型之间做出抉择:
1.这个类型是否主要用来存放数据?
2.这个类型能否做成不可变的类型?
3.这个类型是否比较小?
4.能否完全通过访问其数据成员的属性把这个类型的公有接口定义出来?
5.能否确定该类型将来不会有子类?
6.能否确定该类型将来不需要多态?
底层的数据对象最好是用值类型来表示,而应用程序的行为则适合放在引用类型中。在适当的地方使用值类型,可以让你从类对象中安全地导出数据副本。此外,还可以提高内存的使用效率,因为这些类型的值是基于栈来存放的,而且可以内联到其他的值类型中。在适当的地方使用引用类型,可以让你利用标准的面向对象技术来编写应用程序的逻辑代码。如果你还不确定某个类型将来会怎么用,那就优先考虑将其设为引用类型。
第5条:确保 0 可以当成值类型的有效状态使用
.NET 系统的初始化机制默认会把所有的对象都设置成 0。你无法强迫其他开发者必须用 0 以外的值来初始化值类型的某个实例。如果他是按照默认方式来创建实例的,那么系统自然会把该实例初始化为 0。因此,你所创建的值类型必须能够应对初始值为 0 的情况。
enum(枚举)类型尤其需要注意。如果某个类型无法将 0 当作有效的枚举值来看待,那就不应该把类型设计成enum。所有的enum都继承自System.ValueType,其中的枚举值(也叫枚举数)是从 0 开始算的。不过,你也可以手工指定每个枚举值所对应的整数:
sphere与anotherSphere变量的值都是 0,这并不是有效的枚举值。如果早前编写的一些代码都认为Planet类型的变量总是会取某个有效的枚举值,那么那些代码在遇到这两个变量的时候就无法正常运作了。因此,你自己定义的enum类型必须能够把0当成有效的枚举值来用。如果你的enum是用位模式来表示各种特性的启用情况,那就将0值视为任何特性都没有启用的状态。
就本例来说,可以要求用户必须把Planet类型的枚举变量初始化成某个有效的枚举值:
但是,如果其他类型需要使用你所定义的枚举类型来表示其中的数据,那么使用那个类型的人就很难满足你的要求了。
比方说,他们可能只是简单地新建一个ObservationData对象,而没有把其中的whichPlanet字段设置成有效的枚举值:
对于这个新建的ObservationData对象,其 magnitude(星等)字段为0,这当然是个合理的取值,然而值同样为0的whichPlanet字段却没有合理的解释,因为0对Planet(行星)枚举来说是个无效的值。为了解决这个问题,应该规定一种与默认值 0 相对应的枚举值,但对于本例来说,我们似乎看不出有哪个行星适合设置成默认的行星。在这种情况下,可以用0来表示enum暂时还不具备的具体取值,稍后需要加以更新:
这样修改之后,sphere变量所对应的枚举值就是None了,它用来表示该变量还没有真正设置成某个具体的行星。这也会影响到包含Planet枚举的ObservationData结构体,使得新建的ObservationData对象能够处于合理的初始状态。此时,这份观测数据的星等是0,其观测目标是None(表示还没有加以设定)。你可以明确地提供构造函数,让用户通过该函数来给所有的字段指定初始值:
但是,用户依然可以通过系统默认提供的无参构造函数来创建结构体,这样还是会将每个字段都设置成默认值。你无法阻止用户这么写。
意识到这一点之后,我们就会发现刚才那段代码仍然有问题:如果用户在创建结构体之后一直都不给它的ObservationData字段指定具体的行星,那么该字段始终是None,而针对 None 的观测数据是没有意义的。为了防止程序中出现这样的情况,我们可以考虑把ObservationData从结构体改成类,使得用户无法通过不带参数的构造函数来新建对象。但即便这样,你也只能照顾到ObservationData这一个类型,而无法阻止开发者使用Planet枚举去实现其他类型中的字段。假如他们还是把类型设计成结构体,而不是设计成类,那么用户依然可以通过无参数的构造函数加以构建。枚举只不过是在整数外面稍微封装了一层而已,如果想要表达的抽象概念无法用某套整数常量来体现,那就要考虑采用其他语言特性来实现了。
在讨论其他数值类型之前,再讲几条与enum有关的特殊规则。如果用Flags特性修饰 enum,那么要记得给0这个标志值赋予对应的含义。比方说,在下面这个表示样式的Styles枚举类型中,0的意思是没有运用任何样式(None):
很多开发者喜欢用按位AND(与)运算符来判断枚举变量是否设定了某个标志(或者说是否启用了某个选项),然而,对于值为0的标志来说,这样判断是无效的。例如,下面这种写法可以判断出flag变量是否运用了由Styles.Flat枚举值所表示的样式,但是,若想判断该变量所运用的样式是不是None(或者说,是不是根本就没有运用任何样式),则不能这么写。
如果你也像本例这样采用Flags特性来修饰自己所定义的枚举类型,那么应该在其中设计一个与0相对应的枚举值,用来表示任何标志都没有设定(或任何选项都没有开启)。
如果值类型中包含引用,那么在做初始化的时候也有可能出现问题。例如,我们经常会看到下面这种包含string引用的结构体:
这样制作出来的MyMessage,其msg字段是null。你没有办法强迫用户在构造 MyMessage的时候必须把msg设置成null以外的引用,然而我们可以利用属性机制把这个问题局限在LogMessage结构体之内,不让它影响到外界。比方说,可以创建Message属性,将msg字段的值发布给客户端使用。有了这个属性,就可以在 get 访问器中添加逻辑,以便在msg是null的情况下返回空的字符串:
你在自己的类中也应该使用这个属性,这样做可以确保检测msg引用是不是null的逻辑出现在同一个地方,也就是出现在该属性的 get 访问器中。而且,对于本例来说,如果你是从自己的程序集中获取Message属性的,那么包含检测逻辑的get访问器应该会得到内联。这种写法既能保证效率,又可以降低风险。
系统会把值类型的所有实例都初始化为0,而且你无法禁止用户创建这种内容全都是 0 的值类型实例。因此,应该让程序在遇到这种情况时能够进入某个较为合理的状态中。有一种特殊情况尤其要注意:如果用枚举类型的变量来表示某组标志或选项的使用情况,那么应该将值为0的枚举值与未设定任何标志或未开启任何选项的状态关联起来。
第6条:确保属性能够像数据那样运用
属性是个“双面人”。对于外界来说,它与被动的数据元素很像,但对于包含该属性的类来说,则必须通过方法加以实现。如果不能正确认识这种一体两面的特征,那么就有可能创建出令用户感到困惑的属性。用户通常认为,从外界访问某个属性时,其效果应该与访问相应的数据成员类似,如果创建出来的属性做不到这一点,那么他们就有可能误用你所提供的类型。属性本来应该给人这样一种感觉:调用属性方法与直接访问数据成员有着相同的效果。
如果编写客户代码的开发者能够像平常那样使用你的属性,那就说明该属性正确地表示了它所要封装的数据成员。首先,这要求程序在不受其他语句干扰的情况下前后两次访问该属性都能够得到相同的结果:
在多线程环境中,其他线程可能会在当前线程执行完第一条语句后把控制权抢走,等到本线程拿回控制权并执行第二条语句时,someObject.ImportantProperty属性的值可能已经发生了变化。但是,如果程序没有受到这种干扰,那么反复访问该属性应该得到相同的值。
此外,开发者在使用你所提供的类型时,会认为这个类型的属性访问器与其他类型一样,不会做太多的工作。这就是说,你所编写的 getter 访问器不应该执行太费时间的操作,而 setter 访问器虽然可以进行一些验证,但是调用起来也不应该太慢。
开发者为什么会对你的类做出这样的假设呢?这是因为,他们想把类中的属性当成数据来用,而且想在频繁执行的循环中多次访问这些属性。其实 .NET 的集合类也是如此。用for循环列举数组中的元素时,有可能每次都会获取数组的Length属性:
数组越长,访问Length属性的次数就越多。假如每访问一次Length,系统都要把数组中的元素个数重新计算一遍,然后才能给出数组的长度,那么整个循环的执行时间就会与数组长度的平方成正比。这样一来,就没有人会在循环中调用Length属性了。
让自己的类符合其他开发者的预期其实并不困难。首先,要尽量使用隐式属性。这些属性只是在编译器所生成的后援字段外面稍微封装了一层。访问这样的属性与直接访问数据字段差不多。由于这种属性的访问器实现起来比较简单,因此经常会得到内联。只要能坚持用隐式属性设计自己的类,那么编写客户代码的人就可以顺畅地使用类中的属性。
如果你的属性还带有隐式属性无法实现的行为,那么就必须自己来编写这些属性了。然而,这种情况也是很容易应对的。可以把验证逻辑放在自己编写的 setter 中,这样也能做出符合用户期望的设计。例如,我们早前在给LastName属性编写 setter 时就是这么做的:
这样的验证代码并没有破坏属性应满足的基本要求,因为它只是用来确保对象中的数据有效,而且执行起来也相当快。
有些属性的getter可能要先做运算,然后才能返回属性值。比方说,下面这个Point类的Distance属性用来表示该点与原点之间的距离。它的 getter 必须先算出这个距离,然后才能将其返回给调用方:
计算坐标点与原点之间的距离是很快就能完成的操作,因此,像刚才那样实现Distance属性通常并不会引发性能问题。假如Distance确实成了性能瓶颈,那可以考虑把计算好的距离值缓存起来,这样就不用每次都去计算了。但是,如果计算距离所用的某个分量(或者说某个因子)发生变化,那么缓存就会失效;于是下次执行属性的getter时,就必须重新计算缓存。(另一种办法是把Point类设计成不可变的类型,这样就不用担心其中的分量会发生变化了。)
如果属性的getter特别耗时,那么可能要重新设计公有接口。
其他开发者在使用这个类时,不会料到访问ObjectName属性竟然要在本机与远程数据库之间往返,也不会想到访问过程中还有可能发生异常。为了不使他们感到意外,应该修改公有API。具体怎样修改,要看每个类型的实际用法。就本例来说,可以考虑把远程数据库中的值缓存到本地。
上面这种实现方式也可以改用.NET Framework的Lazy类来完成。也就是说,我们还可以这样写:
如果开发者只是偶尔才会用到ObjectName属性,那么上面的写法就比较合适,因为当用户还没有要求获取该属性时,程序不需要提前把它算出来。但是,这种写法在第一次查询该属性的时候会多花一些时间。如果开发者要频繁使用ObjectName属性,而且这个属性能够有效地予以缓存,那么可以改用另一种写法,也就是在构造函数中提前获取该属性的值,等到用户查询这个属性的时候,直接把早前获取的值返回给他。当然,这样做的前提是 ObjectName属性确实能够正确地纳入缓存。假如程序中的其他代码或是系统中的其他线程要修改远程数据库中的对象名称,那么这种写法就会失效。
从远程数据库中获取数据并将其写回远程数据库其实是相当常见的功能,而且用户完全有理由去调用这些功能。可以把这样的操作放在专门的方法中执行,并且给这些方法起个合适的名字,以免令用户感到意外。例如,我们可以用下面这种办法来编写MyType类:
与getter类似,setter也有可能令用户感到意外,或者说,用户可能没有想到你会在 setter中执行某些任务。现在举个例子。如果ObjectName是可读可写的属性,而你要在它的setter中把这个值写回远程数据库,那么用户使用起来可能就会觉得有些奇怪:
由 setter 来访问远程数据库会让用户在几个方面都感到奇怪。首先,他们不会料到这样一个简单的 setter居然会对远程数据库做调用,而且要花费这样长的时间。其次,他们不会料到在执行 setter 的过程中,还有可能发生各种各样的错误。
除了刚才讲到的问题之外,还有一个问题要注意:调试器为了显示属性的值,可能会自动调用相应的 getter。因此,如果getter抛出异常、耗时过多或是修改了对象的内部状态,那么调试工作就会变得更加复杂。
开发者对属性提出的要求与方法不同,他们希望属性能够迅速执行完毕,从而可以方便地观察或修改对象的状态。他们认为属性在行为与性能这两个方面都应该与数据字段类似。如果你要创建的属性无法满足这些要求,那就应该考虑修改公有接口,把不适合由属性执行的操作放到方法中去执行,从而将属性恢复到它们应有的面貌,也就是只充当访问并修改对象状态的渠道。
第7条:用元组来限制类型的作用范围
C# 给开发者提供了很多种方式使其能够创建自定义的类型,以表示程序中的对象与数据结构。开发者可以自己来定义类、结构体、元组类型以及匿名类型。其中,类与结构体的功能较为丰富,可以用来实现各种设计方案,但是,许多开发者过于盲目地使用这两种类型,而没有考虑到除此之外是否还能采用其他办法。类与结构体虽然很强大,但却要求开发者必须编写许多例行的代码,这对于简单的设计方案来说有些不太值得,因为我们完全可以改用匿名类型或元组类型来实现这些方案。为此,大家应该了解这些类型的写法以及各写法之间的区别,而且还要知道它们与类和结构体之间的差异。
匿名类型是由编译器所生成的不可变的引用类型。为了更好地理解其工作原理,我们现在根据它的定义来逐步进行讲解。要创建匿名类型,应该声明新的变量,并把相应的字段定义在一对花括号中。
这行代码给编译器传达了很多信息。首先,编译器知道要新建一个内部的密封类。其次,它知道这是个不可变的类型,而且其中含有两个只读属性,这两个属性分别用来封装与 X 及 Y 相对应的两个后援字段。
于是,刚才那行代码的效果就相当于下面这段代码:
上面这段代码不需要手写,编译器会自动为你生成。这样做有很多好处。首先,最根本的一点就是:编译器生成比你写代码更快。很多人都能迅速敲出 new 表达式,用以生成某个类型的对象,但在编写这个类型的定义时可就没那么快了。其次,定义这样的类型属于重复性很强的工作,如果我们自己来做,可能会偶尔打错字。本例中的代码比较简单,不太会出现这种错误,但这样的情况毕竟是有可能发生的。反之,交给编译器来做,则不会出现这种只有人类才会犯的错误。最后,这样做可以降低我们需要维护的代码量,不然的话,其他开发者就要专门查看这段代码、理解它的意思、确定它的功能,并找到程序中有哪些地方用到了该代码。由于这段代码现在是由编译器自动生成的,因此开发者需要查阅并加以理解的代码就可以少一些。
使用匿名类有个很大的缺点,就是你不知道类型究竟叫什么名字,于是无法将方法的参数或返回值明确地设定成这种类型。尽管如此,我们还是可以运用一些技巧,把属于某个匿名类型的单个对象或一系列对象传给某方法,或在该方法中返回这样的对象。下面就来编写这样的方法或表达式,以便对定义在某个方法中的匿名类型加以运用。在这种情况下,需要在创建匿名类的外围方法中,通过 lambda 表达式或匿名 delegate来表达要对这种对象所做的处理逻辑。由于泛型方法允许调用者传入函数,因此可以把自己想要实现的方法设计成泛型方法,并像刚才说的那样,将自己对匿名类型的用法写在匿名方法中,从而可以把这个匿名方法当成函数(或者说,以 Func 的形式)传进来。例如,下面的Transform方法就是这样一个泛型方法,它会处理由匿名类型的对象所表示的坐标点,并将该点的 X 与 Y 坐标分别变成原来的 2 倍:
可以把匿名类型的对象传给Transform方法:
这个例子中的算法比较简单,如果算法较为复杂,那么 lambda 表达式可能也会变得复杂起来,而且有可能要多次调用不同的泛型方法。但是,创建这样的算法其实并不难,因为只需要对刚才那个例子加以扩展即可,而无须重新设计。由此可见,匿名类型很适合用来保存计算过程中的临时结果。匿名类型只在定义它的外围方法中起作用。你可以把算法在第一阶段所得到的结果保存在某个匿名类型的对象中,然后把该对象传给算法的第二阶段。可以在定义匿名类型的方法中编写 lambda 表达式并调用相关的泛型方法,以处理这种类型的对象,并对其做出必要的变换。
另外要说的是,用匿名类型的对象来保存算法的中间结果不会“污染”到应用程序的命名空间。由于这些简单的类型是编译器自动创建的,因此,开发者在理解应用程序的工作原理时,不用特意去查看这些类型的实现代码。匿名类型只在声明它的方法中起作用,开发者一看到这样的类型,就会明白该类型只是专门针对那个方法而写的。
早前笔者只是粗略地说,编译器会在你需要匿名类型的时候,自动通过一段与手写方式等效的代码把该类型定义出来。其实,编译器还添加了一些特性,这些特性是无法通过手写而实现的。匿名类型也属于不可变的类型,然而它支持对象初始化语句。如果不可变的类型是你自己创建的,那就必须手工编写相应的构造函数,使得客户代码能够通过该函数给每个字段或属性赋予初始值。在这种情况下,由于这些类型没有给其中的属性安排可以从外界调用的 setter,因此,它们不支持对象初始化语句。与之相对,如果不可变的类型是编译器自动生成的,那么可以(而且必须)通过对象初始化语句来构建匿名类型的实例。编译器会创建一个公有(public)构造函数,用来给每个属性设定初始值,并且会让代码中本来应该调用setter的地方转而调用这个构造函数。
比方说,如果你在代码中是这样写的:
那么,编译器就会把它换成
如果你想创建支持对象初始化语句的不可变类型,那么只能把它定义成匿名类型。手工编写的不可变类型无法利用刚才说的自动转换机制。
最后要说的是,匿名类型在运行期的开销可能没有想象中那样大。有人可能认为,只要写出一个匿名类型,编译器就必然要重新给出一份定义,实际上,它并没有这么机械。如果后来写的匿名类型与早前写过的相同,那么编译器就会复用它早前所生成的定义。
这里必须准确地说明:出现在不同地点的匿名类型必须满足什么样的条件才能算作“同一个匿名类型”。首先,这些类型必须声明在同一个程序集中。
其次,两个匿名类型的各个属性在名称与类型上必须相互匹配,而且属性之间的顺序也必须相同。比方说,下面这两种写法会产生两个不同的匿名类型:
如果两个匿名类型中的各属性其名称与类型都相同,但顺序不同,那么编译器会将这两者当成两个不同的匿名类型。因此,在用某个匿名类型的多个对象来表达同一个概念时,应该按照同一套顺序来书写这些对象中的各个属性,只有这样,编译器才知道这些对象所属的匿名类型是同一个匿名类型。
结束匿名类型之前,还有一种特殊情况要讲。同一个匿名类型的两个对象是否相等是根据其中的值来决定的,这意味着,可以把这样的对象当作复合键使用。比方说,如果要根据给客户提供服务的销售人员以及客户所在地的邮编对客户分组,那么可以像下面这样进行查询:
这样做会产生一个字典(dictionary)。字典中每个元素的键都是复合键,其中包含 SalesRep(销售代表)与ZipCode(邮编)两个字段。字典中每个元素的值都是一个列表,用以表示由这位销售人员负责且其地址位于同一个邮编之下的客户。
元组与匿名类型在某种程度上较为相似,它们都属于轻量级的类型,而且都是在创建实例的时候当场予以定义的。可是,它们又不完全相同,因为元组是带有公有字段的值类型,并且是可变的类型,而匿名类型则是不可变的类型。编译器在实际定义元组的时候,会参照泛型的ValueTuple来定义,并且会参照你所写的名字来生成相应的字段。
此外,编译器在新建元组类型时所用的办法与新建匿名类型时所用的办法是不同的。它新建的元组类型是个封闭的泛型类型,该类型源自ValueTuple的某种泛型形式。(ValueTuple之所以有很多种形式,是为了应对字段数量不同的元组。)
下面这种写法可以创建元组实例:
这行代码会告诉编译器你要创建含有两个整型字段的元组。编译器会把你给这两个字段起的语义名称(也就是X与Y)记下来。这意味着,可以通过这些名字来访问元组中的相应字段:
System.ValueTuple泛型结构体中,有一些方法用来判断元组是否相等,还有一些方法用来进行比较,此外,它的ToString()方法能够打印出元组中每个字段的值。在刚才实例化的ValueTuple中,X字段与Y字段的真实名称其实是Item1与Item2,假如当时还写了其他字段,那么那些字段也会按照 Item 加编号的格式来命名。
C#的类型兼容机制是根据类型的名称而构建的,这意味着,它通常会按照类型的字面写法来判断两个类型之间是否相互兼容;然而,在判断两个元组是否属于同一类型时,它依据的却是其结构。也就是说,即便两个元组的字段名称不同(无论是你自己起的名字,还是编译器生成的名字),只要它们的模样相同,那么就属于同一种元组。例如,凡是像aPoint这样包含两个整数字段的元组都算作同一种元组,它们都是从System.ValueTuple实例化而来的。
字段的语义名称是在初始化的时候设定的。你可以在声明元组变量时直接指明,也可以把初始化语句右侧的元组所用的字段命名方式套用在左侧的元组上。如果左右两侧使用了不同的字段命名方式,那么以左侧为准。
元组类型与匿名类型都是轻量级的类型,都是在实例化该类型对象的语句中定义的。如果你只想简单地保存数据,而不想定义任何行为,那么就可以考虑使用这两种类型。
在匿名类型与元组之间选择时,必须先从这两者的区别入手。元组之间是否相同是依照其结构来判断的,因此,元组很适合用来声明方法的返回值及参数。匿名类型是不可变的类型,它适合充当集合的复合键。元组类型属于值类型,能够发挥出值类型的各种优势,而匿名类型则是引用类型,因此具备引用类型的各项特性。你可以分别尝试这两种类型,看看其中哪一种更符合当前的需求。回顾一下刚才的例子,你就会发现,给这两种类型创建实例时,所用的写法其实差不多。
匿名类型与元组并没有看上去那样奇怪,只要合理地使用它们,就不用担心代码会变得难懂。如果想记录算法的中间结果,而且这种结果很适合用不可变的类型来表示,那就应该使用匿名类型,反之,若要记录具体的而且有可能发生变化的值,则可以考虑使用元组。
第8条:在匿名类型中定义局部函数
从名称的角度观察元组与匿名类型,我们会发现:C# 语言不依照字面名称(或者说名义上的称呼)来判断两种元组是否相同,而匿名类型虽然有名称,但开发者不知道这些名称具体应该怎么写(参见第 7 条)。要想用元组对象或匿名类型的对象来充当方法参数、方法返回值或属性,就必须学会某些技巧,而且还得知道通过这些技巧来使用这两种对象时会受到哪些限制。
首先说元组。如果方法需要返回元组类型,那么把元组的样子描述出来就可以了:
你不用给元组中的字段起名,但是,应该把这些字段的含义告诉调用者。
明白了各字段的含义之后,调用者就可以把上述方法的返回值赋给自己所声明的元组变量,也可以将其拆分到多个不同的变量中(这叫作对该元组做析构):
把元组当成方法的返回值来用是比较容易的,然而若想用匿名类型的对象来充当返回值则较为困难,因为这种类型虽然有名字,但你没办法在源代码里输入名字。不过,可以创建泛型方法,并通过该方法的类型参数来指代这个匿名类型。
比方说,下面这个泛型方法可以把集合中与某个值相符的对象作为序列返回给调用方。
可以像下面这样,用刚才编写的FindValue()方法来处理匿名类型的对象:
其实FindValue()方法并不关心匿名类型具体叫什么,它只是将其当作一个可以用泛型来表示的类型。
像FindValue()这样简单的函数当然只能实现这种比较直白的功能。如果要访问的是匿名类型对象中的某个属性,那就需要借助高阶函数才行。高阶函数是以其他函数为参数或返回其他函数的函数。由于它们能够把另一个函数当成参数来用,因此可以考虑将涉及匿名类型的逻辑实现成匿名函数,并将该匿名函数当作参数传给高阶函数。如果高阶函数本身支持泛型,那么可以依次使用各种匿名函数来实现较为丰富的功能。比方说,可以像下面这样执行稍微复杂一些的查询操作:
该操作以TakeWhile()方法收尾,从这个方法的签名可以看出,它正是支持泛型的高阶函数:
注意,该函数的返回值类型是IEnumerable,而且其中一个参数的类型也是IEnumerable。早前所做的查询操作用到了包含 X 及 Y 属性的匿名类型,而这个匿名类型正可以用TSource来表示。该函数的另一个参数是Func类型,这种类型的对象本身也是一个函数,此函数接受TSource类型的对象(也就是查询操作所涉及的匿名类型的对象)作为参数。
通过这项技巧,我们可以创建出庞大的程序库,并编写相当多的代码来操作匿名类型的对象。刚才编写 lambda 查询表达式的时候,用到了像TakeWhile()那样能够处理匿名类型的泛型方法。由于表达式与匿名类型声明在同一个作用范围内,因此,它完全知道这个匿名类中都有哪些属性。编译器会创建 private 嵌套类,以便能将匿名类型的实例传给其他方法。
下面这段代码创建了一种匿名类型,并把该类型的对象依次交给多个泛型方法来处理:
这段代码其实并没有太过神奇的地方:编译器只是生成了相应的 delegate,并调用它们。编译器针对每个查询方法生成与之对应的另一个方法,后者接受匿名类型的对象作为参数。然后,编译器针对它所生成的方法分别创建 delegate,并把 delegate 当作参数传给相关的查询方法。
过一阵子,程序或许就会逐渐膨胀起来,因为你有可能在其中写了很多算法,而这些算法有可能包含重复的代码。为此,我们应该想办法来整理这些算法,使得程序在功能逐渐增加的过程中依然能够保持简洁,并形成清晰且易于扩展的模块。
其中一种办法是把某些逻辑移动到简单的通用方法中,从而令各种算法都可以调用这个通用方法。例如,我们可以创建下面这个通用的泛型方法,让它接受 lambda 表达式,这样一来,就可以重构早前的算法,把其中对匿名类型的对象所做的各种处理都转换成相应的 lambda 表达式,并将这些表达式分别传给泛型方法。
这样写实际上只是相当于通过泛型方法来做简单的变换,也就是把一种匿名类型的对象变换成另一种匿名类型的对象,甚至仅仅是用修改后的数值来创建同一种匿名类型的对象。
这种技巧的关键之处在于,将算法中与匿名类型的具体细节关系不大的地方给提取出来。所有的匿名类型都重写了Equals()方法,以实现基于数值(而非基于身份或引用)的比较逻辑。如果某段代码只是把匿名类型的对象当成普通的System.Object来看待,并假设对象中有一些由匿名类型定义的公有成员,那么这样的代码就可以像刚才那样提取成相应的 lambda 表达式。重构后的代码与早前并没有太大区别,它仅仅是把我们想要对匿名类型的对象所做的处理写在了lambda表达式中,并把lambda表达式传给了泛型的通用方法,令通用方法去操作匿名类型的对象,而不像早前那样直接以查询语句的形式来操作。
在原方法中,我们已经把对匿名类型的对象所做的操作逻辑抽象到通用的 Map 函数中,然而,该方法中可能还有其他一些代码也会用在许多不同的地方,于是,我们还应该把那些代码提取到相应的泛型函数中,使得原方法与项目中的其他代码都可以调用该函数。
在这样做的时候必须把握尺度,不能滥用这项技巧。如果某个类型对于许多算法来说都相当重要,那么这个类型就不应该表示成匿名类型。要是发现自己频繁使用同一个类型的对象,并且总是在这种对象上做各种各样的处理,那么恐怕应该把对象的类型从匿名类型改成带有名称的普通类型。每个人可能都会针对匿名类型给出各自的建议,但有一条建议或许大多数人都会赞同,那就是:如果使用某个匿名类型的主要算法超过三个,那么最好把该类型改成非匿名的普通类型。如果必须编写很长、很复杂的 lambda 表达式才能够继续使用某个匿名类型的对象,那就意味着需要把该类型转换成普通的类型。
匿名类型是轻量级的类型,能够包含可读可写的属性,这些属性通常用来表示简单的数值。许多算法都能用简单的匿名类型来编写,你可以借助 lambda 表达式及泛型方法等机制,在算法中更加方便地操作匿名类型的对象。此外,你平常可能会用 private 嵌套类来限制类型的作用范围,而匿名类型在这一点上也是相似的,它也只会在某个方法中起作用。结合泛型与高阶函数来使用匿名类型的对象可以在代码中构建出更为清晰的模块。
第9条:理解相等的不同概念及它们之间的关系
在创建类型的时候,可能会同时定义一套规则来判断与该类型有关的两个对象是否相等(无论这个类型是类还是结构体,可能都会涉及这个问题)。C# 提供了 4 种不同的函数,用来决定两个对象是否“相等”:
C#语言允许你为上面的4个方法创建自己的版本。当然,你可以这么做,并不意味着你应该这么做。例如,对于前面那两个 static 函数,就不应该重新去定义。而对于第 3 个函数,也就是名为Equals()的实例方法,则通常可以根据自己的类型所具备的语义来重新定义。在极个别情况下,可能要重写第 4 个函数,也就是operator==(),这通常是为了能够更快地比较值类型的对象。此外还要注意,这 4 个函数是相互关联的,如果修改了其中的一个,那么有可能会影响另一个函数的行为。判断对象是否相等竟然要牵涉 4 个函数,这听上去有些复杂。不过别担心,这个过程可以整理得简单一些。
其实,除了这 4 个方法,还有其他一些机制,也会用来判断对象是否相等。例如,凡是重写了Equals()的类型都应该同时实现IEquatable接口。如果某个类型是从值的意义上(而不是从引用或身份的意义上)来判断对象是否相等的,那么该类型还应该实现 IStructuralEquatable接口。这样算起来,总共可以从6个角度判断对象是否相等。
与 C# 语言中的其他一些复杂机制类似,之所以要从不同的角度来考虑两个对象是否相等,是因为 C# 既允许创建值类型,又允许创建引用类型。两个引用类型的对象是否相等,要看它们引用的是不是同一个实例,也就是要根据对象身份(或者说对象标识)来判断。与之相对,两个值类型的对象是否相等,要看它们是否属于同一种类型,以及是否具有相同的内容。值类型与引用类型之间的差异导致我们需要用不同的方法来判断两个对象是否相等。
为了把判断机制讲清楚,我们首先来看那两个不应该重写的 static 函数。第一个是 ReferenceEquals()函数。如果两个引用指向同一个对象,那么该函数返回 true(真),此时我们说这两个引用所指的对象在身份上是相同的(或者说,这两个引用具备相同的对象标识)。无论是面对引用类型的对象还是面对值类型的对象,这个函数都是根据对象的身份来判断的,而不考虑对象的内容。这意味着,如果把同一个值类型的两个不同对象交给它去比较,那么即便这两个对象的内容一样,也依然会得出 false(假)。有时候,你以为自己是在比较两个不仅内容相同而且身份也相同的对象,但实际上,比较的还是两个身份不相同的对象,因为你可能忽略了装箱问题(内容相同、身份也相同的两个对象装箱之后变成了两个身份不同的对象)。该问题参见《Effective C#》(第3版)第9条。
决不应该重新定义Object.ReferenceEquals(),因为这个函数完全能够把它应该做的事情做好,也就是按照对象的身份来比较两个引用是否相等。
还有一个函数也不应该重新定义,那就是早前提到的第2个静态方法:Object.Equals()。如果你不清楚两个引用的运行期类型,那么可以用这个方法来判断它们是否相等。C# 语言的其他所有类型都是从System.Object继承而来的,因此无论要比较的是两个什么样的变量,都可以将它们视为System.Object实例。问题在于,值类型的实例与引用类型的实例要根据不同的标准来进行判断,那么在不清楚这两个引用所指向的实例究竟是值类型还是引用类型的情况下,Object.Equals()是怎样判断它们是否相等的呢?答案很简单:该方法把任务委托给其中一个类型来处理。下面我们用手工编写的C#代码来模拟Object.Equals()静态方法所用的判断逻辑:
有个新方法出现在了这段代码中,它就是名为Equals()的实例方法。该方法会在稍后进行详细讲解,但是现在,我们先把static(静态)版本的Equals()讨论完。这里的重点是:静态版本的Equals()方法会在left参数上调用实例版本的Equals()方法,以判断两个对象是否相等。
与ReferenceEquals()静态方法类似,Object.Equals()静态方法同样不需要被重写或重新予以定义,因为它本身就能够很好地完成自己应该实现的功能,也就是在不清楚运行期类型的情况下判断两个对象是否相同。由于static Equals()方法把判断任务委派给了left参数的Equals()实例方法, 因此,它的判断结果取决于left参数所指向的对象是什么类型,以及那种类型会采用什么样的规则来进行判断。
知道了为什么不需要重写static ReferenceEquals()及static Equals()方法之后,我们就该讲一讲可以由开发者来重写的那几个方法了。然而,在开始讨论重写之前,首先必须从数学角度看看等同关系具备哪些性质。你所定义并实现出来的Equals()方法也应该具备这些性质,这样才能与其他开发者的想法相符,而不至于令他们产生误解。重写Equals()方法的时候,应该编写相应的单元测试,以确保自己所提供的实现逻辑确实满足这些要求。这意味着,你的Equals()方法必须满足等同关系的 3 项数学性质:自反性、对称性、可传递性。自反性意味着任何对象都与它自身相等,或者说,无论对象a是什么类型,a==a都必定成立。对称性意味着比较的顺序不影响比较的结果,或者说,如果a==b成立,那么b==a也成立,如果a==b不成立,那么b==a也不成立。传递性意味着如果a==b与b==c同时成立,那么a==c也一定成立。
现在就来讲解实例版本的Object.Equals()方法,并谈一谈在什么情况下应该重写这个方法,以及该方法具体应该怎样重写。如果该方法的默认行为不适用于你所创建的类型,那么就可以为该类型创建自己的Equals()方法。在默认情况下,Object.Equals()方法会依照对象的身份(或者说对象的标识)来判断两个引用所指向的对象是否相等,这种判断方式与Object.ReferenceEquals()所采用的方式一样。
但要注意:值类型的默认行为不是这样。用struct关键字创建的所有值类型都继承自System.ValueType,而System.ValueType重写了Object.Equals()方法。由ValueType所实现的Equals()方法在比较两个值类型的引用是否指向同一个对象时,看的是这两个引用所指向的对象是不是同一个类型以及它们的内容是不是也相同。问题在于,ValueType所给出的实现方式的效率并不是很高。由于它是所有值类型的基类,因此它必须按照最通用的方式来实现,以便应对各种各样的值类型,为此,它不能去具体地判断某个对象的运行期类型,而是在这个类型的每一个成员字段上分别进行比较。在C#语言中,这需要通过反射来实现。反射有很多缺点,在追求性能的场合尤其不适合用反射,而判断两个对象是否相等恰恰就是这样一种需要在程序中频繁执行的操作,因此,我们应该寻找比反射更快的办法。而且对于绝大多数的值类型来说,我们确实能够找到更快的实现方式来重写 ValueType所提供的Equals()方法。因此,就值类型而言,有这样一条简单的原则:创建值类型的时候,总是应该针对这个类型重写ValueType.Equals()方法。
对于引用类型来说,只有当你需要修改该类型所定义的语义时,才应该重写实例版本的 Equals()方法。.NET Framework Class Library中的很多类采用值语义而不采用引用语义来判断是否相等。例如,判断两个string(字符串)对象是否相等,看的是它们有没有包含一样的内容;判断两个DataRowView对象是否相等,看的是它们有没有指向一样的 DataRow。总之,如果你的类型需要采用值语义而不是引用语义(或者说,需要按照对象内容而不是对象身份来进行比较),那么就应该针对这个类型重写实例版本的Object.Equals()方法。
明白了何时应该重写Object.Equals()方法之后,现在来看看具体怎样实现它。处理值类型的等同关系时,需要考虑与装箱机制有关的一些问题,对于这些问题请参见《Effective C#》(第 3 版)第9条。处理引用类型的时候,应该确保自己所实现的实例方法不要与预定义的行为有明显的差别,否则,使用你这个类的人就会觉得奇怪。此外,重写 Equals()的时候,还应该让该类型实现IEquatable接口。(稍后会讲到这个问题。)
下面是重写System.Object.Equals的标准流程,其中考虑到了该类型所要实现的IEquatable接口:
Equals()方法决不应该抛出异常,即便抛出了,也没有太大意义,因为调用者在这里所关心的仅仅是两个引用所指向的对象是否相等,而不关心其他方面的错误。如果你认为某些情况属于错误(例如引用是 null,或者参数的类型不对),那就返回false。
现在我们仔细看看这个方法的每个步骤,以便了解它为什么做这些检测,另外,还要谈一下它为什么把某些检测给省略掉了。首先,判断右侧对象是否为 null。至于左侧的this引用,在C#中绝对不可能是null,因为试图在null引用上调用实例方法一定会导致 CLR(Common Language Runtime,公共语言运行时)抛出异常。这样的判断方式其实并不具备对称性,因为在a不是null但b是null的情况下调用a.Equals(b),会得到false的结果,反过来调用b.Equals(a)的时候,按道理也应该得出false,但实际上,却会引发 NullReferenceException异常。
接下来,需要判断两个引用是否指向同一个对象,也就是按照对象的身份进行判断。这是个效率很高的做法,因为如果连身份都相同的话,那么内容必然也相同,所以不需要再判断具体的内容。如果身份不同,那么开始按照内容执行正式的判断。有个很关键的地方值得注意:这段代码没有认定this(本对象)一定是Foo类型,而是在它上面调用了this.getType(),并把获取到的类型与right(右侧对象)的类型相比较。这样做有两个原因。第一,调用该方法的实例this可能不一定属于Foo类型本身,而是属于它的某个子类型。第二,此处必须判断right参数所指向的对象其类型是否与this完全一致,而不能只是简单地通过as运算符试着将其转换成Foo类型,因为right与this一样,未必一定是Foo类型本身,它同样有可能是Foo的某个子类型。假如你以为,只要能保证 right 可以顺利地从 object 转成 Foo 就够了(或者说,只要 as 运算符的转换结果不是 null 就行),那么写出来的代码可能会出现一些微妙的 bug。下面用一套简单的继承体系来演示这些 bug:
按道理来说,Comparison 1(第一次比较)的结果应该与Comparison 2(第二次比较)的一样,如果前者打印Equals(相等),那么后者也应该打印Equals;如果前者打印Not Equal(不相等),那么后者也应该打印Not Equal。但实际上,这两次比较有可能得出相互矛盾的结果,因为早前的代码中有一些地方写得并不正确。就本例而言,第二次比较是决不会得出Equals这一结果的,因为derivedObject是D类型(派生类型)的对象,而 baseObject则是B类型(基础类型)的对象。由于B类型无法自动转换成D类型,因此,系统会把derivedObject.Equals(baseObject)解析到B类型所实现的Equals(B other)方法中。与之相反,第一次比较却有可能得出Equals这一结果,因为它是在基类对象baseObject上,以派生类的对象derivedObject为参数来调用Equals()方法的。系统可以把derivedObject参数从D类型自动转换成B类型,因此,它会把baseObject.Equals(derivedObject)也解析到B类型所实现的Equals(B other)方法上。于是,按照那个方法所采用的判断逻辑,只要由other参数所表示的derivedObject对象中与B类型有关的内容和由this所表示的baseObject对象中的对应内容相等,程序就会判定这两个对象相等,而不会考虑derivedObject中是否还有其他一些baseObject所不具备的内容。由此可见,系统在继承体系中所做的自动转换会破坏Equals方法本来应该具备的对称性质。
下面这样写会令系统把derivedObject从D类型自动转换成B类型:
如果baseObject.Equals()方法仅仅根据与B类型有关的字段是否分别相等来判断两个对象是否相同,那么它就会判定baseObject与derivedObject相同。反过来说,如果把derivedObject写在前面,那么系统无法将参数baseObject从B类型自动转换成 D 类型:
调用derivedObject.Equals(baseObject)总会得出false。由此可见,如果没有通过GetType()来比较两个参数的实际类型是否完全相同,而是只根据能否对right参数顺利执行as操作来判断它是否与本对象同属一个类型,那么就会遇到刚才演示的情况,使得比较的结果会因两个对象的先后顺序而有所差别。
前面的例子还体现出另一个重要的问题,就是如果你的类型重写了Equals(object),那么应该实现IEquatable接口。该接口包含名为Equals(T other)的方法。实现这个接口,意味着向使用这个类型的开发者传达了一条信息,告诉他们自己所写的类型能够以类型安全的方式来判断两个对象是否相等。如果你认定只有当右侧对象与左侧对象是同一种类型时Equals()才有可能返回true,那么采用了这种写法之后,编译器就能帮你把左右两侧对象不是同一类型的情况给拦截下来。
重写Equals(object)方法的时候,要记住一条原则:只有在基类型的Equals (object)不是由System.Object或System.ValueType所提供的情况下,才需要调用基类型的版本。刚才的例子就演示了这种情况。对于D类型的基类B来说,它的Equals(object)并不是由System.Object或System.ValueType所提供的,而是由编写B类的开发者自己定制的,因此,D类型的Equals(object)方法应该通过base.Equals(right)来调用基类版本的Equals(object)方法。与之相反,B类型的 Equals(object)方法则不需要再通过base.Equals(right)来调用其基类型的版本了,因为假如那样做的话,调用的就是System.Object所实现的Equals(object)方法,该方法是根据两个对象的身份而不是内容来判断它们是否相等。这种判断方式并不是编写 B 类型的开发者想要实现的效果,如果他真的要实现这种效果,就没有必要专门在B类型中重写Equals(object)方法了,而是可以直接沿用其基类型System.Object的版本。
总之,在创建值类型的时候,总是应该考虑重写 Equals() 方法,而在创建引用类型的时候,如果你认为这种类型不应该像System.Object预设的那样根据引用来判断两个对象是否相等,而是需要根据内容来进行判断,那么也应该考虑重写 Equals() 方法。重写该方法的时候,请参考早前那个Foo类型的例子来编写判断逻辑。重写Equals()方法意味着需要同时重写GetHashCode()方法,这将在第10条中详述。
现在还剩下==运算符需要考虑。这个运算符是否应该重写是比较容易判断的:如果创建的是值类型,那么可能应该重新定义operator ==(),因为你需要像重写实例版本的 Equals()方法那样,通过重载该运算符来实现高效的比较逻辑。默认版本的 == 运算符会通过反射来比较两个值类型的对象是否相等,这么做的效率当然比你自己实现出来的要低。然而你自己在实现这个运算符的时候,应该遵照《Effective C#》(第 3 版)第 9 条所说的建议,不要在对比两个值类型的对象时触发装箱操作。
请注意,这并不意味着只要重写了实例版本的Equals()方法,就必须重新定义operator==()。这只是说,当你创建的类型是值类型的时候,应该考虑重新定义operator==()。反之,如果创建的是引用类型,那么几乎不需要重写这个操作符。.NET Framework中的类都认为==运算符在string以外的引用类型上应该按照引用(或者说身份)来比较两个对象是否相等。
最后要说IStructuralEquality接口,System.Array与Tuple<>泛型类都实现了这个接口。它使得这些类型本身可以从数值的角度,根据其中的各个元素来判断该类型的两个对象是否相等,而不要求那些对象所在的类型必须实现基于数值的判定逻辑。你自己在创建新类型的时候,很少需要实现这个接口,除非你创建的类型确实是个相当轻量的类型。实现这个接口,意味着该类型的对象需要作为字段或元素融入某个较大的对象中,而那种对象所在的类型需要按照数值来判断两个对象是否相同。
C#提供了很多手段来判断两个对象是否相等,然而你在编写自己的类型时,通常只需要考虑其中的两个手段,并实现与之对应的接口。静态版本的Object.ReferenceEquals()与Object.Equals()方法决不需要重新予以定义,因为无论待比较的两个对象在运行期是什么类型,这两个方法都能正确地进行比较。你要考虑的是针对自己所创建的值类型来重写实例版本的Equals()方法,并重载==运算符,以求提升比较的效率。如果你创建的某个引用类型需要按照内容而非身份来判断两个对象是否相等,那么应该针对该类型重写实例版本的Equals()方法。重写Equals()方法的时候,还应该考虑实现IEquatable接口。笔者把如何判断两个对象是否相等总结成了刚才的几条建议,这个问题现在看起来是不是变得简单一些了呢?
第10条:留意GetHashCode()方法的使用陷阱
这本书中的其他一些条目谈的是应该怎样去编写某个方法,或者应该在什么样的情况下编写某个方法,只有这一条专门用来谈为什么不应该编写某个方法。这个方法指的就是 GetHashCode()。它只有一个用途,就是在基于哈希的集合中定义键的哈希值(也叫哈希码)。这种集合通常指的是HashSet或Dictionary这样的容器。在这种场合,你确实有理由针对键所在的类型来重写这个方法,因为那个类型的基类所提供的版本可能会有一些问题。对于引用类型来说,那个版本的效率不是很高,对于值类型来说,那个版本可能根本就不正确。然而更严重的问题是:你或许无法写出既高效又正确的 GetHashCode()方法来。没有哪个函数能像GetHashCode()这样引发如此多的争论,并给开发者带来如此多的困扰。现在就来谈谈怎样避免这些困扰。
如果你创建的类型根本就不会在容器中当作键(key)来使用,那就不用担心 GetHashCode()方法该怎么定义了。窗口控件、网页控件或数据库连接就属于这种类型,因为你不太会把这些对象当成集合中的键来用。在这些情况下,不要自己去定义 GetHashCode()。如果你创建的类型是引用类型,那么它默认采用的GetHashCode()方法是可以正常运作的,只不过效率比较低罢了。如果你创建的类型是值类型,那么应该尽量将其设计为不可变的类型(具体做法参见第 3 条。在这种情况下,默认的GetHashCode()方法也可以正常运作,然而效率同样很低)。总之,无论你创建的是引用类型还是值类型,在绝大多数情况下,最好不要去动GetHashCode()方法。
如果你创建的类型确实要充当哈希键,那么才需要考虑怎样实现GetHashCode()方法,此时需要看看下面讲的内容。每个对象都可以产生哈希码,这是个整数值。基于哈希的容器在其内部会根据每个元素的哈希码来做出优化,以便更快地进行搜索。这些容器要把存储空间划分成多个桶,从而能够把每个元素都放到对应的桶中。其哈希码符合某项条件的那批元素会放在与该条件相对应的桶中。在保存元素时,容器要计算该元素的键所具备的哈希值,以决定它究竟应该放在哪个桶中。而获取元素时,容器也要根据键的哈希值来确定自己究竟应该在哪个桶中寻找这个元素。哈希机制就是为了提升搜索效率而设计的。在理想的情况下,容器的每一个桶中都应该只包含少数几个元素。
.NET中的每个对象都有哈希码,其哈希码由System.Object.GetHashCode()决定。如果要重写该方法,那么必须遵守下面3条规则:
1.如果(实例版本的Equals()方法认定的)两个对象相等,那么这两个对象的哈希码也必须相同,否则,容器无法通过正确的哈希码来寻找相应的元素。
2.对于任何一个对象A来说,GetHashCode()必须在实例层面上满足这样一种不变条件(或者说,在实例层面上具备这样一种固定的性质)—在A的生命期内,无论实例 A 在执行完GetHashCode()方法之后还执行过其他哪些方法,当它再度执行GetHashCode()时,必定返回与当初相同的值。这条性质用来确保容器总是能把A放在正确的桶中。
3.对于常见的输入值来说,哈希函数应该把这些值均匀地映射到各个整数上,而不应该使自己所输出的哈希码仅仅集中在几个整数上。如果每一个整数都能有相似的概率来充当对象的哈希码,那么基于哈希的容器就能够较为高效地运作。简单来说,就是要保证自己所实现的GetHashCode()能够让元素均匀地保存在容器的每一个桶中。此外,每个桶的元素个数也不宜太多。
要想写出正确而高效的哈希函数,就必须透彻地了解调用该函数的对象所属的真实类型,这样才能确保该函数遵守刚才所说的最后那条规则。System.Object和System.ValueType所定义的版本显然无法确定这个函数究竟在什么类型的对象上调用。由于它不知道具体的类型,所以只能按照最通用的办法来计算。Object.GetHashCode()方法是根据System.Object类的内部字段来生成哈希值的。
现在,我们先看看系统默认提供的Object.GetHashCode()是否符合上面的3条规则。如果两个对象相等,那么Object.GetHashCode()会返回相同的哈希值,因为系统是按照对象的身份来判断两个对象是否相等的。如果两个对象相等,那么说明它们具备相同的身份标识,而Object.GetHashCode()同样是根据对象内部代表身份标识的字段来产生哈希码的,因此,它会为这两个对象输出相同的哈希码。由此可见,系统默认提供的 GetHashCode()方法确实符合第1条规则。但是,如果你决定重写Equals()方法,那么必须同时重写GetHashCode()方法,以确保第 1 条规则能够继续得到遵守(详情参见第 9 条)。
接下来看第2条规则。由于对象创建出来之后系统为它生成的哈希码总是固定不变的,因此,默认版本的Object.GetHashCode()也符合第2条规则。
最后看第3条规则,也就是看Object.GetHashCode()是否能够根据有可能出现的输入值把哈希码均匀地分布到各个整数上。Object.GetHashCode()并不了解调用该方法的对象究竟属于Object类本身,还是属于某个具体的子类,但该方法确实会在它所能做到的范围内尽量保证生成的哈希码是均匀分布的。因此,我们可以认为,它很好地遵守了第3条规则。
在开始讲解如何重写GetHashCode之前,我们还要看看System.ValueType所实现的GetHashCode()方法是否符合刚才那3条规则。System.ValueType重写了GetHashCode()方法,以便给所有的值类型都提供一份默认的实现。它所返回的哈希码,就是类型中第一个字段的哈希码。例如:
MyStruct对象所返回的哈希码就是msg字段的哈希码。这意味着只要msg不是null,下面这段代码便总是返回true:
第1条规则要求两个(由实例版的Equals()方法判定为)相等的对象必须返回相同的哈希码。在大多数情况下,值类型的对象都遵守这条规则,但有人可能会故意违反或是在无意之间违背这条规则。(此外,在编写引用类型的对象时,常常出现两个对象经由Equals()判定为相等但哈希码却不相等的情况。)在本例中,Equals()方法会对比两个结构体(struct)的首个字段,当然也会对比其他的字段,而GetHashCode()则只关注首个字段。在这种实现方式下,它是符合第 1 条规则的。如果你自己重写了Equals()方法,那么只要该方法在判断两个对象是否相等时顾及了结构体的首个字段,那么GetHashCode()方法就依然有效。反之,如果你编写的结构体类型在判断两个对象是否相等时根本就不参考第一个字段,那么由System.ValueType默认提供的GetHashCode()方法就会违背上述第1条规则。
第2条规则要求哈希码必须在实例层面上保持不变。只有当struct的首个字段是不可变的字段时,GetHashCode()才能满足这条规则。如果首个字段的值是可以修改的,那么修改了之后,GetHashCode()所返回的哈希码也会发生相应的变化,从而违背了第2条规则。因此,只要你创建的struct类型允许该类型的对象在其生命期中修改首个字段的值,那么默认的GetHashCode()方法就会在该值发生变化后失效。值类型之所以应该设计成不可变的类型,这也是其中一项理由(参见第3条)。
第3条规则是否得到满足,要看首个字段是什么类型,以及该字段在这种结构体的各个对象中是怎样取值的。如果这个字段所在的类型其GetHashCode()方法能够产生分布较为均匀的哈希码,而且该结构体的各个对象能够在首个字段的取值上相互错开,那么整个struct的GetHashCode()方法就能够生成排列较为均匀的哈希码。反之,如果有许多对象都在第一个字段上具备相同的值,那么GetHashCode()方法就不能很好地满足第3条规则了。比方说,稍微修改一下早前的struct:
如果有多个MyStruct对象都把epoch字段设置成当前这一天(这里假设只精确到天,而不包含更为具体的时刻),那么这些对象的哈希码就都一样。这种取值情况会令 GetHashCode()无法产生均匀分布的哈希码。
现在把系统默认提供的哈希函数总结一下。Object.GetHashCode()方法能够为引用类型的对象生成正确的哈希码,然而它所生成的哈希码未必是均匀分布的。(如果你在自己的类型中重写了实例版本的Object.Equals()方法,那么该方法有可能失效。)对于值类型来说,只有当结构体的第一个字段是只读字段时,ValueType.GetHashCode()才能够正常地运作。如果这种struct的各个对象能够在该字段的取值上相互错开,那么ValueType.GetHashCode()方法所给出的哈希码用起来就比较高效。
如果你想产生更好的哈希码,那么需要对类型做出一些限制。最好是能创建不可变的值类型,因为这种类型的GetHashCode()方法写起来要比不受限制的类型简单得多。现在我们就来看看怎样实现GetHashCode()才能确保早前那3条规则都得到遵守。
首先,第1条规则要求,如果Equals()方法认定两个对象相等,那么GetHashCode()方法为这两个对象所生成的哈希码也必须相等。这意味着,凡是在生成哈希码的过程中用到的属性或数据都需要在判断两个对象是否相等的时候予以考虑,只有这样,才能确保这条规则得到遵守。反之,在判断相等的时候所用到的属性也应该在计算哈希码的过程中予以考虑。有些GetHashCode()方法虽然遵守第1条规则,但却没有做到刚才说的那一点,例如System.ValueType所提供的GetHashCode()方法在计算哈希码的时候,就不一定会把判断两个对象是否相等时所用到的属性考虑进去。于是,这样计算出来的哈希码无法很好地遵守第 3 条规则。总之,判断两个对象是否相等与计算对象的哈希码这两种操作,都应该依据同一套元素来执行。
第2条规则要求GetHashCode()方法的返回值在实例层面上必须固定不变。假如你定义了下面这个Customer引用类型:
然后,又执行了下面这段代码:
那么以后就无法在 hash map 中找到 c1 这个对象了。刚开始把 c1 添加到 map 的时候,它的哈希码是根据内容为Acme Products的字符串而生成的,可是后来又把客户(Customer)的名字(Name)改成了Acme Software,于是该对象现在的哈希码也随之改变,因为 GetHashCode() 方法会根据新的名字(也就是Acme Software)来计算哈希码。刚开始保存 c1 的时候,hash map 会依照从字符串Acme Products计算出来的哈希码来决定这个c1应该保存到哪个桶中,但是,当它的名字变成Acme Software之后,hash map 可能就会误以为它保存在另外一个桶中。由于对象的哈希码没有在该对象的生命期内保持不变(或者说不具备对象层面上的不变性质),因此hash map 以后无法正确地寻找这个对象。如果对象在存储到容器之后其哈希码发生变化,那么容器就有可能找不到这个对象。尽管对象此时依然位于容器中,但容器会误以为它存放在另一个桶中。
这个问题只有当Customer是引用类型时才有可能出现,如果它是值类型,那么就不会出现这个问题,但是,程序的行为依然不太正常,因为它会表现出别的问题。Customer若是值类型,那么保存到 hash map 中的就是 c1 的一份副本,刚才写的最后一行代码虽然修改了 c1 对象的 Name,但却影响不到那份副本的 Name,因此hash map 中保存的Customer对象根本不会得到修改。由于装箱与解除装箱等机制也会涉及副本问题,因此值类型的对象在添加到集合中后,其成员是不太可能发生变化的。
要想遵守第 2 条规则,你只能根据对象中某个(或某些)不会发生变化的属性来计算该对象的哈希码。例如System.Object就是用对象标识符来计算哈希码的,因为同一个对象在其生命期内标识符始终不变。在计算哈希码的时候,System.ValueType会假设该类型的首个字段不会发生变化,如果你无法将自己所写的值类型设计成不可变的值类型,那么恐怕也找不出比这更好的办法。如果某个值类型的对象打算在 hash 容器中当作 key(键)来用,那么该类型必须是不可变的类型。如果某个类型做不到这一点,但你却把该类型的对象当成 key 来用,那么使用这个类型的人就有可能会破坏这个容器,令它无法正常运作。
为了解决这个问题,我们可以修改Customer类的代码,令它的Name属性不可变:
修改后的Customer类可以提供名为ChangeName()的方法,该方法通过构造函数与对象初始化语句来构建新的Customer对象,并把本对象的revenue值赋给新对象的对应属性。这样修改之后,开发者就不能再像早前那样直接修改Name属性了,而是要通过ChangeName()来创建新的对象,该方法会把新对象的Name属性设置成开发者通过参数所传入的那个名字:
使用修改后的Customer类时,开发者为了修改客户名称,必须先把该客户从 myDictionary中移走,然后通过ChangeName()方法创建出具备正确名称的另一个 Customer对象,以代表改名之后的客户。接下来,还要把新的Customer对象重新放回 myDictionary中。与早前的写法相比,这样写虽然较为麻烦,但是能够得出正确的结果,而不像原来那样会让程序出现问题。早前的写法有可能导致开发者写出错误的代码。如果你能像本例这样把计算哈希码时所用到的属性设计成不可变的属性,那么就能够确保程序表现出正确的行为,因为使用该类型的人现在已经无法修改计算哈希码时需要用到的属性了。现在这种写法要求类型的设计者与使用者都必须编写更多的代码才行,然而,这却是很有必要的,因为只有这样做,才能使程序正常运作。总之,如果在计算哈希码的过程中需要用到某个数据成员,那么就必须把该成员设为不可变的成员。
第 3 条规则要求,GetHashCode()应该把有可能出现的各种输入值都均匀地映射到可以充当哈希码的整数上。至于如何满足这项要求,则要看你所创建的类型具体是怎么使用的。假如真的有一个奇妙的公式能适用于所有的类型,那么System.Object早就拿它来计算哈希码了,这样的话,现在的这一条目(即本书的第10条)也就不用写了。有一种常用的哈希算法,是在类型中的每个字段上分别调用其GetHashCode()方法,并把返回的哈希码进行异或(XOR)运算,这样就得到了对象本身的哈希码(注意:可变的字段不参与计算)。只有当该类型的各字段之间相互独立时,这样的算法才能见效,否则,这种算法所生成的哈希码还是有可能集中在某几个值或某几个范围内的值上,从而无法实现均匀分布。这样的话,容器只会把元素集中保存在少数几个桶中,使得这些桶过于拥挤。
.NET 框架中有两个例子,可以用来演示怎样才算较好地实现了第 3 条规则。第一个例子是int,这个类型的GetHashCode()方法会直接把int所表示的整数值当成哈希码返回,这相当于根本就没有做随机处理,于是,取值相近的一组源数据其哈希码也必然会聚集到某个较小的范围中,而无法实现均匀分布。第二个例子是DateTime,它的GetHashCode()方法把内部64比特的Ticks属性分成高 32 位与低 32 位两个部分,并对二者做 XOR 运算,这样得到的哈希码不会过于聚集。在给自己的类型编写GetHashCode()方法时,如果能利用DateTime所实现的版本,而不是直接根据年、月、日等字段计算,那么获得的结果可能会好一些。比方说,如果要编写某个类型来表示学生的信息,那么就要考虑到许多学生都是在同一年出生的,因此,根据年份算出的哈希值就有可能分布得过于密集。总之,要想构建出合适的GetHashCode()方法,就必须先清楚地知道自己所写的类型在各字段的取值上有什么样的特点或规律。
GetHashCode()方法对开发者提出了3个很具体的要求,也就是要求相等的对象必须具备相等的哈希码,而且要求同一个对象在其生命期内必须返回同一个哈希码。此外,为了使基于哈希的容器能够高效地运作,它还要求哈希码必须均匀分布,而不能过于密集。只有当你实现的类型是不可变的类型时,才有可能写出满足这3个要求的GetHashCode()方法。如果这个类型是可变的,那你恐怕就得依赖系统所提供的默认版本了,在这种情况下,你必须了解这么做可能带来哪些问题。