跳出面向对象思想(二) 多态
DateTue 16 December 2014TagsObject Oriented Programming/Experience/jooo
简述
多态一般都要跟继承结合起来说,其本质是子类通过覆盖或重载(在下文里我会多次用到覆盖或重载,我打算把它简化成覆重,意思到就好,不要太纠结这种名词。)父类的方法,来使得对同一类对象同一方法的调用产生不同的结果。这里需要辨析的地方在:同一类对象指的是继承层级再上一层的对象,更加泛化。
举个例子:
Animal -> Cat
Animal -> Dog
Animal.speak() // I'm an Animal
Cat.speak() // I'm a Cat
Dog.speak() // I'm a Dog
此处Cat和Dog虽然不是同一种对象,但它们算是同一类对象,因为他们的父类都是Animal。种和类的表达可能不是很对,其实我也不知道谁更大一点,在文章中我打算用这样的符号来表示两者区别:^和^^
^ 表示他们是同一类
^^ 表示他们同种同类
Animal -> Cat
Animal -> Dog
Cat kitty, kate
Dog lucky, lucy
我们可以这么说:
kitty ^^ kate 同种同类,他们都是猫
kitty ^ lucy 同类不同种,他们都是Animal
kitty !^^ lucy 因为kitty是猫,lucy是狗
kitty ^ kate 他们当然同种啦,都是Animal
应该算是能够描述清楚了吧?嗯,我们开始了。
多态
一般来说我们采用多态的场景还是很多的,有些在设计的时候就是用于继承的父类,希望子类覆盖自己的某些方法,然后才能够使程序正常运行下去。比如:
BaseController需要它的子类去覆盖loadView等方法来执行view的显示逻辑
BaseApiManager需要它的子类去覆盖methodName等方法来执行具体的API请求
以上是我列举的应用多态的几个场景,在基于上面提到的需求,以及站在代码观感的立场,我们在实际采用多态的时候会有下面四种情况:
父类有部分public的方法是不需要,也不允许子类覆重
父类有一些特别的方法是必须要子类去覆重的,在父类的方法其实是个空方法
父类有一些方法是可选覆重的,一旦覆重,则以子类为准
父类有一些方法即便被覆重,父类原方法还是要执行的
这四种情况在大多数支持多态的语言里面都没有做很好的原生限制,在程序规模逐渐变大的时候,会给维护代码的程序员带来各种各样的坑。
父类有部分public的方法是不需要,也不允许子类覆重
对于客户程序员来说,他们是有动机去覆重那些不需要覆重的方法的,比如需要在某个方法调用的时候做UserTrack,或者希望在方法调用之前做一些额外的事情,但是又找不到外面应该在哪儿做,于是就索性覆重一个了。这样做的缺点在于使得一个对象引入了原本不属于它的业务逻辑。如果在引入的这些额外逻辑中又对其他模块产生依赖,那么这个对象在将来的代码复用中就会面临一个艰难的选择:
是把这些不必要的逻辑删干净然后移过去?
还是所以把依赖连带着这个对象一起copy过去?
前者太累,后者太蠢。
如果是要针对原来的对象进行功能拓展,但拓展的时候发现是需要针对原本不允许覆重的函数进行操作,那么这时候就有理由怀疑父类当初是不是没有设计好了。
父类有一些特别的方法是必须要子类去覆重的,在父类的方法其实是个空方法
这非常常见,由于逻辑的主要代码在父类中,若要跑完整个逻辑,则需要调用一些特定的方法来基于不同的子类获得不同的数据,这个特定的方法最终交由子类通过覆重来实现。如果不在父类里面写好这个方法吧,父类中的代码在执行逻辑的时候就调用不到。如果写了吧,一个空函数放在那儿十分难看。
也有的时候客户程序员会不知道在派生之后需要覆重某个方法才能完成完整逻辑,因为空函数在那儿不会导致warning或error,只有在发现程序运行结果不对的时候,才会感觉哪儿有错。如果这时候程序员发现原来是有个方法没覆重,一定会拍桌子骂娘。
总结一下,其实就是代码不好看,以及有可能忘记覆重。
父类有一些方法是可选覆重的,一旦覆重,则以子类为准
这是大多数面向对象语言默认的行为。设计可选覆重的动机其中有一个就是可能要做拦截器,在每个父类方法调用时,先调一个willDoSomething(),然后调用完了再调一个didFinishedSomething(),由子类根据具体情况进行覆重。
一般来说这类情况如果正常应用的话,不会有什么问题,就算有问题,也是前面提到的容易使得一个对象引入原本不属于它的业务逻辑。
父类有一些方法即便被覆重,父类原方法还是要执行的
这个是经典的坑,尤其是交付给客户程序员的时候是以链接库的模式交付的。父类的方法是放在覆重函数的第一句调用呢还是放在最后一句调用?这是个值得深思的问题。更有甚者索性就直接忘记调用了,各种傻傻分不清楚。
解决方案
面向接口编程(Interface Oriented Programming, IOP)是解决这类问题比较好的一种思路。下面我给大家看看应该如何使用IOP来解决上面四种情况的困境:
(示例里面有些表达的约定,可以在这里看完整的上下文规范。)
: APIName() 我们先定义一个ManagerInterface接口,这个接口里面含有原本需要被覆重的方法。: willRun(), didRun() 我们再定义一个Interceptor的接口,它用来做拦截器。BaseManager.child在BaseController里面添加一个property,叫做child,这就要求这个child必须要满足这个接口,但是BaseManager不需要满足这个接口。BaseManager.init() { ... self.child = self 在init的时候把child设置成自己 # 如果语言支持反射,那么我们可以这么写: if self.child implemented{ self.child = self } # 如上的写法就能够保证我们的子类能够基于这些接口有对应的实现 self.interceptor = self # interceptor可以是自己,也可以在初始化的时候设为别的对象,这个都可以根据需求不同而决定。 ...}BaseManager.run() { self.interceptor.willRun() ... apiName = self.child.APIName() # 原本是self.APIName(),然后这个方法是需要子类覆重的,现在可以改为self.child.APIName()了,就不需要覆重了。 request with apiName ... self.interceptor.didRun()}
通过引入这样面向接口编程的做法,就能相对好地解决上面提到的困境,下面我来解释一下是如何解决困境的:
父类有部分public的方法是不需要,也不允许子类覆重
由于子类必须要遵从,架构师可以跟客户程序员约定所有的public方法在一般情况下都是不需要覆重的。除非特殊需要,则可以覆重,其他情况都通过实现接口中定义的方法解决。由于这是接口方法,所以即便引入了原本不需要的逻辑,也能很容易将其剥离。
父类有一些特别的方法是必须要子类去覆重的,在父类的方法其实是个空方法
因为引入了child,父类不再需要摆一个空方法在那儿了,直接从child调用即可,因为child是实现了对应接口的,所以可以放心调用。空方法就消灭了。
父类有一些方法是可选覆重的,一旦覆重,则以子类为准
我们可以通过在接口中设置哪些方法是必须要实现,哪些方法是可选实现的来处理对应的问题。这本身倒不是缺陷,正是多态希望的样子。
父类有一些方法即便被覆重,父类原方法还是要执行的
由于我们通过接口规避了多态,那么这些其实是可以通过在接口中定义可选方法来实现的,由父类方法调用child的可选方法,调用时机就可以由父类决定。这两个方法不必重名,因此也不存在多态时,不能分辨调用时机或是否需要调用父类方法的情况。
总结一下,通过IOP,我们做好了两件事:
将子类与可能被子类引入的不相关逻辑剥离开来,提高了子类的可重用性,降低了迁移时可能的耦合。
接口实际上是子类头上的金箍,规范了子类哪些必须实现,哪些可选实现。那些不在接口定义的方法列表里的父类方法,事实上就是不建议覆重的方法。
什么时候用多态
由于多态和继承紧密地结合在了一起,我们假设父类是架构师去设计,子类由客户程序员去实现,那么这个问题实际上是这样的两个问题:
作为架构师,我何时要为多态提供接入点?
作为客户程序员,我何时要去覆重父类方法?
这本质上需要程序员针对对象建立一个角色的概念。
举个例子:当一个对象的主要业务功能是搜索,那么它在整个程序里面扮演的角色是搜索者的角色。在基于搜索派生出的业务中,会做一些跟搜索无关的事情,比如搜索后进行人工加权重排列表,搜索前进行关键词分词(假设分词方案根据不同的派生类而不同)。那么这时候如果采用多态的方案,就是由子类覆重父类关于重排列表的方法,覆重分词方法。如果在编写子类的程序员忘记这些必要的覆重或者覆重了不应该覆重的方法,就会进入上面提到的四个困境。所以这时候需要提供一套接口,规范子类去做覆重,从而避免之前提到的四种困境:
Search:{search(),split(),resort()}采用多态的方案:Search->ClothSearch:{[Search],@split(),@resort()}functionsearch(){...self.split()#如果子类没有覆重这个方法而父类提供的只是空方法,这里就很容易出问题。如果子类在覆重的时候引入了其他不相关逻辑,那么这个对象就显得不够单纯,角色复杂了。...self.resort()...}采用IOP的方案::{split(),resort()}Search:{search(),assistant}#也可以是这样:Search:{search(),assistant},这么做的话,则要求子类必须实现functionsearch(){...self.assistant.split()#self.assistant可以就是self,也可以由初始化时候指定为其他对象,将来进行业务剥离的时候,只要将assistant里面的方法剥离或者讲assistant在初始化时指定为其他对象也好。...self.assistant.resort()...}Search->ClothSearch:{[Search],split(),resort()}#由于子类被接口要求必须实现split()和resort()方法,因而规避了前文提到的风险,在剥离业务的时候也能非常方便。外面使用对象时:ClothSearch.search()
如果示例中不同的子类对于search()方法有不同的实现,那么这个时候就适用多态。
Search : { search() }
ClothSearch : { [Search], @search() }
此时适用多态,外面使用对象时:ClothSearch.search()
总结是否决定应当使用多态的两个要素:
如果引入多态之后导致对象角色不够单纯,那就不应当引入多态,如果引入多态之后依旧是单纯角色,那就可以引入多态
如果要覆重的方法是角色业务的其中一个组成部分,例如split()和resort(),那么就最好不要用多态的方案,用IOP,因为在外界调用的时候其实并不需要通过多态来满足定制化的需求。
其实这是一个角色问题,越单纯的角色就越容易维护。还有一个就是区分被覆重的方法是否需要被外界调用的问题。好了,现在我们回到这一节前面提出的两个问题:何时引入接入点和何时采用覆重。针对第一个问题架构师一定要分清楚角色,在保证角色单纯的情况下可以引入多态。另外一点要考虑被覆重的方法是否需要被外界使用,还是只是父类运行时需要子类通过覆重提供中间数据的。如果是只要子类通过覆重提供中间数据的,一律应当采用IOP而不是多态。
针对第二个问题,在必须要覆重的场合下就采取覆重的方案好了,主要是可覆重可不覆重的情况下,客户程序员主要还是要遵守:
覆重的方法本身是跟逻辑密切相关的,不要在覆重方法里做跟这个方法本意不相关的事情
如果要覆重一系列的方法,那么就要考虑角色问题和外界是否需要调用的问题,这些方法是不是这个对象的角色应当承担的任务
比如说不要在一个原本要跑步的函数里面去做吃饭的事情,如果真的要吃饭,父类又没有,实在不行的时候,就需要在覆重的方法里面启用IOP,在子类里面弥补架构师的设计缺陷。把这个不属于跑步的事情IOP出去,负责实现对应接口的可以是self,也可以是别人。只要不是强耦合地去覆重,这样在代码迁移的时候,由于IOP的存在,使得代码接收方也可以接受并实现对应的interface,从而不影响整体功能,又能提供迁移的灵活性。
总结
多态在面向对象程序中的应用相当广泛,只要有继承的地方,或多或少都会用到多态。然而多态比起继承来,更容易被不明不白地使用,一切看起来都那么顺其自然。在客户程序员这边,一般是只要多态是可行方案的一种,到最后大部分都会采用多态的方案来解决问题。
然而多态正如它名字中所暗示的,它有非常大的潜在可能引入不属于对象初衷的逻辑,巨大的灵活性也导致客户程序员在面对问题的时候不太愿意采用其他相对更优的方案,比如IOP。在决定是否采用多态时,我们要有一个清晰的角色概念,做好角色细分,不要角色混乱。该是拦截器的,就给他制定一个拦截器接口,由另一个对象(逻辑上的另一个对象,当然也可以是自己)去实现接口里的方法集。不要让一个对象在逻辑上既是拦截器又是业务模块。这样才方便未来的维护。另外也要注意被覆重方法的作用,如果只是单纯为了提供父类所需要的中间数据的,一律都用IOP,这是比直接采用多态更优的方案。
IOP能够带来的好处当然不止文中写到的这些,它在其他场合也有非常好的应用,它最主要的好处就在于分离了定义和实现,并且能够带来更高的灵活性,灵活到既可以对语言过高的自由度有一个限制,也可以灵活到允许同一接口的不同实现能够合理地组合。在架构设计方面是个非常重要的思想。
评论系统我用的是Disqus,不定期被墙。所以如果你看到文章下面没有加载出评论列表,翻个墙就有了。
本文遵守CC-BY。 请保持转载后文章内容的完整,以及文章出处。本人保留所有版权相关权利。