带你读《More Effective C#:改善C#代码的50个有效方法》之二:API设计-阿里云开发者社区

开发者社区> 华章出版社> 正文

带你读《More Effective C#:改善C#代码的50个有效方法》之二:API设计

简介: 本书围绕一些关于C#和.NET的重要主题,包括C#语言元素、.NET资源管理、使用C#表达设计、创建二进制组件和使用框架等,讲述了最常见的50个问题的解决方案,为程序员提供了改善C#和.NET程序的方法。本书通过将每个条款构建在之前的条款之上,并合理地利用之前的条款,来让读者最大限度地学习书中的内容,为其在不同情况下使用最佳构造提供指导,适合各层次的C#程序员阅读。

点击查看第一章

第2章

API设计
在编写自己的类型时,要设计该类型的 API,而这些API实际上就是你与其他开发者相互沟通的一种渠道。你应该把公开发布的构造函数、属性及方法写得好用一些,让使用这些 API 的开发者很容易就能编出正确的代码。要想令 API 更加健壮,就必须从许多方面来考虑这个类型。例如,其他开发者会如何创建该类型的实例?你怎样通过方法与属性把该类型所具备的功能展示出来?该类型的对象应该怎样触发相应的事件或调用其他的方法来表示自己的状态发生了变化?不同的类型之间具备哪些共同的特征,这些特征又应该如何体现?

第11条:不要在API中提供转换运算符

转换运算符使得某个类型的对象可以取代另一种类型的对象。所谓可以取代,意思是说能够当成另一种类型的对象来使用。这当然有好处,例如,派生类的对象可以当成基类的对象来用。几何图形(Shape)就是个很经典的例子。如果用Shape充当各种图形的基类,那么矩形(Rectangle)、椭圆(Ellipse)及圆(Circle)等图形就都可以继承该类。这样的话,凡是需要使用Shape对象的地方,就都可以传入Circle等子类对象,而且在Shape对象上执行某些方法时,程序还可以根据该对象所表示的具体图形体现出不同的行为,也就是会产生多态的效果。之所以能够这样用,是因为Circle是一种特殊的 Shape。
对于你创建出的新类来说,某些转换操作是可以由系统自动执行的。例如,凡是需要用到System.Object实例的地方,系统都允许开发者用这个新类的对象来代替object,因为不管这个新类是什么类型,它都是从 .NET 类型体系中最根本的那个类型(也就是System.Object)中派生出来的。同理,如果你的类实现了某个接口,那么凡是需要用到该接口的地方,就都可以使用该类。此外,如果该接口还有其他的基接口,或是这个新类与 System.Object之间还隔着其他的一些基类,那么凡是用到基接口与基类的地方也都可以使用这个类来代替。另外要注意,C# 语言还能在许多种数值之间转换。
如果给自己的类型定义了转换运算符,那就相当于告诉编译器可以用这个类型来代替目标类型。然而这种转换很容易引发某些微妙的错误,因为你自己的这种类型不一定真的能够像目标类型那样运作。有些方法在处理目标类型的对象时会产生附带效果,令对象的状态发生变化,而这种效果可能不适用于你自己所写的类型。还有一个更严重的问题在于,开发者可能并没有意识到,它操作的并不是自己想操作的那个对象,而是由转换运算符所产生的某个临时对象。在这样的对象上操作是没有意义的,因为这种对象很快就会让垃圾收集器给回收掉。最后还有一个问题:转换运算符是根据对象的编译期类型来触发的,为此,开发者在使用你的类型时可能必须经过多次转换才能触发该运算符,而这样写会导致代码难于维护。
如果允许开发者把其他类型的对象转换为本类型的对象,那么就应该提供相应的构造函数,这样能够使他们更为明确地意识到这是在创建新的对象。假如不这样做,而是通过转换运算符来实现,那么可能会出现难以排查的问题。以一个继承体系为例,在该体系中,Circle(圆形)类与Ellipse(椭圆)类都继承自Shape类,之所以这样做,是因为假如Circle继承自Ellipse,那么在编写实现代码时,可能会遇到一些困难,因此,我们决定不让Circle从Ellipse中继承。然而你很快就会发现,在几何意义上,每一个圆形其实都可以说成是特殊的椭圆。反之,某些本来用于处理圆形的逻辑其实也可以用来处理某些椭圆。
意识到这个问题之后,可能就想添加两种转换运算符,以便在这两种类型的对象之间进行转换。由于每一个圆形都可以当作特殊的椭圆,因此,会添加一种隐式的(implicit)转换操作符,根据Circle对象新建与之相仿的Ellipse对象。凡是本来应该使用Ellipse但却出现了Circle对象的地方,都会自动触发这种转换操作。与之相反,假如把转换操作设计成显式的(explicit)操作,那么这种转换操作就不会自动触发,而是必须由开发者在源代码里通过cast(强制类型转换)来明确地触发。

image.png

有了implicit转换运算符之后,就可以在本来需要使用Ellipse的地方使用Circle类型的对象。这会自动引发转换,而无须手工触发:

image.png
image.png

这很好地体现了什么叫作替换:Circle类型的对象可以代替Ellipse对象出现在需要用到Ellipse的地方。ComputeArea函数是可以与替换机制搭配起来使用的。但是,另外一个函数就没这么幸运了:

image.png

这样写实现不出正确的效果。由于Flatten()方法需要用Ellipse类型的对象作参数,因此,编译器必须把传入的Circle对象设法转换成Ellipse对象。你定义的implicit转换运算符恰好可以实现这种转换,于是,编译器会触发这样的转换,并把转换得到的Ellipse对象当成参数传给Flatten()方法。Ellipse对象只是个临时的对象,它虽然会为Flatten()方法所修改,但是修改过后立刻就变成了垃圾,从而有可能遭到回收。Flatten()方法确实体现出了它的效果,但这个效果是发生在临时对象上的,而没有影响到本来应该套用该效果的那个对象,也就是名为c的Circle对象。
假如把转换操作符从implict改为explicit,那么开发者就必须先将其明确地转为Ellipse对象,然后才能传给Flatten()方法:

image.png

这样改会强迫开发者必须把Circle对象明确地转换成Ellipse对象,但由于传进去的依然是临时对象,因此,Flatten()方法还是会像刚才那样,在这个临时对象上进行修改,而这个临时对象很快就会遭到丢弃,原来的c对象则依然保持不变。如果能在Ellipse类型中提供构造函数,令其根据Circle对象来创建Ellipse对象,那么开发者就会通过构造函数来编写程序,这样的话,很快就会发现代码中的错误:

image.png

许多开发者一看到这样两行代码,立刻就能意识到传给Flatten()方法的Ellipse对象很快就会丢失。为了解决这个问题,他们会创建变量来引用Ellipse对象。

image.png


