教你如何完全解析Kotlin中的型別系統

極客熊貓發表於2019-04-02

簡述: 已經很久沒有更新文章,這大概是2019年第二篇文章了,有很多小夥伴們都在公眾號留言說是不是斷更了、是不是跑路了。在這裡統一回復下我還好,並沒有跑路哈,只是在思考接下來文章主要方向在哪? 如何在提升自己的同時可以幫助他人,以及這段時間也在不斷認清自己和了解自己,發現自己哪裡不足以及如何及時地查漏補缺。下面進入正題:

Kotlin型別系統其中涉及到一個很重要的概念就是大家常說的可空性以及為什麼Kotlin相比Java在一定程度上能降低空指標異常。此外在Kotlin中完全採用和Java不同思路來定義它的型別系統。也正因為這樣型別系統天然具有讓Kotlin在空指標異常出現的頻率明顯低於Java出現的頻率的優勢。此外Kotlin考慮使用和Java完全不同型別系統,以及它是如何去做到極大相容和互操作。

一、首先思考幾個概念

在進入Kotlin型別系統之前,我們不妨先一起來思考以下幾個概念,如果不明確這幾個概念很難從根本上去理解Kotlin型別系統,以及Kotlin在型別系統方面為什麼優於Java。

  • 1、型別的本質

型別本質是什麼呢? 為什麼變數擁有型別? 這兩個問題在維基百科上給出了很好的回答. 型別實際上就是對資料的分類,決定了該型別上可能的值以及該型別的值上可以完成的操作。 需要特別去注意一下後面的闡述: "該型別上可能的值以及該型別的值上可以完成的操作。" 因為Java的型別系統其實並沒有100%符合這個規則,所以這也是Java型別系統所存在的問題,下面會做出具體的分析。

  • 2、類與型別

關於 型別估計很多開發者往往忽略它們之間的區別,因為在真正的應用場景並不會區分這麼細。我們在使用中往往會把類等同於型別,實際上是完全不同兩個東西。其實在Java中也有體現,例如List<String>、Lis<Integer>List,對於前者List<String>List<Integer>只能是型別不能說是類, 而對於List它既可以是List類也可以是型別(Java中的原生型別)。其實在Kotlin則把這個概念提升到一個更高的層次,因為Kotlin中每個類多了一個可空型別,例如String類就對應兩種型別String型別和String?可空型別。而在Java中除了泛型型別,每個類只對應一種型別(就是類的本身),所以往往被忽略。

我們可以把Kotlin中的類可分為兩大類(Java也可以這樣劃分): 泛型類非泛型類

非泛型類

先說非泛型類也就是開發中接觸最多的一般類,一般的類去定義一個變數的時候,它的實際就是這個變數的型別。例如: var msg: String 這裡我們可以說Stringmsg變數的型別是一致的。但是在Kotlin中還有一種特殊的型別那就是可空型別,可以定義為var msg: String?,這裡的Stringmsg變數的String?型別就不一樣了。所以在Kotlin中一個一般至少對應兩種型別. 所以類和型別不是一個東西。

泛型類

泛型類比非泛型類要更加複雜,實際上一個泛型類可以對應無限種型別。為什麼這麼說,其實很容易理解。我們從前面文章知道,在定義泛型類的時候會定義泛型形參,要想拿到一個合法的泛型型別就需要在外部使用地方傳入具體的型別實參替換定義中的型別形參。我們知道在Kotlin中List是一個類,它不是一個型別。由它可以衍生成無限種泛型型別例如List<String>、List<Int>、List<List<String>>、List<Map<String,Int>>

  • 3、子類、子型別與超類、超型別

我們一般說子類就是派生類,該類一般會繼承它的超類。例如: class Student: Person(),這裡的Student一般稱為Person的子類, PersonStudent的超類。

子型別和超型別定義則完全不一樣,我們從上面類和型別區別就知道一個類可以有很多型別,那麼子型別不僅僅是想子類那樣繼承關係那麼嚴格。 子型別定義的規則一般是這樣的: 任何時候如果需要的是A型別值的任何地方,都可以使用B型別的值來替換的,那麼就可以說B型別是A型別的子型別或者稱A型別是B型別的超型別。可以明顯看出子型別的規則會比子類規則更為寬鬆。那麼我們可以一起分析下面幾個例子:

教你如何完全解析Kotlin中的型別系統

