——“面向对象的三大特性是什么?”

——“封装、继承、多态。”

这大概是最容易回答的面试题了。但是,封装、继承、多态到底是什么?它们在面向对象中起到了什么样的作用呢?


多态

多态(Polymorphic)其实也是一个顾名思义的词:“多态”就是一种事物的“多”种形“态”。

多态_第1张图片

“多态”这个概念在面向对象中同样有多种形态:类的多态、方法的多态、以及实例的多态。

类的多态

类的多态最常见、也最好理解。子类继承了父类(包括实现某个接口)后,父类就有了多种形态:它既可以是父类本身,也可以是它的子类A,还可以是它的子类B、甚至可以是子类A的子类A1……在《继承》一文中,这种多态的例子比比皆是,这里就不赘述了。

方法的多态

绝大多数情况下,子类继承了父类之后都会重写父类的方法实现。这时,同一个方法就有了多种不同的实现,这就是一种方法的多态。当然,除了重写之外,还有一种方法的多态叫做“重载”,即通过修改方法的参数列表(参数类型、参数个数等)来为同一个方法提供多种实现。

不过我个人不太喜欢重载,也不太愿意把归入多态之中。Java以方法名+参数列表作为一个方法的“签名”,而重载修改了参数列表,也就修改了方法签名。这时,重载后的这些方法还能称为“同一个方法”的不同实现吗?此外,Java会在编译期就确定使用哪个重载方法,但只有到了运行时才能知道使用的是哪个类重写的方法。也就是说,重载并不具备多态所应有的“在运行时改变程序功能”的作用。套用继承章节中的一句话来说:如果重载甚至不能“like-a”多态,那还能说它“is-a”多态吗?

例如,在下面这段代码中,虽然i_am_a_set实际上是一个Set,并且test(Set)方法也能更精确地匹配到它,但是调用test(i_am_a_set)时,还是执行了test(Collection)方法。

public class OverLoadTest{
    private static void test(Set set){
        System.out.println("set:"+set);
    }
    private static void test(Collection collection){
        System.out.println("collection:"+collection);
    }
    public static void main(String[] args){
        Collection i_am_a_set = new HashSet<>();
        // 这里还是会调用test(Collection)方法
        test(i_am_a_set);
    }
}

当然,重载不算多态只是我的一家之言,姑妄言之姑妄听之吧。

实例的多态

实例的多态同样是一家之言:同一个类拥有多个实例,并且每个实例中的数据各不相同,那么这就是一种实例的多态。考虑到“对象”不仅指编译期间的静态的类、也包括了运行期间的动态的实例,其实实例的多态和类的多态一样,也是同一个对象的多种形态。从这个角度来看,实例的多态也可以理解为一种更广义的多态。

这么一看,我们每次new一个对象、并对它设置不同的数据,都是一种实例的多态。但是实例的多态还有更强大的作用。虽然“绝大多数情况下,子类继承了父类之后都会重写父类的方法实现”,但是在个别情况下,子类并不重写父类的方法,而只是给父类中的某些字段设置一些不一样的值。

例如在下面的例子中,为了让开发和QA看到不同的仪表盘,我们创建了两个不同的子类DashBoardServcie4Dev和DashBoardServcie4Qa。它们并没有重写父类ServiceDashBoardAsChain的任何方法,只是通过构造函数为父类字段list设置了不同的值:

class ServiceDashBoardAsChain implements DashBoardServcie{
    protected List list;
    @Override
    public void fill(DashBoard dashBoard){
        list.foreach(s->s.fill(dashBoard));
    }
}
@Service
class DashBoardServcie4Dev extends ServiceDashBoardAsChain {
    public DashBoardServcie4Dev(@Autowired DashBoardServcie service4Cpu,
                    @Autowired DashBoardServcie service4Memory){
        super();
        list = Arrays.asList(service4Cpu,service4Memory);
    }
}
@Service
class DashBoardServcie4Qa extends ServiceDashBoardAsChain {
    public DashBoardServcie4Qa(@Autowired DashBoardServcie service4Cover,
                    @Autowired DashBoardServcie service4Tests,
                    @Autowired DashBoardServcie service4Sonar){
        super();
        list = Arrays.asList(service4Tests,service4Cover,
            service4Sonar);
    }
}

把对象单纯的理解为编译期间的静态的类、而把运行期间的动态的实例抛诸脑后,是出现这种类的主要原因。把这种类的多态转化成实例的多态,既能减少冗余的类定义、降低类爆炸的风险,又能提高程序功能的灵活性和扩展性:

