第 39 讲:面向对象编程(十一):接口的基本概念
之前我们介绍了面向对象的两大板块:类的基本概念和成员以及类的继承。虽然内容确实挺复杂,讲得好像看起来很简单。但按照我这个思维方式去学习,至少说到过的语法点应该是没有什么大的毛病的。
下面我们进入第三个板块,也是面向对象的最后一个部分:接口(Interface)。接口可以实现一些用类无法做到的事情。下面我们通过一个引例来给大家介绍,为什么类无法做到,只有接口可以做到。
Part 1 引例
让我们先来思考一个例子。动物园举办运动会,大象没有去,为什么?因为大象在冰箱里
好吧,正经一点。动物园举办运动会,显然我们要筛选选手才能一起参加比赛,比如饭量比赛肯定得会吃饭的动物才可以去。显然大象和蚂蚁就不可能一起参加,因为蚂蚁相对于大象的话,就不叫作“会吃饭”,饭量小很多。
需要筛选出会吃饭的小伙伴,那么必然就需要动物们“真的会吃饭”。我不管你自己吃,还是年龄小需要父母喂你吃,但起码你得按照规则行事。可问题在于,抽象类能完成这样类似的任务,因为我们只需要让所有动物类型的实体从这个抽象类派生就可以了。这个抽象类大概设计成这样:
Eat
表示如何吃饭。那么所有实现的类型都从 Eatable
当然,这是一个假设的实现方式。
好吧,别怀疑,dog food 确实是狗粮的意思;cat food 也确实是猫粮的意思。
哦对,foodie 是吃货的意思。我实在是找不到合适的词语来表示“会吃东西”了。要么,就写成一个句子:
SomeoneWhoCanEatSomething
,但是这种说法显然看着很别扭,而且太长了。我就找了个“吃货”来表示这个意思。
可问题来了。抽象类在设计的时候有一点不合理,因为抽象类是用来给子类型派生提供基本模型的,但抽象类想要表达的一个意思是“是什么东西”。比如说之前的“圆是一种形状”。但是 Foodie
是“会吃东西”。那么这样的行为还有很多,比如“会做饭”、“会唱歌”、“会跳舞”啥的。
显然,在上面这些行为里,能够做到至少两个情况的动物类型,绝对不少。可在类的继承的设计一节我们说过,C# 不允许从多个类型派生,因为一个事物只能从一个类别派生。比如这个例子里,猫咪(Cat
类型)和狗狗(Dog
类型)显然应该从动物(Animal
类型)派生才是最合理的设计。而“猫咪会做什么”、“狗狗会做什么”显然不在我们这里所说的设计范畴。因此,抽象类就无法做到这一点。
那么怎么办呢?下面我们就需要接口这种东西的依托了。
Part 2 接口的使用
接口专门用来解决“会做什么”的逻辑。因为“会做什么”这种关系是一对多的,而不是一对一的。你只有一个爸爸,但你可以唱歌、可以跳舞、可以玩游戏,可以做很多事情,它们之间可以完全没有关联,此时设计成抽象类就完全是不合适的。
接口的语法是这样的。假设我们依旧使用“会吃东西”这个行为来作为接口的话:
请注意接口的书写格式。我们为“会吃东西”这个行为取名叫 foodie,那么它的接口名称(接口名你可以类比类名来理解)就应该写成 IFoodie
。前面这个大写的 I 字母是取名的规范,是一种强烈建议写上去的取名规范。为了和普通的类作区分,故意加个字母 I 在最前面,表示它是接口。这个 I 是 interface(接口)的第一个字母。
这个接口里,有一个方法叫 Eat
,可这里的 Eat
方法前面甚至连访问修饰符都没有,而且直接以分号结尾,却没有写 abstract
关键字。
实际上,public
和 abstract
是必须不写的。什么叫做“必须不写”呢?也就是说,public abstract
的组合写了也没有意义,因此干脆不让你写出来。可为什么这两个关键字没有意义呢?下面我们来说一下。
首先来说一下 abstract
。这里的 Eat
方法在接口里本身就起到了类似抽象类里的“派生类必须实现”的作用,因此 abstract
总是去写一遍就有点麻烦,毕竟大家都知道,写接口的目的就是为了让那些个派生类去实现它们,所以接口里的成员默认都是抽象的,不必写 abstract
。那既然都不必写 abstract
了,我又是何必让大家每次没完没了地重复书写这个看起来都没啥意义的 abstract
关键字呢?所以,C# 规定,接口里的成员是不写 abstract
关键字,因为它们本身就一定是抽象的。
至于 public
,这一点不是很好理解,但请听我说。接口设计出来就是为了提供给派生类去实现里面的成员的。这里所说的实现(Implement),其实和抽象类的逻辑差不多:在派生下来后,一定要写 override
关键字来表明我要修改基类型的这个同名成员的逻辑;然后在里面去修改逻辑。如果基类型里给出的是 abstract
修饰的话,那么就等于说“我在实现执行逻辑”;如果是 virtual
关键字的话,那么就等于说“我在重新修改执行逻辑”。而对于接口来说,接口既然专门就是为了提供给子类型实现用的,那么就没有必要为这种数据类型提供任何的访问修饰符,而必须是 public
。因为别的访问修饰符都比 public
要小,因此我们不论如何设计类型其实都是可以的,毕竟它不会暴露给外界。那么既然如此的话,我们就没有必要让接口去做这项任务。因此,public
是没有必要写的,因为接口设计出来就是故意让成员都是 public
的,那写不写都一样,和 abstract
一样的道理,就不准你写出来了。
那么,怎么用接口呢?和类的继承语法一致,还是一个冒号,后面跟上接口即可。因为接口和抽象类有一点不同是“可以从多个接口派生,但只能从一个类派生”,因此要书写多个接口的时候,它们之间是用逗号分隔的;而如果类和接口同时出来的时候,先写基类型(类),然后挨个写接口。接口之间没有先后顺序。
注意两个地方的语法。第一处是第 1 行和第 11 行的继承写法:写的是 : Animal, IFoodie
,另外一处是实现 Eat
方法的时候不写 override
关键字。下面我们针对这两个语法来说明一下。
至于第一点,在派生关系上,我们一定是先写基类型(类名),然后才是接口。因为接口可以有很多,我们如果不按顺序的和基类型混着书写的话,很容易让别人看着不方便,因此先写基类型是一个规定。接着所有书写的不管是基类型也好,接口也好,都要用逗号分隔开。
第二点是不写 override
关键字。因为实现接口本身这个行为就是在实现抽象的成员。所以,我们知道实现成员就必然是在重写逻辑,因此肯定是 override
关键字的修饰。那么,既然必然会有的关系,何必我们要故意写一遍呢?所以,和 public
还有 abstract
一样,这个 override
也不让你写。不过,在实现的时候要写 public
关键字,这是必须的。因为你不写 public
关键字的话,整个方法就是 void Eat()
了。在类的基本用法里我们说过,如果不对成员书写访问修饰符,默认是 private
。因此此时会造成语义冲突,即二义性(到底是用来实现接口才不让写,省略的 public
呢,还是按照默认的访问修饰符 private
来看呢)。所以,public
还是必须写的。
Part 3 再啰嗦一点东西
3-1 口头说法的约定
在前面我们说了接口和抽象类的基本概念和用法的区别,那么我们来说一下,一些口头表述上的约定规则。
在派生关系上(就是冒号后面跟的那些类名称和接口名称),类被称为“从这个类派生”(Extends from some class),而接口被称为“实现这个接口”(Implements some interface)。这是说法上的约定俗成,当然你也可以说“从接口派生”这种说法,不过一般我们不这么说。
然后是名称上。从某个类派生,我们称这个类叫做”基类“(Base class),而接口的话,我们称为”基接口“(Base interface)。”基接口“这个说法其实不是正经说法,但是因为经常配合基类一起说,所以说多了也就当约定俗成来用了。
比如说前面的例子,Cat
从 IFoodie
接口派生,可以说”Cat
类型实现了 IFoodie
接口“,而”IFoodie
接口是 Cat
的一个基接口“。
3-2 好像接口能覆盖的范围更广,那么为啥我们干脆不把抽象类也用接口表示出来呢?
接口是提供一种具体实现用的,因此接口里是不能包含默认成员的。所谓的默认成员(Default Implementation Member),你可以把抽象类里的那些 virtual
修饰的成员称为默认成员。这些默认成员是给了一种默认的执行实现的,而接口里是不允许你追加带有默认实现的成员的(毕竟连关键字 C# 都不让你写进去,何谈 virtual
呢)。
这是第一个原因(接口不让你书写默认成员)。第二个原因是,接口里很多成员实际上是不支持的。接口专门是给实例成员提供的约束的,所以带 static
修饰的所有成员都是不能写进接口的。比如运算符和转换器。
3-3 接口的继承
和抽象类一样,类可以从类派生,接口也一样:接口也可以从接口派生。
在最后这个接口 IOperator
里,它从四个接口派生。接口的派生会把接口里的成员继承下来。如果有一个类型实现了 IOperator
接口,因为继承下来了,所以就必须实现这四个接口里的这四个不同的方法。
3-4 接口是引用类型
接口和类一样,它是一个引用类型。
3-5 接口的默认访问修饰符
接口和类一样。如果访问修饰符不写的话,默认是 internal
。
3-6 接口里的成员类型可以有哪些?
上面我们只讲到了方法这一种接口成员类型。因为我们说接口成员类型并不是所有的都支持,所以可能有一些成员类型是不支持的;但是,光从理论上说了,可能你也不知道是哪些。下面我们来说一下。