注意: 某個型別也是它自己本身的子型別,很明顯Person型別的值任意出現地方,Person肯定都是可以替換的。屬於子類關係的一般也是子型別關係。像String型別值肯定不能替代Int型別值出現的地方,所以它們不存在子型別關係

再來看個例子,所有類的非空型別都是該類對應的可空型別的子型別,但是反過來說就不行,就比如Person非空型別是Person?可空型別的子型別,很明顯嘛,任何Person?可空型別出現值的地方,都可以使用Person非空型別的值來替換。其實這些我在開發過程中是可以體會得到的,比如細心的同學就會發現,我們在Kotlin開發過程,如果一個函式接收的是一個可空型別的引數,呼叫的地方傳入一個非空型別的實參進去是合法的。但是如果一個函式接收的是非空型別引數,傳入一個可空型別的實參編譯器就會提示你,可能存在空指標問題,需要做非空判斷。 因為我們知道非空型別比可空型別更安全。來幅圖理解下:

教你如何完全解析Kotlin中的型別系統

二、Java型別系統存在空指標異常的本質問題

有了上述關於型別本質的闡述,我們一起來看下Java中的一些基本型別來套用型別本質的定義,來看看有什麼問題。

  • 使用型別的定義驗證int型別:

例如一個int型別的變數,那麼表明它只能儲存int型別的資料,我們都知道它用4個位元組儲存,數值表示範圍是-2147483648 ~ 2147483647,那麼規定該型別可能存在的值,然後我們可以對該型別的值進行運算操作。似乎沒毛病,int型別和型別本質闡述契合的是如此完美。但是String型別呢?也是這樣的嗎?請接著往下看

  • 使用型別的定義驗證String型別或其他定義類對應的型別:

例如一個String型別的變數,在Java中它卻可以存在兩種值: 一個是String類的例項另一種則是null。然後我們可以對這些值進行一些操作,第一種String類例項當然允許你呼叫String類所有操作方法,但是對於第二種null值,操作則非常有限,如果你強行使用null值去操作String類中的操作方法,那麼恭喜你,你將獲得一個NullPointerException空指標異常。在Java中為了程式的健壯性,這就要求開發者對String型別的值還得需要做額外的判斷,然後再做相應的處理,如果不做額外判斷處理那麼就很容易得到空指標異常。 這就出現同一種型別變數存在多種值,卻不能得到平等一致的對待。對比上述int型別的存在的值都是一致對待,所有該型別上所有可能的值都可以進行相同的運算操作。下面接著看著一個很有趣例子:

教你如何完全解析Kotlin中的型別系統

貌似連Java中的instanceof都不承認null是一個String型別的值。這兩種值的操作也完全不一樣: 真實的String允許你呼叫它的任何方法,而null值只允許非常有限的操作。那麼Kotlin型別系統是如何解決這樣的問題的呢? 請接著往下看。

三、Kotlin型別系統如何解決問題(為什麼會設計出可空型別)

Java中的型別系統中String型別或其他自定義類的型別,貌似和型別本質定義不太符合,該型別的所有可能值卻被區別對待,存在二義性。還得額外判斷,直接問題就是給開發者帶來了額外負擔得做非空判斷,一旦處理不好就會出現空指標導致程式崩潰。這就是Java中引發空指標問題的本質。

抓住問題的本質,Kotlin做一個很偉大的舉措那就是型別的拆分,將Kotlin中所有的型別拆分成兩種: 一種是非空型別,另一種則是可空型別;其中非空型別變數不允許null值的賦值操作,換句話說就是String非空型別只存在String類的例項不存在null值,所以針對String非空型別的值你可以大膽使用String類所有相關方法,不存在二義性。 當然也會存在null情況,那就可以使用可空型別,在使用可空型別的變數的時候編譯器在編譯時期會做針對可空型別做一定判斷,如果存在可空型別的變數操作該對應類的方法,就提示你需要做額外判空處理,這時候開發者就根據提示去做判空處理了,想象下都這樣處理了,你的Kotlin程式碼還會出現空指標嗎?(但是有一點很重要就是定義了一個變數你需要明確它是可空還是非空,如果定義了可空型別你就需要對它負責,並且編譯器也會提示幫助你對它做額外判空處理。)。一起來看下幾個例子:

  • 1、非空型別變數或常量不能接收null值

    教你如何完全解析Kotlin中的型別系統

  • 2、非空型別的變數或常量中is(相當於java中instanceof)

    教你如何完全解析Kotlin中的型別系統

  • 3、可空型別的變數或常量直接操作相應方法會有明顯的編譯錯誤並提示判空操作

    教你如何完全解析Kotlin中的型別系統

然而上面那些都是Java給不了你的,所以Java程式中一般會存在三種狀態: 一種佛系判空,經常會出現空指標問題。另一種就是一股腦全部判空,可是程式碼中充斥著if-else程式碼,可讀性非常差。最後一種就是非常熟悉程式邏輯以及資料流向的開發者可以正常判斷出哪裡需要判空處理,哪裡可以不需要,這一種對開發者要求極高,因為人總是會犯錯的。

四、可空型別

  • 1、安全呼叫運算子 "?."

?.相當於判空處理,如果不為null就執行?.後面的表示式,否則就返回null

text?.substring(0,2) //相當於 if(text != null) text.substring(0,2) else null
複製程式碼

其實Kotlin為了型別判空處理可算是操碎了心,我們都知道在Java中做判空處理無非就是if-else? xxx : xxx三目運算子來實現。但是有時候出現巢狀判空的時候整個程式碼就是一個“箭頭”,可讀性就很差了。由以上例子可知?.if-else省了很多程式碼,這還無法完全顯露它的優點,下面這個例子就更加明顯了。

Java中的if-else 巢狀處理

教你如何完全解析Kotlin中的型別系統

Kotlin中的安全呼叫運算子?.鏈式呼叫處理

教你如何完全解析Kotlin中的型別系統

對比兩種方式的實現你會不會覺得Kotlin也許更適合你呢,利用?.鏈式呼叫的方式把巢狀if-else處理解開了。

  • 2、Elvis運算子 "?:"

如果?:前面表示式為null, 就執行?:後面的表示式,它一般會和?.一起使用。(注意: 它與Java中的? xxx : xxx 三目運算子不一樣) carbon (29).png

教你如何完全解析Kotlin中的型別系統

  • 3、安全型別轉化運算子 as?

如果型別轉化失敗就返回null值,否則返回正確的型別轉化後的值

val student = person as? Student//相當於 if(person is Student) person as Student else null
複製程式碼
  • 4、非空斷言運算子 !!契約(contract) 簡化非空表示式

非空斷言運算子!!, 是強制告訴編譯器這個變數的值不可能null,存在使用風險。一旦存在為null直接丟擲空指標異常

很多Kotlin開發者很厭惡這個操作符,覺得寫起來不優雅很影響程式碼的可讀性,關於如何避免在Kotlin的程式碼中使用 !! 操作符。請參考我之前的一篇文章 [譯]如何在你的Kotlin程式碼中移除所有的!!(非空斷言).

其實是非空斷言的使用場景是存在的,例如你已經在一個函式中對某個變數進行判空處理了,但是後面邏輯中再次使用到了它並且你可以確定它不可能為空,可能此時編譯器無法識別它是否是非空,但由於它又是一個可空型別,那麼它又會提示你進行判空處理,很煩人是不,很多人這時候可能就採用了 !! 確實缺乏可讀性。

針對上述問題,除了之前文章中給出解決方案,這次又提供一個新的解決方案,那就是契約(實際上主動告訴編譯器某個規則,這樣它就不會提示做判空處理了) 契約官方正式提出來是Kotlin1.3的版本,雖然還處於Experimental(比如自定義契約)中,但是實際上Kotlin內部程式碼,早就使用了契約。具體使用可參考我之前的一篇文章 JetBrains開發者日見聞(二)之Kotlin1.3的新特性(Contract契約與協程篇) 一起來看下內建契約是如何解決這個問題的。

教你如何完全解析Kotlin中的型別系統
一起來瞅瞅內建契約的內部實現原始碼
教你如何完全解析Kotlin中的型別系統

通過上述我們可以知道在Kotlin中擁有著與Java中完全不一樣的型別系統。在Java中是不存在所謂的可空型別和非空型別。但是我們都知道Kotlin與Java的互操性很強,幾乎是完全相容Java。那麼Kotlin是如何相容Java中的變數型別的呢?我們在Kotlin中肯定需要經常呼叫Java程式碼,有的人可能會回答說Java中使用@NotNull和@Nullable註解來標識。確實Kotlin可以識別多種不同風格的註解,包括javax.annotationandroid.support.annotationorg.jetbrains.annotation等。但是一些之前的第三方庫並沒有寫的這麼規範,顯然無法通過這種方式完全解決這個問題。

