Kotlin教程(五)型別

胡奚冰發表於2018-03-29

寫在開頭:本人打算開始寫一個Kotlin系列的教程,一是使自己記憶和理解的更加深刻,二是可以分享給同樣想學習Kotlin的同學。系列文章的知識點會以《Kotlin實戰》這本書中順序編寫,在將書中知識點展示出來同時,我也會新增對應的Java程式碼用於對比學習和更好的理解。

Kotlin教程(一)基礎
Kotlin教程(二)函式
Kotlin教程(三)類、物件和介面
Kotlin教程(四)可空性
Kotlin教程(五)型別
Kotlin教程(六)Lambda程式設計
Kotlin教程(七)運算子過載及其他約定
Kotlin教程(八)高階函式
Kotlin教程(九)泛型


基本資料型別

Java把基本資料型別和引用型別做了區分。一個基本資料型別(如int)的變數直接儲存了它的值,而一個引用型別(如String)的變數儲存的是指向包含該物件的記憶體地址的引用。
基本資料型別的值能夠更高效地儲存和傳遞,但是你不能對這些值呼叫方法,或是把他們存放在集合中。Java提供了特殊的包裝型別(如Integer)在你需要物件的時候對基本資料型別進行封裝。因此,你不能用Collection<int> 來定義一個整數的集合,而必須用Collection<Integer> 來定義。
Kotlin並不區分基本資料型別和包裝型別,你使用的永遠是同一個型別(如Int):

val i: Int = 1
val list: List<Int> = listOf(1, 2, 3)
複製程式碼

這樣很方便。此外,你還能對一個數字型別的值呼叫方法。例如下面使用了標準庫的函式coerceIn 來把值限制在特定的範圍內:

fun showProgress(progress: Int) {
    val percent = progress.coerceIn(0, 100)
    println("We're $percent% done!")
}

>>> showProgress(146)
We're 100% done!
複製程式碼

如果基本資料型別和引用型別是一樣的,是不是意味著Kotlin使用物件來表示所有的數字是非常低效的?確實低效,所以Kotlin並沒有這樣做。
在執行時,數字型別會盡可能地使用最高效的方式來表示。大多數情況下Kotlin的Int型別會被編譯成Java基本資料型別int。當然,泛型、集合之類的還是會被編譯成對應的Java包裝型別。
像Int這樣的Kotlin型別在底層可以輕易地編譯成對應的Java基本資料型別,因為兩種型別都不能儲存null引用。反過來也一樣,當你在Kotlin中使用Java宣告時,Java基本資料型別會變成非空型別(而不是平臺型別,平臺型別詳見《Kotlin教程(四)可空性》),因為他們不能持有null值。

可空的基本資料型別

Kotlin中的可空型別(如Int?)不能用Java的基本資料型別表示,因為null只能被儲存在Java的引用型別的變數中。這意味著任何時候只要使用了基本資料型別的可空版本,它就會編譯成對應的包裝型別Int? -> Integer

/* Kotlin */
class Dog(val name: String, val age: Int? = null)

/* Java */
Dog dog = new Dog("julie", 3);
Integer age = dog.getAge();
複製程式碼

可以看到val age: Int? 在Java中使用編譯成了Integer,因此,在Java中使用的時候需要注意可能為null的情況。當然在Kotlin中也需要使用?.!! 等安全呼叫方式。

數字轉換

Kotlin和Java之間一條重要的區別就是處理數字轉換的方式。Kotlin不會自動地把數字從一段型別轉換成另外一種,即便是轉換成範圍更大的型別。

val i = 1
val l: Long = i //錯誤:型別不匹配
複製程式碼

必須顯示進行轉換:

val i = 1
val l: Long = i.toLong()
複製程式碼

每一種基本資料型別(Boolean除外)都定義有轉換函式:toByte()toShort()toChar()等。這些函式支援雙向轉換:即可以把小範圍的型別擴充套件到大範圍Int.toLong(),也可以把大範圍的型別擷取到小範圍Long.toInt(),當然於Java中類似首先要確保大範圍的型別的值超過小範圍上限。
為了避免意外發生,Kotlin要求轉換必須是顯式的,尤其是在比較裝箱值的時候。比較兩個裝箱值的equals方法不僅會檢查他們儲存的值,還要比較裝箱型別。在Java中new Integer(42).equals(new Long(42)) 會返回false。假設Kotlin支援隱式轉換,你可能會這樣寫:

val x = 1
val list = listOf(1L, 2L, 3L)
x in list  //假設Kotlin支援隱式轉換的話返回false
複製程式碼

但這與我們期望是不同的。因此,x in list 這行程式碼根本不會編譯。Kotlin要求你顯式轉換型別,這樣只有型別相同的值才能比較:

>>> val x = 1
>>> println(x.toLong() in listOf(1L, 2L, 3L))
true
複製程式碼

如果程式碼中同時用到了不同的數字型別,你就必須顯式的轉換這些變數,來避免意想不到的行為。

基本資料型別字面值
Kotlin除了支援簡單的十進位制數字之外,還支援下面這些在程式碼中書寫數字字面值的方式:

  • 使用字尾L表示Long型別的字面值:123L
  • 使用標準浮點數表示Double字面值:0.12, 2.0, 1.2e10, 1.2e-10
  • 使用字尾F表示Float字面值:123.4f, .456F,1e3f
  • 使用字首0x或者0X表示十六進位制字面值:0xCAFEBABE, 0xbcdL
  • 使用字首0b或者0B表示二進位制字面值:0b000000101

注意,Kotlin 1.1 才開始支援數字字面值中下劃線。對字元字面值來說,可以使用和Java幾乎一樣的語法。把字元寫在單引號中,必要時還可以使用轉義序列:'1' ,'\t'(製表符), '\u0009'(使用Unicode轉義序列表示的製表符)。

當你書寫數字字面值的時候一般不需要使用轉換函式。算數運算子也被過載了,他們可以接收所有適當的數字型別:

fun foo(l: Long) = println(l)

>>> val b: Byte = 1 
>>> val l = b + 1L   //Byte + Long -> Long
>>> foo(42)  //42被當做是Long型別
42
複製程式碼

Kotlin標準庫提供了一套相似的擴充套件方法,用來把字串轉換成基本資料型別:"42".toInt() 。每個這樣的函式都會嘗試吧字串的內容解析成對應的型別,如果解析失敗則丟擲NumberFormatException。

Any 和 Any? :根型別

和Object作為Java類層級結構的根差不多,Any型別是Kotlin所有非空型別的超型別,如果可能持有null值,則是Any?型別。在底層,Any型別對應java.lang.Object。Kotlin吧Java方法引數和返回型別中用到Object型別看做Any(更切確地說是平臺型別,因為其可空性未知)。當Kotlin函式使用Any時,它會被編譯成Java位元組碼的Object。

/* Kotlin */
fun a(any: Any) {}

/* 編譯成的Java */
public static final void a(@NotNull Object any) {}
複製程式碼

所有Kotlin類都包含下面三個方法:toString、equals、hashCode。這些方法都繼承自Any。Any並不能使用其他Object的方法(如wait和notify),如果你確認想用這些方法,可以通過手動把值轉換成Object來呼叫這些方法。

Unit型別:Kotlin的void

Kotlin中的Unit型別完成了Java中的void一樣的功能。當函式沒有什麼有意思的結果要返回時,它可以用作函式的返回型別:

fun f(): Unit {}
複製程式碼

在教程(一)中,我們就說到Unit可以直接省略:fun f() {}
大多數情況下,你不會留意到void和Unit之間的區別。如果你的Kotlin函式使用Unit作為返回型別並且沒有重寫泛型函式,在底層它會被編譯成舊的void函式。 那麼Kotlin的Unit和Java的void到底有什麼不一樣呢?Unit是一個完備的型別,可以作為型別引數,而void卻不行。只存在一個值是Unit型別,這個值也叫做Unit,並且在函式中會被隱式返回(不需要再顯示return null)。當你在重寫返回泛型引數的函式時這非常有用,只需要讓方法返回Unit型別的值:

interface Processor<T> {
    fun process(): T
}

class NoResultProcessor : Processor<Unit> {
    override fun process() {
        //do something 不需要顯式return
    }
}
複製程式碼

和Java對比一下,Java中為了解決使用“沒有值”作為引數型別的任何一種可能解法,都沒有Kotlin的解決方案這樣漂亮。一種選這是使用分開的介面定義來區別表示需要和不需要返回值的介面。另一種是用特殊的Void型別作為型別引數。即便選擇了後者,還是需要加入一個return null; 語句來返回唯一能匹配這個型別的值,因為只要返回型別不是void,你就必須始終有顯式餓return語句。
你也許會奇怪為什麼Kotlin選擇使用一個不一樣的名字Unit而不是把它叫做Void。在函數語言程式設計語言中,Unit這個名字習慣上被用來表示“只有一個例項”,這正式Kotlin的Unit和Java的void的區別。Kotlin本可以沿用Void這個名字,但是還有一個Nothing的型別,它有著完全不同的功能。Void和Nothing兩種型別的名字含義如此相近,會令人困惑。

