如果说,类的设计思路,是以数据为基础的纵向组织结构,只有唯一的分类方式,有相同基类的,就意味着其相似性,共同点都体现在基类上;那么,接口就是以功能以性质从横向上,来看待类的相似性,并且存在无数的横向视角(否则就失去意义)。
静态面向对象语言,这里不考虑template,c++的template是鸭子类型,本质上,c++编译期就是一个功能完备的动态语言。代码上的复用就只能以基类为粒度来进行,比如,函数int fn(Base* bb),只有Base的子类,才有资格成为函数fn的会员。函数fn之所以声明其变量bb的类型为Base,就是为了使用类型Base里面的一些东西,一般就是成员函数(对于清教徒来说,不是一般,而是必然)。假如,函数fn的实现中,就用到Base的几个成员函数,比如说f1,f2,…,fn。换句话说,虽然fn(Base* bb)表面上要求一定要Base的子孙后代才能担当重任,但实际上,只要别的class,不必跟Base有半毛钱关系,只要这个class里面支持f1,f2,…,fn这些操作,那么原则上他就有资格到fn里面一游。天下唯有德者居之,不必讲究什么贵族。但是,在没有接口的等级森严的封建社会里面,就算你有惊天之地之能,就因为你没有某种高贵的血统,所以你就不行。
在单根类的王国中,所有对象都源于Object,也可以通过反射,通过函数名字运行时获取串f1,f2,fn等成员函数,然后再人肉编译器关于参数信息和返回值类型,以摆脱Base的类型桎梏,但是,估计也只有在最特殊的时候,才会这样玩。这样玩,简直置编译器的类型检查于不顾,静态语言就是要尽可能的挖掘编译器类型检查的最后一丝潜力。
接口的出现,就在纵向的类型关系上撕开一道道口子,从而尽最大限度释放对象的能力。时代不同了,现在接口IBase里面声明f1,f2,fn等函数,然后函数fn的入参为IBase,也即是 int fn(IBase* bb),以明确表示fn里面只用到IBase的函数,语义的要求上更加精准。然后,任何class,只要其实现了接口IBase,就有资格被fn接纳,不必再是Base之后了。所以说,要面向接口编程,就是要面向功能来搬砖,选择的样本空间就广阔了很多。接口是比具体类型要灵活,但不意味着所有的地方就必须只出现接口,class类型就没用了,当然不是,有些地方就很有必要用具体类型,比如说string类型,比如说复数这些,就必须明确规定具体类型,无须用到接口的灵活性。总之,还是那句话,没有银弹,具体问题具体分析。
使用对象,其实就是在使用对象的成员函数,那么,接口也可以看成是成员函数的粒度管理工具。所以,接口就表示了一批成员函数,需要用一批成员函数的时候,用接口最为方便。坊间有一些犯virtual恐惧症的c++猿猴,高高兴兴地用一批function代替接口,罔顾其性能(时间空间)的损失、使用上的不便,哎!面向对象是强有力的抽象工具,比之于面向过程,函数式,有着独特的优点,反正代码构架上,优先使用面向对象,绝不会错。而面向对象,就必然回避不了接口。
坊间支持面向对象语言中对接口的支持,当以rust,scala的trait机制最为令人喜欢,非侵入式啊,自然狗语言的也还好,但是,本人最反感,反正,狗语言上一切独有特性,本人都本能地毫无理由排斥。自然,java、C#或者c++的多继承,最为笨拙,呆板。
java、C#里面,类能够实现的接口,在类的定义中,就已经定下来了。类一旦定义完毕,与该类相关的接口就定下来,铁板一块,密不透风,不能增不能减也不能改。你明明看到一个类就已经实现了某个接口的所有方法(函数名字和签名一模一样),但就是因为该类没有在定义中明确说明实现该接口,所以编译器就死活不承认该类实现这个接口。只能用适配器模式,也即是新造一个class,实现该接口,包含旧类的对象,将接口的所有方法都委托给对象的相应函数来做。java的繁文缛节就是这样来的,规规矩矩,毕恭毕敬,一步一个脚印。更麻烦的是,每次传递参数都要new一个适配器对象来满足参数的要求,这是最让人难受的地方。
java、C#的这种接口机制,实在与现实对不上号,真是找不到任何原型,任何类型的物品,就算是新造的东西,我们都不可能一开始就穷尽它的所有性质所有功能。就算是药物,都有可能是歪打正着的功能,比如伟哥的功能,是其研发阶段中意想不到的。java、c#的这种接口,会很干扰类的完整最小化的设计原则,进而加大类的设计难度。当然,它也非一无是处,起码,类支持多少接口,一眼就看出来了,毫无疑义。问题是,接口这种东西,本质上就应该是不确定的横向视角来考察类的关系。java、C#下的接口问题,大大限制了接口的使用场合。
其次,继承时,子类就继承了基类的所有东西,包括其实现的接口。但是,有些时候,子类并不想拥有父类的某些接口。比如,鸭子应该算是鸟类的一个子类,而鸟类支持“会飞”这个接口,但是鸭子显然不会飞,也就是说,虽然鸭子包含了鸟类的所有数据,但是它不拥有会飞这个功能。对此,我们希望在编译期间,就能在要求会飞的场合下,传鸭子对象进去时,编译器报错。但是,对此,只能在运行中报错,而且,还是在调用会飞的成员函数里面才报错。原则上,编译器是可以知道鸭子不会飞这个概念的,但是,由于java、C#的接口控制粒度单一,满足不了这种要求。
再次,接口不能组合,比如说,函数fn的参数,假设名字为pp,pp要求同时实现接口IA,IB。对此,java、C#中是没有语法满足这种多个接口的要求。遇到这种需求时,只能用强制类型转换,先随便让参数类型为IA或者IB,然后在必要时,强制转换为另外的类型,只能在运行时报错。又或者是,新造一个接口IAB从IA,IB上继承,然后函数fn的参数pp的类型为IAB,但是这样,依然存在不足,假如某个类实现IA和IB,但是没有表明它实现IAB,那么还是不能满足参数的要求。接口组合的问题,不管是go、rust,都没有很好的支持,只能到运行时类型转换才能发生。
最重要的是,这种接口机制违反了零惩罚的机制。就以c++为例来说明,就只论接口好了,也即是只有虚函数但是没有成员字段的基类。为了方便描述,还是举例子。
struct IA {virtual void fa() = 0;};
struct IB {virtual void fb() = 0;};
struct Base{…};
struct Derived : public Base, public IA, public IB{…};
接口IA有虚函数,里面就要有一个指针指向其虚函数表,所以其内存占用就是一个指针的大小;同理,IB也如此。表面的意思是Derived实现了接口IA,IB,实际上,在C++中,接口实现就是继承,也就是说每个Derived的实例都要包含IA,IB里面的数据,指向对应虚函数表的指针字段,也即是有两个指针。这里做不到零惩罚的意思,是说, Derived为了表明自己有IA、IB的能力,每个对象付出了两个多余的内存指针空间的代价,即便是对象不需要在IA、IB的环境下使用,这个代价都避免不了。零惩罚抽象,就是要用到的时候才付出代价,哪怕这个代价可以大一点。用不到时,则不必消耗哪怕一点点空间时间上的浪费。空间上浪费的问题不在于节省内存,而在于丧失了精致的内存布局,进而影响到二进制的复用。这一点,非侵入式接口就不用也没办法在对象身上包含其所支持的所有接口的虚函数表指针,因为类型定义完毕,后面还可能在其上添加新的接口实现。
而由这几点问题引申出来的其他缺陷就不必提了。反正,C++,包括java,C#的这种接口机制最不讨人喜欢了。
至于狗语言的鸭子接口,有时会出现函数名字冲突的小问题,稍微改一下名字就好了。主要是这种接口机制只要一个类包含了某个接口的所有成员函数,就隐式认为它实现了这个接口。这里会有暗示(误导,诱惑),就是定义类的成员函数时,会有意或者无意地迁就现有接口的成员函数,同样,声明接口成员函数时,也会有意无意地往现有类的成员函数上靠。从而导致真正函数的语义上把控不够精准。并且,这种机制太过粗暴,万一这个类虽然支持某个接口的所有函数,但是并不一定就意味着它就要实现这个接口了。狗语言最令人反感之处就是各种自作聪明自以为是的规定。当然,由于狗语言的成员函数可以非侵入式,这个问题造成的不便一定程度上有所减轻,但是,说实在,就连非侵入式的成员函数,本座也不太喜欢了。另外,仅仅从语言层面上,不借助文档,很难知道一个类到底实现那些接口,某个接口被那些类实现,java、C#的接口在这一点的表现上就很卓越。其实,本座反感狗语言的最大原因还是因为狗粉,相比之下,java粉、php粉等粉,就可爱多了。
rust以trait形式实提供的接口机制就不多说了,语法形式上简洁漂亮,基本上梦寐以求的接口样子就是这样子的了。
以上语言的接口,全部属于静态接口,也即是类型所实现的接口在编译期间就全部定下来了,运行时就不再有任何变化。但是,如果对象一直在变化,好比生物,就说人类好了,有婴儿少年青年中年老年死亡这些变化阶段,显然每一阶段的行为能力都大不一样,也拥有不同头衔,不同身份。也就是说,现实中,活生生对象的接口集合并非一成不变,它完全可以现在就不支持某个接口,高兴时候又可以支持了,不高兴时就又不支持了,聋了就听不到声音,盲了就看不见,好似消息发送那样子,显然以上语言是不支持这种动态需求的接口的。
另外,com的接口查询虽然发生在运行时,但是,com的规范,比如对称性、传递性、时间无关性等规则,硬是把com从动态接口降维到静态接口,这也可以理解,因为动态接口的应用场景真的并不多。这些都没什么,com最根本的问题,还是在于接口要承载类的功能,当然,这样也有好处,比如语言的无关性。IUnknown的三大成员函数分明就是类的本职工作,AddRef,Release管理对象的生命周期,Query查询所要的接口。生命周期由对象粒度细化为接口粒度,就显得太琐碎,要谨记好几条规则,要小心翼翼地应付AddRef,Release的函数调用,智能指针也只能减轻部分工作量,这就是粒度过小带来的痛苦。而Query的本质就是对象所实现接口集合,这是对象的本分工作,现在搞成接口与接口之间的关系。由于接口越俎代庖,承接了类的职责,就要求每个接口都要继承IUnknown,本来接口之间就应该没什么关联性的才对,还导致com的实现以及使用,在c++下,非常繁复麻烦,令人头皮发麻。所以说,类与接口,一体两面,谁也不能代替谁。
---------------------------------------------------------------------------------------------------------------------------------
备注:现实世界中,一种或几种功能就能推导出来其他性质,对应到接口中,就是如果对象实现某些接口,就表示它能实现另外其他接口。目前的语言,也就是接口继承,子接口继承父接口,那么,如果一个类实现了子接口,就表示它也实现了父接口,语言明面上只支持这种接口的蕴含关系。对于其他的蕴含情况,只能用适配器来凑数,而在非侵入式接口中,其语言形式就显得更加的累赘,这一点,在java上尤为突出。其实,说到底,适配器模式只是弥补语言不支持接口蕴含机制的产物。