所以Kotlin引入一種新的概念叫做: 平臺型別,平臺型別本質上就是Kotlin不知道可空性資訊的型別,既可以把它當做可空型別又可以把它當做非空型別。 這就意味你要像Java程式碼中一樣對你在這個型別上做的操作負全部責任,說的有味道點就是你在Java中拉的便便,Kotlin是不會給你擦屁股的。所以對於Java中函式引數,Kotlin去呼叫的時候系統預設會處理可空型別(為了安全性考慮),如果你明確了不為空,可以直接把它修改為非空型別,系統也是不為報編譯錯誤的,但是一旦這樣處理了,你必須保證不能為空。

教你如何完全解析Kotlin中的型別系統

那麼問題來了,很多人就疑問出於安全性考慮為什麼不直接全部轉化可空型別呢? 實際上這種方案看似可行,實際上有點不妥,對於一些明確不可能為空的變數還需要做大量額外的判空操作就顯得冗餘。否則非空型別就沒有存在的意義了。

五、基本資料型別和其他基本型別

  • 1、基本資料型別

我們都知道在Java中針對基本資料型別和包裝型別做了區分。例如一個基本資料型別int的變數直接儲存了它的值。而一個引用型別(包裝型別) String的變數僅僅儲存的是指向該物件的記憶體地址的引用。基本資料型別有著天然的高效儲存以及傳遞的優勢,但是不能直接呼叫這些型別的方法,而且在Java中集合中不能將它作為泛型實參型別。

實際上在Kotlin中並沒有像Java那樣分為了基本資料型別和包裝型別,在Kotlin中永遠是同一種型別。很多人估計會問了既然在Kotlin中基本資料型別和包裝型別是一樣的,那麼是不是意味著Kotlin是使用引用型別來儲存資料呢?是不是非常低效呢?不是這樣的,Kotlin在執行時儘量會把Int等型別轉換成Java中的int基本資料型別,而遇到類似集合或泛型的時候就會轉化成Java中對應的Integer等包裝型別。這實際上是一個底層優化,至於什麼場景轉化成int,什麼場景轉化成Integer,關於這塊可以參考之前一篇有關內聯類自動裝箱和拆箱的文章: [譯]Kotlin中內聯類的自動裝箱和高效能探索(二)

基本資料型別也分為可空型別和非空型別, 具體可參考如下的型別層次結構圖:

教你如何完全解析Kotlin中的型別系統

  • 2、Any和Any?型別

Any型別是所有非空型別的超型別,Any?型別則是所有的型別的超型別,即是非空型別的超型別也是所有可空型別的超型別。因為Any?是Any的超型別。具體的層次可參考下面這張圖:

教你如何完全解析Kotlin中的型別系統

  • 3、Unit型別

Unit型別也即是Kotlin中的空型別,相當於Java中的void型別,預設情況下它可以被省略

  • 4、Nothing型別

Nothing型別是所有型別的子型別,它既是所有非空型別的子型別也是所有可空型別的子型別,因為Nothing是Nothing?的子型別,然而Nothing?又是所有可空型別的子型別。 具體可以看下如下的層次結構圖:

教你如何完全解析Kotlin中的型別系統

六、集合和陣列型別

  • 1、可變集合與只讀集合之間的區別和聯絡(以Collection集合為例) Collection只讀集合與MutableCollectio可變集合區別:

在Collection只具有訪問元素的方法,不具有類似add、remove、clear之類的方法,而在MutableCollection中則相比Collection多出了修改元素的方法。

Collection只讀集合與MutableCollectio可變集合聯絡:

MutableCollection實際上是Collection集合介面的子介面,他們之間是繼承關係。

教你如何完全解析Kotlin中的型別系統

  • 2、集合之間類的關係

通過Collection.kt檔案中可以瞭解到有這些集合Iterable(只讀迭代器)和MutableIterable(可變迭代器)、Collection和MutableCollection、List和MutableList、Set和MutableSet、Map和MutableMap。那麼它們之間的類關係圖是怎樣的。

Iterable和MutableIterable介面分別是隻讀和可變集合的父介面,Collection繼承Iterable然後List、Set介面繼承自Collection,Map介面比較特殊它是單獨的介面,然後MutableMap介面是繼承自Map.

教你如何完全解析Kotlin中的型別系統

  • 3、Java中的集合與Kotlin中集合對應關係

