Java 笔记 RTTI - 运行时类型检查

Posted LaterEqualsNever

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java 笔记 RTTI - 运行时类型检查相关的知识,希望对你有一定的参考价值。

运行时类型检查,即Run-time Type Identification。这是Java语言里一个很强大的机制,那么它到底给我们的程序带来了什么样的好处呢?
在了解运行时类型检查之前,我们要首先知道另一个密切相关的概念,即运行时类型信息(Run-time Information - 也可以缩写为RTTI)
运行时类型信息使得你可以在程序运行时发现和使用类型信息。 来自:《Thinking in Java》。
OK,那么我们总结来说,RTTI就是能够让我们在程序的运行时去获取类型的信息。接下来我们就逐级的去了解RTTI常见的使用方式。

  • A = (A)b; 向下转型

面向对象编程的一个重要特性就是多态,也就是说我们可以针对于基类来编程从而降低程序的耦合度。
以Java来说,常常就是通过继承的方式,来实现这种效果,就像下面的代码里做的一样:

public class Demo {
    public static void main(String[] args) {
          Animal [] animals = new Animal[2];
          animals[0] = new Tiger();
          animals[1] = new fish();
          for (Animal animal : animals) {
            animal.breath();
        }
    }
}

abstract class Animal {
    abstract void breath();
}

class Tiger extends Animal{

    @Override
    void breath() {
        // TODO Auto-generated method stub

    }

}

class fish extends Animal{

    @Override
    void breath() {
        // TODO Auto-generated method stub

    }   
}

这样的代码是很常见的,而它们之所以能够编译通过,是因为程序在编译期时,
上述代码中的Tiger和Fish类型都会被向上转型称为基类,也就是说其实它们自身的类型信息会丢失。
但是呢,在程序进入运行期后,当我们调用”animal.breath”,它们却能准确的找到所属类型当中的方法进行调用。
这到底是为什么呢?其实原因我们在JAVA 笔记(一)里已经说过,就是因为运行时绑定(即动态绑定)机制。

所以,这里我们就可以进一步分析,既然程序在运行时才选择最合适的方法进行绑定。
那么,很自然的就可以联想到,要选择方法进行绑定,前提当然的是能在程序运行时获取到类型的方法列表,继承结构等信息。
这也就引出了我们下面要说的东西:运行时类型信息(Run-time Type Information)

  • load class (类的装载过程)

实际上,我们在JAVA 笔记(一)当中总结类的初始化顺序的时候,已经提到过关于类的装载。这里我们再次总结一下:

  • Java的程序在其开始运行之前,并不会被完全加载。所有的类都是在其第一次被使用时,动态的加载到内存当中。
  • 所谓的第一次被使用时加载。也就是指当程序创建第一个对类的静态成员的引用的时候,就会被加载。
  • 对一个类进行装载的工作过程很简单:类装载器(ClassLoader)会首先检查类是否已经被加载过;
    如果检查到之前该类型还没有被进行过装载,则根据类名查找对应的.class文件将其装载进内存当中。

简单的来说,类的装载过程就是这样。但是这里我可以关注到另一个关键的信息:
与我们本文中说到的运行时类型信息对应,那么有没有一个编译时类型信息呢?
其实不难想象肯定是有的,因为“羊毛出在羊身上”。实际上当一个类文件经过编译,其包含的信息就被装载进编译后的字节码文件中了。
所以我们可以将.class文件看做是类的“编译时类型信息”;而当期在运行时被类装载器加载后,就引出了我们马上要说到的另一个概念。

  • “class of classes” 类的类

前面我们说到类的字节码文件可以被我们视作类的编译时类型信息,那么当其被装载过后又会怎么体现呢?
其实这也就是我们本文的重点:运行时类型信息。编译时的类型信息以“.class文件”作为载体。
那么,很明显同理来说,运行时类型信息自然也需要一个载体,而这个载体就是”Class”类型对象。

我们知道一个Java程序实际上就是由一个个的类(即class)组合而成的,但这里的此”Class”并非 彼“class”。
更形象的比喻来说,”Class对象”又被称作”class of classes“,即”类的类“。
之所以我们进行这样的比喻,实际上正是因为每一个类都会有一个”Class对象”;
每当我们编写一个新类,就会产生一个新的”Class对象”(被保存在同名的.class文件)。