class ServiceDashBoardAsChain implements DashBoardServcie{
    @Setter
    private DashBoardServcie list;
    @Override
    public void fill(DashBoard dashBoard){
        list.foreach(s->s.fill(dashBoard));
    }
}
/** 有了这一个类,就可以生成DashBoardServcie4Dev、DashBoardServcie4QA、
 *  DashBoardServcie4Ops、DashBoardServcie4Pm等多个类所需的实例。
 *  用户还能根据自己的关注点来自定义不同的面板,更加灵活。* */
@Component
class DashBoardServcieFactory{
    /** 单独注入。其中:
     * CPU --> service4Cpu
     * MEMORY --> service4Memory
     * TEST --> service4Tests
     * COVER --> service4Cover
     * SONAR --> service4Sonar
     * */
    @Resource
    private Map dashBoardMap;
    public DashBoardServcie build(DashBoardType... types){
        ServiceDashBoardAsChain chain = new ServiceDashBoardAsChain();
        chain.setList(Stream.of(types)
                .map(dashBoardMap::get)
                .collect(Collector.toList()));
        return chain;
    }
}

显然,实例的多态都可以转化为类的多态;再把重载排除在多态之外的话,出现了方法的多态就一定会出现类的多态。所以,我们后面的讨论都围绕类的多态展开。


多态与面向对象

与封装、继承不同,多态不是构成面向对象的要素,而是面向对象自身的特征。如果说封装是父亲、继承是母亲、面向对象是他们的孩子,那么多态就是这个孩子身上无穷的生命力和无尽的可能性。

面向对象模拟着现实世界的类型体系创建了对象体系,这套对象体系也就与类型体系有着相同之处。现实世界中的“多态”俯拾皆是,我们所熟知的“生物多样性”就是一个绝佳的例子。

地球上的生物自诞生之初,就呈现出千人千面的形态。在寒武纪生命大爆炸中出现的三叶虫,就有诸如莱德利基虫、球接子、褶颊虫、镜眼虫、小油栉虫,大卫奇异虫、齿肋虫、裂肋虫等不同种类;植物登上陆地之后,在石炭纪就演化出了石松类、节蕨植物、真蕨类、种子蕨和裸子植物的参天绿意;时至今日,哪怕是小小的加拉帕戈斯雀,也因身体大小、鸟喙形态的不同而分了十几种之多。生命就是以这样的千姿百态,适应了地球上千奇百怪的环境,占据了从赤道到两极、从天空到深渊、从雨林到沙漠的每一个角落;更是在历经五次大灭绝之后,从(最残酷时)残存不到5%的物种开始,复苏、生发、壮大,最终形成了现在这个千娇百媚的世界。

多态_第2张图片

如果用计算机的语言来描述,“地球Online”的这套生态系统,就是靠着生物物种的“多态”特性,满足了形形色色的“产品需求”。即使是在五次删库跑路之后,借助着“多态”特性,这个系统也能在残存不到5%的代码和数据的基础上,演化出了今天这套“地球Online 6.0”:不仅仍能满足所有环境和生态位的需求,更是开发出了智能生物,不得不令人啧啧称奇。

面向对象的对象体系在通过继承来模拟现实中的类型体系的时候,也毫不客气地把多态特性“顺手牵羊”了。实际上,只要有了继承、只要允许多个子类继承同一个父类,那么就自然而然地有了多态特性。由于有了多态特性,对象体系也拥有了“演化”能力,也就是在保持系统和抽象基本稳定的前提下,引入新的功能、结构以满足新的需求的能力。而这种演化能力,正是系统为满足不同的业务需求而必须具备的生命力和可能性。

例如下图就是我们系统中某个功能模块的“演化”过程。

多态_第3张图片

最初,这个模块只提供一种功能,只需要ServiceA这一个类就足够了。后来,我们需要在原有功能的基础上增加一项新的功能。新功能与原功能大同小异,而且原功能还要保留——调用ServiceA的地方非常多,无论开发还是测试,都不能保证覆盖到每一个改动点。这时,多态特性就发挥了作用:我们在ServiceA的基础上扩展出了一个子类ServiceB。所有沿用原功能、调用了ServiceA的地方无需做任何改动;所有需要使用新功能的地方调用ServiceB即可。然而,随着对业务的深入理解,我们发现ServiceA和ServiceB看似一脉相承、实则南辕北辙。这一点在随后的需求中就体现了出来:我们需要从ServiceB的功能中细分出一种更新的功能;它与ServiceB还是异曲同工,但与ServiceA已相去甚远。为了更好的描述对应这些功能的类之间的关系,我们通过抽取出BaseService类,把ServiceA和ServiceB由父子关系转变为兄弟关系。同时再次借助继承与多态,在ServiceB的基础上扩展出子类ServiceC,用以满足新的业务功能。

