本文由 Prefert 發表在 ScalaCool 團隊部落格。
無論在靜態語言還是動態語言中,「型別系統」都起到了至關重要的作用。
一、型別系統簡介
在電腦科學中,型別系統用於定義如何將程式語言中的數值和表示式歸類為許多不同的型別,如何操作這些型別,這些型別如何互相作用。
型別可以確認一個值或者一組值具有特定的意義和目的(雖然某些型別,如抽象型別和函式型別,在程式執行中,可能不表示為值)。
型別系統的作用
型別系統在各種語言之間存在比較大的差異。最主要的差異存在於編譯時期的語法,以及執行時期的操作實現方式。我們可以簡單理解為兩個部分:
- 一組基本型別構成的PTS(Primary Type Set,基本型別集合);
- PTS上定義的一系列組合、運算、轉換規則等。
但是他們的目的都是一致的:
1. 安全。有了型別系統以後就可以實現型別安全,這時候程式就變成了一個嚴格的數學證明過程,編譯器可以機械地驗證程式某種程度的正確性,從而杜絕很多錯誤的發生。比如:Scala、Java。但是 JavaScript 等動態語言/弱型別語言就要藉助其他外掛(如 ESLint)來提示語法等錯誤。
2. 抽象能力。在安全的前提下,一個強大的型別系統的標準是抽象能力,能將程式中的很多東西納入安全的型別系統中進行抽象,這在安全性的前提下又不損耗靈活性,甚至效能也能很優化。動態語言的抽象能力可以很強,但安全性和效能就不行了。泛型、高階函式(閉包)、型別類、Monad
、Lifetime
(Rust) 屬於這一塊。
3. 工程能力。一個強型別的程式語言比動態型別的語言更適合大規模軟體的構建,哪怕不存在效能問題,但是同樣取決於前兩點。
Hint: 想深入瞭解型別系統的朋友可以參考 《Type Systems》和 《Types and Programming》
Kotlin 作為一門靜態型別程式語言,同樣擁有著強大的型別系統。
二、Kotlin 的型別系統
你可能會對型別後面的 ?
產生疑問,那我們就先來看看 Kotlin 中的可空型別。
可空型別(Nullable Types) —— Int?
Boolean?
及其他
許多程式語言中最常見的陷阱之一是訪問空引用的成員,導致空引用異常。在 Java 中,這被稱作 NullPointerException
或簡稱 NPE
。
Kotlin 的型別系統旨在從我們的程式碼中消除 NullPointerException
。
NPE 發生的原因可能是
- 顯式呼叫
throw NullPointerException();
- 使用
!!
操作符(要求丟擲NullPointerException
) - 外部 Java 程式碼導致
- 初始化時有一些資料不一致(如一個未初始化的 this 用於建構函式的某個地方)。
與 Java 不同,Kotlin 區分非空(non-null)和可空(nullable)型別。到目前為止,我們看到的型別都是非空型別,Kotlin 不允許 null 作為這些型別的值。訪問非空型別的變數將永遠不會丟擲空指標異常。
由於 null
只能被儲存在 Java 的引用型別的變數中,所以在 Kotlin 中基本資料的可空版本都會使用該型別的包裝形式。
同樣的,如果你用基本資料型別作為泛型類的型別引數,Kotlin 同樣會使用該型別的包裝形式。
我們可以在任何型別後面加上?
,比如Int?
,實際上等同於Int? = Int or null
,通過合理的使用,我們能夠簡化很多判空程式碼。並且我們能夠有效規避 NullPointerException
導致的崩潰。
深入 Nullable Types
接下去讓我們看看,非空的原理到底怎麼樣的。
對於以下一段 Kotlin 程式碼:
fun testNullable1(x: String, y: String?): Int {
return x.length
}
fun testNullable2(x: String, y: String?): Int? {
return y?.length
}
fun testNullable3(x: String, y: String?): Int? {
return y!!.length
}
複製程式碼
我們利用 Idea 反編譯後,產生的 Java 程式碼如下:
public final class NullableTypesKt {
public static final int testNullable1(@NotNull String x, @Nullable String y) {
Intrinsics.checkParameterIsNotNull(x, "x"); // 如果為 null, 丟擲異常
return x.length();
}
@Nullable
public static final Integer testNullable2(@NotNull String x, @Nullable String y) {
Intrinsics.checkParameterIsNotNull(x, "x");
return y != null?Integer.valueOf(y.length()):null;
}
@Nullable
public static final Integer testNullable3(@NotNull String x, @Nullable String y) {
Intrinsics.checkParameterIsNotNull(x, "x");
if(y == null) {
Intrinsics.throwNpe();
}
return Integer.valueOf(y.length());
}
}
複製程式碼
可以看到,在不可空變數呼叫函式之前,都使用 kotlin.jvm.internal.Intrinsics
類裡面的 checkParameterIsNotNull
方法檢查是否為 null
,如果是 null
則丟擲異常:
public static void checkParameterIsNotNull(Object value, String paramName) {
if (value == null) {
throwParameterIsNullException(paramName);
}
}
複製程式碼
基於可空型別,Kotlin 才擁有很多促使安全的運算子。
?.
—— 安全呼叫
?.
允許我們把一次 null
檢查和一次方法的呼叫合併成一個操作,比如:
str?.toUpperCase()
等同於 if (str != null) str.toUpperCase() else null
當然,?.
同樣可以處理屬性:
class User(val nickname: String, val master: User?)
fun masterInfo(user: User): String? = user.master?.nickname
// test
val ceo = User("boss", null)
val employee = User("employee-1", ceo)
println(masterInfo(employee)) // boss
println(masterInfo(ceo)) // null
複製程式碼
?:
—— Elvis 運算子
剛開始我也不知道為什麼稱之為「Elvis 」運算子——直到我看到了這張圖...
如果你不喜歡這個名字,我們也可以叫它——「null 合併運算子」。如果你學習過 Scala,這類似於 getOrElse
:
fun getOrElse(str: String?) {
val result: String = str ?: "" // 等價於 str == null ? "" : str
}
複製程式碼
另外還有as?
(安全轉換)、!!
(非空斷言)、let
、lateinit
(延遲初始化屬性)等此處就不詳細介紹。
基本資料型別 —— Int
, Boolean
及其他
我們都知道,Java 將 基本資料型別 和 引用型別 做了區分:
- 基本資料型別,例如 int 的變數直接儲存了它的值,我們不能對這些值呼叫方法,或者把它們放到集合中。
- 引用型別的變數儲存的是指向包含該物件的記憶體地址的引用。
在 Kotlin 中,並不區分基本資料型別和包裝型別 —— 你使用的永遠是同一個型別。
數字轉換
Kotlin 中我們必須使用 顯示轉換 來對數字進行轉換,例:
fun main(args: Array<String>) {
val z = 13
println(z.toLong() in list(9L, 5L, 2L))
}
複製程式碼
如果覺得這種方式不夠簡便,你也可以嘗試使用 Kotlin 中的字面量:
- 使用字尾
L
表示Long
:123L
- 使用字尾
F
表示Float
:.123f
、1e3f
- 使用字首
0x
/0X
表示十六進位制:0xadcL
- ...
當你使用字面量去初始化一個型別已知的變數,或是把字面量作為實參傳給函式時 ,會發生隱式轉換,並且算數運算子會被過載。 例:
fun long(l: Long) = println(1)
fun main(args: Array<String>) {
val b: Byte = 1 // Int -> Byte
val l = b + 1L // 過載 plus 運算子
foo(234)
}
複製程式碼
通用型別系統 —— Any
, Any?
和 Object
作為 Java 類層級結構的頂層類似,Any
型別是 Kotlin 中 所有非空型別(ex: String
, Int
) 的頂級型別——超類。
與 Java 不同的是: Kotlin 不區分「原始型別」(primitive type)和其它的型別。它們都是同一型別層級結構的一部分。
如果定義了一個沒有指定父型別的型別,則該型別將是 Any
的直接子型別:
class Fruit(val weight: Double)
複製程式碼
如果你為定義的型別指定了父型別,則該父型別將是新型別的直接父型別,但是新型別的最終祖先為 Any
。
abstract class Fruit(val weight: Double)
class Banana(weight: Double, val size: Double): Fruit(weight)
class Peach(weight: Double, val color: String): Fruit(weight)
複製程式碼
如果你的型別實現了多個介面,那麼它將具有多個直接的父型別,而 Any
同樣是最終的祖先。
interface ICanGoInASalad
interface ICanBeSunDried
class Tomato(weight: Double): Fruit(weight), ICanGoInASalad, ICanBeSunDried
複製程式碼
Kotlin 的 Type Checker 強制執行父子關係。
例如: 你可以將子型別值儲存到父型別變數中:
var f: Fruit = Banana(weight = 0.1)
f = Peach(weight = 0.15)
複製程式碼
但是你不能將父型別值儲存到子型別變數中:
val b = Banana(weight=0.1)
val f: Fruit = b
val b2: Banana = f
// Error: Type mismatch: inferred type is Fruit but Banana was expected
複製程式碼
正好也符合我們的日常理解:“香蕉是水果,水果不是香蕉。”
另外,Kotlin 把 Java 方法引數和返回型別中用到的 Object
型別看作 Any
(更確切地是當做「平臺型別」)。當 Kotlin 函式函式中使用 Any
時,它會被編譯成 Java 位元組碼中的 Object
。
Hint: 平臺型別本質上就是 Kotlin 不知道可控性資訊的型別 —— 所有 Java 引用型別在 Kotlin 中都表現為平臺型別。
上面提到:在 Kotlin 中, Any
是所有 非空型別 的超類。
你可能會有疑問: null
型別的父類是什麼呢?
Unit —— Kotlin 裡的 void
Kotlin 是一種表示式導向的語言,所有流程控制語句都是表示式。它沒有 Java 和 C 中的 void
函式,函式總是會返回一個值。有時候函式並沒有計算任何東西 —— 這被我們稱作他們的副作用(side effect),這時將會返回 Unit
——具有單一值的型別。
大多數情況下,你不需要明確指定 Unit
作為返回型別或從函式返回 Unit
。如果編寫的函式具有塊程式碼體,並且不指定返回型別,則編譯器會將其視為返回 Unit
型別,否則編譯器會使用推斷的型別。
fun example() {
println("block body and no explicit return type, so returns Unit")
}
val u: Unit = example()
複製程式碼
Unit
並沒什麼特別之處。就像任何其他型別一樣,它是 Any
的子型別,而 Unit?
是 Any?
的子型別。
然而 Unit?
型別卻是一個奇怪的特殊例子,這是 Kotlin 的型別系統一致性的結果。Unit?
型別只有兩個值:Unit
單例和 null
。我暫時還沒發現使用 Unit?
型別的地方,但是在型別系統中沒有特殊的 void 這一事實,使得處理各種函式泛型變得更加容易。
Nothing
在 Kotlin 型別層級結構的最底層是 Nothing
型別。
顧名思義,Nothing
是沒有例項的型別。Nothing
型別的表示式不會產生任何值。
注意 Unit
和 Nothing
之間的區別,對 Unit
型別的表示式求值將返回 Unit
的單例,而對 Nothing
型別的表示式求值則永遠都不會返回。
這意味著任何型別為 Nothing
的表示式之後的所有程式碼都是無法得到執行的(unreachable code),編譯器和 IDE 會向你發出警告。
什麼樣的表示式型別為 Nothing
呢?流程控制中與跳轉相關的表示式。
例如 throw
關鍵字會中斷表示式的計算,並從函式中丟擲異常。因此 throw
就是 Nothing
型別的表示式。
通過將 Nothing
作為所有型別的子型別,型別系統允許程式中的任何表達求值失敗。例如: JVM 在計算表示式時記憶體不足,或者是有人拔掉了計算機的電源插頭。這也意味著我們可以從任何表示式中丟擲異常。
fun formatCell(value: Double): String =
if (value.isNaN())
throw IllegalArgumentException("$value is not a number")
else
value.toString()
複製程式碼
你可能會驚奇地發現,return
語句的型別也為 Nothing
。return
是一個流程控制語句,它立即從函式中返回一個值,打斷其所在表示式的求值。
fun formatCellRounded(value: Double): String =
val rounded: Long = if (value.isNaN()) return "#ERROR" else Math.round(value)
rounded.toString()
複製程式碼
進入無限迴圈或殺死當前程式的函式返回型別也為 Nothing。例如 Kotlin 標準庫將 exitProcess
函式宣告為:
fun exitProcess(status: Int): Nothing
複製程式碼
如果你編寫返回 Nothing
的自定義函式,編譯器同樣能檢查出呼叫函式後無法得到執行的程式碼,就像使用語言本身的流程控制語句一樣。
inline fun forever(action: ()->Unit): Nothing {
while(true) action()
}
fun example() {
forever {
println("doing...")
}
println("done") // Warning: Unreachable code
}
複製程式碼
與空安全一樣,不可達程式碼分析是型別系統的一個特性。無需像 Java 一樣在編譯器和 IDE 中使用一些手段進行特殊處理。
可空的 Nothing?
Nothing
像任何其他型別一樣,如果允許其為空則可以得到對應的型別 Nothing?
。Nothing?
只能包含一個值:null
。事實上 Nothing?
就是 null
的型別。
Nothing?
是所有可空型別的最終子型別,所以我們可以使用 null 作為任何可空型別的值。
三、總結
如果你還是對 Kotlin 型別系統不夠清晰,下面這張圖可能會對你有所幫助:
作為「Better Java」,Kotlin 的型別系統更加簡潔,同時為了提高程式碼的安全性、可靠性,引入了一些新的特性(ex. Nullable Types 和 Immutable Collection)。
我們將在下一篇詳細介紹 Kotlin 中的集合。
參考: