Java分派实践

引言

  在OO(object-oriented)语言中使用了继承来描述不同的类之间的“社会关系”——类型层次。而这些类实例化的对象们则是对这个类型层次的体现。因此大部分OO语言的对象都存在两个身份证:静态类型和实际类型。所谓静态类型,就是对象被声明时的类型;而实际类型则便是创建对象时的类型。OO还有一个重要的特点:一个类中可以存在两个相同名称的方法,而它们是根据参数类型的不同来区分的。正因以上两个原因,便产生了分派——根据类型的不同来选择不同的方法的过程—OO语言的重要特性。

分派分类

  分派可以发生在编译期或者是运行期。因此按此标准,分派分为静态分派和动态分派。在程序的编译期,只有对象的静态类型是有效的,因此静态分派就是根据对象(包括参数对象)的静态类型来选择方法的。最典型的便是方法重载(overloading)。在运行期,动态分派会根据对象的实际类型来选择方法。典型的例子便是方法重写(overriding)而OO语言正是由以上两种分派方式来提供多态特性的。
  按照选择方法时所参照的类型的个数,分派分为单分派和多分派。OO语言也因此分为了单分派(Uni-dispatch)语言和多分派(Multi-dispatch)语言。说到多分派,就不得提到另一个概念:多重分派(multiple dispatch)。它指由多个单分派组成的分派过程(而多分派则往往不能分割的)。因此单分派语言可以通过多重分派的方式来实现和多分派语言一样的效果。
  那么我们熟悉的Java 语言属于哪一种分派呢?

java分派实践

  先来看看在Java中最常见的特性:重载(overloading)与重写(overriding)。
  下面是重载的一个具体的小例子,这是一个再简单不过的代码了:

//Test For OverLoading
public class Test4OverLoading {

    public void doSomething(int i){
        System.out.println("doString int="+i);
    }

    public void doSomething(String s){
        System.out.println("doString String="+s);
    }

    public void doSomething(int i ,String s){
        System.out.println("doString int="+i+"String="+s);
    }

    public static void main (String[]args){
        Test4OverLoading t=new Test4OverLoading();
        int i = 0;
        t.doSomething(i);
    }
}

  没什么好稀奇的,你对这部分知识已经熟练掌握了,那么你对下面这段代码的用意也一定了如指掌了吧。

//Test For Overriding
public class Test4Overriding {

    public static void main (String[] args){
        Father f = new Father();
        Father s = new Son();
        f.dost();
        s.dost();
    }
}

class Father{
    public void dost (){
        System.out.println("Welcome Father!");
    }
}

class Son extends Father{
    public void dost(){
        System.out.println("Welcome Son!");
    }
}

  还有这段代码!

public class Test {

    public static void main (String[] args){
        Father f = new Father();
        Father s = new Son();

        f.dost(1);
        s.dost(4);
        s.dost("dispatchTest");
        //s.dost("test",5);
    }
}

class Father{
    public void dost (int i){
        System.out.println("Welcome Father!int="+i);
    }
    public void dost(String s){
        System.out.println("Welcome Father! String="+s);
    }
}

class Son extends Father{
    public void dost (int i){
        System.out.println("Welcome Son!int="+i);
    }
    public void dost(int i ,String s){
        System.out.println("Welcome Son! int="+i+"String="+s);
    }
}

  在编译期间,编译器根据f、s的静态类型来为他们选择了方法,当然都选择了父类Father的方法。而到了运行期,则又根据s的实际类型动态的替换了原来选择的父类中的方法。这便是结果产生的原因。
  如果把上面代码中的注释去掉,则会出现编译错误。原因便是在编译期,编译器根据s的静态类型Father 找不到带有两个参数的方法dost。

  那么下面这个代码呢?

import java.io.Serializable;

public class Test {

    public static void sayHello(Object arg){
        System.out.println("Hello Object!");
    }

    public static void sayHello(int arg){
        System.out.println("Hello int!");
    }

    public static void sayHello(long arg){
        System.out.println("Hello long!");
    }

    public static void sayHello(Character arg){
        System.out.println("Hello Character!");
    }

    public static void sayHello(char arg){
        System.out.println("Hello char!");
    }

    public static void sayHello(char... arg){
        System.out.println("Hello char...!");
    }

    public static void sayHello(Serializable arg){
        System.out.println("Hello Serializable!");
    }

    public static void main (String[]args){
        sayHello('a');
    }
}

  Test类中定义了一系类的重载方法sayHello,在main函数中进行了调用,传入的参数不是带类型的变量,而是字符字面量’a’。这里并没有显式的指明变量的静态类型,每个方法都是满足重载的方法。因为你可以说字面量’a’是char型,可以说它是Character型,甚至可以说它是Object型。那么编译器该如何抉择呢?编译器是根据匹配优先级确定方法的执行版本,因为’a’最符合char型的定义,所以优先匹配sayHello(char arg)方法。上面的代码执行后将输出"Hello char…!"。
  如果把sayHello(char arg)注释掉,会输出什么呢?这个时候编译器会自动将’a’转型为int,将会调用sayHello(int arg),输出"Hello int!"。这是由于’a’除了可以代表一个字符,还可以代表数字97(字符’a’的Unicode值是97)。
  如果再把sayHello(int arg)注释掉呢?这里我们就不挨个的去解释了。这些类型匹配的优先级是:char->int->long->float->double->Character->Serializable->Object->char…。

  再来一个,可要注意看了:

public class Test {

    public void dost(Father f,Father fl){
        System.out.println("f");
    }

    public void dost(Father f,Son s){
        System.out.println("fs");
    }

    public void dost(Son s,Son s1){
        System.out.println("ss");
    }

    public void dost(Son s,Father fl){
        System.out.println("sf");
    }

    public static void main (String[] args){
        Father f = new Father();
        Father s = new Son();
        Test t=new Test();
        t.dost(f,new Father());
        t.dost(f,s);
        t.dost(s,f);
    }
}

class Father{}

class Son extends Father{}

  执行结果没有像预期的那样输出ff、fs、sf而是输出了三个ff。为什么?原因便是在编译期,编译器使用s的静态类型为其选择方法,于是这三个调用都选择了第一个方法;而在运行期,由于Java仅仅根据方法所属对象的实际类型来分派方法,因此这个“错误”就没有被纠正而一直错了下去……
  可以看出,在编译期间,编译器需要根据方法的变量的静态类型和参数才能确定方法的描述符。所以Java的静态分派属于多分派。在运行期间,方法的名称和描述符已经是确定了的,但是在执行真正的方法调用时,JVM需要根据方法的接收者的实际类型去决定执行的方法版本,所以Java的动态分派属于单分派。
  因此可以说Java语言支持静态多分派和动态单分派。
  虚拟机动态分派的实现:


public class Test {

    public static void main (String[] args){
        Father s = new Son();
        s.dost();
    }
}

class Father{
    public void dost(){
        System.out.println("Welcome Father!");
    }
}

class Son extends Father{
}

  代码很简单,类Son继承Father,但是没有重写父类的dost方法。main函数中实例化Son,赋静态类型Father并调用实例的dost方法。这里的输出相信各位读者都知道:“Welcome Father!”。这里方法的接收者是Son,虚拟机会先去Son类里面寻找dost方法,但是Son类中并没有这个方法,所以虚拟机会向上查找Son的父类Father,调用Father的dost方法。
  道理很简单,但是动态分派是个非常频繁的动作,如果每次都这么向上查找的话,会严重影响虚拟机的执行性能。所以虚拟机针对这种情况会做出一些优化手段,最常用的”稳定优化“手段就是为类在方法区中建立一个虚方法表(Virtual Method Table,也称为vtable),使用虚方法表的索引来代替元数据查找以提高性能。

虚方法表
  虚拟机会为每个类建立一个虚方法表,如上图左右两个表格分别为Father和Son的虚方法表。虚方法表中存放各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的。对于上面的例子,Father和Son都默认继承Object,所以它们的vtable里面继承的方法clone、hashCode、equals等都指向Object中对应的方法。这里Son没有重写父类Father的dost方法,所以它的vtable中dost方法地址入口指向Father中的dost方法。如果这里Son重写了dost方法,它的vtable里面这一项就会指向Son类的dost方法地址入口。

  由于使用了vtable技术,虚拟机在执行动态分派的时候,只需要找到方法接收者所对应的类的虚方法表,就能立即找到实际的方法,不用再向上查找。我们的例子比较简单,只有一个继承层级,真实应用中很可能类存在多个层级,使用vtable技术可以很大程度上提高虚拟机的执行性能。与此对应的,对于接口方法的查找也会用到方法表,只是换了个名字”接口方法表“–Interface Method Table,简称itable。

拓展

  成员变量与方法在分派上的体现

public class Test {

    public static void main (String[] args){
        Father f = new Father();
        Father s = new Son();
        System.out.println("f.i"+f.i);
        System.out.println("s.i"+s.i);
        f.dost();
        s.dost();
    }
}

class Father{
    int i=0;
    public void dost(){
        System.out.println("Welcome Father!");
    }
}

class Son extends Father{
    int i=9;
    public void dost(){
        System.out.println("Welcome Son!");
    }
}

输出结果

f.i0
s.i0
Welcome Father!
Welcome Son!

  产生的原因是Java编译和运行程序的机制。“数据是什么”是由编译时决定的;而“方法是哪个”则在运行时决定。

双重分派实现

  Java不能支持动态多分派,但是可以通过代码设计来实现动态的多重分派。这里举一个双重分派的实现例子。大致的思想便是通过一个参数来传递JVM不能判断的类型。通过Java的动态单分派来完成一次分派后,在方法中使用instanceof来判断参数的类型,进而决定执行哪个相关方法。

public class Test {

    public static void main (String[] args){
        Father f = new Father();
        Father s = new Son();
        s.dost(f);
        s.dost(s);
        f.dost(f);
        f.dost(s);
    }
}

class Father{
    public void dost(Father f){
        if(f instanceof Son){
            System.out.println("Here is Father's Son");
        } else if(f instanceof Father){
            System.out.println("Here is Father's Father");
        }
    }
}

class Son extends Father{
    public void dost(Father f){
        if(f instanceof Son){
            System.out.println("Here is Son's Son");
        } else if(f instanceof Father){
            System.out.println("Here is Son's Father");
        }
    }
}

输出结果

Here is Son's Father
Here is Son's Son
Here is Father's Father
Here is Father's Son

  用这种方式来实现双重分派,思路比较简单清晰。但是对于复杂一点的程序,则代码显得冗长,不易读懂。而且添加新的类型比较麻烦,不是一种好的设计方案。设计模式中访问者(Visitor)模式则较好的解决了这种模式的不足。

参考
1、AT92,深入浅出设计模式:话说分派
2、南唐三少,Java多态性——分派

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值