事实上,“Class对象”的作用 就是用来创建对应类型的所有“常规”对象的。
(所以我们可以把这个对象看做是“雕版”,我们程序中创建使用的对象即印刷出的“百元大钞”)

更加细致的来说,也就是当类被类装载器装载进内存之后,就会有一个对应的”Class”类型对象进入内存。
被装载进的这个“Class对象”保存该类的所有自身信息,而JVM也正是通过这个对象来进行RTTI(程序运行时类型检查)工作的。

就以我们前面说到的“向下转型”的工作来说,其实也就是通过这样的方式来实现类型检查的。
通过一个简单的例子,我们可以更好的理解这种检查工作。假设我们的程序中有这样一个方法:

public class Demo {

    public static <T> void test(T t){
        Demo d = (Demo) t;
    }

}

这段程序在编译时是能够通过,因为程序在编译器并不能确定被传入的t的类型,这是在程序运行时才决定的。
那么,假设我们在运行时调用该方法,传入一个String类型的参数,会得到什么结果?

    public static void main(String[] args) {
         test("123");
    }

没错,程序运行该段代码的时候,会得到一个运行时异常:ClassCastException。
注意了,这时我们就值得思考了?为什么JVM在运行时能够判断出String类型不能被转换为Demo类型。
答案当然就是,JVM能够通过某种方式在运行时去获取到这两种类型的类型信息。而这种方式就是通过“Class对象”

我们从Class类的API文档当中,选取一些比较常用的具有代表性的方法接口来看一看:

  • static Class< ? > forName(String className) 返回与带有给定字符串名的类或接口相关联的 Class 对象。
  • ClassLoader getClassLoader() 返回该类的类加载器。
  • Class< ? > getComponentType() 返回表示数组组件类型的 Class。
  • Constructor< T > getConstructor(Class< ? >… parameterTypes) 返回一个 Constructor 对象,它反映此 Class 对象所表示的类的指定公共构造方法。
  • Field getField(String name) 返回一个 Field 对象,它反映此 Class 对象所表示的类或接口的指定公共成员字段。
  • Class< ? >[] getInterfaces() 确定此对象所表示的类或接口实现的接口。
  • Method[] getMethods () 返回一个包含某些 Method 对象的数组。
  • String getName() 以 String 的形式返回此 Class 对象所表示的实体(类、接口、数组类、基本类型或 void)名称。
  • Package getPackage() 获取此类的包。
  • Class< ? super T > getSuperclass() 返回表示此 Class 所表示的实体(类、接口、基本类型或 void)的超类的 Class。

同上上面截取的部分接口列表我们已经可以发现,一个类的所有信息其实我们都可以在其对应的”Class对象”里找到。
所以当我们了解了原理了之后,现在自己也可以模仿JVM在上面报出类型转换异常的代码处所做的检查工作。

我们先来看一段比较有趣的测试代码:

public class Demo {
    public static void main(String[] args) {
        Father f1 = new Father();
        test(f1);
        Father f2 = new Son();
        test(f2);
    }

    public static <T> void test(T t) {
        // Son s = (Son) t;
        Class clazzT = t.getClass();
        System.out.println("T.className ==>> " + clazzT.getSimpleName());
    }

}

class Father {}
class Son extends Father {}

这段测试程序的运行结果,其实还是比较有意思:

T.className ==>> Father
T.className ==>> Son

我们注意到,虽然引用f2出现了“父类引用指向子类对象的情况”。但其实它的Class对象可以清楚的知道它实际是“Son”类型。
那么,我们其实可以很轻松的通过“Class对象”来模仿JVM对类型转换所做的检查工作:

public class Demo {
    public static void main(String[] args) {
        Father f1 = new Father();
        test(f1);
        Father f2 = new Son();
        test(f2);
    }

    public static <T> void test(T t) {
        // Son s = (Son) t;
        Class clazzT = t.getClass();        
        Class clazzTarget = Son.class;

        if(clazzT.equals(clazzTarget)){
            System.out.println("可以转换");
        }else{
            System.out.println("不能转换");
        }
    }

}

class Father {}
class Son extends Father {}

运行我们修改后的代码,发现输出结果为:

不能转换
可以转换

由此,我们也如我们所了解到的那样,实际正是通过“Class对象”来完成了程序的运行时类型检查工作。

  • Class对象的获取方式

前面我们了解了Class对象带来的好处,那么,在程序当中我们可以通过哪些方式来获取Class对象呢?

  • new Object().getClass();