经过Flatten()方法处理的椭圆现在会保存到变量e中,而不会像早前那样立刻变成垃圾。用构造函数取代转换操作符非但不会减少程序的功能,反而可以更加明确地体现出程序会在什么样的地方新建什么样的对象。(熟悉 C++ 语言的开发者应该能注意到,C# 并不会通过调用构造函数来实现隐式或显式的转换,只有当开发者明确用new运算符来新建对象时,它才会去调用构造函数,除此以外的其他情况C#都不会自动帮你调用构造函数。因此,C#中的构造函数没有必要拿explicit关键字来修饰。)
如果你编写的转换运算符不是像早前的例子那样把一种对象转换成另一种对象,而是把对象内部的某个字段返回给了调用方,那么就不会出现临时对象的问题了,但是,这样做会有其他的问题,因为这种做法严重地破坏了类的封装逻辑,使得该类的用户能够访问到本来只应该在这个类的内部所使用的对象。本书第17条解释了为什么要避免这种做法。
转换运算符可以用来实现类型替换,但这样做可能会让程序出现问题,因为用户总是觉得他可以把你所写的类型用在本来需要使用另一个类型的地方。如果他真的这样用了,那么他所修改的可能只是转换运算符所返回的某个临时对象或内部字段,而这种效果无法反映到他本来想要修改的对象上。经过修改的临时对象很快就会遭到回收。如果他没有把那个对象保留下来,那么修改的结果就无法体现出来。这种 bug 很难排查,因为涉及对象转换的这些代码是由编译器自动生成的。总之,不要在 API 中公布类型转换运算符。

第12条:尽量用可选参数来取代方法重载

C# 允许调用者根据位置或名称来给方法提供实际参数(argument,简称实参),这样的话,形式参数(formal parameter,简称形参)的名称就成了公有接口的一部分。如果类型的设计者修改了公有接口中某个方法的形参名称,那么可能会导致早前用到该方法的代码无法正常编译。为了避免这个问题,调用方法的人最好不要使用命名参数来进行调用(或者说,最好不要用指定参数名称的办法来进行调用),而设计接口的人也应该注意,尽量不要修改 公有或受保护(protected)方法的形参名称。
C# 语言的设计者提供这项特性当然不是为了故意给编程制造困难,而是基于一定的原因,而且,它确实有一些合理的用法。例如,把命名参数与可选参数相结合,能够让许多 API 变得清晰,尤其是给 Microsoft Office 设计的那些 COM API。现在考虑下面这段代码,它通过经典的 COM 方法来创建 Word 文档,并向其中插入一小段文本:

image.png

这段程序很小,而且并没有什么特别有意义的功能。然而,此处的重点在于,它把Type.Missing对象接连用了 4 次。对于其他一些涉及 Office interop(互操作)的应用程序来说,它们使用Type.Missing对象的次数可能远远多于这个例子。这些写法会让应用程序的代码显得很杂乱,从而掩盖了软件真正想要实现的功能。
C#语言之所以引入可选参数与命名参数,在很大程度上正是想要消除这些杂乱的写法。利用可选参数这一机制,Office API 中的Add()方法能够给参数指定默认值,以便在调用方没有明确为该参数提供数值的情况下,直接使用默认的Type.Missing来充当参数值。改用这种写法之后,刚才那段代码就变得很简单了,而且读起来也相当清晰:

image.png
image.png

当然,你可能既不想让所有的参数都取默认值,也不想逐个去指定这些参数,而是只想明确给出其中某几个参数的取值。还以刚才那段代码为例。如果新建的不是 Word 文档,而是一个网页(或者说 Web 页面),那么你可能要单独指出最后一个参数的取值,而把前三个参数都设为它们的默认值。在这种情况下,你可以通过命名参数来调用Add()方法,也就是只把需要明确加以设定的参数给写出来,并指出它的取值:

image.png

由于 C# 允许开发者按照参数名称来调用方法,因此,对于其参数带有默认值的 API 来说,调用者可以只把那些自己想要明确指定数值的参数给写出来。这一特性使得 API 的设计者不用再去提供很多个重载版本。如果不使用命名参数及可选参数等特性,那么对于 Add() 这样带有 4 个参数的方法来说,就必须创建 16 个相互重载的版本才能实现出类似的效果。有一些 Office API 的参数多达 16 个,由此可见,命名参数与可选参数确实极大地简化了 API 的设计工作。
刚才那两个例子在调用Add()方法的时候,都为参数指定了ref修饰符,不过,C# 4.0 修改了规则,允许开发者在 COM 环境下省略这个ref。接下来的Range()方法用的就是这种写法。如果把ref也写上去,那么反而会误导阅读这段代码的人,而且,在大多数产品代码中,调用Add()方法时所传递的参数都不应该添加ref修饰符。(刚才那两个例子之所以写了ref,是想反映出Add()方法的真实API签名。)
笔者以COM与Office API为例演示了命名参数与可选参数的正当用途,然而,这并不意味着它们只能用在涉及 Office interop 的应用程序中,实际上,也无法禁止开发者在除此以外的其他场合使用这些特性。例如,其他开发者在调用你所提供的 API 时,有可能通过命名参数来进行调用,而你无法禁止他们这么用。
比如,有下面这个方法:

image.png

调用者可以通过命名参数来明确地体现出自己所提供的这两个值分别对应于哪个参数,从而避开了到底是姓在前还是名在前的问题。

image.png

调用方法的时候,把参数的名称标注出来可以让人更清楚地看到每个参数的含义,而不至于在顺序上产生困惑。如果把参数的名称写出来,能够令阅读代码的人更容易看懂程序的意思,那么开发者就很愿意采用这种写法。在多个参数都是同一种类型的情况下,更应该像这样来调用方法,以厘清这些值所对应的参数。
修改参数的名称会影响到其他代码。参数名称只保存在 MSIL 的方法定义中,而不会同时保存在调用方法的地方,因此,如果你修改了参数的名称,并且把修改后的组件重新发布了出去,那么对于早前已经用旧版组件编译好的程序集来说,其功能并不会受到影响。但是,如果开发者试着用你发布的新版组件来编译他们早前所写的代码,那么就会出现错误,只有那些已经根据旧版组件编译好了的程序集才能够继续与新版组件搭配着运行。开发者虽然不愿意见到这种错误,但并不会因此太过责怪你。举个例子,假如你把SetName()方法的参数名改成下面这个样子:

image.png

然后,你把修改后的代码编译好,并将程序集作为补丁发布了出去。那么,已经编译好的其他程序集依然能够正常调用SetName()方法,即便它们的代码当初是通过指定参数名称的方式进行调用的,也依然不会受到影响。但是,如果开发者想把手中的代码依照你所发布的新版组件来进行编译,那么就会发现这些代码无法编译:

image.png

无法编译的原因在于,参数的名称已经变了。
此外,修改参数的默认值也需要重新编译代码,只有这样,才能把修改后的默认值套用到使用这个方法的代码上。如果你把修改后的代码加以编译,并作为补丁发布出去,那么,对于那些已经根据旧版组件编译好的程序集来说,他们所使用的默认值还是旧版的默认值。
其实,你也不希望使用你这个模块的开发者因为方法发生变化而遇到困难。因此,你必须把参数的名称也当作公有接口的一部分来加以考虑,并且要意识到:如果修改了这些参数的名字,那么其他开发者在根据新版模块来编译原有的代码时,就会出现错误。
此外,给方法添加参数也会导致程序出错,只不过这个错误是出现在运行期的。就算新添加的参数带有默认值,也还是会让程序在运行的时候出错。这是因为,可选参数的实现方式其实与命名参数类似,在 MSIL 中,某个参数是否有默认值以及默认值具体是什么都保存在定义函数的地方。遇到函数调用语句时,系统会判断调用者所没有提供的这些参数有没有默认值可以使用,如果有,那么就以这些默认值来进行调用。
因此,如果给模块中的某个方法添加了参数,那么早前已经编译好的程序是没有办法与新版模块一起运作的,因为它们在编译的时候并不知道这个方法还需要使用你后来添加的这几个参数,于是,等运行到这个方法的时候就会出错。如果新添加的参数带有默认值,那么还没有开始编译的代码是不会受到影响的。
看完这些解释之后,你应该更清楚这一条的标题所要表达的意思了。为模块编写第1版代码时,尽量利用可选参数与命名参数等机制来设计API,以便涵盖同一个函数在参数上的不同用法。这样一来,就无须针对这些用法分别进行重载。但是,如果你把这个模块发布出去之后又发现需要自己添加参数,那么就只好创建重载版本,唯有这样,才能保证早前按旧版模块构建的应用程序依然可以与新版模块协同运作。另外要注意,更新模块的时候,不应该修改参数的名称,因为当你把模块的第1版发布出去之后,这些名称实际上已经成了 public 接口的一部分。

第13条:尽量缩减类型的可见范围

并不是所有人都需要看到程序中的每一个类型,因此无须将这些类型都设为public(公有)。你应该在能够实现正常功能的前提下,尽量缩减它们的可见范围。其实这个范围通常比你所认为的要小,因为 internal(内部)与private(私有)类也可以实现公有接口,而且即便公有接口声明在private类型中,其功能也依然可以为客户代码所使用。
由于 public 类型创建起来相当容易,因此,很多人不假思索地就把类型设计为 public。其实,许多独立的类完全可以设计成 internal 类。你还可以把某些类嵌套在其他类中,并将这些类设计成 protected(受保护)类或 private 类,以进一步减少该类的暴露范围。这个范围越小,将来在更新整个系统时所需修改的地方就越少。把能够访问到某个类型的地方变得少一些,将来在修改这个类型时,必须同步做出调整的地方就能相应地少一些。
只公布那些确实需要公布的类型。在用某个类型来实现公有接口的时候,应该尽量缩减该类型的可见范围。.NET Framework 中随处可见的 Enumerator 模式就是遵循着这条原则来设计的。System.Collections.Generic.List类中含有一个名为Enumerator的结构体,这个结构体实现了IEnumerator接口:

image.png


在使用List编程的时候,你并不需要知道其中有这样一个List.Enumerator结构体,只需要知道在List对象上调用GetEnumerator()方法会得到一个实现了IEnumerator接口的对象。至于这个对象究竟是什么类型以及该类型是怎样实现IEnumerator接口的,则属于细节问题。.NET Framework的设计者在其他几种集合类上也沿用了这一模式,例如Dictionary类中包含名为Dictionary .Enumerator的结构体,Queue类中包含名为 Queue.Enumerator的结构体。
如果把Enumerator这样的类型设计成private类型,那么会带来很多好处。首先,这使得List类能够在不需要告知用户的前提下,改用另一种类型来实现IEnumerator接口,而无须担心已有的程序及代码会受到影响。用户之所以能够使用由GetEnumerator()方法所返回的 enumerator,并不是因为他们完全了解这个 enumerator 是由什么类型来实现的,而是因为他们明白:无论这个 enumerator 是由什么类型来实现的,都必然会遵循IEnumerator接口所拟定的契约。在早前那个例子中,实现相关接口的enumerator其实是public结构体,之所以没有设计成 private,仅仅是为了提升性能,而不是鼓励你去直接使用这些类型本身。
有一种办法能够缩减类型的可见范围,但很多人都忽视了它,这就是创建内部类,因为大多数程序员总是直接把类设为 public,而没有考虑除此之外还有没有其他选项。笔者写这一条是想提醒你,以后不要直接把类型设为 public,而要仔细思考这个新类型的用法,看它是开放给所有客户使用的,还是主要用在当前这个程序集的内部。
由于可以通过接口来发布功能,因此,内部类的功能依然可以为本程序集之外的代码所使用,因为很容易就能让这个类实现相关的接口,并使得外界通过此接口来使用本类的功能(参见第 17 条)。有些类型不一定非要设为public,而是可以用同时实现了好几个接口的内部类来表示。如果这样做了,那么将来可以很方便地拿另一个类来替换这个类,只要那个类也实现了同一套接口就行。比方说,我们编写下面这个类,用来验证电话号码:

image.png


它正常地运作了好几个月,直到有一天,你发现自己还需要验证其他格式的电话号码。此时,这个PhoneValidator就显得不够用了,因为它的代码是固定的,只能按照美国的电话号码格式来执行验证。现在,软件不仅要验证美国的电话号码,而且必须能够验证国际上的电话号码,可是,你又不想把这两块验证逻辑耦合得过于紧密,于是,可以把新的逻辑放到原有的PhoneValidator类之外。为此,需要创建一个接口来验证任意电话号码:

image.png

接下来,要让已有的那个类实现上述接口。此时,可以考虑将其从public类改为 internal类:

image.png


最后,创建新的类,把验证国际电话号码的逻辑写到这个类中:

image.png

为了把整套方案实现好,还需要提供一种手段,以便根据电话号码的类型来确定相关的验证逻辑所在的类,并创建该类的对象。这种需求可以用工厂模式来做。本程序集以外的地方只知道工厂方法所返回的对象实现了IPhoneValidator接口,而看不到该对象所属的具体类型。那些具体类型分别用来处理世界各地的电话号码格式,它们仅在本程序集之内可见。这套方案使得我们可以很方便地针对各个地区的电话号码来编写相应的格式验证逻辑,同时,又不会影响到系统内的其他程序集。由于这些具体类型的可见范围较小,因此,更新并扩充整个系统时,所需修改的代码也会少一些。

image.png
image.png

也可以创建名为PhoneValidator的public抽象基类,把每一种具体的电话号码验证器都需要用到的算法提取到该类中。这样的话,外界就可以通过这个基类来使用它所发布的各种功能了。在刚才的例子中,这些具体的PhoneValidator之间,除了验证电话号码之外,几乎没有其他相似的功能,因此,最好是将其表述成接口,假如它们之间确实有其他一些相似的功能,那么应该将这些功能以及实现这些功能所需的通用代码提取到抽象基类中。无论采用哪种做法,你所要公开的类型数量都比直接把各种具体的验证器设为public要少一些。
public类型变少之后,外界能够访问的方法也会相应地减少,这样的话,方法的测试工作就可以变得较为轻松。由于API公布的是接口,而不是实现该接口的具体类型,因此,在做单元测试的时候,可以构造一些实现了该接口的 mock-up 对象(模拟对象)或stub对象(替代对象),从而轻松地完成测试。
向外界公布类和接口相当于对其他开发者做出了约定或承诺,因此,在后续的各个版本中,必须继续保持当初的 API 所宣称的功能。API 设计得越繁杂,将来修改的余地就越小,反之,如果能尽量少公布一些 public 类型,那么将来就可以更加灵活地对相关的实现做出修改及扩充。

第14条:优先考虑定义并实现接口,而不是继承

抽象基类可以作为类体系中其他类的共同祖先,而接口则用来描述与某套功能有关的一组方法,以便让实现该接口的那些类型各自去实现这组方法。这两种设计手法都很有用,但你必须知道它们分别适合用在什么样的地方。接口可以用来描述一套设计约定(design contract,设计契约),也就是说,它可以要求实现该接口的类型必须对接口中的方法做出相应的实现。与之相对,抽象基类描述的是一套抽象机制,从该类继承出来的类型应该是彼此相关的一组类型,它们都会用到这套机制。有几句老话虽然已经说烂了,但还是值得再说一遍:继承描述的是类别上的从属关系,乙类继承自甲类意味着乙是一种特殊的甲;接口描述的是行为上的相似关系,乙类型实现了甲类型意味着乙表现得很像甲。这些话之所以反复有人提起,是因为它们很好地体现了继承某个基类与实现某个接口之间的区别:某对象所属的类型继承自某个基类,意味着该对象就是那个基类的一种对象,而某对象所属的类型实现了某个接口,则意味着该对象能够表现出那个接口所描述的行为。
接口描述的是一套功能,这些功能合起来构成一份约定。可以在接口中规定一套方法、属性、索引器及事件,使得实现该接口的非抽象类型必须为接口中所定义的这些元素提供具体的实现代码。也就是说,它们必须实现接口所定义的每一个方法,而且要为接口所定义的每一个属性及索引器实现出相应的访问器。此外,还必须把每一个事件都实现出来。在设计类型体系的时候,可以想一想,有哪些行为是能够复用的,并把这些行为提取到接口中。在设计方法的时候,其参数类型及返回值类型也可以设计成接口类型。彼此无关的多个类型完全可以实现同一个接口。对于其他开发者来说,实现你所提供的接口要比继承你所提供的类更为容易。
接口本身无法给其中的成员提供实现代码。它既不能含有实现代码,也不能包含具体的数据成员,只能用来规定实现该接口的类型所必须支持的功能或行为。可以针对接口创建扩展方法,使得该接口看起来好像真的定义了这些方法一样。比方说,System.Linq.Enumerable类就针对IEnumerable接口提供了三十多个扩展方法,只要对象所属的类型实现了IEnumerable接口,那么就可以在该对象上调用这些方法(参见《Effective C#》(第3版)第27条)。

image.png

抽象基类可以提供某些实现,以供派生类使用,当然它也能够用来描述派生类所共同具备的行为。可以在其中指定数据成员与具体方法,并实现 virtual 方法、属性、事件及索引器。可以把许多个派生类都有可能用到的方法放在基类中实现,以便让派生类复用这些代码,而无须分别去编写。抽象基类的成员可以设为 virtual,也可以设为 abstract,还可以不用 virtual 修饰。抽象基类能够给某种行为提供具体的实现代码,而接口则不行。
通过抽象基类来复用实现代码还有一个好处,就是如果给基类添加了新的方法,那么所有派生类都会自动得到增强。这相当于把某项新的行为迅速推广到继承该基类的多个类型中。只要给基类添加某项功能并予以实现,那么所有派生类就立刻具备该功能。反之,给现有的接口中添加成员则有可能影响实现该接口的类型,因为它们不一定实现了这个新的成员,如果没有实现,那么代码就无法编译了。要想让代码能够编译,就必须更新这些类型,把接口中添加的新成员给实现出来。另一种办法是从原接口中继承一个新的接口,并把功能添加到新的接口中,这样的话,实现了原接口的类型就不会受到影响了。
选用抽象基类还是选用接口,要看你的抽象机制是否需要不断变化。接口是固定的,一旦发布出来就会形成一套约定,以要求实现该接口的类型都必须提供其中所规定的功能。与之相对,基类则可以随时变化,对它所做的扩充会自动体现在每一个继承自该类的子类上。
这两种思路可以合起来使用,也就是把基本的功能定义在接口中,让用户在编写他们自己的类型时去实现这些接口,同时在其他类中,对接口予以扩充,使得用户实现的类型能够自动使用你所提供的扩充功能。这就相当于让客户所编写的类型自动复用了你为这些扩充功能所编写的实现代码,这样一来,他们就不用再重新编写这些代码了。.NET Framework 中的IEnumerable接口与System.Linq.Enumerable类就明确地体现出这一点,前者定义了一些基本的功能,而后者则针对前者提供了相当多的扩展方法。像这样把基本功能与扩展功能分开有很大的好处,因为IEnumerable接口的设计者可以只把最基本的功能定义在接口中,而把较为高级的功能或是以后出现的新功能以扩展方法的形式定义在System.Linq.Enumerable这样的类中,这既不会破坏早前已经实现了IEnumerable接口的类型,又可以令那些类型自动具备扩展方法所提供的功能,于是,那些类型就不用再为这些扩展功能去编写实现代码了。
下面举一个例子来演示这种用法。比方说,开发者可以编写WeatherDataStream类,并让该类实现.NET Framework所提供的IEnumerable接口:

image.png
image.png

为了把多项天气观测数据表示成一个序列,我们设计WeatherDataStream类,并让它实现IEnumerable接口。这意味着,该类必须创建两个方法,一个是泛型版的GetEnumerator方法,另一个是经典的GetEnumerator方法。该类采用明确指定接口(Explicit Interface Implementation)的方式来实现后者,这使得一般的客户代码(也就是没有采用明确指出接口的办法来调用GetEnumerator的代码)会解析到前者,也就是解析到泛型版的接口方法上。该方法会直接把元素类型视为T(也就是本例中的 WeatherData),而不会像后者那样仅仅将其视为普通的System.Object。
由于WeatherDataStream类实现了IEnumerable接口,因此,它自动支持由System.Linq.Enumerable类为该接口所定义的扩展方法。这意味着,我们可以把 WeatherDataStream当成数据源,并在它上面进行LINQ查询:

image.png

LINQ查询语句会编译成方法调用代码,比方说,刚才那条查询语句就会转译成下面这种方法调用代码:

image.png


代码中的Where方法和select方法看上去好像属于IEnumerable接口,但实际上并不是。之所以觉得它们属于该接口,是因为可以作为该接口的扩展方法而得到调用,实际上,它们是定义在System.Linq.Enumerable中的静态方法。编译器会把刚才那行方法调用代码转变成下面这种静态方法调用语句(只用来做演示,并不是说真的要这么写):

image.png

上面这个例子让我们看到:接口本身确实不能包含实现代码,然而其他类可以给该接口提供扩展方法,使得这些方法看起来好像真的是定义并实现在该接口中的。System.Linq.Enumerable类正是采用了这种写法为IEnumerable接口创建了许多扩展方法。
说起扩展方法,我们还会想到参数与返回值的类型其实也可以声明为接口类型。同一个接口可以由多个互不相关的类型来实现。针对接口来设计要比针对基类来设计显得更加灵活,其他开发者用起来也更加方便。这是很重要的一点,因为 .NET 的类型体系只支持单继承。
下面这3个方法都能完成同样的任务:

image.png

第一个方法最有用。凡是支持IEnumerable接口的类型其对象都可以充当该方法的参数。这意味着,除了WeatherDataStream之外,还可以用List、SortedList、数组以及任何一次LINQ查询所得到的结果来充当方法参数。第二个方法支持很多类型,但它写得比第一个稍差,因为它用的是不带泛型的普通IEnumerable接口。第三个方法能够复用的范围最小,因为它是针对具体的WeatherDataStream类而写的,因此,不支持Array、ArrayList、DataTable、Hashtable、ImageList.ImageCollection以及其他许多集合类。
用接口来定义类中的 API还有个好处,就是能让这个类用起来更加灵活。比方说,WeatherDataStream类的API中就有这样一个方法,它返回由WeatherData对象所构成的集合。有人可能会把该方法写成下面这样:

image.png

这样写,以后修改起来就比较困难了,因为将来我们可能想把该方法所返回的集合从 List改为普通的数组,或是改为经过排序的SortedList。到了那个时候,你会发现,原来依照List所编写的代码必须做出相应的调整。修改某个 API 的参数类型或返回值类型相当于修改了这个类的公有接口,而修改了公有接口又意味着整个系统中有很多地方需要相应地更新,早前通过这个接口来访问该类的代码现在必须遵照修改后的参数类型或返回值类型来使用此接口。
这样写还有个更严重的缺陷,因为List类所提供的许多方法都可以修改列表中的数据,也就是说,拿到了List对象的人可以删除或修改列表中的对象,甚至把整个列表的内容全都换掉,在绝大多数情况下,这都不是WeatherDataStream类的设计者想要看到的效果。为此,可以设法限制用户在这个列表上所能执行的操作。不要直接把指向内部对象的引用返回给用户,而是以接口的形式返回这个对象,使得用户只能通过此接口所支持的功能来操作该对象。在本例中,这个接口是IEnumerable。
如果你写的类型直接把属性所在的类公布给外界,那么相当于允许外界使用那个类的各种功能来操作该属性,反之,如果公布的仅仅是属性所在的接口,那么外界就只能在这个属性上使用此接口所支持的方法与属性了。与此同时,实现接口的那个类可以自行修改其实现细节,而无须担心用户所写的代码会受到影响(参见第 17 条)。
同一个接口可以由彼此无关的多个类型来实现。比方说,你的应用程序想把雇员、客户及厂商的信息给显示出来,可是这些类型的实体彼此之间却没有关联,至少从类的角度来看,它们不该处在同一个继承体系中。尽管如此,这些类型之间还是确实有一些相似的功能,例如它们都有名字或名称,而你的应用程序正需要将这些信息显示在控件中。

image.png

Employee(雇员)、Customer(客户)及Vendor(厂商)这 3 个类不应该继承自同一个基类,然而它们确实拥有一些相似的属性,除了刚才演示的Name(名称)之外,可能还包括地址与联系电话。这些属性可以提取到接口中:

image.png

这个接口可以简化编程工作,让你能够用同一套逻辑来处理这些彼此不相干的类型:

image.png


凡是实现了IContactInfo接口的实体都可以交给上面这个例程来处理,这意味着,该例程能够支持Customer、Employee与Vendor等不同类型的对象。之所以如此,并不是因为这 3 个类型都继承自某个基类,而是因为它们都实现了同一个接口,而且它们所共有的功能也已经提取到了该接口中。
接口还有个好处,就是可以不用对struct进行解除装箱操作,从而能降低一些开销。如果某个struct已经装箱,那么可以直接在它上面调用接口所具备的功能。也就是说,如果你是通过指向相关接口的引用来访问这个struct的,那么系统就不用对其解除装箱,而是能够直接在这个已经装箱的struct上进行操作。为了演示这种用法,我们定义下面这样的结构体来封装一个链接(URL)以及与该链接有关的一条描述信息:

image.png
image.png

这个例子用到了C# 7的两项新特性,其中一项是条件运算符的第一部分(也就是进行判断的那一部分)所使用的模式匹配表达式。这种写法会判断obj是否为URLInfo对象,如果是,就将其赋给other变量。另一项特性是条件运算符的第三部分(也就是当条件不成立时所执行的那一部分)所使用的throw表达式。如果obj不是URLInfo,那么就抛出异常。这样写可以直接在条件运算符中抛异常,而无须单独编写语句。
由于URLInfo实现了IComparable及IComparable接口,因此,很容易就能创建一份经过排序的列表,使得其中的URLInfo之间按照一定的顺序出现。即便是依赖老式IComparable接口的代码,也依然能够少执行一些装箱与解除装箱操作,因为客户代码可以把URLInfo对象当成老式的IComparable接口,并在它上面明确地调用非泛型版的CompareTo()方法,这样做不会导致系统给该对象执行解除装箱操作。
基类可以描述彼此相关的一些具体类型所共同具备的行为,并对其加以实现,而接口则用来描述一套功能,其中的每项功能都自成一体,彼此无关的多个类型均可实现这套功能。这两种机制都有各自的用途。你要创建的具体类型可以用一个一个的类来表示,而那些类型所具备的功能则可以提取到接口中。懂得类与接口的区别之后,就能做出更容易应对变化的设计方案了。彼此相关的一组类型可以纳入同一个继承体系,而不同体系的类型之间,如果有相似的功能,那么这些功能可以描述成接口,使得这些类型全都实现这个接口。

第15条:理解接口方法与虚方法之间的区别

从表面上看,实现接口的方法与重写类中的抽象函数似乎一样,因为这两种做法都是给声明在另一个类型中的成员提供定义。然而事实并非如此。实现接口的方法与重写类中的虚(virtual)函数是大不相同的。对基类中的 abstract 或 virtual 成员所做的实现也必须是 virtual 的,而对接口中的成员所做的实现则未必设为 virtual。当然,我们确实经常用 virtual来实现接口中的成员。接口方法可以明确地予以实现(或者说,显式地予以实现),这相当于把它从类的公有API中隐藏了起来,除非调用者指名要调用这个接口方法,否则,它是不会纳入考虑范围的。总之,实现接口方法与重写虚函数是两个不同的概念,而且有着各自的用法。
某个类实现了接口方法之后,它的派生类还是可以修改该类所提供的实现逻辑。在这种情况下,基类对接口方法所做的实现实际上相当于一个挂钩函数。
为了演示接口方法与虚方法之间的区别,我们先创建一个简单的接口,并编写一个类来实现该接口:

image.png

MyClass类的Message()方法成了该类公有API中的一部分,当然,这个方法也可以通过该类所实现的IMessage接口来调用。如果MyClass类还有子类,那么情况会稍微复杂一点:

image.png

注意,笔者刚才在定义Message方法的时候,还用到了new关键字(对于这个关键字的用法参见《Effective C#》(第 3 版)第 10 条)。基类MyClass的Message()方法并不是virtual方法,因此,它的子类MyDerivedClass不能通过重写该方法来提供自己的版本,而是只能创建新的版本,这个版本虽然也叫Message,但它没有重写基类MyClass的Message方法,而是将其隐藏起来。不过,基类的Message方法仍然可以通过IMessage接口来调用:

image.png


如果在设计类的时候想实现某个接口,那么意味着你写的类必须通过相关的方法来履行接口中拟定的契约。至于这些方法到底应不应该设为 virtual 方法,则可以由你自己来把握。
我们现在回顾一下 C# 语言中与实现接口有关的一些规则。如果在声明类时于它的基类型列表中写出了某个接口,而这个类的超类也实现了那个接口,那么就要思考接口中所拟定的成员究竟会对应到本类中的某个成员上,还是会对应到超类中的某个成员上。C#系统在判断的时候,首先会考虑本类所给出的实现版本,其次才会考虑从超类自动继承下来的版本。也就是说,它会先在本类的定义中寻找对接口中的某个成员所做的实现,如果找不到,那么再从可以访问到的超类成员中寻找。同时要注意,系统会把 virtual 成员与 abstract 成员当成声明它们的那个类型所具有的成员,而不是重写它们的那个类型所具有的成员。
在许多情况下,都是先创建接口,然后在基类中实现它们,将来如果有必要的话,又会在子类中修改基类的行为。然而除此之外,还有另一种情况,就是基类不受控制,此时,可以考虑在子类中重新实现该接口:

image.png

在基类型列表中,添加了IMessage接口之后,这个子类的行为就和原来不同了。如果还是像早前那段代码一样,通过IMessage接口来调用Message方法,那么会发现你调用的是子类的版本:

image.png

修改后的子类在书写Message方法时,依然应该标上new关键字,以表示此处仍有问题需要注意(参见第 33 条)。可是即便这样写,子类也没能完全屏蔽基类的Message方法,因为我们还是可以通过基类引用来访问这个方法:

image.png

如果想把通过基类引用所执行的方法调用也派发到实际的类型上,那么可以考虑修改基类本身,将其中所实现的接口方法声明为 virtual:

image.png

这样写会让MyClass的所有子类(当然也包括这里的MyDerivedClass)都能够声明它们自己的Message方法。重写之后的版本总是能够得到调用,无论是通过子类引用来访问、通过接口来访问还是通过基类引用来访问,都会产生同样的效果。
如果你讨厌这种含有代码的虚方法—或者说,更喜欢不含代码的纯虚方法—那么可以稍稍修改基类,将其定义成 abstract(抽象)类,并把Message()方法也设为abstract:

image.png

这样写之后,基类便可以只宣称自己实现某个接口,而不用真的去为接口中的方法编写代码。如果基类用 abstract 方法来实现接口中的对应方法,那么从该类继承下来的具体子类就必须重写这些成员,以提供各自的版本。具体到本例来看,MyClass基类宣称自己实现了IMessage接口,但并没有针对接口中的Message()方法编写实现代码,而是把这些代码留给具体的子类去写。
还有一种办法可以部分解决这个问题。可以让基类的接口方法去调用该类的某个 virtual 方法,并让子类去重写这个 virtual 方法。例如,MyClass类可以改成

image.png
image.png

这样修改之后,凡是继承MyClass的类都可以重写OnMessage()方法,使得与自身有关的一些逻辑能够在程序执行Message()的过程中顺带运行。这种用法在其他地方也能见到,如基类在实现IDisposable接口的Dispose()方法时(参见《Effective C#》(第3版)第17条)。
还有一个与接口方法有关的问题,就是以明确指定接口的方式来实现接口所要求的方法,这叫作 Explicit interface implementation(显式接口实现),参见《Effective C#》(第 3 版)第26条。这样实现出来的方法会从本类型的公有API中隐藏起来。如果类中存在这样两个版本,一个是以明确指定接口的方式所实现的版本,还有一个是以重写基类 virtual 方法的形式所实现的版本,那么系统会把对同名方法所做的调用派发到后一个版本上。《Effective C#》(第 3 版)第20条以IComparable接口为例详细讲解了这个问题。
最后再讲一个问题,它涉及接口与基类。如果子类宣称自己实现某个接口,而该类所继承的基类又碰巧提供了这样一个符合接口要求的方法,那么,子类就会自动拿这个方法来实现接口方法。下面这个例子演示了这种情况:

image.png

由于基类所提供的方法满足子类想要实现的接口所拟定的契约,因此,子类可以直接宣称自己实现了该接口,而无须再为其中的方法编写实现代码。只要子类能够访问到的某个基类方法拥有适当的方法签名,那么子类就可以自动用它来实现接口中的对应方法。
通过这一条,大家可以看出,接口方法可以用很多手段来实现,而不一定非要在基类中将其实现成 virtual 函数,并在子类中重写。除了采用这种做法,还可以直接在基类中把接口方法写好,或是干脆不写代码,而是将其设为 abstract 方法,并交给子类去编写。此外,也可以在基类中把接口方法的大致流程定好,并在其中调用某个 virtual 函数,使得子类能够重写那个 virtual 函数,以修改基类的默认行为。总之,接口方法既可以用 virtual 函数来实现,也可以用别的办法来实现,它的重点在于描述某项约定,你只要满足这项约定即可。

版权声明:如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件至:developerteam@list.alibaba-inc.com 进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容。

分享:

华章出版社

官方博客
官网链接