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)也就有了瞭解。相信對於我們的使用會有一定的幫助。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章