new一个对象这种工作,我们恐怕已经做过无数回了。而要获取new出的对象的“模板”对象,就是通过obj.getClass():

public class Demo {
    public static void main(String[] args) {
       Class clazzDemo =  new Demo().getClass();
    }
}
  • Class.forName(String className);

第二种方式,就是通过Class类的成员方法”forName”来获取。这种方式需要注意的就是处理异常。
因为根据类名来查找并获取”Class对象”,就自然要考虑到查找不到传入的类名对应的对象的情况。

public class Demo {
    public static void main(String[] args) {
        try {
            Class clazzDemo = Class.forName("Demo");
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}
  • 类字面常量 className.class

类字面常量是另一种获取Class对象的方式。与使用forName()方法相比,它的好处是:更加简单和安全。
之所以更加安全是因为,它在编译时就会受到检查。由此它也可以避免Try-catch语句块。

public class Demo {
    public static void main(String[] args) {
            Class clazzDemo = Demo.class;
    }
}
  • Class.forName 与 类字面常量的特点比较

虽然我们说过这两种方式都可以获取到类运行时信息”Class对象”,但同时它们也具备一些不同的特点。
要了解这之间的差别,我们首先要弄清楚当使用一个类时所作的准备工作的步骤,事实上这个步骤分为三步:

  • 加载。即类装载器在指定路径下查找对应的字节码文件,并从这些字节码中创建出一个Class对象。
  • 链接。即验证类中的字节码,为静态域分配存储区域;如果有需要,还将解析这个类创建的对其它类的引用。
  • 初始化。也就是我们常说的“类的初始化工作(包括静态域,成员变量,构造器等等)。

Class.forName 与 类字面常量的区别就在于“初始化”。对于类字面常量来说,“初始化工作”拥有足够的“惰性”。
我们仍然先通过一段代码,来看一看具体的体现形式:

public class Demo {

    public static void main(String[] args) {
        try {
            Class clazzTest = Class.forName("Test");
        } catch (ClassNotFoundException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}

class Test {
    static {
        System.out.println("静态初始化子句执行");
    }
}

运行以上代码,程序的输出结果将是:

静态初始化子句执行

接着,我们来看看如果使用类字面常量来获取Class对象,会是什么情况?

public class Demo {

    public static void main(String[] args) {
        Class clazzTest = Test.class;
    }
}

class Test{
    static {
        System.out.println("静态初始化子句执行");
    }
}

根据打印结果,我们会发现Test类的“初始化”并没有执行。这恰好印证了我们说的“惰性”。
那么,这个所谓的“惰性”究竟被延迟到了什么时候呢?答案就是:延迟到了对静态方法或者非常数的静态域进行首次访问的时候。

也就是说,假设我们有一个如下的测试类:

class Test {
    static int x = 100;
    static final int x1 = 100;
    static final int x2 = new Random().nextInt();

    static {
        System.out.println("静态初始化子句执行");
    }

    static void test(){

    }   
}

那么,就只有当“x”或者”test()”方法被首次访问时,才会进行初始化工作。
而“x1”即使被访问也不会进行初始化,因为这样定义的域是“编译时常量”,不需要初始化就可以访问。
而“x2”的访问仍然会引发类的初始化,因为虽然这是一个常量,但是是我们在JAVA 笔记(一)里说到过的运行时常量。

由此,你可能已经发现,相对于使用Class.forName的方式来说,其实类字面常量显然有更多的好处:
比如更加安全,更加简洁明了,而且拥有足够的初始化“惰性”。那我们为什么还要使用Class.forName呢?
你是否还记得,我们说类字面常量之所以更安全是因为它在编译时就会受到检查。即类型在编译时就已被明确。
但现实的情况是,有些时候我们要使用到的类型在程序的编写时期是无法确定的。
这个时候Class.forName就有了用武之地,即我们常说的类的放射,我们马上就会接着说道。

  • 类的反射

反射的根本就是在程序运行时,动态的选择适合的类型使用。也就是我们说的在编写代码时,无法确定类型的时候。
其实反射的原理很简单,我们借助《Thinking in java》当中的一段文字可以很好的得以了解:
这里写图片描述
但概念性的东西说到底都还是挺乏味的,我们可以通过一个简单但具有一定代表性的例子来加深理解。

举例来说,假设我们为一个工厂编写生产汽车的类。那么,很面显的,汽车可能具有不同的类型。
也就是说,在真正开始生产之前,我们无法确定采用哪种方式生产汽车。
假设生产不同类型汽车的部门都属于都一个工厂旗下,那情况还比较容易处理一些。
例如,该工厂可以生产轿车和卡车,这两种类型的汽车的生产由同一工厂的不同部门负责。

public class Factory {
    void produce(ProduceDepartment pd) {
        pd.produce();
    }
}

interface ProduceDepartment {
    void produce();
}

class CarProduceDepartment implements ProduceDepartment {

    @Override
    public void produce() {
        System.out.println("轿车生产");
    }

}

class TruckProduceDepartment implements ProduceDepartment {

    @Override
    public void produce() {
        System.out.println("卡车生产");
    }

}

由于生产的部门都属于同一家工厂,虽然不确定在运行时的调用类型,但我们通过以上的代码编写方式,已经足够控制。

但假设一下,假设汽车的生产由不同的生产厂商完成。也就是说,你根本无法确定在实际生产时,有哪些生产厂商。
对应在编程的思想中来说,也就是说,你在编写程序的时候,根本就不知道有哪些类型能够提供给你。
所以,情况就变得有些复杂了。这个时候,就是反射站出来装逼的时候。我们可以修改我们的代码如下:

public class Factory {

    @SuppressWarnings("unchecked")
    void produce(String className) {
        Class<ProduceStandard> clazzPS = null;
        try {
            // 通过反射查询到对应的类的Class对象
            clazzPS = (Class<ProduceStandard>) Class.forName(className);
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }

        if (clazzPS != null) {
            try {
                // 通过Class对象生产我们要使用的常规对象
                ProduceStandard ps = clazzPS.newInstance();
                ps.produce(); // 生产
            } catch (InstantiationException e) {
                e.printStackTrace();
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            }
        }
    }
}

interface ProduceStandard {
    void produce();
}

这下牛逼了。实际我们做的工作原理仍然很简单,我声明生产汽车的规范(接口).
之后任何生产厂商都可以根据该规范去具体实现。我们在运行时通过类名(即生产厂商的实现)去查找到你的实现。
然后通过newInstance创建可以使用的常规对象,从而来调用并进行生产。这就是所谓的反射。

相信到此你就明白了所谓的反射的原理,实际上反射在实际的编码工作中,最常见的就是驱动程序。
最最最最最最最熟悉的,应该就是数据库驱动jdbc的使用了。以mysql的访问来说,我们在初始化驱动的时候,都会用到这样的代码:

 Class.forName("com.mysql.jdbc.Driver")

现在我们总算知道它的用意了。很显然,Java的设计者在设计的时候肯定不会知道之后具体有哪些数据库需要实现驱动。
所以,使用反射就是最合适的了。

  • instanceof 与 Class.isInstance(Object obj)

实际上,Java当中对于RTTI-运行时类型检查还有另一种常用的方式,即使用instanceof进行类型判断。

public class Demo {

    static <T> void f(T t){
        if(t instanceof Object){

        }
    }
}

而通过Class对象,也可以完成这样的判断。即通过Class.isInstance(Object obj)方法来进行判断。
我们来看一下Java的API文档当中对于该方法的说明是怎么样的:

判定指定的 Object 是否与此 Class 所表示的对象赋值兼容。此方法是 Java 语言 instanceof 运算符的动态等效方法

如果指定的 Object 参数非空,且能够在不引发 ClassCastException 的情况下被强制转换成该 Class 对象所表示的引用类型,则该方法返回 true;否则返回 false。

所以说,我们下面代码中使用的两种判断方式,实际上效果是等价的:

public class Demo {

    static <T> void f(T t){
        // 1.
        Father.class.isInstance(t);
        // 2.
        if(t instanceof Father){

        }
    }
}

class Father{};
class Son extends Father{};

OK,到此,我们对于Java当中的运行时类型检查机制(RTTI)也就有了了解。相信对于我们的使用会有一定的帮助。

以上是关于Java 笔记 RTTI - 运行时类型检查的主要内容,如果未能解决你的问题,请参考以下文章

Thinking in Java 整理笔记:类型信息

Thinking in Java 整理笔记:类型信息

类型信息

运行时类型信息RTTI

MFC 六大机制 RTTI(运行时类型识别)

读书笔记——类型信息