带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

Effective系列丛书
点击查看第二章
More Effective C#:改善C#代码的50个有效方法
(原书第2版)
More Effective C#:50 Specific Ways to Improve Your C#, Second Edition

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据


[美] 比尔·瓦格纳(Bill Wagner) 著
爱飞翔 译

第一章

处理各种类型的数据
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 在把对象中的数据与用户界面中的控件绑定时,都以相关的属性为依据。数据绑定机制会通过反射在类型中寻找与名称相符的属性:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

这段代码会把textBoxCity控件的Text属性与address对象的City属性绑定。假如你在address对象中用的是名为City的公有数据字段,而不是属性,那么这段代码将无法正常运作,因为Framework Class Library的设计者本来就没打算支持这种写法。直接使用公有数据成员是一种糟糕的编程方式,Framework Class Library 不为这种方式提供支持。这也是促使开发者改用属性来编程的原因之一。
数据绑定机制只针对那些其元素需要显示在用户界面(UI)中的类,然而,属性的适用范围却不仅仅局限于此。在其他的类与结构中,也应该多使用属性,这样可以让你在发现新的需求时,更为方便地修改代码。比方说,如果你现在决定Customer类型中的 name(名字)数据不应出现空白值,那么只需修改Name属性的代码即可:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据
带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

假如当初没有通过公有属性来实现Name,而是采用了公有数据成员,那么现在就必须在代码库里找到设置过该成员的每行代码,并逐个修改,这会浪费很多时间。
由于属性是通过方法实现的,因此,开发者很容易就能给它添加多线程支持。例如可以像下面这样实现get与set访问器,使外界对Name数据的访问得以同步(本书第39条会详细讲解这个问题):

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

C#方法所具备的一些特性同样可以体现在属性身上,其中很明显的一条就是属性也可以声明为virtual:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据
带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