Nothing型別:這個函式永不返回

對某些Kotlin函式來說,返回型別的概念沒有任何意義,因為他們從來不會成功地結束,例如,許多測試庫中都有一個叫做fail的函式,它通過丟擲帶有特定訊息的異常來讓當前測試失敗。一個包含無線迴圈的函式也永遠不會成功地結束。
當分析呼叫這樣函式的程式碼時,知道函式永遠不會正常終止時很有幫助的。Kotlin使用一種特殊的返回型別Nothing來表示:

fun fail(message: String): Nothing {
    throw IllegalStateException(message)
}
複製程式碼

Nothing型別沒有任何值,只有被當做函式返回值使用,或者被當做泛型函式返回值的型別引數使用才會有意義。
返回Nothing的函式可以方法Elvis運算子的右邊來做先決條件檢查:

val address = company.address ?: fail("No address")
println(address)
複製程式碼

上面這個例子展示了在型別系統中擁有Nothing為什麼極其有用。編譯器知道這種返回型別的函式從不正常終止,然後在分析呼叫這個函式的程式碼時利用這個資訊。在這例子中,編譯器會把address的型別推斷成非空,因為它為null時的分支處理會始終丟擲異常。

可空性和集合

對前後一致的型別系統來說知道集合是否可以持有null元素是非常重要的一件事情。而Kotlin就可以非常顯眼的表示。

fun readNumbers(reader: BufferedReader): List<Int?> {
    val result = ArrayList<Int?>()
    for (line in reader.lineSequence()) {
        try {
            val number = line.toInt()
            result.add(number)
        } catch (e: NumberFormatException) {
            result.add(null)
        }
    }
    return result
}
複製程式碼

這個函式從一個檔案中讀取文字行的列表,並嘗試把每一行文字解析成一個數字。List<Int?> 是能持有Int? 型別值得雷柏啊,換句話說可以持有Int或者null。
注意List<Int?>List<Int>? 的區別。前一種表示列表本身始終不為null,但列表中的每個值都可以為null。後一種型別的變數可能包含空引用而不是列表例項,但列表的元素保證是非空的。
處理可空值得集合時,通過要首先要判斷是否為null,如果是則不處理,也即過濾掉null值。Kotlin提供了一個標準庫函式filterNotNull來完成它:

>>> val list = listOf(1L, null, 3L)
>>> println(list)
[1, null, 3]
>>> println(list.filterNotNull())
[1, 3]
複製程式碼

這種過濾也影響了集合的型別。過濾前是List<Long?>,過濾後是List<Long>,因為過濾保證了集合不會在包含任何為null的元素。

只讀集合與可變集合

Kotlin將Java的集合中訪問集合資料的介面和修改集合資料的介面進行了拆分。分離出只讀集合kotlin.collections.Collection,使用這個介面可以遍歷集合中的元素,獲取集合大小、判斷集合中是否包含某個元素,以及執行其他從該集合中讀取資料的操作,但這個介面沒有任何新增或移除元素的方法。

public interface Collection<out E> : Iterable<E> {
    public val size: Int
    public fun isEmpty(): Boolean
    public operator fun contains(element: @UnsafeVariance E): Boolean
    override fun iterator(): Iterator<E>
    public fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean
}
複製程式碼

另一個則是kotlin.collections.MutableCollection 介面可以修改集合中的資料。它繼承kotlin.collections.Collection,提供了方法來新增和移除元素,清空集合等:

public interface MutableCollection<E> : Collection<E>, MutableIterable<E> {
    override fun iterator(): MutableIterator<E>
    public fun add(element: E): Boolean
    public fun remove(element: E): Boolean
    public fun addAll(elements: Collection<E>): Boolean
    public fun removeAll(elements: Collection<E>): Boolean
    public fun retainAll(elements: Collection<E>): Boolean
    public fun clear(): Unit
}
複製程式碼

就像val和var之間的分離一樣,只讀集合介面與可變集合介面的分離能讓程式中的資料發生的事情更容易理解。如果函式接受Collection而不是MutableCollection作為引數,你就知道它不會修改集合,而只是讀取集合中的資料。如果函式要求你傳遞給他MutableCollection作為引數,可以認為它將會修改資料。

fun <T> copyElements(source: Collection<T>, target: MutableCollection<T>) {
    for (item in source) {
        target.add(item)
    }
}
複製程式碼

