起源
泛型程式設計是一種程式設計風格,其中演算法以儘可能抽象的方式編寫,而不依賴於將在其上執行這些演算法的資料形式。這個概念在1989年首次由David Musser和Alexander A. Stepanov提出[1.參考]。
2011年,Alexander A. Stepanov和他的同事Deniel E. Rose出版的From Mathematics to Generic Programming一書中文版已出版對泛型程式設計進行更為精確的定義。
泛型程式設計是一種專注於對演算法及其資料結構進行設計的程式設計方式,它使得這些演算法即資料結構能夠在不損失效率的前提下,運用到最為通用的環境中。
泛型程式設計的提出者
泛型這個詞並不是通用的,在不同的語言實現中,具有不同的命名。在Java/Kotlin/C#中稱為泛型(Generics),在ML/Scala/Haskell中稱為Parametric Polymorphism,而在C++中被叫做模板(Template),比如最負盛名的C++中的STL。任何程式設計方法的發展一定是有其目的,泛型也不例外。泛型的主要目的是加強型別安全和減少強制轉換的次數。
Java中的泛型程式設計
在Java中有泛型類和泛型方法之分,這些都是表現形式的改變,實質還是將演算法儘可能地抽象化,不依賴具體的型別。
generics add a way to specify concrete types to general purposes classes and methods that operated on Object before
通用的類和方法,具有代表性的就是集合類。在Java1.5之前,Java中的泛型都是透過單根繼承的方式實現的。比如:
public class ArrayList // before Java SE 5.0
{
public Object get(int i)
public void add(Object o)
public boolean contains(Object o);
private Object[] elementData;
}
雖然演算法足夠通用了,但是這樣會帶來兩個問題。一個是型別不安全,還有一個是每次使用時都得強制轉化。減少型別轉換次數比較容易理解,在沒有泛型(引數化型別)的時候,裝進容器的資料,其型別資訊丟失了,所以取出來的時候需要進行型別轉換。 例如:
List list = new ArrayList();
list.add(1);
assertThat(list.get(0), instanceOf(Integer.TYPE));
assertThat((Integer)list.get(0), is(1)); //存在強制轉換
因為這個類裡只有Object的宣告,所以任意型別的物件都可以加入到這個集合當中,在使用過程中就會存在強制到具體的型別失敗的問題,這將喪失編譯器檢查的好處。
List list = new ArrayList();
list.add(1);
list.add("any type");
assertThat(list.get(1), instanceOf(String.class));
assertThat((Integer) list.get(1), is(1));//-> java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer
2005 Java SE 5引入了泛型,不僅有效地提高了演算法的通用程度,同時也保留強型別語言在編譯期檢查的好處。
Generics This long-awaited enhancement to the type system allows a type or method to operate on objects of various types while providing compile-time type safety. It adds compile-time type safety to the Collections Framework and eliminates the drudgery of casting.
所以上述的程式會寫成這樣:
List<Integer> list = new ArrayList<Integer>();
list.add(1);
// list.add("no way"); 編譯出錯
assertThat(list.get(0), instanceOf(Integer.TYPE));
assertThat(list.get(0), is(1)); // 不需要強制轉換
型別安全
在靜態強型別語言中,編譯期間的檢查非常重要,因為它可以有效地避免低階錯誤。這些低階錯誤就是型別安全解決的問題。型別安全包含了賦值安全和呼叫安全。其底層實質上就是在某塊記憶體中,始終存在被同種型別的指標指向。
- 型別賦值檢查
long l_num = 1L;
int i_num = l_num; // 編譯錯誤
在強型別的語言當中,型別不一致是無法互相賦值的。
\2. 型別呼叫檢查 Clojure就是一門強型別語言,而且還是一門函式式語言,所以重新賦值不被允許,它的型別安全表現在針對型別的呼叫安全。
user=> (+ "" 1)
...
java.lang.ClassCastException: java.lang.String cannot be castto java.lang.Number
這裡存在一個隱式型別轉化的過程,但是由於String無法轉化成Number,所以方法呼叫失敗。由於Clojure是動態語言,所以只有在執行時才會丟擲錯誤。
另一個簡單的例子,如果一個型別不存在某個方法,那就沒法去呼叫它。在動態強型別語言中,執行時一定會報錯。其實質是型別是記憶體堆上的一塊區域,如果該區域之上沒有想要呼叫的方法,那麼呼叫在編譯期或者執行期間一定會出錯。
new Object().sayNothing() // 編譯出錯
為什麼說型別安全對於開發人員友好,這個特性對於程式語言很重要?其實這可以追溯到三次程式設計正規化解決的根本問題上。Clean Architecture(架構整潔之道)一書中,對結構化,面向物件和函數語言程式設計語言做了很透徹的分析。
首先我們得明確一點,這些正規化從來沒有擴充套件程式語言的能力,而是在不同方面對程式語言的能力進行了約束。
- 結構化程式設計 對程式的直接控制進行約束和規範,goto considered harmful.
- 面向物件程式設計 對程式的間接控制進行約束和規範,pointer considered harmful.
- 函數語言程式設計 對程式的賦值進行約束和規範,mutability considered harmful.
按照這樣的思路,泛型程式設計無非是對既有的正規化做了進一步的約束。泛型程式設計旨在對程式的間接控制進一步進行約束和規範。它把型別安全放在第一位,而將型別轉化限制在編譯期間。
我們甚至可以遵循前面的定義方式,說: 2.1 泛型程式設計 對程式的間接控制進一步進行約束和規範,type casting considered harmful.
Kotlin中的泛型程式設計
variance - 變化
和Java泛型中的泛型方法和泛型類概念類似,Kotlin將對應的概念稱為引數化函式和引數化型別。
parameterized function 引數化函式
假設我們要返回三個物件中任一一個物件,同時保證型別一致。引數化函式是很恰當的選擇。
fun <T> random(one: T, two: T, three: T): T
parameterized type 引數化型別
除了引數化函式,型別本身也可以定義自己的引數化型別。比如:
class Dictionary<K, V>
bounded polymorphism 限定引數化型別
大部分情況下,引數化型別不會是無限抽象的,無限抽象往往不利於語言的表達性。所以限定的引數化型別應運而生。
fun <T : Comparable<T>> min(first: T, second: T): T {
val k = first.compareTo(second)
return if (k <= 0) first else second
}
如果需要用多個邊界來限定型別,則需要用到where語句,表達T被多個邊界類或者介面限制。
class MultipleBoundedClass<T> where T : Comparable<T>, T : Serializable
invariance 不變
open class Animal
class Dog : Animal()
class Cat : Animal()
class Box<T>(val elements: MutableList<T>) {
fun add(t: T) = elements.add(t)
fun last(): T = elements.last()
}
fun foo(box: Box<Animal>){
}
val box = Box(mutableListOf(Dog()))
// -> val box: Box<Dog> = Box(mutableListOf(Dog()))
box.add(Dog()) // ok
box.add(Cat()) // 編譯錯誤
這裡出現的編譯錯誤,原因是box的真實型別是Box<Dog>,所以嘗試向中新增Cat物件是不會成功的。這樣總能保證型別安全。
Dog是Animal的子型別,那麼編譯器是否承認是Box<Animal>的子型別,在使用時進行隱式轉換呢?
val box: Box<Animal> = Box(mutableListOf(Dog()))
// type inference failed. Expected type mismatch.
編譯器是不會允許這樣行為發生。原因就是這樣做會導致型別不安全。我們試想一下,假如這種轉換是允許的,也就是說,我們承認了是的子類,那麼我們就可以繼續新增其它繼承了的子類物件,比如:
val box: Box<Animal> = Box(mutableListOf(Dog())
box.add(Cat()) // 由於對外的介面是 Animal,所以意外接收的是 Cat,但是例項方法要求的是 Dog。
這樣就導致子類的例項方法add(t: Dog)(注意,這裡的 T 被具現化成了 Dog)方法接收了一個型別的引數,這顯然會丟擲ClassCastException,所以這是非型別安全的。編譯器不會容許這種事情發生,所以報出編譯錯誤,如下:
val box: Box<Dog> = Box(mutableListOf(Dog()))
val animalBox: Box<Animal> = box // 編譯錯誤
還有一種情況,dog是的子型別,那麼可不可以是的子型別呢?即:
val box: Box<Dog> = Box(mutableListOf(Animal())
val dog: Dog = box.last() //由於對外的介面是 Dog,所以返回的是 Dog,但是例項方法要求的是 Animal。
如此會導致子類的例項方法last(): Animal方法返回了一個型別的返回值,但是不是所有的 Animal 都是 Dog,這也會導致,所以編譯器會阻止這種使用方式,報出編譯錯誤,如下:
val box: Box<Animal> = Box(mutableListOf(Animal()))
val dogBox: Box<Dog> = box // 編譯錯誤
不過,總的來說,不變的限制太過於嚴苛了,泛型的設計既要考慮型別安全性,也要對靈活性有所關照。
對於前一種情況,我們只需要從這個box“讀取”元素,即泛型引數作為方法的返回值,而不需要往裡面“新增”元素,即泛型引數不能作為方法的引數,那麼這種轉換就是型別安全的。而後一種情況,我們規定只能往box裡“新增”元素,但不“讀取”元素,那麼也能到達同樣的目的。故而,協逆變出現了。
covariance 協變
如上圖所示,當是的子型別,那麼也是的子型別,這種繼承關係就是協變。在Kotlin中,我們需要使用out關鍵字表示這種關係。
class CovarianceBox<out T : Animal>(val elements: MutableList<out T>) {
fun add(t: T) = elements.add(t) //編譯錯誤
fun last(): T = elements.last()
}
基於這種協變關係,我們可以這樣呼叫
val dogs: CovarianceBox<Dog> =CovarianceBox(mutableListOf(Dog(), Dog()))
val animals: CovarianceBox<Animal> = dogs
print(animals.last())
我們注意上面的CovarianceBox的add方法出現了編譯錯誤,原因就是在協變關係中,泛型引數只能作為輸出引數,而不能作為輸入引數。因為在拒絕了輸入泛型引數的前提下,協變發生的時候,才不會出現強制轉化的錯誤,這裡的原因和不變的第一種情況是一樣的。
不過,這種解決方式也不是萬能的,屬於殺敵一千,自損八百的戰術。因為對於Collection而言,不可能做到任何泛型引數都不會出現在入參的位置上。
public interface Collection<out E> : Iterable<E> {
public operator fun contains(element: @UnsafeVariance E): Boolean
public fun containsAll(elements: Collection<@UnsafeVarianceE>): Boolean
}
所以,針對這種情況,我們知道某些方法其實並不會有新增的操作,可以在入參的位置上加上@UnsafeVariance,以此消除掉編譯器的錯誤。
contravariance 逆變
contravariance 逆變
當Dog是Animal的子型別,那麼Box<Animal>也是Box<Dog>的子型別,這種繼承關係就是逆變。在Kotlin中,我們需要使用in關鍵字表示這種關係。
class ContravarianceBox<in T>(val elements: MutableList<inT>) {
fun add(t: T) = elements.add(t)
fun first(): T = elements.first() // 編譯錯誤
}
基於這種逆變關係,我們可以這樣呼叫
val animals = ContravarianceBox(mutableListOf(Animal()))
val dogs: ContravarianceBox<Dog> = animals
dogs.add(Dog()) // 編譯透過
這個時候,型別始終是安全的。但是我們也注意到ContravarianceBox的first方法出現了編譯錯誤,原因就是在逆變關係中,泛型引數只能作為輸入引數,而不能作為輸出引數。在拒絕了輸出引數的前提下,逆變發生的時候,才不會出現強制轉換的錯誤,具體理由同上述的不變原因一致。
val animals = ContravarianceBox(mutableListOf(Animal()))
val dogs: ContravarianceBox<Dog> = animals
dogs.add(Dog())
val dog: Dog = dogs.first() // 編譯錯誤
reification 變現
reify is To convert mentally into a thing; to materialize.
Kotlin中的Reification的實現使用的是inline模式,就是在編譯期間將型別進行原地替換。
// 定義
inline fun <reified T : Any> loggerFor(): Logger =LoggerFactory.getLogger(T::class.java)
// 使用
private val logger = loggerFor<AgreementFactory>()
因此,所以原來呼叫處的程式碼會在編譯期間展開成如下:
private val logger = LoggerFactory.getLogger(AgreementFactory::class.java)
使用reification操作,可以精簡掉很多模板程式碼。
type projection 型別投影
type projection 型別投影
上述過程中,我們看到協變和逆變都是針對可以編輯的類。但是如果遇到已經存在的類,這件事就得運用型別投影技術。拿Class這個類舉例:
val dog = Dog::class.java
val animal: Class<Animal> = dog //編譯不透過
Kotlin中的type projection就是為了解決這個問題的。
val dog = Dog::class.java
val animal: Class<out Animal> = dog
同理,
val animal = Animal::class.java
val dog: Class<in Dog> = animal
我們來看一個真實的場景,
val agreementClass: Class<RentalAgreement> =RentalAgreement::class.java
private val virtualTable = mapOf(RentalPayload.type toRentalAgreement::class.java)
private fun dispatch(type: String): Class<outAgreement<Payload>> {
return virtualTable[type]
?: throw RuntimeException("No suitable Agreement of this type found, please check your type: $type")
}
只有這樣,我們才能將具體的Class<RentalAgreement>投射到Class<out Agreement<Payload>>父型別之上,後續透過某種方式,例項化出RentalAgreement的例項,其繼承自Agreement<Payload>。
泛型程式設計的思考
過程式程式碼 vs. 面向物件
Bob 大叔的 Clean Code 一書的第六章《物件和資料結構》中提到了一個很有意思的現象:資料、物件的反對稱性。在這裡,資料結構暴露資料,沒有提供有意義的函式;物件把資料隱藏起來,暴露操作資料的函式。
過程式程式碼會基於資料結構進行操作。例如:首先會定義好資料結構Square, Circle和Triangle,然後統一在area(shape: Any)的函式中求shape資料的面積,如:
fun area(shape: Any): Double {
return when(shape) {
is Square -> return shape.side * shape.side
else -> 0.0
}
}
而面向物件擁躉一定會嗤之以鼻——顯然應該抽象出一個shape類包含area方法,讓其它的形狀類繼承。如:
interface Shape {
fun area(): Double
}
class Square(val side: Double) : Shape {
override fun area(): Double {
return side * side
}
}
在新增新的形狀的要求下,面向物件的程式碼是優於過程式的,因為面向物件對型別的擴充套件開放了。而過程式程式碼卻不得不修改原來area方法的實現。
但是,如果此時需要新增一個求周長primeter的函式。相對於面向物件程式碼,過程式程式碼由於無需修改原來的實現,反而更加容易擴充套件。反觀面向物件的程式碼,在介面Shape中新增一個primeter會導致所有的子類都得發生修改。
這就是資料和型別的反對稱性。在變化方向不同的時候,它們面臨的阻力也是不一樣的。
隔離阻抗
我們既想要過程式對方法擴充套件的優點,又執著面向物件自然的型別擴充套件的好處,該怎麼辦呢?可以考慮結合起來使用。
這樣的結合不是說原有的雙向阻力消失了,而是在不同的層次上應用各自的優點。也就是說,Shape需要求面積、周長,同時也要支援型別擴充套件,這種要求之下,基本不可能調解出一種符合開閉原則的方案。不過,如果對於所有Shape類,都需要統一進行某些操作,例如:集合的排序,過濾等等。那麼合併兩者的好處就變得顯著起來。
泛型補充
基於最先分析的透過繼承的方式進行泛型程式設計的缺點:
- 太多強制轉換
- 非型別安全。 恰當地引入了泛型T,以期編譯期的佔位和執行時的替換。
泛型限定
不過沒有限定的泛型大部分情況下是沒有用處的,因為無限的抽象沒有意義,所以需要更加精準的泛型限定。
依賴倒置
在我們做完這一切以後,會驚喜地發現依賴倒置(DIP)原則貫穿始終。不論是繼承體系,還是改善之後的泛型繼承體系。它們秉持的原則就是在編譯期,始終朝著穩定、抽象的方向移動,而且不斷在易變、具體的方向延遲決策,直到執行時方能確定。
書籍推薦
書籍推薦
腦圖
知識梳理
參考連結
泛型 一個會寫詩的程式設計師
可執行程式碼示例