人们常说:系统架构不是设计出来的、而是演化出来的。然而,如果没有多态特性,我们的系统只能一次又一次地“重做”,根本无法演化。


多态与抽象

从抽象的角度来看,多态是什么呢?首先,抽象的作用在于“隐藏细节”。多态就是它要隐藏的一种细节。例如,我们系统需要根据用户的位置信息定位到用户所在的省、市、区县。显然的,这个功能可以抽象为这样一个LocationService接口:

public interface LocationServcie{
    /** 
     * 根据Location中的经纬度或者ip地址,定位City中的省、市、区县代码及名称。
     *
     * @param loc 经度和维度要么都有值、要么都没有值;不能一个有值、一个没有值。
     *            经纬度和IP地址至少有一个有值。    
     * @return 如果定位成功,将返回省、市、区县三级代码及相应的名称。    
     *         三级代码保证非空;如果是在直辖市,三级代码相同;如果在市区,市、区县代码相同。    
     *         如果定位失败,将返回null。    
     * */
    City locate(Location loc);
}

不过,具体要怎样定位呢?如果用户授权我们使用定位信息,那么我们就可以根据经纬度信息,调用某地图API来查到地址;然后将地址转换为所需的代码。如果某地图API出了问题——无论是服务自身还是运营商网络出了问题——我们都可以更换另一家地图API来查询地址。如果所有地图API都调用失败、或者用户压根就不让我们使用定位信息,我们还可以使用IP地址进行定位——尽管IP定位并不准确,但是些许聊胜无,至少它可以“尽可能”地保证业务继续处理下去:

多态_第4张图片

但是,对调用方来说,它只需要知道LocationService接口的相关约束:入参要传入哪些值、出参会返回哪些值,这就够了。至于这个模块到底是通过经纬度定位的、还是通过IP地址定位的呢?这个模块到底是用哪一家的地图API定位的呢?调用方不需要知道。这就像我们去银行取钱时,只要输入正确的密码、能拿到所需的钞票就可以了。至于柜台后面坐着的是男是女、是老是少、是机器人还是外星人,这不重要。

多态_第5张图片

我们还可以换一个角度来看多态与抽象:多态不仅仅是抽象内部的细节,同时也是实现细节的“最佳实践”。我们不妨设想一下:如果面向对象不支持多态——例如,一个接口只能有一个实现类、一个抽象类只能有一个子类、不允许非抽象类拥有子类,我们要怎样实现一个接口呢?

仍以上面的定位功能为例。如果LocationService接口下只允许有一个实现类,那么,为了提供ByMapApiXxx、ByMapApiYyy和ByIp这三种服务,这个硕果仅存的实现类只会有两种可能的编码方式:要么,它对外暴露三个方法、分别提供三种服务,由调用方自己选择和处理方法调用逻辑;要么,它仍然只提供一个方法,但是方法内部用if-else等方式把三种调用逻辑“一网打尽”。

我们网上购物凑优惠时常常遇到极其复杂的规则:又要组战队、又要每日签到、又要分享集赞……不仅又啰嗦又麻烦,而且稍不留神就会算错折扣;好不容易算好了账,活动方一个规则补丁,又要全部从头再来。跟直接撒币发红包相比,这种所谓的“优惠”实在是费时费力又不讨好。

多态_第6张图片

如果接口使用三个方法来提供三种服务——就像下面这段代码这样,那就跟这种毫无诚意的优惠活动差不多:本来简单明了的一件事情,由于接口把内部细节都暴露了出来,使得调用方代码又啰嗦又容易重复,并且调用方很容易对接口逻辑产生误解和误用。不仅如此,接口方法一旦发生变化——尤其是新增或者下线一个定位服务——那么所有的调用方都要修改代码。这种“发散变化”是任何一个开发人员都不能接受的。

/** 接口和实现类定义了三个不同的方法 */
public class LcationServiceImpl implements LocationService{
    public City locateByApiXxx(Location loc){...}
    public City locateByApiYyy(Location loc){...}
    public City locateByIp(Location loc){...}
}
public class CityService{
    public void userCity(UserInfo user, Location loc){
        /* 调用方使用时就要这样写代码 */
        City city = locationServcie.locateByApiXxx(loc);
        if(city == null){
            city = locationServcie.locateByApiYyy(loc);
        }
        if(city == null){
            city = locationService.locateByIp(loc);
        }
        if(city != null){
            // 略
        }
    }
}

那么,在一个方法内用if-else等方式把多种服务“一网打尽”呢?相比提供多个方法,这种方式的确可以更好地保持接口的抽象性和稳定性。但是,这种方式就像是使用了二向箔一样:它抹平了抽象的层级,把本可以逐层分解的业务复杂性全部堆叠到一层,人为地推高了代码复杂性,不仅把代码变得难以维护,而且把原本简单的业务也变成了水中花、雾中月,捉摸不透、脆弱不堪。

多态_第7张图片

在我们某个系统中,所有的业务功能都是通过if-else来区分处理的。当if-else累积到一定程度之后,出现了一件非常诡异的事情:所有人都说这个系统的业务逻辑很简单;但所有人都说不出系统中的业务逻辑是怎样的——哪怕只是一个产品、一种用户的完整业务都说不出来。为什么?因为这些流程全都散落在系统的if-else里:这个if里有一段、那个else里有一段;这种产品跟那种产品的逻辑纠缠在一起,这类用户和那类用户的流程混合在一起。要想把它们挑拣出来、拼凑完整,简直比从肯德基全家桶里拼凑出一只完整的小公鸡还要困难。

多态_第8张图片

这还只是问题的开始。由于没有人能说清楚完整的业务逻辑,所以每当产品提出新需求的时候,也就没有人能说清楚到底要怎么改,只能通过“扒代码”来估计改动范围和开发工作量。但是,就如用归纳法永远也找不出真理一样,“扒代码”永远也无法明确地告诉你“改动范围就这么大”、“工作量就这么点”。事实上,在开发过程中发现新的改动点、在测试时发现其它功能受到影响,对这个系统来说是家常便饭;相应的,延期、加班、线上bug……也就纷至沓来了。

如果使用多态呢?我曾经用多态的方式,重构过一个类似的系统。重构完成之后,只用一张表格就可以把完整的业务流程、以及不同产品不同用户所做的特殊操作全部展示出来。在这张表格中,一个新的需求要改什么、加什么、删什么,全都一目了然;改动范围和工作量也都变得清晰明确了。延期?不存在;加班?没必要;bug?我们有枪手——“枪手,走遍天下,蚊虫无忧”哈哈哈。

多态_第9张图片

为什么使用了多态就能达到这样的效果呢?因为多态能够充分利用抽象的层级特性,从而把纷繁复杂的实现细节分散在不同的抽象层级中。通过这样的层层分解,我们一定能找到这样两个抽象层级:一个既能够完整的描述业务流程,又不会陷入底层细节中、绕行“山路十八弯”后仍然“云深不知处”;另一个则把某一类业务的细节描述得纤毫毕现,但对其它类型的业务则“事不关己高高挂起”。

有了第一个抽象层级,我们对业务流程就有了一个清晰而明确的总体认识,对产品需求有哪些改动点、有多少工作量、有哪些潜在风险,自然也就一目了然了。这就像遇到了张松的刘备一样,掌握了蜀中的道路、地形、布防、民情等整体情报后,对如何施行“跨有荆益”这一战略、入蜀要途径哪些城池关隘、哪里可以募兵哪里可以筹粮等问题自然也就胸有成竹了。有了这样清晰的战略部署,成都还不是手到擒来。

多态_第10张图片

而有了第二个抽象层级,我们的业务流程和代码模块就可以充分地解耦合,从而降低彼此之间的牵制和掣肘,从而减少不必要的bug和开发测试工作量。


多态与高内聚低耦合

无论使用多态还是if-else,都可以把抽象下的多种服务聚合到同一个模块内。但是,多态所提供的低耦合是其它任何方式都无法比拟的。

例如,在我们的系统中,有这样一段代码:

