搜索
Table_bottom

标签云
Table_bottom

分类
Table_bottom

声明
文章若未特別註明,皆採用 知识共享许可协议 請自覺遵守
Table_bottom

鏈。。。
Table_bottom

存档
Table_bottom

匆匆过客
41231
Table_bottom

功能
Table_bottom

Java的泛型——坑、優秀與缺陷

人云E云 posted @ 2017年12月31日 19:22 in 信息技術 with tags java Coding Programming Language , 330 阅读

最近一直在寫一個自己的Android程序(https://github.com/renyuneyun/Easer),所以Java用得比較多。又由於我懶,所以總喜歡讓編譯器做更多,於是想到用泛型來解決之前存在的Object滿地飛又滿地強制類型轉換的情況。然而這時候卻發現,泛型只能解決其中一部分問題,另一部分問題依然存在。

於是起意記錄一下自己知道的、用過的以及碰到的東西,以期有人能給出更加的解決方案(或是乾脆直接指出我錯了最好。。。這樣解決起來最簡單)。

 

一般而言,Java的泛型可以讓程序員寫出一些“形式相同,但具體參數類型不同”的代碼。從字面上說,這一機制在許多語言中都有(如C++的模板),但由於各個語言的實現方式和語義取捨不同,導致具體支持的功能千差萬別。

本文主要集中於Java泛型機制中的坑爹之處。爲了介紹坑爹之處,於是也就需要涉及該機制的理解,同時也會簡單涉及其實現部分。當然,其中一些有意義的地方也會順帶提及(在做對比時)。

引子

由於我個人對語言的學習順序,C++的模板機制是我首先接觸的(不考慮C的宏,因爲其只是簡單的文本替換),Java的泛型是在其後學習的。最初學習時,我以爲Java的泛型和C++的模板(不考慮變量做模板參數,因爲當時沒有學過)幾乎就是一樣的,無非是字面上的語法有點區別。但隨着看了更多的一些Java代碼,發現Java的泛型還支持對類型參數進行一定限制/細化(extendssuper關鍵字);隨着寫了更多Java代碼,發現Java的泛型之下類型參數不能完全被當作類型名來用(例如不能new一個新對象,不能寫在instanceof關鍵字之後)。

最後,最終導致寫這篇文章的契機,則是爲了讓編譯器做更多檢查,我嘗試在我的代碼中需要寫出可以接受類型參數的類型參數(嵌套的泛型)——這一機制完全無法被Java支持。在翻閱一定資料之後,發現Java的泛型和我原來的理解有很大不同。

Java泛型的表象

在Java中,泛型的出現是爲了解決Object滿地跑的現象:如果沒有泛型,List之類的容器(即自動增長的數據類型)內將會是滿地的Object——add的參數類型會是Objectget的返回值類型會是Object……這就導致了類型安全完全無法保障:程序員可以任意地向一個List中傳遞(添加)任意類型的數據,從而導致使用方(get)出現問題。更不要提滿地的強制類型轉換了。

雖然泛型的出現解決了這一問題,使得List等容器可以使用泛型來書寫(聲明和定義),程序員只須在使用時指明要使用的類型即可。或許和該主要需求有關,Java對泛型的支持方式導致一般情況下泛型的作用變成了“存儲”相關,而非“功能”相關。這使得它和C++的模板功能相比起來在“泛”這個程度上差了很多。

表象上來說,Java泛型和直覺中其能幹的事情所差的部分有這些:

  1. Java泛型的類型參數無法被new,無法被用於instanceof檢查(這兩者均有workaround)
  2. Java泛型的類型參數無法直接.class
  3. Java泛型無法(以符合直覺的方式)寫出諸如addsum之類的數學函數
  4. Java泛型的類型參數無法是一個泛型(即無法達成嵌套的泛型)

(如果你無法想像這幾項的應用場景,那麼有可能你並不是本文的受衆。)

前兩項表面上看令人很詫異,畢竟從直覺上這些都是理所當然的:我(程序員)明知那個T是一個“類型名”,爲什麼不可以將其用在這些需要類型名的地方;第三點其實在某種程度上已經反映了Java泛型的一些限制及機制;而最後一項雖然許多人或許用不到,但也反映了Java泛型機制的一個限制。

實際上,這幾項均是Java泛型的機制本身決定的限制,並非是什麼“額外的問題”——其主要原因在於Type Erasure,但也有部分(主要是最後一項,也有第三項)不完全歸咎於Type Erasure。

Java泛型的機制:優點與限制

“優點”主要是指和C++相比,Java泛型所能額外做的一些事情。但整體考量下來,其限制更大,而且這些優點在某種意義上有些不倫不類(甚至在部分情況下可以說是冗餘)。

在閱讀下文之前,請向自己強調一下:Java是編譯型語言(雖然是編譯到字節碼)。

Bounded Type Parameter

Java的泛型支持在聲明類型參數時同時指定其“可能”的上界或下界(通過extendssuper關鍵字來指定)。那些指明了上界或下界的類型參數被稱爲Bounded Type Parameter(受限類型參數),而未指明的則被稱爲Unbounded Type Parameter(未受限類型參數)。由於Java中所有類均默認繼承自Object類,所以未受限類型參數也可以被視爲上界爲Object受限類型參數。

abstract class MyClass {
  public void print();
}

<T extends MyClass> void func(T a) {
  a.print();
}

該機制的兩面(上界和下界)分別具有不同用處,雖然本質上有相似性但表面上(使用上)有很大不同:指明上界會限制該段程序只能對其進行讀取,而無法寫入;指明下界會限制該段程序只能對其進行寫入,而無法讀取。個人覺得Kotlin的文檔對此講得較爲清楚。

在C++處,語法上並不支持該特性。但由於C++的模板機制和Java的泛型有很大不同,所以對此的缺失在語言功能的角度上是可以接受的(但IDE的自動補全或提示功能受到的影響無法解決)。

Type Erasure

Java處理泛型的機制中一個最重要的部分叫做Type Erasure。該機制保證Java泛型僅存在於編譯期而非運行時,但同時它也是Java泛型功能限制的罪魁禍首(但似乎Java對此很滿意?)。

Type Erasure主要做三件事

  1. 將類型參數直接替換爲其約束邊界(即extends後的那個類型;沒有extends可以視爲extends Object
  2. 在需要的地方自動加入強制類型轉換(以保持類型安全)
  3. 生成橋接代碼,以便維持多態的正確執行

看起來似乎做了很多事,但實際上,前兩點直接說明了Java泛型的本質:泛型沒有增加新東西,所有的只是多態(繼承上的多態)和自動書寫的強制類型轉換;最後一點則是爲了正確進行對重寫函數的調用(橋接到重載函數上)。

所以,存在於編譯期的多態機制,在被編譯成字節碼以後就已經消失,而被替換成了我們所熟悉的強制類型轉換。這也就解釋了爲什麼我們無法對類型參數進行newinstanceof檢查或是調用.class:因爲它們(在最壞的情況下)被替換爲了Object而非我們所要(用)的那個類型,在Object上的調用並不是我們需要的。

泛型數學運算

前文提過,泛型狀態下我們無法進行數學計算,這主要歸咎於Type Erasure,但又不全歸咎於它。本節更詳細解釋這一問題。

在Java中,只有primitive type可以使用+-等算數運算符(不考慮String+代表的“連接”),但泛型類型參數只能爲類,所以天生無法調用這些算數運算符。即使我們考慮auto(un)boxing,由於Type Erasure的存在,(在泛型函數中)我們所使用的變量的類型處於未知狀態,所以該機制也無從發揮(即編譯期需要考慮:Number類(甚至是Object類)的unboxing是什麼?)。

但從另一個角度,我們會發現即使Java的Generics設定成這樣,Type Erasure存在,在對語言的其他設定進行一定變動之後泛型就可以支持數學計算了:只要Java支持運算符重載。然而看起來Java似乎並沒有打算支持運算符重載。

嵌套的泛型

對這一機制的需求源於我代碼的一種設計,該設計可以簡化爲這樣:

interface Data {
}

interface CatData extends Data {
}

interface DogData extends Data {
}

class Box<T extends Data> {
	T data;
	T getData() {
		return data;
	}
}

class CatBox<T extends CatData> extends Box<T> {
}

class DogBox<T extends DogData> extends Box<T> {
}

class PuppyData extends DogData {
}

class PuppyDogBox extends DogBox<PuppyData> {
}

class Asset<T extends Box> {
	<D extends Data> T findBox(D data) {
		// Do something
	}
}

class DogAsset extends Asset<DogBox> {
}

由於這段代碼中的DogBox實際上也是泛型,我們期望Asset(及其子類)的findBox函數的返回值攜帶類型信息,即Asset的定義可以形如這樣:

class Asset<T extends Box> {
	<D extends Data> T<D> findBox(D data) {
		// Do something
	}
}

這樣,當調用DogAsset.findBox(new PuppyData())時,其返回值類型爲DogBox<PuppyData>(即對應於PuppyDogBox),而非僅僅是DogBox

這就是本文所謂的“嵌套的泛型”。該機制在Java中並不被支持,所以在調用Asset.findBox()函數時返回值類型需要再額外進行一下限定(忽略編譯器警告)。

而就我所知,C++的模板支持這種做法(template template parameter);在查閱資料時,發現Scala也支持這種做法(higher kinded type)。

總結

Java泛型的實現約等於自動書寫強制類型轉換,並沒有給運行時增加額外特性。雖然其中有一些有意義的設計(如Bounded Type Parameter),但整體而言其缺點似乎更爲嚴重。對我目前的使用來說,其所最主要欠缺的是嵌套的泛型,類似於C++的template template parameter或Scala的higher kinded type。

atang 说:
2018年2月09日 15:15

Easer 和 Tasker 是否有类似的地方呢。

Avatar_small
人云E云 说:
2018年2月18日 22:45

應該很多方面很像吧,但我沒用過tasker,所以不確定細節或是“通用性”上有多少類似。

其實我最開始並不知道Tasker的存在,但我以前用過一個叫ProfileSwitcher的軟件,它的功能靈活性就比較差。
寫Easer的一個目的是爲了允許複雜邏輯的存在,或者說允許用戶來進行一定程度上的編程(但不是通過寫代碼的形式)。(當然另一個原因就是沒見過開源的這類軟件……)


登录 *


loading captcha image...
(输入验证码)
or Ctrl+Enter