我們剛剛說到在Kotlin中集合的設計與Java不一樣,但是每一個Kotlin的介面都是其對應的Java集合介面的一個例項,也就是在Kotlin中集合與Kotlin中的集合存在一定的對應關係。Java中的ArrayList類和HashSet類實際上Kotlin中的MutableList和MutableSet集合介面的實現類。把這種關係加上,上面的類關係圖可以進一步完善。

教你如何完全解析Kotlin中的型別系統

  • 4、集合的初始化

由於在Kotlin中集合主要分為了只讀集合和可變集合,那麼初始化只讀集合和可變集合的函式也不一樣。以List集合為例,對於只讀集合初始化一般採用listOf()方法對於可變集合初始化一般採用mutableListOf()或者直接建立ArrayList<E>,因為mutableListOf()內部實現也是也還是採用建立ArrayList,這個ArrayList實際上是Java中的java.util.ArrayList<E>,只不過在Kotlin中使用typealias(關於typealias的使用之前部落格有過詳細介紹)取了別名而已。關於具體內容請參考這個類kotlin.collections.TypeAliasesKt實現

  • 5、集合使用的注意事項

注意點一: 在程式碼的任何地方都優先使用只讀集合,只在需要修改集合的情況下才去使用可變集合

注意點二: 只讀集合不一定是不可變的,關於這個只讀和不可變類似於val的只讀和不可變原理。

注意點三: 不能把一個只讀型別的集合作為引數傳遞給一個帶可變型別集合的函式。

  • 6、平臺型別的集合轉化規則

正如前面所提及的可空性平臺型別一樣,Kotlin中無法知道可空性資訊的型別,既可以把它當做可空型別又可以把它當做非空型別。集合的平臺型別和這個類似,在Java中宣告的集合型別的變數也被看做平臺型別一個平臺型別的集合本質上就是可變性未知的集合,Kotlin中可以把它看做是隻讀的集合或者是可變的集合. 實際上這都不是很重要,因為你只需要根據你的需求選擇即可,想要執行的所有操作都能正常工作,它不像可空性平臺存在額外判斷操作以及空指標風險。

注意: 可是當你決定使用哪一種Kotlin型別表示Java中集合型別的變數時,需要考慮以下三種情況:

  • 1、集合是否為空?

如果為空轉換成Kotlin中集合後面新增 ?,例如Java中的List<String>轉化成Kotlin中的List<String>?

  • 2、集合中的元素是否為空?

如果為空轉換成Kotlin中集合泛型實參後面新增 ?,例如Java中的List<String>轉化成Kotlin中的List<String?>

  • 3、操作方法會不會修改集合?(集合的只讀或可變)

如果是隻讀的,例如Java中的List<String>轉化成Kotlin中的List<String>;如果是可變的,例如Java中的List<String>轉化成Kotlin中的MutableList<String>.

注意: 當然上面三種情況可以一種或多種同時出現,那麼轉化成Kotlin中的集合型別也是多種情況最終重組的型別。

七、總結

到這裡有關Kotlin的型別系統基本就說得差不多,該涉及到的內容基本都涉及了。其實仔細去體會下為什麼Kotlin的型別系統要如此設計,確實是它一定道理的。我們經常聽別人誇Kotlin比Java優點是啥,很多人都說少了很多空指標異常,但是為什麼能Kotlin相比Java有更少的空指標異常相信這篇文章也足夠回答你了吧。

接下來再扯點別的大家都知道Android開發已經進入了一個平穩期了, 泡沫逐漸散去, 那麼對Android開發者的要求也會越來越高,只會使用的API時代早已經過去了,所以開發者需要不斷調整自己不斷提升自己的能力來面對這些變化。分析過原始碼的小夥伴就知道看懂原始碼其中最關鍵點就是原始碼中使用的資料結構演算法以及使用一些高階的設計模式。正因為這樣後期文章方向會針對資料結構演算法、設計模式、原始碼分析這塊做一定輸出,近期計劃是每週一篇Kotlin相關文章(原創或翻譯),每週一篇設計模式相關和每週一篇資料結構演算法相關(結合LeetCode上的題目)

教你如何完全解析Kotlin中的型別系統

歡迎關注Kotlin開發者聯盟,這裡有最新Kotlin技術文章,每週會不定期翻譯一篇Kotlin國外技術文章。如果你也喜歡Kotlin,歡迎加入我們~~~

Kotlin系列文章,歡迎檢視:

原創系列:

Effective Kotlin翻譯系列

翻譯系列:

實戰系列:

相關文章