public class AuditServiceImpl implements AuditService{
    @Override
    public void audit(Apply apply){
        // 一堆公共逻辑
        // 然后根据产品类型做不同的必填项校验
        if(apply.getProduct() == ProductA) {
            // 执行ProductA对应的校验,略
        }else if(apply.getProduct() == ProductB)){
            // 执行ProductB对应的校验,略
        }else{
            // 执行ProductC对应的校验,略
        }
        // 又一堆公共逻辑
        // 又根据产品类型组装不同的数据
        if(apply.getProduct() == ProductB) {
            // 略
        }// else-if,略
        // 还有一堆公共逻辑
        // 再次根据产品类型按不同的逻辑处理返回数据
        if(apply.getProduct() == ProductC) {
            // 略
        }// else-if,略
    }
}

这好像是我们写业务代码时最常见的方式:一开始只有ProductA;然后业务上增加了大同小异的ProductB,于是代码中也在差异化的地方加上if(ProductB);接着又有了ProudctC/ProductD/ProudctE,于是代码中这个地方加一个if(ProductC),那个地方加一个if(ProductD || ProductE),久而久之,代码就变成了上面这个样子。我们经常吐槽说自己系统里的代码是“Shit Hill”,其实很多时候,“Shit Hill”就是这么来的。

“Shit Hill”的问题可谓罄竹难书,模块间的强耦合就是罪魁祸首之一:从ProductA到ProductE,相关的功能代码全都杂糅在一起,使得这几个本应相互独立的产品和业务之间产生了耦合性最强、也最另令人深恶痛绝的内容耦合。

Content coupling is said to occur when one module uses the code of other module, for instance a branch. This violates information hiding - a basic design concept.内容耦合是指一个模块直接使用另一个模块的代码。这种耦合违反了信息隐藏这一基本的设计概念。

花园的景昕,公众号:景昕的花园细说几种耦合

内容耦合使得我们在为一个产品修改代码的时候,总会感到“战战兢兢,如履薄冰,如临深渊”,因为谁也不知道自己改的代码会不会影响到其它产品的业务。我就曾经在这样一个方法的第十几行处加了一行代码;没想到在一百多行开外,这行代码引发了另一个bug。

这种抓狂的感觉……谁写bug谁知道啊。

要怎样化解这些问题呢?多态就是一种非常好的方案。例如,我们可以用多态把上面这段代码改写成这样:

abstract class AuditServcieAsSkeleton implements AuditService{
    @Override
    public void audit(Apply apply){
        // 一堆公共逻辑
        // 校验入参
        doValid(apply);
        // 又一堆公共逻辑
        // 构建请求数据
        Request request = buildRequst(apply);
       // 再来一堆公共逻辑
       // 处理返回数据
       dealResponse(response);
    }
    protected abstract void doValid(Apply apply);
    protected abstract Request buildRequest(Apply apply);
    protected abstract void dealResopsne(Response resp);
}
class AuditServiceAsDispatcher implements AuditService{
    private Map dispatcher;
    @Override
    public void audit(Apply apply){
        dispatcher.get(apply.getProductType())
                  .audit(apply);
    }
}
class AuditService4ProductA extends AuditServiceAsSkeleton{
    @Override
    protected void doValid(Apply apply){
        // 产品A的校验逻辑
    }
    @Override
    protected Request buildRequest(Apply apply){
        // 组装产品A所需的请求数据
    }
    @Override
    protected void dealResopsne(Response resp){
        // 按产品A的逻辑处理返回结果
    }
}
class AuditService4ProductB extends AuditServiceAsSkeleton{
    // 按产品B的需求处理;略。// 产品C、D、E的类也略。
}

借助多态方案,ProductA/B/C/D/E的相关代码被分散到完全独立的几个类中,从而把产品功能之间的内容耦合降低为特征耦合甚至数据耦合。这样,无论是哪个产品要修改自己的功能、或者我们要再新增一套新的产品,都可以做到与其它产品毫无瓜葛;从而做到“代码耦合少,bug远离我”。

Stamp coupling occurs when modules share a composite data structure and use only parts of it, possibly different parts .特征耦合是指多个模块共享一个数据结构、但是只使用了这个数据结构的一部分——可能各自使用了不同的部分。

花园的景昕,公众号:景昕的花园细说几种耦合

Data coupling occurs when modules share data through, for example, parameters. Each datum is an elementary piece, and these are the only data shared (e.g., passing an integer to a function that computes a square root).数据耦合是指模块间通过传递数值来共享数据。传递的每个值都是基本数据,而且传递的值是就是要共享的值。

花园的景昕,公众号:景昕的花园细说几种耦合

不过,使用多态就难免要使用继承;因而也难免会遇到困扰继承的子类耦合。但这并不是多态带来的问题,而是使用继承所需要特别注意的。

多态_第11张图片