這個例子中我們讀取source中的元素新增到target中,因此宣告函式的時候可以很好的區別:一個只讀,一個可變。

使用集合介面時需要牢記一點只讀集合不一定是不可變的。如果你使用的變數擁有一個只讀介面型別,它可能只是同一個集合的眾多引用中的一個。可能有另一個可變集合也指向這個集合,在另一個地方(執行緒)中對這個集合作出了改變。

這種分離只在Kotlin的程式碼中有效,上面這個例子轉換成Java程式碼後:

public static final void copyElements(@NotNull Collection source, @NotNull Collection target) {
      Iterator var3 = source.iterator();
      while(var3.hasNext()) {
         Object item = var3.next();
         target.add(item);
      }

   }
複製程式碼

可以看到都變成了Java中Collection介面,也即是可變的完整的集合介面。也就是說即是Kotlin中把集合宣告成只讀的。Java程式碼也能夠修改這個集合。Kotlin編譯器不能完全分析Java程式碼到底對集合做了什麼,因此Kotlin無法拒絕向可以修改集合的Java程式碼傳遞只讀Collection。如果你將定義的函式中會將只讀集合傳遞給Java,你有責任將引數宣告成正確的引數型別,取決於Java程式碼是否會修改集合。
這個注意事項也同樣適用於Kotlin定義的非空元素集合傳遞給Java時,可能會存入null值。

*集合建立函式

集合型別 只讀 可變
List listOf mutableListOf, arrayListOf
Set setOf mutableSetOf, hashSetOf, linkedSetOf, sortedSetOf
Map mapOf mutableMapOf,hashMapOf,linkedMapOf,sortedMapOf

作為平臺型別的集合

上一篇關於可空性的文章,Kotlin中把哪些定義在Java程式碼中的型別看成平臺型別。Kotlin沒有任何關於平臺型別的可空性資訊,所以編譯器允許Kotlin程式碼將其視為可空或者非空。同樣,Java中宣告的集合型別的變數也被視為平臺型別,一個平臺型別的集合本質上就是可變性未知的集合。特別是當你在Kotlin中重寫或者實現簽名中有集合型別的Java方法時,就要考慮到底用哪一種型別來重寫:

/* Java */
interface Processor {
    void process(List<String> values);
}

/* Kotlin */
class ProcessorImpl : Processor {
    override fun process(values: MutableList<String?>?) {}
}

class ProcessorImpl2 : Processor {
    override fun process(values: MutableList<String>?) {}
}

class ProcessorImpl3 : Processor {
    override fun process(values: MutableList<String>) {}
}

class ProcessorImpl4 : Processor {
    override fun process(values: List<String>) {}
}
複製程式碼

這些繼承方法的定義都是可以的,你要根據實際情況做出選擇:

  • 集合是否可空?
  • 集合中的元素是否可空?
  • 你的方法會不會修改集合?

當然如果你不確定,可以用最保險的方式:

override fun process(values: MutableList<String?>?) {}
複製程式碼

但是使用的時候就得考慮各種可能為空的情況了。

物件和基本資料型別的陣列

之前的好多例子其實都出現了Kotlin的陣列:

Array<String>
複製程式碼

Kotlin中的一個陣列是一個帶有型別引數的類,其元素型別被指定為相應的型別引數。可以通過arrayOfarrayOfNulls和Array構造方法來建立陣列。

Kotlin程式碼中最常見的建立陣列的情況之一是需要呼叫引數為陣列的Java方法時,或是呼叫帶有vararg引數的Kotlin函式。在這些情況下,通常已經將資料儲存在集合中,只需要將其轉換為陣列即可。可以使用toTypeArray方法的來執行:

>>> val strings = listOf("a", "b", "c")
>>> println("%s/%s/%s".format(*strings.toTypeArray())) //期望varvag引數時使用展開運算子傳遞陣列
a/b/c
複製程式碼

陣列型別的型別引數始終會變成物件型別。如果你宣告瞭一個Array<Int>它將會是一個包含裝箱整型的陣列Integer[]。如果你需要建立沒有裝箱的基本資料型別的陣列,必須使用一個基本資料型別陣列的特殊類。 為了表示基本資料型別的陣列。Kotlin提供了若干獨立的類,每一種基本資料型別都對應一個,例如Int型別的陣列叫做IntArray,還有ByteArray,BooleanArray等等。這些對應Java中的基本資料型別陣列:int[]btye[]boolean[]等等。 要穿件一個基本資料型別的陣列,你可以通過intArrayOf之類的工廠方法,或者構造方法傳入size或者lambda。

相關文章