请注意,刚才那几个例子在涉及属性的地方用的都是隐式写法。还有一种常见的写法,是通过属性来包装某个backing store(后援字段)。采用隐式写法时,开发者不用自己在属性的 getter 与 setter 中编写验证逻辑。也就是说,我们在用属性来表示比较简单的字段时,无须通过大量的模板代码来构建这个属性,编译器会为我们自动创建私有字段(该字段通常称为后援字段,并实现get与set这两个访问器所需的简单逻辑。
属性也可以是抽象的,从而成为接口定义的一部分,这种属性写起来与隐式属性相似。下面这段代码,就演示了怎样在泛型接口中定义属性。虽然与隐式属性的写法相似,但这种属性没有对应的实现物。定义该属性的接口只是要求实现本接口的类型都必须满足接口所订立的契约,也就是必须正确地提供Name及Value这两个属性。

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

在 C# 语言中,属性是功能完备的“一等公民”,它可以视为对方法所做的扩充,用以访问或修改内部数据。凡是能在成员方法上执行的操作都可以在属性上执行。此外,由于属性不能传递给方法中用ref或out关键字所修饰的参数,因此与直接使用字段相比,它可以帮我们避开与此有关的一些严重问题。
对于类型中的属性来说,它的访问器分成 getter(获取器)与 setter(设置器)这两个单独的方法,这使我们能够对二者施加不同的修饰符,以便分别控制外界对该属性的获取权与设置权。由于这两种权限可以分开调整,因此我们能够通过属性更为灵活地封装数据元素:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据
带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

属性不只适用于简单的数据字段。如果某个类型要在其接口中发布能够用索引来访问的内容,那么就可以创建索引器,这相当于带有参数的属性,或者说参数化的属性。下面这种写法很有用,用它创建出的属性能够返回序列中的某个元素:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

与只代表单个元素的属性相似,索引器在 C# 语言中也受到很多支持。由于它们是根据你所编写的方法来实现的,因此可以在索引器的逻辑代码中进行相关的验证或计算。索引器可以是virtual(虚拟)的,也可以是abstract(抽象)的,可以声明在接口中,也可以设为只读或可读可写。若参数是整数的一维索引器,则可以参与数据绑定;若参数不是整数的一维索引器,则可以用来定义映射关系:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

和C#中的数组类似,索引器也可以是多维的,而且对于每个维度使用的索引,其类型可以互不相同:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

注意,索引器一律要用 this 关键字来声明。由于 C# 不允许给索引器起名字,因此同一个类型中的索引器必须在参数列表上有所区别,否则就会产生歧义。对于属性所具备的功能,索引器几乎都有,如索引器可以声明成 virtual 或 abstract,也可以为 setter 与 getter 指定不同的访问权限。然而有一个地方例外,那就是索引器必须明确地实现出来,而不能像属性那样可以由系统默认实现。
属性是个相当好的机制,而且它在当前的 C# 语言中所受的支持比在旧版 C# 语言中更多。尽管如此,有些人还是想先创建普通的数据成员,然后在确实有必要的情况下再将其替换成属性,以便利用属性所具备的优势。这种想法听上去很有道理,但实际上并不合适。例如,我们考虑下面这个类的定义代码:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

这个类描述的是 Customer(客户),其中有个名为 Name 的数据成员,用来表示客户的名称。可以用大家很熟悉的写法来获取或设置这个成员:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

上面这两种用法都很直观。有人可能就觉得,将来如果要把 Name 从数据成员换为属性,那么程序中的其他代码是不需要改动的。这种说法在某种程度上也对,因为从语法上来看,属性确实可以像数据成员那样来访问。然而,从 MSIL(MicroSoft Intermediate Language)的角度来看却不是这样,因为访问属性时所用的指令与访问数据成员时所用的指令是有区别的。
尽管属性与数据成员在源代码层面可以通用,但在二进制层面却无法兼容。这显然意味着:如果把公有的数据成员改成对应的公有属性,那么原来使用公有数据成员的代码就必须重新编译。C# 把二进制程序集视为语言中的“一等公民”,因为 C# 的一项目标就是让开发者能够只针对其中某个程序集来发布更新,而无须更新整个应用程序。如果把数据成员改成属性,那么就破坏了这种二进制层面的兼容机制,使得自己很难单独更新某个程序集。
看到了访问属性时所用的 MSIL 指令后,你可能会问:是以属性的形式来访问数据比较快,还是以数据成员的形式来访问比较快?其实,前者的效率虽然不会超过后者,但也未必落后于它。因为JIT编译器可能会对某些方法调用进行内联,这也包括属性访问器。如果 JIT 编译器对属性访问器做了内联处理,那么它的效率就会与数据成员相同。即便没有内联,两者在函数调用效率上的差别也可以忽略不计。只有在极个别的情况下,这种差别才会比较明显。
尽管属性需要由相关的方法来实现,但从主调方的角度来看,属性在代码中的用法其实与数据是一样的,因此,使用属性的人总是会认为自己能够像使用数据成员那样来使用它们,或者说,他们会认为访问属性跟访问数据成员没什么区别,因为这两种写法看起来是一样的。了解到这一点之后,你就应该清楚自己所写的属性访问器需要遵循用户对属性的使用习惯,其中,get 访问器不应产生较为明显的副作用;反之,set 访问器则应该明确地修改状态,使得用户能够看到这种变化。
除了要在写法与效果方面贴近数据字段,属性访问器在性能方面也应该给用户类似的感觉。为了使属性的访问速度能够与数据字段一样,你只应该在访问器中执行较为简单的数据访问操作,而不应该执行特别影响性能的操作;此外,也不应该执行非常耗时的运算或是跨应用程序的调用(如执行数据库查询操作)。总之,凡是让用户感到它与普通数据成员访问起来不太一样的操作都不要在属性的访问器中执行。
如果要在类型的公有或受保护(protected)接口中发布数据,那么应该以属性的形式来发布,对于序列或字典来说,应以索引器的形式发布。至于类型中的数据成员,则应一律设为私有(private)。做到了这一点,你的类型就能够参与数据绑定,而且以后也可以方便地修改相关方法的实现逻辑。在日常工作中,用属性的形式来封装变量顶多会占用你一到两分钟的时间,反之,如果你一开始没有使用属性,后来却想要改用属性来设计,那么就得用好几个小时去修正。现在多花一点时间,将来能省很多工夫。

第2条:尽量采用隐式属性来表示可变的数据

C# 为属性提供了很多支持,允许通过属性清晰地表达出自己的设计思路,而且当前的 C# 语言还允许我们很方便地修改这些属性。如果你一开始就能采用属性来编写代码,那么以后便可以从容地应对各种变化。
在向类中添加可供访问的数据时,要实现的属性访问器通常很简单,只是对相应的数据字段做一层包装而已。在这种情况下,其实可以采用隐式写法来创建属性,从而令代码变得更加简洁:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

编译器会生成一个名字来表示与该属性相对应的后援字段。可以用属性的 setter 修改这个后援字段的值。由于该字段是编译器生成的,因此,即便在自己所写的类中,也得通过属性访问器进行操作,而不是直接修改字段本身。这种区别其实并不会造成太大影响,因为编译器所生成的属性访问器中只包含一条简单的赋值语句,因此,很有可能得到内联,这样一来,通过属性访问器来操纵数据就和直接操纵后援字段差不多了。从程序运行时的行为来看,访问隐式属性与访问后援字段是一样的,就算从性能角度观察,也是如此。
隐式属性也可以像显式实现的属性那样对访问器施加修饰符。例如,可以像下面这样缩小 set 访问器的使用范围:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

隐式属性是通过后援字段来实现的,它与在早前版本的 C# 代码中手工创建出来的属性效果相同,好处在于写起来更加方便,而且类的代码也变得更加清晰。声明隐式属性可以准确呈现出设计者所要表达的意思,而不像手工编写属性时那样要添加很多其他代码,那些代码可能会掩盖真实的设计意图。
由于编译器为隐式属性所生成的代码与开发者显式编写出来的属性实现代码相同,因此,也可以用隐式属性来定义或覆盖 virtual 属性,或实现接口所定义的属性。
对于编译器所生成的后援字段,派生类是无法访问的,但派生类在覆盖基类的 virtual 属性时,可以像覆盖其他 virtual 方法那样调用基类的同名方法。如下面这段代码就用到了基类的 getter 与 setter:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

使用隐式属性还有两个好处。第一,如果以后要自己实现这个属性,以便验证数据或执行其他处理,那么这种修改不会破坏二进制层面的兼容性。第二,数据的验证逻辑只需要写在一个地方就可以了。
使用旧版的 C# 编程时,很多开发者都在自己的类中直接访问后援字段,这样做会让源文件中出现大量的验证代码与错误检测代码。现在,我们不应该再这么写了。由于访问属性的后援字段相当于调用对应的属性访问器(这个访问器可能是私有的),因此,只需要把隐式属性改成显式实现的属性,并将验证逻辑放到自己新写的属性访问器中就可以了:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据
带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

使用隐式属性,可以在一处创建所有的验证。如果继续使用访问器而不是直接访问后援字段,那么所有的验证只需要一次就够了。
隐式属性有一项重要的限制,就是无法在经过Serializable修饰的类型中使用。因为持久化文件的存储格式会用到编译器为后援字段所生成的字段名,而这种自动生成的字段名却不一定每次都相同。如果修改了包含该字段的类,那么编译器为这个字段所生成的名字就有可能发生变化。
尽管隐式属性有上述两个方面的问题需要注意,但总体来说,它还是具备很多优点的,例如,可以节省开发者的时间,可以产生更容易读懂的代码,还可以让开发者在有需要的时候把与该属性有关的修改及验证逻辑都放在一个地方来处理。借助隐式属性,可以写出更为清晰的代码,并有助于我们更好地维护这些代码。

第3条:尽量把值类型设计成不可变的类型

不可变的类型是个很容易理解的概念,这种类型的对象一旦创建出来,就始终保持不变。把构建该对象所用的参数验证好之后,可以确保这个对象以后将一直处于有效的状态中。由于它的内部状态无法改变,因此不可能陷入无效的状态中。这种对象创建出来之后,状态保持不变,于是无须再编写错误检测代码来阻止用户将其切换到某种无效的状态上。此外,不可变的类型本身就是线程安全的(或者说本身就具备线程安全性),因为多个线程在访问同一份内容时,看到的总是同样的结果,你用不着担心它们会看到彼此不同的值。在设计其他对象的时候,可以从对象中把这些类型的值发布给调用方,而无须担心后者会修改它们的内部状态。
不可变的类型很适合用在基于哈希的集合中。例如Object.GetHashCode()方法所返回的值必须是个实例不变式(也叫作对象不变式,参见第 10 条),而不可变的类型本身就能保证这一点。
实际工作中,很难把每一种类型都设计成不可变的类型,因此笔者的建议是,尽量把原子类型与值类型设计成不可变的类型。其他的类型应该拆分成小的结构,使每个结构都能够相当自然地同某个单一实体对应起来。例如 Address(地址)类型就可以算作单一实体,因为它虽然可以细分为很多小的字段,但只要其中一个字段发生变化,其他字段就很有可能也需要同步修改。反之,Customer(客户)类型则不是原子类型,因为它是由很多份信息组成的,这些信息能够各自独立地发生变化。例如,客户在修改电话号码的时候不一定同时要修改住址,而在修改住址的时候,也不一定要同时修改电话号码。同理,他在修改姓名的时候,依然可以沿用原来的地址与电话号码。这种对象虽然不是原子对象,但可以拆分成许多个不可变的值,或者说,它可以由许多个不可变的值通过组合来构建,例如可以拆分成地址、姓名以及一份联系方式清单,该清单中的每个条目都是由电话号码及类型所形成的值对。这些不可变的值可以通过原子类型来体现,这种类型就属于刚才说的单一实体:如果某个对象是原子类型的对象,那么不能单独修改其中的某一部分内容,而是要把整套内容全都替换掉。下面举例说明单独修改其中的某一个字段所引发的问题。
假设我们还是像往常那样,把 Address 类实现成可变类型:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据
带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

上面这段代码在修改 a1 对象的内部状态时,有可能破坏该对象所要求的不变关系,因为设置完 City(城市)属性后,a1 对象会(暂时)处于无效的状态—此时的 ZipCode(邮编)与 State(州)无法与 City 相匹配。这种写法虽然看上去没有太大的问题,但要放在多线程的环境中执行就有可能引发混乱,因为系统可能在当前线程刚修改完 City 属性但还没来得及修改 ZipCode 与 State 时进行上下文切换,从而导致切换到的线程在获取 a1 对象的内容时,看到彼此不协调的 3 个属性。
就算不在多线程环境中执行,这种修改对象内部状态的写法也会导致错误。例如开发者在修改完 City 属性后,确实想到了自己应该同步修改 ZipCode 属性,然而他却给 ZipCode 设定了无效的值,于是程序就会在执行 setter 时抛出异常,从而令 a1 对象陷入无效的状态中。要想解决这个问题,必须在对象内部添加大量的验证代码,以确保构成该结构体的属性能够相互协调。这些验证代码会令项目膨胀,从而变得更加复杂。为了确保程序在抛出异常时也能够处于有效的状态中,必须在修改字段之前先给这些字段做一份拷贝,以防修改到一半的时候突然发生异常。此外,为了使程序支持多线程,还必须在每个属性访问器上进行大量的线程同步检查,set 与 get 访问器都要这样处理。总之,工作量特别大,并且还会随着新功能的增多而不断增多。
Address 这样的对象如果要设计成struct(结构体),那么最好是设计成不可变的 struct。首先,把所有的实例字段都改成外界只能读取而无法写入的字段。

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

现在,从公有接口的角度来看,Address 已经是不可变的类型了。为了使调用便于使用这个类型,必须提供适当的构造函数,以便能把 Address 结构体中的各项内容全都设置好。具体到本例来说,只需要提供一个构造函数,用来对 Address 中的每个字段进行初始化。不需要实现拷贝构造函数,因为赋值运算符已经够用了。要注意:默认的构造函数依然能够访问。在由那个函数所生成的地址中,每一个字符串型的字段都是 null,ZipCode 字段的值是0。

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据
带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

改为不可变的类型之后,调用方需要用另一种写法来修改地址对象的状态。具体到本例来说,就是要初始化一个新的 Address 对象,并将其赋给原来的变量,而不能直接修改原实例:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据


a1只可能有两种状态:要么是本来的取值,也就是 City 属性为 Anytown 时的状态;要么是更新之后的取值,也就是 City 属性为 Ann Arbor 时的状态。由于它的属性在设置完后便无法修改,因此不会像上一个例子那样,其中有些属性已经修改,另一些属性却尚未同步更新,而暂时陷入无效的状态。它只会在执行构造函数的那一小段时间内出现这种不协调的现象,然而这种现象在构造函数之外是看不出来的。只要新的Address对象构造完成,它的各项属性值就会固定下来,始终不发生变化。这种写法还能保证程序状态不会在抛出异常时陷入混乱,因为 a1 要么是原来的地址,要么就是新的地址。即便在构造新地址的过程中发生异常,程序的状态也依然稳固,因为此时的a1仍指向原来的旧地址。
创建不可变的类型时,要注意代码中是否存在漏洞导致客户代码可以改变该对象的内部状态。值类型由于没有派生类,因此无须防范通过派生类来修改基类内容的做法。但是,如果不可变类型中的某个字段引用了某个可变类型的对象,那么就要多加小心了。在给这样的不可变类型编写构造函数时,应该给可变类型的参数做一份拷贝。下面通过几段范例代码来说明这个问题。为了便于讨论,这些代码都假设 Phone 是值类型,而且是不可变的值类型。

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据
带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

数组(array)类是个引用类型。在本例中,PhoneList 结构体中的 phones 数组与该结构体外的 phones 数组其实指向同一块存储空间。因此,我们可以通过后者来修改数组的内容。如果想预防这个问题,那就需要把该数组在结构体中拷贝一份。还有一种办法是采用 System.Collections.Immutable 命名空间中的 ImmutableArray 类来取代 Array,该类与 Array 的功能相似,但它是不可变的。直接使用可变的集合有可能出现刚才说的这种问题,此外,假如 Phone 是个可变的引用类型,那么依旧会产生类似的问题。就本例来说,通过 readonly 来修饰 phones 数组只能保证数组本身不变,无法保证其中的元素不被替换。要想保证这一点,可以改用 ImmutableList 集合类型来实现 phones 字段:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

不可变的类型应该怎样初始化,这取决于它本身是否较为复杂。有下面 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关键字来区分自己新创建的对象是值类型还是引用类型。较小的或者说轻量级的对象应该设计成值类型,而彼此之间形成一套体系的对象则应该以引用类型来表示。本节将通过这两种类型的用法来帮助你理解值类型与引用类型之间的区别。
首先,考虑下面这个类型。我们想在某个方法中把该类型的对象当成返回值使用:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

如果MyData是值类型,那么系统会把Foo()方法所返回的内容复制到v所在的存储区域中。反之,如果MyData是引用类型,那么上述代码会把内部变量myData引用的MyData对象通过Foo()方法的返回值公布给外界,从而破坏封装。于是,客户代码可以绕过你所设计的 API,直接修改myData的内容(详情参见第17条)。
现在考虑另一种写法:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

如果采用这种写法,那么系统会把myData复制一份存放到v中。由于MyData是引用类型,因此这将导致堆上出现两个对象,一个是本来的MyData对象,另一个是从该对象中复制出来的MyData对象。这样写确实不会暴露内部数据,但必须在堆上多创建一个对象,总之,这是一种效率比较低的写法。
通过公有方法导出的数据以及充当属性的数据都应该设计成值类型。这当然不是说所有的公有成员都必须返回值类型而不应该返回引用类型,这只是说,如果要返回的对象是用来存放数值的,那么应该把它设计成值类型。例如在早前的代码中,MyData 类型就是这样一个用来存放数值的类型,因此,应该设计成值类型。
下面这段代码演示了另外一种情况:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

myType变量在这里充当的是Foo3()方法的返回值,然而此处提供这个变量并不是为了让人去访问其中的数值,而是为了通过该对象调用IMyInterface接口中所定义的DoWork()方法。
这段代码体现了值类型与引用类型之间的重要区别。前者是为了存储数值,而后者则用来定义行为。以类的形式来创建引用类型可以让我们通过各种机制定义出很多复杂的行为。例如可以实现继承,或是方便地管理这些对象的变化情况。把某个类型的对象当成接口类型来返回并不意味着一定会引发装箱与取消装箱等操作。与引用类型相比,值类型的运作机制比较简单,你可以通过这种类型来创建公有API,以确保某种不变关系,但若想通过它们表达较为复杂的行为则比较困难。这些较为复杂的行为最好是通过引用类型来建模。
现在,我们进一步观察这些类型在内存中的保存方式,以及由这些方式所引发的性能问题。考虑下面这个类:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

这种写法创建了多少个对象?每个对象又是多大?这要依照具体情况来定。如果 MyType是值类型,那么就只需要做一次内存分配。分配的内存空间相当于MyType大小的两倍。如果MyType是引用类型,那么需要做 3 次内存分配,其中一次针对 C 类型的对象,另外两次分别针对该对象中的两个MyType对象。在采用32位指针的情况下,第一次分配的内存空间是8个字节,这是因为需要给C对象中的两个MyType各设立一个指针,而每个指针要占据4个字节。内存分配的次数之所以有区别,是因为值类型的对象会内联在包含它们的对象中(或者说,随着包含它们的对象一起分配),而引用类型则不会。如果某个变量表示的是引用类型的对象,那么必须为该引用分配空间。
为了更加清楚地理解这种区别,我们考虑下面这种写法:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

如果MyType是值类型,那么只需要分配一次内存,而且分配的内存空间是单个 MyType对象的100倍。如果MyType是引用类型,那么也只分配一次内存,但是,在这种情况下,数组里的每一个元素都是 null。等到需要给这些元素做初始化的时候,就得再执行 100 次内存分配,因此,实际上需要分配 101 次内存,这样做花的时间比只分配 1 次要多。像这样频繁地给引用类型的对象分配内存空间会导致堆内存变得支离破碎,从而降低程序的性能。如果只是为了保存数值,那么就应该创建值类型,这样可以减少内存的分配次数。不过,在值类型与引用类型之间选择时,首先还是要根据类型的用法来判断,至于内存分配次数也是一项可供考虑的因素,但与用法相比,它并不是最为重要的因素。
一旦把某个类型实现成了值类型或引用类型,以后就很难改变了,因为那样做可能需要调整大量的代码。比方说,我们把Employee设计成了值类型:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

这个类型很简单,只有一个方法,该方法用来支付薪酬。这种写法起初并没有问题,但是过了一段时间,公司的员工变多了,于是,你想把这些人分开对待,例如销售人员可以获取提成,管理人员可以得到奖金。为此,需要把Employee类型从结构体改为类:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据
带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

修改之后,原来使用这个类型的代码可能就会出问题,因为按值传递变成了按引用传递,早前按值传递的参数现在也要按引用来传递了。比方说,下面这段代码的功能在修改之后就与早前有很大区别:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

本来是打算给 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 开始算的。不过,你也可以手工指定每个枚举值所对应的整数:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据
带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

sphere与anotherSphere变量的值都是 0,这并不是有效的枚举值。如果早前编写的一些代码都认为Planet类型的变量总是会取某个有效的枚举值,那么那些代码在遇到这两个变量的时候就无法正常运作了。因此,你自己定义的enum类型必须能够把0当成有效的枚举值来用。如果你的enum是用位模式来表示各种特性的启用情况,那就将0值视为任何特性都没有启用的状态。
就本例来说,可以要求用户必须把Planet类型的枚举变量初始化成某个有效的枚举值:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

但是,如果其他类型需要使用你所定义的枚举类型来表示其中的数据,那么使用那个类型的人就很难满足你的要求了。

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

比方说,他们可能只是简单地新建一个ObservationData对象,而没有把其中的whichPlanet字段设置成有效的枚举值:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

对于这个新建的ObservationData对象,其 magnitude(星等)字段为0,这当然是个合理的取值,然而值同样为0的whichPlanet字段却没有合理的解释,因为0对Planet(行星)枚举来说是个无效的值。为了解决这个问题,应该规定一种与默认值 0 相对应的枚举值,但对于本例来说,我们似乎看不出有哪个行星适合设置成默认的行星。在这种情况下,可以用0来表示enum暂时还不具备的具体取值,稍后需要加以更新:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

这样修改之后,sphere变量所对应的枚举值就是None了,它用来表示该变量还没有真正设置成某个具体的行星。这也会影响到包含Planet枚举的ObservationData结构体,使得新建的ObservationData对象能够处于合理的初始状态。此时,这份观测数据的星等是0,其观测目标是None(表示还没有加以设定)。你可以明确地提供构造函数,让用户通过该函数来给所有的字段指定初始值:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

但是,用户依然可以通过系统默认提供的无参构造函数来创建结构体,这样还是会将每个字段都设置成默认值。你无法阻止用户这么写。
意识到这一点之后,我们就会发现刚才那段代码仍然有问题:如果用户在创建结构体之后一直都不给它的ObservationData字段指定具体的行星,那么该字段始终是None,而针对 None 的观测数据是没有意义的。为了防止程序中出现这样的情况,我们可以考虑把ObservationData从结构体改成类,使得用户无法通过不带参数的构造函数来新建对象。但即便这样,你也只能照顾到ObservationData这一个类型,而无法阻止开发者使用Planet枚举去实现其他类型中的字段。假如他们还是把类型设计成结构体,而不是设计成类,那么用户依然可以通过无参数的构造函数加以构建。枚举只不过是在整数外面稍微封装了一层而已,如果想要表达的抽象概念无法用某套整数常量来体现,那就要考虑采用其他语言特性来实现了。
在讨论其他数值类型之前,再讲几条与enum有关的特殊规则。如果用Flags特性修饰 enum,那么要记得给0这个标志值赋予对应的含义。比方说,在下面这个表示样式的Styles枚举类型中,0的意思是没有运用任何样式(None):

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

很多开发者喜欢用按位AND(与)运算符来判断枚举变量是否设定了某个标志(或者说是否启用了某个选项),然而,对于值为0的标志来说,这样判断是无效的。例如,下面这种写法可以判断出flag变量是否运用了由Styles.Flat枚举值所表示的样式,但是,若想判断该变量所运用的样式是不是None(或者说,是不是根本就没有运用任何样式),则不能这么写。

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

如果你也像本例这样采用Flags特性来修饰自己所定义的枚举类型,那么应该在其中设计一个与0相对应的枚举值,用来表示任何标志都没有设定(或任何选项都没有开启)。
如果值类型中包含引用,那么在做初始化的时候也有可能出现问题。例如,我们经常会看到下面这种包含string引用的结构体:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

这样制作出来的MyMessage,其msg字段是null。你没有办法强迫用户在构造 MyMessage的时候必须把msg设置成null以外的引用,然而我们可以利用属性机制把这个问题局限在LogMessage结构体之内,不让它影响到外界。比方说,可以创建Message属性,将msg字段的值发布给客户端使用。有了这个属性,就可以在 get 访问器中添加逻辑,以便在msg是null的情况下返回空的字符串:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

你在自己的类中也应该使用这个属性,这样做可以确保检测msg引用是不是null的逻辑出现在同一个地方,也就是出现在该属性的 get 访问器中。而且,对于本例来说,如果你是从自己的程序集中获取Message属性的,那么包含检测逻辑的get访问器应该会得到内联。这种写法既能保证效率,又可以降低风险。
系统会把值类型的所有实例都初始化为0,而且你无法禁止用户创建这种内容全都是 0 的值类型实例。因此,应该让程序在遇到这种情况时能够进入某个较为合理的状态中。有一种特殊情况尤其要注意:如果用枚举类型的变量来表示某组标志或选项的使用情况,那么应该将值为0的枚举值与未设定任何标志或未开启任何选项的状态关联起来。

第6条:确保属性能够像数据那样运用

属性是个“双面人”。对于外界来说,它与被动的数据元素很像,但对于包含该属性的类来说,则必须通过方法加以实现。如果不能正确认识这种一体两面的特征,那么就有可能创建出令用户感到困惑的属性。用户通常认为,从外界访问某个属性时,其效果应该与访问相应的数据成员类似,如果创建出来的属性做不到这一点,那么他们就有可能误用你所提供的类型。属性本来应该给人这样一种感觉:调用属性方法与直接访问数据成员有着相同的效果。
如果编写客户代码的开发者能够像平常那样使用你的属性,那就说明该属性正确地表示了它所要封装的数据成员。首先,这要求程序在不受其他语句干扰的情况下前后两次访问该属性都能够得到相同的结果:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

在多线程环境中,其他线程可能会在当前线程执行完第一条语句后把控制权抢走,等到本线程拿回控制权并执行第二条语句时,someObject.ImportantProperty属性的值可能已经发生了变化。但是,如果程序没有受到这种干扰,那么反复访问该属性应该得到相同的值。
此外,开发者在使用你所提供的类型时,会认为这个类型的属性访问器与其他类型一样,不会做太多的工作。这就是说,你所编写的 getter 访问器不应该执行太费时间的操作,而 setter 访问器虽然可以进行一些验证,但是调用起来也不应该太慢。
开发者为什么会对你的类做出这样的假设呢?这是因为,他们想把类中的属性当成数据来用,而且想在频繁执行的循环中多次访问这些属性。其实 .NET 的集合类也是如此。用for循环列举数组中的元素时,有可能每次都会获取数组的Length属性:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

数组越长,访问Length属性的次数就越多。假如每访问一次Length,系统都要把数组中的元素个数重新计算一遍,然后才能给出数组的长度,那么整个循环的执行时间就会与数组长度的平方成正比。这样一来,就没有人会在循环中调用Length属性了。
让自己的类符合其他开发者的预期其实并不困难。首先,要尽量使用隐式属性。这些属性只是在编译器所生成的后援字段外面稍微封装了一层。访问这样的属性与直接访问数据字段差不多。由于这种属性的访问器实现起来比较简单,因此经常会得到内联。只要能坚持用隐式属性设计自己的类,那么编写客户代码的人就可以顺畅地使用类中的属性。
如果你的属性还带有隐式属性无法实现的行为,那么就必须自己来编写这些属性了。然而,这种情况也是很容易应对的。可以把验证逻辑放在自己编写的 setter 中,这样也能做出符合用户期望的设计。例如,我们早前在给LastName属性编写 setter 时就是这么做的:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

这样的验证代码并没有破坏属性应满足的基本要求,因为它只是用来确保对象中的数据有效,而且执行起来也相当快。
有些属性的getter可能要先做运算,然后才能返回属性值。比方说,下面这个Point类的Distance属性用来表示该点与原点之间的距离。它的 getter 必须先算出这个距离,然后才能将其返回给调用方:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

计算坐标点与原点之间的距离是很快就能完成的操作,因此,像刚才那样实现Distance属性通常并不会引发性能问题。假如Distance确实成了性能瓶颈,那可以考虑把计算好的距离值缓存起来,这样就不用每次都去计算了。但是,如果计算距离所用的某个分量(或者说某个因子)发生变化,那么缓存就会失效;于是下次执行属性的getter时,就必须重新计算缓存。(另一种办法是把Point类设计成不可变的类型,这样就不用担心其中的分量会发生变化了。)

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据
带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

如果属性的getter特别耗时,那么可能要重新设计公有接口。

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据


其他开发者在使用这个类时,不会料到访问ObjectName属性竟然要在本机与远程数据库之间往返,也不会想到访问过程中还有可能发生异常。为了不使他们感到意外,应该修改公有API。具体怎样修改,要看每个类型的实际用法。就本例来说,可以考虑把远程数据库中的值缓存到本地。

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

上面这种实现方式也可以改用.NET Framework的Lazy类来完成。也就是说,我们还可以这样写:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据
带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

如果开发者只是偶尔才会用到ObjectName属性,那么上面的写法就比较合适,因为当用户还没有要求获取该属性时,程序不需要提前把它算出来。但是,这种写法在第一次查询该属性的时候会多花一些时间。如果开发者要频繁使用ObjectName属性,而且这个属性能够有效地予以缓存,那么可以改用另一种写法,也就是在构造函数中提前获取该属性的值,等到用户查询这个属性的时候,直接把早前获取的值返回给他。当然,这样做的前提是 ObjectName属性确实能够正确地纳入缓存。假如程序中的其他代码或是系统中的其他线程要修改远程数据库中的对象名称,那么这种写法就会失效。
从远程数据库中获取数据并将其写回远程数据库其实是相当常见的功能,而且用户完全有理由去调用这些功能。可以把这样的操作放在专门的方法中执行,并且给这些方法起个合适的名字,以免令用户感到意外。例如,我们可以用下面这种办法来编写MyType类:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

与getter类似,setter也有可能令用户感到意外,或者说,用户可能没有想到你会在 setter中执行某些任务。现在举个例子。如果ObjectName是可读可写的属性,而你要在它的setter中把这个值写回远程数据库,那么用户使用起来可能就会觉得有些奇怪:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

由 setter 来访问远程数据库会让用户在几个方面都感到奇怪。首先,他们不会料到这样一个简单的 setter居然会对远程数据库做调用,而且要花费这样长的时间。其次,他们不会料到在执行 setter 的过程中,还有可能发生各种各样的错误。
除了刚才讲到的问题之外,还有一个问题要注意:调试器为了显示属性的值,可能会自动调用相应的 getter。因此,如果getter抛出异常、耗时过多或是修改了对象的内部状态,那么调试工作就会变得更加复杂。
开发者对属性提出的要求与方法不同,他们希望属性能够迅速执行完毕,从而可以方便地观察或修改对象的状态。他们认为属性在行为与性能这两个方面都应该与数据字段类似。如果你要创建的属性无法满足这些要求,那就应该考虑修改公有接口,把不适合由属性执行的操作放到方法中去执行,从而将属性恢复到它们应有的面貌,也就是只充当访问并修改对象状态的渠道。

第7条:用元组来限制类型的作用范围

C# 给开发者提供了很多种方式使其能够创建自定义的类型,以表示程序中的对象与数据结构。开发者可以自己来定义类、结构体、元组类型以及匿名类型。其中,类与结构体的功能较为丰富,可以用来实现各种设计方案,但是,许多开发者过于盲目地使用这两种类型,而没有考虑到除此之外是否还能采用其他办法。类与结构体虽然很强大,但却要求开发者必须编写许多例行的代码,这对于简单的设计方案来说有些不太值得,因为我们完全可以改用匿名类型或元组类型来实现这些方案。为此,大家应该了解这些类型的写法以及各写法之间的区别,而且还要知道它们与类和结构体之间的差异。
匿名类型是由编译器所生成的不可变的引用类型。为了更好地理解其工作原理,我们现在根据它的定义来逐步进行讲解。要创建匿名类型,应该声明新的变量,并把相应的字段定义在一对花括号中。

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

这行代码给编译器传达了很多信息。首先,编译器知道要新建一个内部的密封类。其次,它知道这是个不可变的类型,而且其中含有两个只读属性,这两个属性分别用来封装与 X 及 Y 相对应的两个后援字段。
于是,刚才那行代码的效果就相当于下面这段代码:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

上面这段代码不需要手写,编译器会自动为你生成。这样做有很多好处。首先,最根本的一点就是:编译器生成比你写代码更快。很多人都能迅速敲出 new 表达式,用以生成某个类型的对象,但在编写这个类型的定义时可就没那么快了。其次,定义这样的类型属于重复性很强的工作,如果我们自己来做,可能会偶尔打错字。本例中的代码比较简单,不太会出现这种错误,但这样的情况毕竟是有可能发生的。反之,交给编译器来做,则不会出现这种只有人类才会犯的错误。最后,这样做可以降低我们需要维护的代码量,不然的话,其他开发者就要专门查看这段代码、理解它的意思、确定它的功能,并找到程序中有哪些地方用到了该代码。由于这段代码现在是由编译器自动生成的,因此开发者需要查阅并加以理解的代码就可以少一些。
使用匿名类有个很大的缺点,就是你不知道类型究竟叫什么名字,于是无法将方法的参数或返回值明确地设定成这种类型。尽管如此,我们还是可以运用一些技巧,把属于某个匿名类型的单个对象或一系列对象传给某方法,或在该方法中返回这样的对象。下面就来编写这样的方法或表达式,以便对定义在某个方法中的匿名类型加以运用。在这种情况下,需要在创建匿名类的外围方法中,通过 lambda 表达式或匿名 delegate来表达要对这种对象所做的处理逻辑。由于泛型方法允许调用者传入函数,因此可以把自己想要实现的方法设计成泛型方法,并像刚才说的那样,将自己对匿名类型的用法写在匿名方法中,从而可以把这个匿名方法当成函数(或者说,以 Func 的形式)传进来。例如,下面的Transform方法就是这样一个泛型方法,它会处理由匿名类型的对象所表示的坐标点,并将该点的 X 与 Y 坐标分别变成原来的 2 倍:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

可以把匿名类型的对象传给Transform方法:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

这个例子中的算法比较简单,如果算法较为复杂,那么 lambda 表达式可能也会变得复杂起来,而且有可能要多次调用不同的泛型方法。但是,创建这样的算法其实并不难,因为只需要对刚才那个例子加以扩展即可,而无须重新设计。由此可见,匿名类型很适合用来保存计算过程中的临时结果。匿名类型只在定义它的外围方法中起作用。你可以把算法在第一阶段所得到的结果保存在某个匿名类型的对象中,然后把该对象传给算法的第二阶段。可以在定义匿名类型的方法中编写 lambda 表达式并调用相关的泛型方法,以处理这种类型的对象,并对其做出必要的变换。
另外要说的是,用匿名类型的对象来保存算法的中间结果不会“污染”到应用程序的命名空间。由于这些简单的类型是编译器自动创建的,因此,开发者在理解应用程序的工作原理时,不用特意去查看这些类型的实现代码。匿名类型只在声明它的方法中起作用,开发者一看到这样的类型,就会明白该类型只是专门针对那个方法而写的。
早前笔者只是粗略地说,编译器会在你需要匿名类型的时候,自动通过一段与手写方式等效的代码把该类型定义出来。其实,编译器还添加了一些特性,这些特性是无法通过手写而实现的。匿名类型也属于不可变的类型,然而它支持对象初始化语句。如果不可变的类型是你自己创建的,那就必须手工编写相应的构造函数,使得客户代码能够通过该函数给每个字段或属性赋予初始值。在这种情况下,由于这些类型没有给其中的属性安排可以从外界调用的 setter,因此,它们不支持对象初始化语句。与之相对,如果不可变的类型是编译器自动生成的,那么可以(而且必须)通过对象初始化语句来构建匿名类型的实例。编译器会创建一个公有(public)构造函数,用来给每个属性设定初始值,并且会让代码中本来应该调用setter的地方转而调用这个构造函数。
比方说,如果你在代码中是这样写的:

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

那么,编译器就会把它换成

带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据

如果你想创建支持对象初始化语句的不可变类型,那么只能把它定义成匿名类型。手工编写的不可变类型无法利用刚才说的自动转换机制。
最后要说的是,匿名类型在运行期的开销可能没有想象中那样大。有人可能认为,只要写出一个匿名类型,编译器就必然要重新给出一份定义,实际上,它并没有这么机械。如果后来写的匿名类型与早前写过的相同,那么编译器就会复用它早前所生成的定义。
这里必须准确地说明:出现在不同地点的匿名类型必须满足什么样的条件才能算作“同一个匿名类型”。首先,这些类型必须声明在同一个程序集中。
其次,两个匿名

上一篇:带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之一:简介


下一篇:带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系